devtools-fleet-mcp 0.1.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/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "devtools-fleet-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "chrome-devtools-mcp for ten agents at once: one isolated Chrome per agent, saved login states, origin allowlists, and a CLI to see and reap them all",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "devtools-fleet-mcp": "src/index.js",
9
+ "devtools-fleet": "bin/cli.js"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "bin/",
14
+ "skills/",
15
+ ".claude-plugin/",
16
+ "LICENSE",
17
+ "README.md",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "scripts": {
21
+ "start": "node src/index.js",
22
+ "test": "node --test --test-concurrency=1 test/unit/*.test.js",
23
+ "test:integration": "node --test --test-concurrency=1 --test-timeout=900000 test/integration/*.test.js",
24
+ "test:all": "npm test && npm run test:integration",
25
+ "lint": "eslint src/ bin/"
26
+ },
27
+ "keywords": [
28
+ "chrome",
29
+ "devtools",
30
+ "chrome-devtools-mcp",
31
+ "mcp",
32
+ "model-context-protocol",
33
+ "browser",
34
+ "multi-agent",
35
+ "claude-code"
36
+ ],
37
+ "author": "Marcel Schmitz",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/pluginslab/devtools-fleet-mcp.git"
42
+ },
43
+ "homepage": "https://github.com/pluginslab/devtools-fleet-mcp#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/pluginslab/devtools-fleet-mcp/issues"
46
+ },
47
+ "engines": {
48
+ "node": ">=22.12.0"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.31.0",
52
+ "@puppeteer/browsers": "^3.2.3",
53
+ "chrome-devtools-mcp": "^1.10.1",
54
+ "commander": "^15.0.0",
55
+ "zod": "^4.6.5"
56
+ },
57
+ "devDependencies": {
58
+ "@eslint/js": "^10.0.1",
59
+ "eslint": "^10.11.0"
60
+ }
61
+ }
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: devtools-fleet
3
+ description: Open, test, debug or profile a web page in this session's own isolated Chrome (devtools-fleet, which runs Google's chrome-devtools-mcp tools), optionally already logged in from a saved state. Use for any browser task when the devtools-fleet MCP server is connected, especially when the page needs a login or other agents are using browsers at the same time.
4
+ ---
5
+
6
+ # devtools-fleet
7
+
8
+ You have your own Chrome. Other agents have theirs; nothing you do in yours affects them. All of chrome-devtools-mcp's tools (`new_page`, `navigate_page`, `take_snapshot`, `click`, `fill`, `evaluate_script`, `list_console_messages`, `list_network_requests`, `performance_start_trace`, `lighthouse_audit`, ...) work as usual.
9
+
10
+ ## Starting
11
+
12
+ - Any browser tool starts the browser on first use. You only need `browser_start` to choose options.
13
+ - **Needs a login?** Call `state_list` first. If a matching state exists, call `browser_start({ state: "<name>", url: "<page>" })`. You start logged in.
14
+ - **No matching state?** Don't try to log in with credentials you were not given. Ask the person to run `devtools-fleet login <name> <url>` in a terminal, then use the state they create.
15
+ - A browser can't switch to a different state while running. `browser_stop`, then `browser_start` with the new one.
16
+
17
+ ## The allowlist
18
+
19
+ A browser started from a state only reaches that state's origins. When something is blocked, the tool result says so (`is blocked` / `devtools-fleet blocked N request(s)`).
20
+
21
+ - This is deliberate. Don't look for another way to reach the blocked origin.
22
+ - If the task genuinely needs that origin, tell the person which one, so they can re-save the state with `--allow <origin>`.
23
+ - A blocked link click leaves the tab on an error page. Navigate it back with `navigate_page`.
24
+
25
+ ## When things go wrong
26
+
27
+ - A tool result starting with `devtools-fleet:` means the browser or the tooling was recovered. Read it: after a browser restart, tabs are gone and page ids are new, so call `list_pages` again.
28
+ - `devtools-fleet is at its limit of N browsers`: tell the person. Don't stop other sessions' browsers; you can't see or reach them anyway.
29
+
30
+ ## Showing the person something
31
+
32
+ - `browser_restart({ headless: false })` relaunches with a visible window (same cookies and tabs, pages reload). Use it when the person needs to watch, solve a captcha or pass 2FA. `browser_restart({ headless: true })` hides it again.
33
+ - The person can also watch without interrupting you: `devtools-fleet ls`, then `devtools-fleet show <id>`. `browser_status` tells you your id.
34
+
35
+ ## Saving a login
36
+
37
+ After the person has logged in inside your visible browser, `state_save({ name })` stores it for other sessions. It keeps only cookies for the open allowed origins, can't widen your allowlist, and can't overwrite states a person created.
38
+
39
+ ## Cleaning up
40
+
41
+ Call `browser_stop` when the task is done and the browser isn't needed any more. If you forget, the reaper closes it after your session ends.
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { startServer } from './server.js';
3
+
4
+ startServer().catch((err) => {
5
+ console.error(`[devtools-fleet-mcp] failed to start: ${err.message}`);
6
+ process.exit(1);
7
+ });
package/src/lib/cdp.js ADDED
@@ -0,0 +1,105 @@
1
+ // Minimal Chrome DevTools Protocol client over the built-in WebSocket.
2
+ //
3
+ // Fleet keeps its own CDP connection next to chrome-devtools-mcp's, for the
4
+ // few things upstream doesn't do: cookie and storage transfer, and the
5
+ // navigation allowlist. Flat sessions only (Target.setAutoAttach flatten).
6
+
7
+ export class CdpConnection {
8
+ #ws;
9
+ #nextId = 1;
10
+ #pending = new Map();
11
+ #listeners = new Map();
12
+ #closed = false;
13
+
14
+ static async connect(wsUrl, { timeoutMs = 10_000 } = {}) {
15
+ const ws = new WebSocket(wsUrl);
16
+ await new Promise((resolve, reject) => {
17
+ const timer = setTimeout(() => reject(new Error(`CDP connect timed out: ${wsUrl}`)), timeoutMs);
18
+ ws.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
19
+ ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error(`CDP connect failed: ${wsUrl}`)); }, { once: true });
20
+ });
21
+ return new CdpConnection(ws);
22
+ }
23
+
24
+ constructor(ws) {
25
+ this.#ws = ws;
26
+ ws.addEventListener('message', (event) => this.#onMessage(event.data));
27
+ ws.addEventListener('close', () => this.#onClose());
28
+ }
29
+
30
+ get closed() {
31
+ return this.#closed;
32
+ }
33
+
34
+ send(method, params = {}, sessionId = undefined) {
35
+ if (this.#closed) return Promise.reject(new Error(`CDP connection closed (${method})`));
36
+ const id = this.#nextId++;
37
+ const message = { id, method, params };
38
+ if (sessionId) message.sessionId = sessionId;
39
+ return new Promise((resolve, reject) => {
40
+ this.#pending.set(id, { resolve, reject, method });
41
+ this.#ws.send(JSON.stringify(message));
42
+ });
43
+ }
44
+
45
+ /** Listen for an event. Handler gets (params, sessionId). Returns an unsubscribe function. */
46
+ on(method, handler) {
47
+ if (!this.#listeners.has(method)) this.#listeners.set(method, new Set());
48
+ this.#listeners.get(method).add(handler);
49
+ return () => this.#listeners.get(method)?.delete(handler);
50
+ }
51
+
52
+ waitFor(method, predicate = () => true, timeoutMs = 10_000) {
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => { off(); reject(new Error(`Timed out waiting for ${method}`)); }, timeoutMs);
55
+ const off = this.on(method, (params, sessionId) => {
56
+ if (!predicate(params, sessionId)) return;
57
+ clearTimeout(timer);
58
+ off();
59
+ resolve({ params, sessionId });
60
+ });
61
+ });
62
+ }
63
+
64
+ close() {
65
+ if (this.#closed) return;
66
+ this.#ws.close();
67
+ this.#onClose();
68
+ }
69
+
70
+ #onMessage(data) {
71
+ let message;
72
+ try {
73
+ message = JSON.parse(data);
74
+ } catch {
75
+ return;
76
+ }
77
+ if (message.id !== undefined) {
78
+ const pending = this.#pending.get(message.id);
79
+ if (!pending) return;
80
+ this.#pending.delete(message.id);
81
+ if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
82
+ else pending.resolve(message.result);
83
+ return;
84
+ }
85
+ const handlers = this.#listeners.get(message.method);
86
+ if (!handlers) return;
87
+ for (const handler of handlers) {
88
+ try {
89
+ handler(message.params, message.sessionId);
90
+ } catch (err) {
91
+ console.error(`[devtools-fleet] CDP handler for ${message.method} threw:`, err);
92
+ }
93
+ }
94
+ }
95
+
96
+ #onClose() {
97
+ if (this.#closed) return;
98
+ this.#closed = true;
99
+ for (const { reject, method } of this.#pending.values()) {
100
+ reject(new Error(`CDP connection closed (${method})`));
101
+ }
102
+ this.#pending.clear();
103
+ for (const handler of this.#listeners.get('__close') ?? []) handler();
104
+ }
105
+ }
@@ -0,0 +1,222 @@
1
+ import { spawn, execFileSync } from 'node:child_process';
2
+ import { createServer } from 'node:net';
3
+ import { existsSync } from 'node:fs';
4
+ import { Browser, computeSystemExecutablePath, detectBrowserPlatform } from '@puppeteer/browsers';
5
+ import { parseViewport } from './config.js';
6
+ import { ensurePrivateDir, isPidAlive, sleep } from './fs-utils.js';
7
+
8
+ // Flags close to what puppeteer/chrome-devtools-mcp use: quiet, no first-run
9
+ // UI, no background noise that would show up in the agent's network panel.
10
+ const BASE_ARGS = [
11
+ '--no-first-run',
12
+ '--no-default-browser-check',
13
+ '--disable-background-networking',
14
+ '--disable-background-timer-throttling',
15
+ '--disable-backgrounding-occluded-windows',
16
+ '--disable-renderer-backgrounding',
17
+ '--disable-breakpad',
18
+ '--disable-client-side-phishing-detection',
19
+ '--disable-component-update',
20
+ '--disable-default-apps',
21
+ '--disable-sync',
22
+ '--disable-hang-monitor',
23
+ '--disable-popup-blocking',
24
+ '--disable-prompt-on-repost',
25
+ '--metrics-recording-only',
26
+ '--password-store=basic',
27
+ '--use-mock-keychain',
28
+ '--export-tagged-pdf',
29
+ ];
30
+
31
+ export function resolveChromePath({ chromePath, channel = 'stable' }) {
32
+ if (chromePath) {
33
+ if (!existsSync(chromePath)) throw new Error(`chromePath does not exist: ${chromePath}`);
34
+ return chromePath;
35
+ }
36
+ try {
37
+ return computeSystemExecutablePath({ browser: Browser.CHROME, channel, platform: detectBrowserPlatform() });
38
+ } catch (err) {
39
+ throw new Error(
40
+ `Could not find Chrome (${channel} channel). Install it, or set chromePath / DEVTOOLS_FLEET_CHROME_PATH.\n${err.message}`,
41
+ { cause: err },
42
+ );
43
+ }
44
+ }
45
+
46
+ function getFreePort() {
47
+ return new Promise((resolve, reject) => {
48
+ const server = createServer();
49
+ server.unref();
50
+ server.once('error', reject);
51
+ server.listen(0, '127.0.0.1', () => {
52
+ const { port } = server.address();
53
+ server.close(() => resolve(port));
54
+ });
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Launch Chrome detached, in its own process group, so it survives the fleet
60
+ * process (that is what makes re-adoption possible).
61
+ *
62
+ * The port is picked here rather than by Chrome (port 0) so that
63
+ * --remote-allow-origins can name exactly the debugging server's own origin.
64
+ * That lets the DevTools inspector Chrome serves on that port connect (what
65
+ * `devtools-fleet show` opens), while no web page can.
66
+ * @returns {Promise<{ pid: number, port: number, wsPath: string }>}
67
+ */
68
+ export async function launchChrome(opts) {
69
+ let lastError;
70
+ for (let attempt = 0; attempt < 3; attempt++) {
71
+ const port = await getFreePort();
72
+ try {
73
+ return await launchOnPort({ ...opts, port });
74
+ } catch (err) {
75
+ lastError = err;
76
+ // Someone grabbed the port between probe and launch: Chrome can't bind and exits. Try another.
77
+ if (!/exited during startup|did not open/.test(err.message)) throw err;
78
+ }
79
+ }
80
+ throw lastError;
81
+ }
82
+
83
+ async function launchOnPort({ executablePath, profileDir, headless, viewport, extraArgs = [], url = 'about:blank', timeoutMs = 30_000, port, onSpawn = () => {} }) {
84
+ ensurePrivateDir(profileDir);
85
+
86
+ const args = [
87
+ ...BASE_ARGS,
88
+ `--remote-debugging-port=${port}`,
89
+ `--remote-allow-origins=http://127.0.0.1:${port}`,
90
+ `--user-data-dir=${profileDir}`,
91
+ ];
92
+ if (headless) args.push('--headless=new', '--hide-scrollbars', '--mute-audio');
93
+ if (viewport) {
94
+ const { width, height } = parseViewport(viewport);
95
+ args.push(`--window-size=${width},${height}`);
96
+ }
97
+ args.push(...extraArgs, url);
98
+
99
+ const child = spawn(executablePath, args, { detached: true, stdio: 'ignore' });
100
+ let spawnError = null;
101
+ child.once('error', (err) => { spawnError = err; });
102
+ let exitCode = null;
103
+ child.once('exit', (code, signal) => { exitCode = code ?? signal; });
104
+ child.unref();
105
+ // Record the pid before waiting, so a launch that hangs or crashes is still cleaned up.
106
+ if (child.pid) onSpawn(child.pid);
107
+
108
+ // With a fixed port Chrome doesn't write DevToolsActivePort; ask the
109
+ // debugging server itself for the browser endpoint.
110
+ const deadline = Date.now() + timeoutMs;
111
+ while (Date.now() < deadline) {
112
+ if (spawnError) throw new Error(`Failed to start Chrome at ${executablePath}: ${spawnError.message}`);
113
+ if (exitCode !== null) throw new Error(`Chrome exited during startup (${exitCode}). Profile: ${profileDir}`);
114
+ const wsPath = await browserEndpoint(port);
115
+ if (wsPath && exitCode === null) return { pid: child.pid, port, wsPath };
116
+ await sleep(100);
117
+ }
118
+ await terminate(child.pid);
119
+ throw new Error(`Chrome did not open its debugging port within ${timeoutMs / 1000}s`);
120
+ }
121
+
122
+ async function browserEndpoint(port) {
123
+ try {
124
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1000) });
125
+ if (!res.ok) return null;
126
+ const info = await res.json();
127
+ if (!/Chrome/i.test(info.Browser ?? '')) return null;
128
+ return new URL(info.webSocketDebuggerUrl).pathname;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export function browserWsUrl({ port, wsPath }) {
135
+ return `ws://127.0.0.1:${port}${wsPath}`;
136
+ }
137
+
138
+ /** Page targets as the debugging server lists them (no CDP connection needed). */
139
+ export async function listPages({ port }, timeoutMs = 2000) {
140
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(timeoutMs) });
141
+ const targets = await res.json();
142
+ return targets.filter((t) => t.type === 'page').map((t) => ({ id: t.id, url: t.url, title: t.title }));
143
+ }
144
+
145
+ /**
146
+ * The DevTools inspector Chrome serves itself, with a live screencast of the
147
+ * page. Watching this way doesn't disturb the agent's own connection.
148
+ */
149
+ export function inspectorUrl({ port }, targetId) {
150
+ return `http://127.0.0.1:${port}/devtools/inspector.html?ws=127.0.0.1:${port}/devtools/page/${targetId}&remoteFrontend=true`;
151
+ }
152
+
153
+ /** True when a Chrome answers on the port with the expected browser endpoint. */
154
+ export async function isChromeAlive({ port, wsPath, chromePid }, timeoutMs = 2000) {
155
+ if (!port || (chromePid && !isPidAlive(chromePid))) return false;
156
+ try {
157
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(timeoutMs) });
158
+ if (!res.ok) return false;
159
+ const info = await res.json();
160
+ return !wsPath || String(info.webSocketDebuggerUrl || '').endsWith(wsPath);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /** True when pid is a Chrome started on this profile dir (guards against pid reuse). */
167
+ export function pidOwnsProfile(pid, profileDir) {
168
+ if (!pid || !isPidAlive(pid)) return false;
169
+ if (process.platform === 'win32') return true;
170
+ try {
171
+ const args = execFileSync('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
172
+ return args.includes(`--user-data-dir=${profileDir}`);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Close a fleet browser: politely over CDP, then by signal. Only signals a pid
180
+ * that is still a Chrome on this profile, so a recycled pid is never hit.
181
+ */
182
+ export async function closeChrome({ port, wsPath, chromePid, profileDir }, { cdp = null, timeoutMs = 5000 } = {}) {
183
+ const ours = pidOwnsProfile(chromePid, profileDir);
184
+ if (ours && await isChromeAlive({ port, wsPath, chromePid })) {
185
+ try {
186
+ if (cdp && !cdp.closed) {
187
+ await Promise.race([cdp.send('Browser.close'), sleep(2000)]);
188
+ } else {
189
+ const { CdpConnection } = await import('./cdp.js');
190
+ const conn = await CdpConnection.connect(browserWsUrl({ port, wsPath }), { timeoutMs: 2000 });
191
+ await Promise.race([conn.send('Browser.close').catch(() => {}), sleep(2000)]);
192
+ conn.close();
193
+ }
194
+ } catch {
195
+ // fall through to signals
196
+ }
197
+ }
198
+ const deadline = Date.now() + timeoutMs;
199
+ while (chromePid && isPidAlive(chromePid) && Date.now() < deadline) await sleep(100);
200
+ if (chromePid && isPidAlive(chromePid) && ours) await terminate(chromePid);
201
+ }
202
+
203
+ /** SIGTERM, then SIGKILL if Chrome is still there after a grace period. */
204
+ async function terminate(pid, graceMs = 3000) {
205
+ killPid(pid, 'SIGTERM');
206
+ const deadline = Date.now() + graceMs;
207
+ while (isPidAlive(pid) && Date.now() < deadline) await sleep(100);
208
+ if (isPidAlive(pid)) killPid(pid, 'SIGKILL');
209
+ }
210
+
211
+ function killPid(pid, signal = 'SIGTERM') {
212
+ try {
213
+ // Negative pid: the whole process group (Chrome's helpers included).
214
+ process.kill(-pid, signal);
215
+ } catch {
216
+ try {
217
+ process.kill(pid, signal);
218
+ } catch {
219
+ // already gone
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export const FLEET_HOME = process.env.DEVTOOLS_FLEET_HOME || join(homedir(), '.devtools-fleet');
6
+
7
+ export const PATHS = {
8
+ home: FLEET_HOME,
9
+ browsers: join(FLEET_HOME, 'browsers'),
10
+ profiles: join(FLEET_HOME, 'profiles'),
11
+ states: join(FLEET_HOME, 'states'),
12
+ locks: join(FLEET_HOME, 'locks'),
13
+ config: join(FLEET_HOME, 'config.json'),
14
+ reaperPid: join(FLEET_HOME, 'reaper.pid'),
15
+ reaperLog: join(FLEET_HOME, 'reaper.log'),
16
+ };
17
+
18
+ export const DEFAULTS = {
19
+ headless: true,
20
+ viewport: null,
21
+ channel: 'stable',
22
+ chromePath: null,
23
+ maxBrowsers: 10,
24
+ orphanTimeoutMinutes: 15,
25
+ launchTimeoutSeconds: 30,
26
+ // Extra flags for chrome-devtools-mcp, e.g. ["--no-usage-statistics"].
27
+ upstreamArgs: [],
28
+ // Extra flags for Chrome itself.
29
+ chromeArgs: [],
30
+ };
31
+
32
+ // Flags fleet owns. Passing them through would hand browser lifecycle back to
33
+ // chrome-devtools-mcp and defeat the point.
34
+ const RESERVED_UPSTREAM_FLAGS = [
35
+ 'browserUrl', 'browser-url', 'u', 'wsEndpoint', 'ws-endpoint', 'w', 'wsHeaders', 'ws-headers',
36
+ 'autoConnect', 'auto-connect', 'isolated', 'userDataDir', 'user-data-dir', 'headless',
37
+ 'channel', 'executablePath', 'executable-path', 'e', 'viewport', 'chromeArg', 'chrome-arg',
38
+ ];
39
+
40
+ const ENV = {
41
+ headless: ['DEVTOOLS_FLEET_HEADLESS', parseBool],
42
+ viewport: ['DEVTOOLS_FLEET_VIEWPORT', String],
43
+ channel: ['DEVTOOLS_FLEET_CHANNEL', String],
44
+ chromePath: ['DEVTOOLS_FLEET_CHROME_PATH', String],
45
+ maxBrowsers: ['DEVTOOLS_FLEET_MAX_BROWSERS', parsePositiveInt],
46
+ orphanTimeoutMinutes: ['DEVTOOLS_FLEET_ORPHAN_TIMEOUT_MINUTES', parsePositiveInt],
47
+ launchTimeoutSeconds: ['DEVTOOLS_FLEET_LAUNCH_TIMEOUT_SECONDS', parsePositiveInt],
48
+ };
49
+
50
+ export function loadConfig() {
51
+ let file = {};
52
+ try {
53
+ file = JSON.parse(readFileSync(PATHS.config, 'utf8'));
54
+ } catch (err) {
55
+ if (err.code !== 'ENOENT') throw new Error(`Invalid ${PATHS.config}: ${err.message}`, { cause: err });
56
+ }
57
+ const config = { ...DEFAULTS, ...file };
58
+ for (const [key, [name, parse]] of Object.entries(ENV)) {
59
+ if (process.env[name] !== undefined && process.env[name] !== '') config[key] = parse(process.env[name], name);
60
+ }
61
+ validateConfig(config);
62
+ return config;
63
+ }
64
+
65
+ export function validateConfig(config) {
66
+ if (!['stable', 'beta', 'dev', 'canary'].includes(config.channel)) {
67
+ throw new Error(`channel must be stable, beta, dev or canary (got "${config.channel}")`);
68
+ }
69
+ if (config.viewport !== null) parseViewport(config.viewport);
70
+ for (const key of ['maxBrowsers', 'orphanTimeoutMinutes', 'launchTimeoutSeconds']) {
71
+ if (!Number.isInteger(config[key]) || config[key] < 1) throw new Error(`${key} must be a positive integer`);
72
+ }
73
+ for (const key of ['upstreamArgs', 'chromeArgs']) {
74
+ if (!Array.isArray(config[key]) || !config[key].every((a) => typeof a === 'string')) {
75
+ throw new Error(`${key} must be an array of strings`);
76
+ }
77
+ }
78
+ for (const arg of config.upstreamArgs) {
79
+ const flag = arg.replace(/^--?(no-)?/, '').split('=')[0];
80
+ if (RESERVED_UPSTREAM_FLAGS.includes(flag)) {
81
+ throw new Error(`upstreamArgs may not include "${arg}": devtools-fleet manages the browser itself`);
82
+ }
83
+ }
84
+ }
85
+
86
+ /** "1280x720" → { width: 1280, height: 720 } */
87
+ export function parseViewport(value) {
88
+ const match = /^(\d{2,5})x(\d{2,5})$/.exec(String(value));
89
+ if (!match) throw new Error(`viewport must look like 1280x720 (got "${value}")`);
90
+ return { width: Number(match[1]), height: Number(match[2]) };
91
+ }
92
+
93
+ function parseBool(value, name) {
94
+ if (/^(1|true|yes|on)$/i.test(value)) return true;
95
+ if (/^(0|false|no|off)$/i.test(value)) return false;
96
+ throw new Error(`${name} must be true or false`);
97
+ }
98
+
99
+ function parsePositiveInt(value, name) {
100
+ const n = Number(value);
101
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${name} must be a positive integer`);
102
+ return n;
103
+ }
@@ -0,0 +1,31 @@
1
+ import { mkdirSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ /** Create a directory readable only by the current user. */
6
+ export function ensurePrivateDir(dir) {
7
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
8
+ // mkdir's mode is masked by umask and ignored for existing dirs.
9
+ chmodSync(dir, 0o700);
10
+ }
11
+
12
+ /** Write JSON atomically (temp file + rename), mode 0600. */
13
+ export function writeJsonAtomic(file, data) {
14
+ ensurePrivateDir(dirname(file));
15
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
16
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
17
+ renameSync(tmp, file);
18
+ }
19
+
20
+ export function isPidAlive(pid) {
21
+ if (!Number.isInteger(pid) || pid <= 0) return false;
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ // EPERM: exists but owned by someone else. Still alive.
27
+ return err.code === 'EPERM';
28
+ }
29
+ }
30
+
31
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,93 @@
1
+ import { checkUrl } from './origins.js';
2
+ import { PLACEHOLDER_PATH, fulfillPlaceholder } from './storage.js';
3
+
4
+ // The allowlist's second layer: fleet's own CDP connection auto-attaches to
5
+ // every target (tabs, popups, out-of-process iframes, workers) and fails
6
+ // requests to origins that aren't allowed. waitForDebuggerOnStart means a new
7
+ // tab is held until interception is on, so a popup can't slip its first
8
+ // navigation through.
9
+ //
10
+ // Non-strict: only Document requests (navigations). Strict: every request,
11
+ // which also stops fetch()/XHR/beacon exfiltration from evaluate_script.
12
+ //
13
+ // Fails closed: a tab or frame whose interception can't be switched on is
14
+ // closed, and so is any page in a browser context other than the default one
15
+ // (the browser-level auto-attach doesn't reach new contexts).
16
+
17
+ const PAGE_LIKE = new Set(['page', 'iframe']);
18
+ const WORKERS = new Set(['worker', 'service_worker', 'shared_worker']);
19
+
20
+ export async function installGuard(cdp, { allowlist, strict = false, onBlock = () => {} }) {
21
+ if (!allowlist) return () => {};
22
+ const pattern = { urlPattern: '*', requestStage: 'Request', ...(strict ? {} : { resourceType: 'Document' }) };
23
+
24
+ // Only sessions this guard enabled interception on. Other code on the same
25
+ // connection (storage.js) runs its own Fetch sessions and answers those itself.
26
+ const guarded = new Set();
27
+
28
+ const offPaused = cdp.on('Fetch.requestPaused', (params, sessionId) => {
29
+ if (!guarded.has(sessionId)) return;
30
+ const { allowed, origin } = checkUrl(allowlist, params.request.url);
31
+ // Fleet's own storage tab (see storage.js). When two interceptors see the
32
+ // same request, whichever gets it must answer it the same way, or a
33
+ // "continue" from here would send it to the real site.
34
+ if (allowed && params.resourceType === 'Document' && new URL(params.request.url).pathname === PLACEHOLDER_PATH) {
35
+ fulfillPlaceholder(cdp, params.requestId, sessionId);
36
+ return;
37
+ }
38
+ if (allowed) {
39
+ cdp.send('Fetch.continueRequest', { requestId: params.requestId }, sessionId).catch(() => {});
40
+ } else {
41
+ onBlock({ url: params.request.url, origin, resourceType: params.resourceType });
42
+ cdp.send('Fetch.failRequest', { requestId: params.requestId, errorReason: 'BlockedByClient' }, sessionId).catch(() => {});
43
+ }
44
+ });
45
+
46
+ const offAttached = cdp.on('Target.attachedToTarget', async ({ sessionId, targetInfo, waitingForDebugger }) => {
47
+ const pageLike = PAGE_LIKE.has(targetInfo.type);
48
+ if (pageLike || (strict && WORKERS.has(targetInfo.type))) {
49
+ guarded.add(sessionId);
50
+ try {
51
+ await cdp.send('Fetch.enable', { patterns: [pattern] }, sessionId);
52
+ if (pageLike) await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, sessionId);
53
+ } catch {
54
+ guarded.delete(sessionId);
55
+ if (pageLike) {
56
+ // Can't guard it, so it doesn't get to run.
57
+ onBlock({ url: targetInfo.url, origin: null, resourceType: `${targetInfo.type} that could not be guarded (closed)` });
58
+ await cdp.send('Target.closeTarget', { targetId: targetInfo.targetId }).catch(() => {});
59
+ return;
60
+ }
61
+ }
62
+ }
63
+ if (waitingForDebugger) await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
64
+ });
65
+
66
+ const offDetached = cdp.on('Target.detachedFromTarget', ({ sessionId }) => guarded.delete(sessionId));
67
+
68
+ // Pages in a browser context other than the default one are closed on sight.
69
+ // Defence in depth: fleet already refuses isolatedContext, the only way
70
+ // upstream creates one, so this catches only direct CDP clients, which could
71
+ // do anything anyway. It closes such a page, it can't stop its first load.
72
+ const { defaultBrowserContextId: defaultContext } = await cdp.send('Target.getBrowserContexts');
73
+ const { targetInfos } = await cdp.send('Target.getTargets');
74
+ const closeForeign = ({ targetInfo }) => {
75
+ if (targetInfo.type !== 'page' || !defaultContext || targetInfo.browserContextId === defaultContext) return;
76
+ onBlock({ url: targetInfo.url || 'about:blank', origin: null, resourceType: 'page in a separate browser context (closed)' });
77
+ cdp.send('Target.closeTarget', { targetId: targetInfo.targetId }).catch(() => {});
78
+ };
79
+ const offCreated = cdp.on('Target.targetCreated', closeForeign);
80
+ await cdp.send('Target.setDiscoverTargets', { discover: true });
81
+ for (const targetInfo of targetInfos) closeForeign({ targetInfo });
82
+
83
+ await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
84
+
85
+ return () => {
86
+ offCreated();
87
+ offDetached();
88
+ offPaused();
89
+ offAttached();
90
+ cdp.send('Target.setAutoAttach', { autoAttach: false, waitForDebuggerOnStart: false }).catch(() => {});
91
+ cdp.send('Target.setDiscoverTargets', { discover: false }).catch(() => {});
92
+ };
93
+ }