From ea5bb15322ec96ed9934b9ab44c8f76c50069f6b Mon Sep 17 00:00:00 2001 From: Marco Prontera Date: Wed, 15 Jul 2026 21:30:17 +0200 Subject: [PATCH] feat: adding network request overrides --- README.md | 10 +- docs/tool-reference.md | 37 +- .../network_override_local_file_test.ts | 42 ++ .../network_override_redirect_test.ts | 42 ++ skills/chrome-devtools-cli/SKILL.md | 4 + skills/chrome-devtools/SKILL.md | 22 + src/McpContext.ts | 9 + src/McpPage.ts | 20 + src/NetworkOverrideManager.ts | 486 ++++++++++++++++ src/bin/chrome-devtools-cli-options.ts | 81 +++ src/telemetry/tool_call_metrics.json | 38 ++ src/tools/ToolDefinition.ts | 8 + src/tools/network.ts | 182 ++++++ tests/tools/network.test.ts | 546 ++++++++++++++++++ 14 files changed, 1523 insertions(+), 4 deletions(-) create mode 100644 scripts/eval_scenarios/network_override_local_file_test.ts create mode 100644 scripts/eval_scenarios/network_override_redirect_test.ts create mode 100644 src/NetworkOverrideManager.ts diff --git a/README.md b/README.md index 3bb64ae6c..e69da88d2 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ A [CLI](docs/cli.md) is also provided for use without MCP. - **Get performance insights**: Uses [Chrome DevTools](https://github.com/ChromeDevTools/devtools-frontend) to record traces and extract actionable performance insights. -- **Advanced browser debugging**: Analyze network requests, take screenshots and - check browser console messages (with source-mapped stack traces). +- **Advanced browser debugging**: Analyze and override network requests, take + screenshots and check browser console messages (with source-mapped stack + traces). - **Reliable automation**. Uses [puppeteer](https://github.com/puppeteer/puppeteer) to automate actions in Chrome and automatically wait for action results. @@ -512,9 +513,12 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`performance_analyze_insight`](docs/tool-reference.md#performance_analyze_insight) - [`performance_start_trace`](docs/tool-reference.md#performance_start_trace) - [`performance_stop_trace`](docs/tool-reference.md#performance_stop_trace) -- **Network** (2 tools) +- **Network** (5 tools) + - [`add_network_override`](docs/tool-reference.md#add_network_override) - [`get_network_request`](docs/tool-reference.md#get_network_request) + - [`list_network_overrides`](docs/tool-reference.md#list_network_overrides) - [`list_network_requests`](docs/tool-reference.md#list_network_requests) + - [`remove_network_override`](docs/tool-reference.md#remove_network_override) - **Debugging** (8 tools) - [`evaluate_script`](docs/tool-reference.md#evaluate_script) - [`get_console_message`](docs/tool-reference.md#get_console_message) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index f2a1c9833..2641d5af5 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -27,9 +27,12 @@ - [`performance_analyze_insight`](#performance_analyze_insight) - [`performance_start_trace`](#performance_start_trace) - [`performance_stop_trace`](#performance_stop_trace) -- **[Network](#network)** (2 tools) +- **[Network](#network)** (5 tools) + - [`add_network_override`](#add_network_override) - [`get_network_request`](#get_network_request) + - [`list_network_overrides`](#list_network_overrides) - [`list_network_requests`](#list_network_requests) + - [`remove_network_override`](#remove_network_override) - **[Debugging](#debugging)** (8 tools) - [`evaluate_script`](#evaluate_script) - [`get_console_message`](#get_console_message) @@ -319,6 +322,20 @@ ## Network +### `add_network_override` + +**Description:** Adds a page-scoped network override that redirects matching requests or fulfills them from a local file. Add the override before navigating or reloading the page. + +**Parameters:** + +- **urlPattern** (string) **(required)**: CDP URL pattern to match. Use '*' for any sequence, '?' for one character, and a backslash to escape a wildcard. +- **contentType** (string) _(optional)_: Content-Type for a local-file response. When omitted, it is inferred from the file extension. +- **redirectUrl** (string) _(optional)_: Absolute HTTP(S) URL to load instead. Exactly one of redirectUrl or responseFilePath is required. +- **resourceType** (enum: "document", "stylesheet", "image", "media", "font", "script", "texttrack", "xhr", "fetch", "prefetch", "eventsource", "websocket", "manifest", "signedexchange", "ping", "cspviolationreport", "preflight", "fedcm", "other") _(optional)_: Only override requests of this resource type. When omitted, all resource types can match. +- **responseFilePath** (string) _(optional)_: Local file to serve as the response. The file is read again for every matching request so rebuilds are picked up. Exactly one of responseFilePath or redirectUrl is required. + +--- + ### `get_network_request` **Description:** Gets a network request by an optional reqid, if omitted returns the currently selected request in the DevTools Network panel. @@ -331,6 +348,14 @@ --- +### `list_network_overrides` + +**Description:** Lists the network overrides configured for the selected page. + +**Parameters:** None + +--- + ### `list_network_requests` **Description:** List all requests for the currently selected page since the last navigation. @@ -344,6 +369,16 @@ --- +### `remove_network_override` + +**Description:** Removes a network override from the selected page. + +**Parameters:** + +- **id** (integer) **(required)**: ID returned by [`add_network_override`](#add_network_override). + +--- + ## Debugging ### `evaluate_script` diff --git a/scripts/eval_scenarios/network_override_local_file_test.ts b/scripts/eval_scenarios/network_override_local_file_test.ts new file mode 100644 index 000000000..fe5a6a6f3 --- /dev/null +++ b/scripts/eval_scenarios/network_override_local_file_test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: + 'Open the website at and replace the script named my-third-party.js using my local JavaScript file at /my-third-party-local.js.', + maxTurns: 4, + htmlRoute: { + path: '/network_override_local_file_test.html', + htmlContent: ` +

Local network override test

+ + `, + }, + expectations: result => { + const pageId = result.consumePageNavigation(); + const overrideCall = result.remainingCalls.find( + call => call.name === 'add_network_override', + ); + assert.ok( + overrideCall, + `Expected add_network_override after navigation, got: ${result.remainingCalls.map(call => call.name).join(', ')}`, + ); + assert.strictEqual(overrideCall.args.urlPattern, '*my-third-party.js*'); + assert.strictEqual(overrideCall.args.resourceType, 'script'); + assert.strictEqual( + overrideCall.args.responseFilePath, + '/my-third-party-local.js', + ); + assert.strictEqual(overrideCall.args.redirectUrl, undefined); + if (result.hasPageIdRouting) { + assert.strictEqual(overrideCall.args.pageId, pageId); + } + }, +}; diff --git a/scripts/eval_scenarios/network_override_redirect_test.ts b/scripts/eval_scenarios/network_override_redirect_test.ts new file mode 100644 index 000000000..f85f00493 --- /dev/null +++ b/scripts/eval_scenarios/network_override_redirect_test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: + 'Open the website at and replace the script named my-third-party.js with the script hosted at https://www.examplescripts.agi/another.js.', + maxTurns: 4, + htmlRoute: { + path: '/network_override_redirect_test.html', + htmlContent: ` +

Redirect network override test

+ + `, + }, + expectations: result => { + const pageId = result.consumePageNavigation(); + const overrideCall = result.remainingCalls.find( + call => call.name === 'add_network_override', + ); + assert.ok( + overrideCall, + `Expected add_network_override after navigation, got: ${result.remainingCalls.map(call => call.name).join(', ')}`, + ); + assert.strictEqual(overrideCall.args.urlPattern, '*my-third-party.js*'); + assert.strictEqual(overrideCall.args.resourceType, 'script'); + assert.strictEqual( + overrideCall.args.redirectUrl, + 'https://www.examplescripts.agi/another.js', + ); + assert.strictEqual(overrideCall.args.responseFilePath, undefined); + if (result.hasPageIdRouting) { + assert.strictEqual(overrideCall.args.pageId, pageId); + } + }, +}; diff --git a/skills/chrome-devtools-cli/SKILL.md b/skills/chrome-devtools-cli/SKILL.md index 018418e72..000ef9abf 100644 --- a/skills/chrome-devtools-cli/SKILL.md +++ b/skills/chrome-devtools-cli/SKILL.md @@ -101,6 +101,10 @@ chrome-devtools list_network_requests # List all network requests chrome-devtools list_network_requests --pageSize 50 --pageIdx 0 # List network requests with pagination chrome-devtools list_network_requests --resourceTypes Fetch # Filter requests by resource type chrome-devtools list_network_requests --includePreservedRequests true # Include preserved requests +chrome-devtools add_network_override --urlPattern 'https://example.com/app.js*' --resourceType script --redirectUrl 'https://dev.example.com/app.js' +chrome-devtools add_network_override --urlPattern 'https://example.com/app.js*' --resourceType script --responseFilePath /absolute/path/to/app.js +chrome-devtools list_network_overrides +chrome-devtools remove_network_override --id 1 ``` ## Debugging & Inspection diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md index d62f66eca..977b4a378 100644 --- a/skills/chrome-devtools/SKILL.md +++ b/skills/chrome-devtools/SKILL.md @@ -29,6 +29,28 @@ Addional tooling can be enabled by providing the following flags: - Use pagination (`pageIdx`, `pageSize`) and filtering (`types`) to minimize data - Set `includeSnapshot: false` on input actions unless you need updated page state +### Testing with a network override + +Use network overrides when a production page should load a development resource +without changing the production HTML: + +1. Call `add_network_override` before the target navigation or reload. +2. Provide `urlPattern` and exactly one action: + - `redirectUrl` to load another HTTP(S) URL. + - `responseFilePath` to fulfill the request from a local file. +3. Restrict the rule with `resourceType` when possible, for example `script`. +4. Navigate or reload, then use `list_network_requests` and page behavior to + verify the replacement. +5. Call `list_network_overrides` to inspect active rules and + `remove_network_override` when the experiment is complete. + +Overrides are scoped to the selected page, persist across its navigations, and +are removed when that page closes. Newer matching rules take precedence. Local +files are read again for every match, so a rebuild is picked up on the next +reload. Service-worker-generated responses can bypass network interception. +Changed script bytes still fail an incompatible Subresource Integrity hash, and +cross-origin redirects remain subject to CSP and CORS. + ### Tool selection - **Automation/interaction**: `take_snapshot` (text-based, faster, better for automation) diff --git a/src/McpContext.ts b/src/McpContext.ts index b5834cbc1..742084b79 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -756,6 +756,15 @@ export class McpContext implements Context { throw new Error(`Not allowed by allowlist: ${url}`); } + validateNetworkUrl(url: string): void { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + throw new Error('Unsupported URL protocol: ' + parsedUrl.protocol); + } + this.#validateUrlNotBlocked(parsedUrl); + this.#validateUrlAllowed(parsedUrl); + } + async loadResource(path: string): Promise { const url = new URL(path); diff --git a/src/McpPage.ts b/src/McpPage.ts index eb68b3973..50cde6ded 100644 --- a/src/McpPage.ts +++ b/src/McpPage.ts @@ -59,6 +59,11 @@ import { createTargetUniverse, type TargetUniverse, } from './devtools/DevtoolsUtils.js'; +import { + NetworkOverrideManager, + type NetworkOverride, + type NetworkOverrideInput, +} from './NetworkOverrideManager.js'; import { ConsoleCollector, NetworkCollector, @@ -137,6 +142,7 @@ export class McpPage implements ContextPage { #hasNetworkBlockOrAllowlist: boolean; #locatorClass: typeof Locator; + #networkOverrideManager: NetworkOverrideManager; constructor( page: Page, @@ -150,6 +156,7 @@ export class McpPage implements ContextPage { this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist; this.#locatorClass = options.locatorClass; this.pptrPage = page; + this.#networkOverrideManager = new NetworkOverrideManager(page); this.id = id; this.isolatedContextName = options.isolatedContextName; this.#dialogHandler = (dialog: Dialog): void => { @@ -424,10 +431,23 @@ export class McpPage implements ContextPage { dispose(): void { this.pptrPage.off('dialog', this.#dialogHandler); + void this.#networkOverrideManager.dispose(); this.networkCollector.dispose(); this.consoleCollector.dispose(); } + addNetworkOverride(input: NetworkOverrideInput): Promise { + return this.#networkOverrideManager.add(input); + } + + listNetworkOverrides(): NetworkOverride[] { + return this.#networkOverrideManager.list(); + } + + removeNetworkOverride(id: number): Promise { + return this.#networkOverrideManager.remove(id); + } + async executeThirdPartyDeveloperTool( toolName: string, params: Record, diff --git a/src/NetworkOverrideManager.ts b/src/NetworkOverrideManager.ts new file mode 100644 index 000000000..e9567512f --- /dev/null +++ b/src/NetworkOverrideManager.ts @@ -0,0 +1,486 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; + +import type {HTTPRequest, Page, ResourceType} from './third_party/index.js'; +import {logger} from './utils/logger.js'; +import {Mutex} from './utils/Mutex.js'; + +interface BaseNetworkOverride { + urlPattern: string; + resourceType?: ResourceType; +} + +export interface RedirectNetworkOverrideInput extends BaseNetworkOverride { + kind: 'redirect'; + redirectUrl: string; +} + +export interface FileNetworkOverrideInput extends BaseNetworkOverride { + kind: 'file'; + responseFilePath: string; + contentType?: string; + loadResponseFile: () => Promise>; +} + +export type NetworkOverrideInput = + RedirectNetworkOverrideInput | FileNetworkOverrideInput; + +interface NetworkOverrideBase extends BaseNetworkOverride { + id: number; +} + +export interface RedirectNetworkOverride extends NetworkOverrideBase { + kind: 'redirect'; + redirectUrl: string; +} + +export interface FileNetworkOverride extends NetworkOverrideBase { + kind: 'file'; + responseFilePath: string; + contentType: string; +} + +export type NetworkOverride = RedirectNetworkOverride | FileNetworkOverride; + +interface StoredNetworkOverrideBase extends NetworkOverrideBase { + matcher: RegExp; +} + +interface StoredRedirectNetworkOverride extends StoredNetworkOverrideBase { + kind: 'redirect'; + redirectUrl: string; +} + +interface StoredFileNetworkOverride extends StoredNetworkOverrideBase { + kind: 'file'; + responseFilePath: string; + contentType: string; + loadResponseFile: () => Promise>; +} + +type StoredNetworkOverride = + StoredRedirectNetworkOverride | StoredFileNetworkOverride; + +const CONTENT_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.htm': 'text/html; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webp': 'image/webp', + '.xml': 'application/xml; charset=utf-8', +}; + +const SENSITIVE_REDIRECT_HEADERS = new Set([ + 'authorization', + 'cookie', + 'proxy-authorization', +]); + +const CROSS_ORIGIN_REDIRECT_HEADERS = new Set([ + 'accept', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'origin', + 'pragma', + 'range', + 'user-agent', +]); + +function inferContentType(filePath: string): string { + return ( + CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? + 'application/octet-stream' + ); +} + +function escapeRegExpCharacter(character: string): string { + return /[\\^$.*+?()[\]{}|]/.test(character) ? '\\' + character : character; +} + +/** + * CDP Fetch URL patterns use '*' for any sequence, '?' for one character, + * and a backslash to escape the next character. + */ +function compileUrlPattern(pattern: string): RegExp { + let source = '^'; + let escaping = false; + + for (const character of pattern) { + if (escaping) { + source += escapeRegExpCharacter(character); + escaping = false; + continue; + } + if (character === '\\') { + escaping = true; + continue; + } + if (character === '*') { + source += '.*'; + continue; + } + if (character === '?') { + source += '.'; + continue; + } + source += escapeRegExpCharacter(character); + } + + if (escaping) { + source += '\\\\'; + } + source += '$'; + return new RegExp(source); +} + +function toNetworkOverride(override: StoredNetworkOverride): NetworkOverride { + if (override.kind === 'redirect') { + return { + id: override.id, + kind: override.kind, + urlPattern: override.urlPattern, + resourceType: override.resourceType, + redirectUrl: override.redirectUrl, + }; + } + return { + id: override.id, + kind: override.kind, + urlPattern: override.urlPattern, + resourceType: override.resourceType, + responseFilePath: override.responseFilePath, + contentType: override.contentType, + }; +} + +export class NetworkOverrideManager { + #page: Page; + #overrides = new Map(); + #nextId = 1; + #active = false; + #cacheDisabled = false; + #disposed = false; + #lifecycleMutex = new Mutex(); + + #requestHandler = (request: HTTPRequest): Promise => { + return this.#handleRequest(request); + }; + + constructor(page: Page) { + this.#page = page; + } + + async add(input: NetworkOverrideInput): Promise { + return await this.#runLifecycleOperation(() => this.#add(input)); + } + + async #add(input: NetworkOverrideInput): Promise { + if (this.#disposed) { + throw new Error('Cannot add an override after the page was disposed.'); + } + const id = this.#nextId++; + let override: StoredNetworkOverride; + if (input.kind === 'redirect') { + override = { + id, + kind: input.kind, + urlPattern: input.urlPattern, + resourceType: input.resourceType, + matcher: compileUrlPattern(input.urlPattern), + redirectUrl: input.redirectUrl, + }; + } else { + await input.loadResponseFile(); + if (this.#disposed) { + throw new Error('Cannot add an override after the page was disposed.'); + } + override = { + id, + kind: input.kind, + urlPattern: input.urlPattern, + resourceType: input.resourceType, + matcher: compileUrlPattern(input.urlPattern), + responseFilePath: input.responseFilePath, + contentType: + input.contentType ?? inferContentType(input.responseFilePath), + loadResponseFile: input.loadResponseFile, + }; + } + + const needsActivation = this.#overrides.size === 0; + this.#overrides.set(id, override); + try { + if (needsActivation) { + await this.#activate(); + } + } catch (error) { + this.#overrides.delete(id); + throw error; + } + return toNetworkOverride(override); + } + + list(): NetworkOverride[] { + const overrides: NetworkOverride[] = []; + for (const override of this.#overrides.values()) { + overrides.push(toNetworkOverride(override)); + } + return overrides; + } + + async remove(id: number): Promise { + return await this.#runLifecycleOperation(() => this.#remove(id)); + } + + async #remove(id: number): Promise { + if (this.#disposed) { + throw new Error('Cannot remove an override after the page was disposed.'); + } + if (!this.#overrides.has(id)) { + return false; + } + if (this.#overrides.size === 1) { + await this.#deactivate(); + } + this.#overrides.delete(id); + return true; + } + + async dispose(): Promise { + await this.#runLifecycleOperation(async () => { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#overrides.clear(); + try { + await this.#deactivate(); + } catch (error) { + logger?.('Failed to dispose network overrides', error); + } + }); + } + + async #runLifecycleOperation( + operation: () => Promise, + ): Promise { + const guard = await this.#lifecycleMutex.acquire(); + try { + return await operation(); + } finally { + guard.dispose(); + } + } + + async #activate(): Promise { + if (this.#active) { + return; + } + if (this.#page.isClosed()) { + throw new Error('Cannot add an override to a closed page.'); + } + + this.#page.on('request', this.#requestHandler); + try { + this.#cacheDisabled = true; + await this.#page.setCacheEnabled(false); + if (this.#page.isClosed()) { + throw new Error('Cannot add an override to a closed page.'); + } + await this.#page.setRequestInterception(true); + this.#active = true; + if (this.#page.isClosed()) { + throw new Error('Cannot add an override to a closed page.'); + } + } catch (error) { + let interceptionDisabled = this.#page.isClosed(); + if (!interceptionDisabled) { + try { + await this.#page.setRequestInterception(false); + interceptionDisabled = true; + } catch (cleanupError) { + this.#active = true; + logger?.( + 'Failed to disable request interception after activation failed', + cleanupError, + ); + } + } + if (interceptionDisabled) { + this.#active = false; + this.#page.off('request', this.#requestHandler); + if (this.#page.isClosed()) { + this.#cacheDisabled = false; + } else if (this.#cacheDisabled) { + try { + await this.#page.setCacheEnabled(true); + this.#cacheDisabled = false; + } catch (cleanupError) { + logger?.( + 'Failed to restore the cache after activation failed', + cleanupError, + ); + } + } + } + throw error; + } + } + + async #deactivate(): Promise { + if (this.#page.isClosed()) { + this.#active = false; + this.#cacheDisabled = false; + this.#page.off('request', this.#requestHandler); + return; + } + + if (this.#active) { + await this.#page.setRequestInterception(false); + this.#active = false; + this.#page.off('request', this.#requestHandler); + } else { + this.#page.off('request', this.#requestHandler); + } + if (this.#cacheDisabled) { + await this.#page.setCacheEnabled(true); + this.#cacheDisabled = false; + } + } + + #findOverride(request: HTTPRequest): StoredNetworkOverride | undefined { + const overrides = [...this.#overrides.values()]; + for (let index = overrides.length - 1; index >= 0; index--) { + const override = overrides[index]; + if (!override) { + continue; + } + if (!override.matcher.test(request.url())) { + continue; + } + if ( + override.resourceType && + override.resourceType !== request.resourceType() + ) { + continue; + } + return override; + } + return undefined; + } + + async #handleRequest(request: HTTPRequest): Promise { + if (request.isInterceptResolutionHandled()) { + return; + } + + const override = this.#findOverride(request); + if (!override) { + await this.#continueRequest(request); + return; + } + + try { + if (override.kind === 'redirect') { + if ( + this.#isCrossOrigin(request.url(), override.redirectUrl) && + request.method() !== 'GET' && + request.method() !== 'HEAD' + ) { + throw new Error( + 'Cross-origin redirects only support GET and HEAD requests.', + ); + } + const headers = this.#redirectHeaders( + request.url(), + override.redirectUrl, + request.headers(), + ); + if (headers) { + await request.continue({url: override.redirectUrl, headers}); + } else { + await request.continue({url: override.redirectUrl}); + } + return; + } + + const body = await override.loadResponseFile(); + await request.respond({ + status: 200, + contentType: override.contentType, + headers: {'Cache-Control': 'no-store'}, + body, + }); + } catch (error) { + logger?.( + 'Failed to apply network override ' + override.id, + request.url(), + error, + ); + await this.#abortRequest(request); + } + } + + #isCrossOrigin(sourceUrl: string, redirectUrl: string): boolean { + return new URL(sourceUrl).origin !== new URL(redirectUrl).origin; + } + + #redirectHeaders( + sourceUrl: string, + redirectUrl: string, + originalHeaders: Record, + ): Record | undefined { + if (!this.#isCrossOrigin(sourceUrl, redirectUrl)) { + return undefined; + } + + const headers: Record = {}; + for (const [name, value] of Object.entries(originalHeaders)) { + if (SENSITIVE_REDIRECT_HEADERS.has(name.toLowerCase())) { + continue; + } + if (CROSS_ORIGIN_REDIRECT_HEADERS.has(name.toLowerCase())) { + headers[name] = value; + } + } + return headers; + } + + async #continueRequest(request: HTTPRequest): Promise { + if (request.isInterceptResolutionHandled()) { + return; + } + try { + await request.continue(); + } catch (error) { + logger?.('Failed to continue an intercepted network request', error); + } + } + + async #abortRequest(request: HTTPRequest): Promise { + if (request.isInterceptResolutionHandled()) { + return; + } + try { + await request.abort('failed'); + } catch (error) { + logger?.('Failed to abort a request after an override error', error); + } + } +} diff --git a/src/bin/chrome-devtools-cli-options.ts b/src/bin/chrome-devtools-cli-options.ts index d82cb1e2a..483ee0335 100644 --- a/src/bin/chrome-devtools-cli-options.ts +++ b/src/bin/chrome-devtools-cli-options.ts @@ -23,6 +23,69 @@ export type Commands = Record< } >; export const commands: Commands = { + add_network_override: { + description: + 'Adds a page-scoped network override that redirects matching requests or fulfills them from a local file. Add the override before navigating or reloading the page.', + category: 'Network', + args: { + urlPattern: { + name: 'urlPattern', + type: 'string', + description: + "CDP URL pattern to match. Use '*' for any sequence, '?' for one character, and a backslash to escape a wildcard.", + required: true, + }, + resourceType: { + name: 'resourceType', + type: 'string', + description: + 'Only override requests of this resource type. When omitted, all resource types can match.', + required: false, + enum: [ + 'document', + 'stylesheet', + 'image', + 'media', + 'font', + 'script', + 'texttrack', + 'xhr', + 'fetch', + 'prefetch', + 'eventsource', + 'websocket', + 'manifest', + 'signedexchange', + 'ping', + 'cspviolationreport', + 'preflight', + 'fedcm', + 'other', + ], + }, + redirectUrl: { + name: 'redirectUrl', + type: 'string', + description: + 'Absolute HTTP(S) URL to load instead. Exactly one of redirectUrl or responseFilePath is required.', + required: false, + }, + responseFilePath: { + name: 'responseFilePath', + type: 'string', + description: + 'Local file to serve as the response. The file is read again for every matching request so rebuilds are picked up. Exactly one of responseFilePath or redirectUrl is required.', + required: false, + }, + contentType: { + name: 'contentType', + type: 'string', + description: + 'Content-Type for a local-file response. When omitted, it is inferred from the file extension.', + required: false, + }, + }, + }, click: { description: 'Clicks on the provided element', category: 'Input automation', @@ -753,6 +816,12 @@ export const commands: Commands = { category: 'Extensions', args: {}, }, + list_network_overrides: { + description: + 'Lists the network overrides configured for the selected page.', + category: 'Network', + args: {}, + }, list_network_requests: { description: 'List all requests for the currently selected page since the last navigation.', @@ -982,6 +1051,18 @@ export const commands: Commands = { }, }, }, + remove_network_override: { + description: 'Removes a network override from the selected page.', + category: 'Network', + args: { + id: { + name: 'id', + type: 'integer', + description: 'ID returned by add_network_override.', + required: true, + }, + }, + }, resize_page: { description: "Resizes the selected page's window so that the page has specified dimension", diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index a41da140e..3a8be3f65 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -885,6 +885,44 @@ } ] }, + { + "name": "add_network_override", + "args": [ + { + "name": "url_pattern_length", + "argType": "number" + }, + { + "name": "resource_type", + "argType": "string" + }, + { + "name": "redirect_url_length", + "argType": "number" + }, + { + "name": "response_file_path_length", + "argType": "number" + }, + { + "name": "content_type_length", + "argType": "number" + } + ] + }, + { + "name": "list_network_overrides", + "args": [] + }, + { + "name": "remove_network_override", + "args": [ + { + "name": "id", + "argType": "number" + } + ] + }, { "name": "get_heapsnapshot_object_details", "args": [ diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 841accde5..a4b515161 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -12,6 +12,10 @@ import type { DuplicateStringGroup, } from '../HeapSnapshotManager.js'; import type {McpPage} from '../McpPage.js'; +import type { + NetworkOverride, + NetworkOverrideInput, +} from '../NetworkOverrideManager.js'; import {zod} from '../third_party/index.js'; import type { Dialog, @@ -194,6 +198,7 @@ export type SupportedExtensions = */ export type Context = Readonly<{ validatePath(filePath?: string): Promise; + validateNetworkUrl(url: string): void; ensureExtension( filePath: string, extension: Extension, @@ -334,6 +339,9 @@ export type ContextPage = Readonly<{ viewport?: Viewport; }): Promise; waitForTextOnPage(text: string[], timeout?: number): Promise; + addNetworkOverride(input: NetworkOverrideInput): Promise; + listNetworkOverrides(): NetworkOverride[]; + removeNetworkOverride(id: number): Promise; }>; export function defineTool( diff --git a/src/tools/network.ts b/src/tools/network.ts index 6e7fb50df..a363c4049 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from 'node:fs/promises'; + import {zod} from '../third_party/index.js'; import type {ResourceType} from '../third_party/index.js'; @@ -142,3 +144,183 @@ export const getNetworkRequest = definePageTool({ } }, }); + +export const addNetworkOverride = definePageTool({ + name: 'add_network_override', + description: + 'Adds a page-scoped network override that redirects matching requests or fulfills them from a local file. Add the override before navigating or reloading the page.', + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: false, + }, + schema: { + urlPattern: zod + .string() + .min(1) + .describe( + "CDP URL pattern to match. Use '*' for any sequence, '?' for one character, and a backslash to escape a wildcard.", + ), + resourceType: zod + .enum(FILTERABLE_RESOURCE_TYPES) + .optional() + .describe( + 'Only override requests of this resource type. When omitted, all resource types can match.', + ), + redirectUrl: zod + .string() + .optional() + .describe( + 'Absolute HTTP(S) URL to load instead. Exactly one of redirectUrl or responseFilePath is required.', + ), + responseFilePath: zod + .string() + .optional() + .describe( + 'Local file to serve as the response. The file is read again for every matching request so rebuilds are picked up. Exactly one of responseFilePath or redirectUrl is required.', + ), + contentType: zod + .string() + .optional() + .describe( + 'Content-Type for a local-file response. When omitted, it is inferred from the file extension.', + ), + }, + blockedByDialog: false, + verifyFilesSchema: ['responseFilePath'], + handler: async (request, response, context) => { + const {params} = request; + if ( + (params.redirectUrl === undefined) === + (params.responseFilePath === undefined) + ) { + throw new Error( + 'Exactly one of redirectUrl or responseFilePath must be provided.', + ); + } + if (params.redirectUrl !== undefined) { + if (params.contentType !== undefined) { + throw new Error('contentType can only be used with responseFilePath.'); + } + context.validateNetworkUrl(params.redirectUrl); + const override = await request.page.addNetworkOverride({ + kind: 'redirect', + urlPattern: params.urlPattern, + resourceType: params.resourceType, + redirectUrl: params.redirectUrl, + }); + response.appendResponseLine( + 'Added network override ' + + override.id + + '. Reload or navigate the page to apply it.', + ); + return; + } + + const responseFilePath = params.responseFilePath; + if (responseFilePath === undefined) { + throw new Error('responseFilePath is required.'); + } + if ( + params.contentType !== undefined && + (params.contentType.trim() === '' || /[\r\n]/.test(params.contentType)) + ) { + throw new Error( + 'contentType must be non-empty and cannot contain line breaks.', + ); + } + await context.validatePath(responseFilePath); + const override = await request.page.addNetworkOverride({ + kind: 'file', + urlPattern: params.urlPattern, + resourceType: params.resourceType, + responseFilePath, + contentType: params.contentType, + loadResponseFile: async () => { + await context.validatePath(responseFilePath); + return await fs.readFile(responseFilePath); + }, + }); + response.appendResponseLine( + 'Added network override ' + + override.id + + '. Reload or navigate the page to apply it.', + ); + }, +}); + +export const listNetworkOverrides = definePageTool({ + name: 'list_network_overrides', + description: 'Lists the network overrides configured for the selected page.', + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: true, + }, + schema: {}, + blockedByDialog: false, + verifyFilesSchema: [], + handler: async (request, response) => { + const overrides = request.page.listNetworkOverrides(); + if (overrides.length === 0) { + response.appendResponseLine( + 'No network overrides are configured for this page.', + ); + return; + } + + for (const override of overrides) { + const resourceType = override.resourceType + ? ' [' + override.resourceType + ']' + : ''; + if (override.kind === 'redirect') { + response.appendResponseLine( + override.id + + ': ' + + override.urlPattern + + resourceType + + ' -> ' + + override.redirectUrl, + ); + } else { + response.appendResponseLine( + override.id + + ': ' + + override.urlPattern + + resourceType + + ' -> ' + + override.responseFilePath + + ' (' + + override.contentType + + ')', + ); + } + } + }, +}); + +export const removeNetworkOverride = definePageTool({ + name: 'remove_network_override', + description: 'Removes a network override from the selected page.', + annotations: { + category: ToolCategory.NETWORK, + readOnlyHint: false, + }, + schema: { + id: zod + .number() + .int() + .positive() + .describe('ID returned by add_network_override.'), + }, + blockedByDialog: false, + verifyFilesSchema: [], + handler: async (request, response) => { + if (!(await request.page.removeNetworkOverride(request.params.id))) { + throw new Error( + 'Network override ' + request.params.id + ' was not found.', + ); + } + response.appendResponseLine( + 'Removed network override ' + request.params.id + '.', + ); + }, +}); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 0e6096f62..a0c3ca05b 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -5,11 +5,18 @@ */ import assert from 'node:assert'; +import {mkdtemp, rm, writeFile} from 'node:fs/promises'; +import type {IncomingHttpHeaders} from 'node:http'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {describe, it} from 'node:test'; import { + addNetworkOverride, getNetworkRequest, + listNetworkOverrides, listNetworkRequests, + removeNetworkOverride, } from '../../src/tools/network.js'; import {serverHooks} from '../server.js'; import { @@ -21,6 +28,545 @@ import { describe('network', () => { const server = serverHooks(); + + function addJavaScriptRoute( + routePath: string, + source: string, + onRequest?: () => void, + ): void { + server.addRoute(routePath, (_req, res) => { + onRequest?.(); + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.statusCode = 200; + res.end(source); + }); + } + + describe('network overrides', () => { + it('requires exactly one replacement source', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage(); + + await assert.rejects( + addNetworkOverride.handler( + { + params: {urlPattern: 'https://example.com/script.js'}, + page, + }, + response, + context, + ), + /Exactly one of redirectUrl or responseFilePath must be provided/, + ); + + await assert.rejects( + addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/script.js', + redirectUrl: 'https://example.com/replacement.js', + responseFilePath: join(tmpdir(), 'replacement.js'), + }, + page, + }, + response, + context, + ), + /Exactly one of redirectUrl or responseFilePath must be provided/, + ); + }); + }); + + it('redirects matching requests across reloads until removed', async () => { + let originalRequests = 0; + let replacementRequests = 0; + addJavaScriptRoute( + '/redirect-original.js', + "document.documentElement.dataset.overrideSource = 'original';", + () => { + originalRequests++; + }, + ); + addJavaScriptRoute( + '/redirect-replacement.js', + "document.documentElement.dataset.overrideSource = 'replacement';", + () => { + replacementRequests++; + }, + ); + server.addHtmlRoute( + '/redirect-page', + html``, + ); + + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const page = mcpPage.pptrPage; + await addNetworkOverride.handler( + { + params: { + urlPattern: server.getRoute('/redirect-original.js'), + redirectUrl: server.getRoute('/redirect-replacement.js'), + }, + page: mcpPage, + }, + response, + context, + ); + assert.deepStrictEqual(response.responseLines, [ + 'Added network override 1. Reload or navigate the page to apply it.', + ]); + + await page.goto(server.getRoute('/redirect-page')); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'replacement', + ); + + await page.reload(); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'replacement', + ); + assert.strictEqual(originalRequests, 0); + assert.strictEqual(replacementRequests, 2); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page: mcpPage}, + response, + context, + ); + await page.reload(); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'original', + ); + assert.strictEqual(originalRequests, 1); + }); + }); + + it('sanitizes cross-origin headers while preserving Origin', async () => { + let replacementHeaders: IncomingHttpHeaders = {}; + server.addHtmlRoute('/cross-origin-setup', html`
Setup
`); + addJavaScriptRoute( + '/cross-origin-original.js', + "document.documentElement.dataset.overrideSource = 'original';", + ); + server.addHtmlRoute( + '/cross-origin-page', + html``, + ); + server.addRoute('/cross-origin-replacement.js', (req, res) => { + replacementHeaders = req.headers; + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.statusCode = 200; + res.end( + "document.documentElement.dataset.overrideSource = 'replacement';", + ); + }); + + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const page = mcpPage.pptrPage; + await page.goto(server.getRoute('/cross-origin-setup')); + await page.evaluate(() => { + document.cookie = 'override_secret=cookie; path=/'; + }); + await page.setExtraHTTPHeaders({ + Authorization: 'Bearer secret', + Origin: server.baseUrl, + 'X-Api-Key': 'secret', + }); + + const replacementUrl = + server.baseUrl.replace('localhost', '127.0.0.1') + + '/cross-origin-replacement.js'; + await addNetworkOverride.handler( + { + params: { + urlPattern: server.getRoute('/cross-origin-original.js'), + resourceType: 'script', + redirectUrl: replacementUrl, + }, + page: mcpPage, + }, + response, + context, + ); + + await page.goto(server.getRoute('/cross-origin-page')); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'replacement', + ); + assert.strictEqual(replacementHeaders.origin, server.baseUrl); + assert.strictEqual(replacementHeaders.authorization, undefined); + assert.strictEqual(replacementHeaders.cookie, undefined); + assert.strictEqual(replacementHeaders.referer, undefined); + assert.strictEqual(replacementHeaders['x-api-key'], undefined); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page: mcpPage}, + response, + context, + ); + }); + }); + + it('enforces redirect URL access policies', async () => { + const redirectUrl = 'https://blocked.example/replacement.js'; + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage(); + await assert.rejects( + addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/original.js', + redirectUrl, + }, + page, + }, + response, + context, + ), + /Blocked by blocklist/, + ); + }, + {blockedUrlPattern: ['https://blocked.example/*']}, + ); + + await withMcpContext( + async (response, context) => { + const page = context.getSelectedMcpPage(); + await assert.rejects( + addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/original.js', + redirectUrl, + }, + page, + }, + response, + context, + ), + /Not allowed by allowlist/, + ); + }, + {allowedUrlPattern: ['https://allowed.example/*']}, + ); + }); + + it('rejects local files outside negotiated roots', async () => { + await withMcpContext(async (response, context) => { + context.setRoots([]); + const page = context.getSelectedMcpPage(); + await assert.rejects( + addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/original.js', + responseFilePath: join(process.cwd(), 'package.json'), + }, + page, + }, + response, + context, + ), + /Access denied/, + ); + }); + }); + + it('serves the latest contents of a local file', async () => { + const directory = await mkdtemp(join(tmpdir(), 'network-override-')); + const filePath = join(directory, 'local-override.js'); + try { + await writeFile( + filePath, + "document.documentElement.dataset.overrideSource = 'caffè-☕';", + ); + addJavaScriptRoute( + '/local-original.js', + "document.documentElement.dataset.overrideSource = 'original';", + ); + server.addHtmlRoute( + '/local-page', + html``, + ); + + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const page = mcpPage.pptrPage; + await addNetworkOverride.handler( + { + params: { + urlPattern: server.getRoute('/local-original.js'), + responseFilePath: filePath, + }, + page: mcpPage, + }, + response, + context, + ); + + await page.goto(server.getRoute('/local-page')); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'caffè-☕', + ); + + await writeFile( + filePath, + "document.documentElement.dataset.overrideSource = 'rebuilt';", + ); + await page.reload(); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'rebuilt', + ); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page: mcpPage}, + response, + context, + ); + }); + } finally { + await rm(directory, {recursive: true, force: true}); + } + }); + + it('matches cache-busting query strings with a wildcard', async () => { + let originalRequests = 0; + addJavaScriptRoute( + '/query-original.js?v=42', + "document.documentElement.dataset.overrideSource = 'original';", + () => { + originalRequests++; + }, + ); + addJavaScriptRoute( + '/query-replacement.js', + "document.documentElement.dataset.overrideSource = 'replacement';", + ); + server.addHtmlRoute( + '/query-page', + html``, + ); + + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const page = mcpPage.pptrPage; + await addNetworkOverride.handler( + { + params: { + urlPattern: server.baseUrl + '/query-original.js*', + redirectUrl: server.getRoute('/query-replacement.js'), + }, + page: mcpPage, + }, + response, + context, + ); + + await page.goto(server.getRoute('/query-page')); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + 'replacement', + ); + assert.strictEqual(originalRequests, 0); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page: mcpPage}, + response, + context, + ); + }); + }); + + it('lists and removes overrides by id', async () => { + const directory = await mkdtemp(join(tmpdir(), 'network-override-')); + const filePath = join(directory, 'listed-override.js'); + try { + await writeFile(filePath, 'void 0;'); + addJavaScriptRoute('/listed-replacement.js', 'void 0;'); + + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage(); + await listNetworkOverrides.handler( + {params: {}, page}, + response, + context, + ); + assert.deepStrictEqual(response.responseLines, [ + 'No network overrides are configured for this page.', + ]); + + response.resetResponseLineForTesting(); + await addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/redirect.js', + redirectUrl: server.getRoute('/listed-replacement.js'), + }, + page, + }, + response, + context, + ); + response.resetResponseLineForTesting(); + await addNetworkOverride.handler( + { + params: { + urlPattern: 'https://example.com/local.js', + resourceType: 'script', + responseFilePath: filePath, + }, + page, + }, + response, + context, + ); + + response.resetResponseLineForTesting(); + await listNetworkOverrides.handler( + {params: {}, page}, + response, + context, + ); + assert.deepStrictEqual(response.responseLines, [ + '1: https://example.com/redirect.js -> ' + + server.getRoute('/listed-replacement.js'), + '2: https://example.com/local.js [script] -> ' + + filePath + + ' (application/javascript; charset=utf-8)', + ]); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page}, + response, + context, + ); + assert.deepStrictEqual(response.responseLines, [ + 'Removed network override 1.', + ]); + + response.resetResponseLineForTesting(); + await listNetworkOverrides.handler( + {params: {}, page}, + response, + context, + ); + assert.deepStrictEqual(response.responseLines, [ + '2: https://example.com/local.js [script] -> ' + + filePath + + ' (application/javascript; charset=utf-8)', + ]); + + await assert.rejects( + removeNetworkOverride.handler( + {params: {id: 999}, page}, + response, + context, + ), + /Network override 999 was not found/, + ); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 2}, page}, + response, + context, + ); + }); + } finally { + await rm(directory, {recursive: true, force: true}); + } + }); + + it('aborts the request when a local file disappears', async () => { + const directory = await mkdtemp(join(tmpdir(), 'network-override-')); + const filePath = join(directory, 'removed-override.js'); + try { + let originalRequests = 0; + await writeFile( + filePath, + "document.documentElement.dataset.overrideSource = 'local';", + ); + addJavaScriptRoute( + '/fallback-original.js', + "document.documentElement.dataset.overrideSource = 'original';", + () => { + originalRequests++; + }, + ); + server.addHtmlRoute( + '/fallback-page', + html``, + ); + + await withMcpContext(async (response, context) => { + const mcpPage = context.getSelectedMcpPage(); + const page = mcpPage.pptrPage; + await addNetworkOverride.handler( + { + params: { + urlPattern: server.getRoute('/fallback-original.js'), + responseFilePath: filePath, + }, + page: mcpPage, + }, + response, + context, + ); + + await rm(filePath); + await page.goto(server.getRoute('/fallback-page'), { + waitUntil: 'load', + timeout: 5_000, + }); + assert.strictEqual( + await page.evaluate( + () => document.documentElement.dataset.overrideSource, + ), + undefined, + ); + assert.strictEqual(originalRequests, 0); + + response.resetResponseLineForTesting(); + await removeNetworkOverride.handler( + {params: {id: 1}, page: mcpPage}, + response, + context, + ); + }); + } finally { + await rm(directory, {recursive: true, force: true}); + } + }); + }); + describe('network_list_requests', () => { it('list requests', async () => { await withMcpContext(async (response, context) => {