Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 3.2.0

### New Features

- **3 JS execution / injection tools, uniform across cloakbrowser + camoufox:**
- `interceptor_browser_evaluate` — run a JS file in the page (`page.evaluate`), return the JSON-serialised result. File body is wrapped as `(__args) => { ... }` so it can `return` directly. `world: "isolated"` (default, stealthy) or `world: "main"` (camoufox-only via `mw:` prefix; requires `main_world_eval: true` at launch — detected up-front with a clear error message).
- `interceptor_browser_inject_init_script` — inject a JS file as `page.addInitScript`, runs before every page script on next navigation. Safest stealth primitive on cloakbrowser; on camoufox runs in privileged Juggler scope and does NOT patch main world ([camoufox#48](https://github.com/daijro/camoufox/issues/48)) — the tool returns this caveat in its response.
- `interceptor_browser_add_script_tag` — `page.addScriptTag` wrapper. Marked DOM-visible / not stealth in the tool description and return payload.
- All three accept an absolute `script_path` (no inline-source param). Per-backend stealth tradeoffs documented in the README "Browser DevTools-equivalents" section.
- `camoufox` launch result `details` now carries `main_world_eval: boolean` so downstream tools can branch on capability.

## 3.0.0

### New Features
Expand Down
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ Browser automation uses [cloakbrowser](https://cloakbrowser.dev/) — a stealth-

| Capability | proxy-mcp |
|---|---|
| See/modify DOM, run JS in page | Via `interceptor_browser_snapshot` + `interceptor_browser_list_storage_keys` (also reachable from custom scripts via `page.evaluate`) |
| See/modify DOM, run JS in page | `interceptor_browser_evaluate` (run JS file, return value), `interceptor_browser_inject_init_script` (pre-document hook, every navigation), `interceptor_browser_add_script_tag` (DOM-visible — avoid for stealth); plus `interceptor_browser_snapshot` for ARIA reads |
| Read cookies, localStorage, sessionStorage | Yes — `interceptor_browser_list_cookies`, `interceptor_browser_list_storage_keys` |
| Capture HTTP request/response bodies | Via the MITM proxy (4 KB preview cap by default; `full` capture profile on persisted sessions stores complete bodies) |
| Modify requests in-flight (headers, body, mock, drop) | Yes (declarative rules, hot-reload) |
Expand Down Expand Up @@ -665,9 +665,9 @@ Sets 18+ env vars covering curl, Node.js, Python requests, Deno, Git, npm/yarn.

Two modes: `exec` (live injection, existing processes need restart) and `restart` (stop + restart container). Uses `host.docker.internal` for proxy URL.

### Browser DevTools-equivalents (9)
### Browser DevTools-equivalents (12)

Playwright-driven tools for the browser target. Each takes a `target_id` directly — no session binding, no sidecar.
Playwright-driven tools for the browser target. Each takes a `target_id` directly — no session binding, no sidecar. Works on both cloakbrowser (`browser_*` IDs) and camoufox (`camoufox_*` IDs) targets via the shared `getPageForTarget()` resolver.

| Tool | Description |
|------|-------------|
Expand All @@ -680,9 +680,55 @@ Playwright-driven tools for the browser target. Each takes a `target_id` directl
| `interceptor_browser_get_storage_value` | Get one storage value by `item_id` |
| `interceptor_browser_list_network_fields` | Header field listing from proxy-captured traffic since the browser was launched |
| `interceptor_browser_get_network_field` | Get one full header field value by `field_id` |
| `interceptor_browser_evaluate` | Run a JS file in the page (file body wrapped as `(__args) => { ... }`); returns the result. `world: "isolated"` (default, stealthy) or `world: "main"` (camoufox-only, requires `main_world_eval: true` at launch) |
| `interceptor_browser_inject_init_script` | Inject a JS file as `page.addInitScript` — runs before every page script on the next navigation. Safest stealth primitive on cloakbrowser; on camoufox runs in privileged Juggler scope and does NOT patch main world ([camoufox#48](https://github.com/daijro/camoufox/issues/48)) |
| `interceptor_browser_add_script_tag` | Append a `<script>` to the current page. **DOM-visible — avoid for stealth.** Use for benign payloads where main-world execution + page visibility is intentional |

Network data is sourced from the MITM proxy rather than a browser-side protocol — the proxy sees every wire request regardless of what the browser reported.

**Stealth tradeoffs for JS injection:**

| Method | Cloakbrowser | Camoufox |
|---|---|---|
| `evaluate` isolated | Safe (isolated utility world) — rate-limit before reCAPTCHA, each call is CDP traffic | Safe (Juggler isolated world, invisible to page JS) |
| `evaluate` main | Not supported by Playwright API | Supported via `mw:` prefix, requires `main_world_eval: true` at launch; fully observable from page |
| `inject_init_script` | **Best for stealth** — pre-document, no DOM artifact | Stealthy but inert for main-world patching ([camoufox#48](https://github.com/daijro/camoufox/issues/48)); use [camoufox-add_init_script](https://github.com/techinz/camoufox-add_init_script) WebExtension instead |
| `add_script_tag` | Detectable (DOM node, MutationObserver, CSP) | Detectable (same) |

References: [Playwright evaluate](https://playwright.dev/docs/evaluating), [Playwright addInitScript](https://playwright.dev/docs/api/class-page#page-add-init-script), [Camoufox main-world eval](https://camoufox.com/python/main-world-eval/), [Camoufox stealth](https://camoufox.com/stealth/).

#### Worlds and isolation — what your JS can and can't see

The two backends ship different world models. Picking the wrong tool is the most common stealth footgun on Camoufox, so the boundary matters.

**Cloakbrowser (Chromium).** Playwright's `evaluate` runs in an isolated "utility" world that *shares globals with the page's main world*. An `addInitScript` patch to `navigator.webdriver` is visible to (a) your subsequent `evaluate` probes AND (b) anti-bot code the site loads. This is the model most "stealth playbooks" assume. Detection vectors are CDP-side (`Runtime.evaluate` chatter) — cloakbrowser's C++ patches mitigate those. The 48 source patches scrub the JS-observable leaks (`__playwright__binding__`, stack-trace `sourceURL` hints) at compile time.

**Camoufox (Firefox via Juggler).** Two strictly separated JS heaps:

- **Main world** — the page's real `window`. Site scripts, anti-bot fingerprint code, and tags injected via `addScriptTag` run here.
- **Isolated/Juggler world** — Playwright's private scope. Different `window` object, same DOM. `evaluate` (without `mw:`) and `addInitScript` both land here.

Consequence: an `addInitScript` that does `Object.defineProperty(navigator, 'webdriver', { get: () => false })` patches the *isolated* `navigator`. Your subsequent `evaluate` probe reads from the *same* isolated scope, sees the patched value, and returns `false`. The test passes. **But the site's detection code runs in main world and reads the unpatched `navigator`.** The patch is invisible to it. This is exactly [camoufox#48](https://github.com/daijro/camoufox/issues/48).

| Read | Reads from | Sees init-script patch? |
|---|---|---|
| `interceptor_browser_evaluate` (no `world`) | isolated | yes |
| `interceptor_browser_evaluate world: "main"` (camoufox) | main | **no** |
| Anti-bot JS loaded by the site | main | **no** |

This isn't a Camoufox bug; it's the design. Camoufox spoofs fingerprints in the **C++ binary** (configured at launch via `os`, `fonts`, `webgl_config`, `humanize`, etc.) so main-world JS sees the spoofed values *as if they were the real ones*. The Chromium-era playbook of "patch at runtime via `addInitScript`" is what Camoufox is replacing.

**Practical rules:**

| Use case | Cloakbrowser | Camoufox |
|---|---|---|
| Read DOM / extract data | `interceptor_browser_evaluate` (isolated) | `interceptor_browser_evaluate` (isolated) |
| Modify page state, click via JS | `interceptor_browser_evaluate` (isolated; globals are shared) | `interceptor_browser_evaluate` with `world: "main"` + launch `main_world_eval: true` |
| Spoof navigator / window fingerprints | `interceptor_browser_inject_init_script` | **Configure at launch (`os`, `fonts`, `webgl_config`, `humanize`, `firefox_user_prefs`).** `inject_init_script` will look like it worked from your isolated probes, but the page won't see it. |
| Load a 3rd-party JS lib into the page | `interceptor_browser_add_script_tag` (page sees it — usually OK if intentional) | Same — runs in main world (good for the use case), but DOM node is detectable |

Strong stealth corollary on Camoufox: anti-bot code running in main world **literally cannot observe** your `evaluate` reads. No `Function.toString` leak, no stack frames in page scripts, no shadow globals. It's a different world. The same isolation that makes `addInitScript` "fail" makes scraping unobservable.

### Sessions (13)

Persistent, queryable on-disk capture for long runs and post-crash analysis.
Expand Down Expand Up @@ -787,6 +833,12 @@ humanizer_type --target_id "browser_<id>" --text "user@example.com" --wpm 45
humanizer_scroll --target_id "browser_<id>" --delta_y 300
humanizer_idle --target_id "browser_<id>" --duration_ms 2000 --intensity subtle

# Run / inject JS in the page (cloakbrowser + camoufox)
interceptor_browser_evaluate --target_id "browser_<id>" --script_path /tmp/probe.js
interceptor_browser_evaluate --target_id "camoufox_<id>" --script_path /tmp/probe.js --world main # camoufox + main_world_eval=true
interceptor_browser_inject_init_script --target_id "browser_<id>" --script_path /tmp/hook.js # applies on next navigation
interceptor_browser_add_script_tag --target_id "browser_<id>" --script_path /tmp/lib.js # DOM-visible — avoid for stealth

# Query/export recorded session
proxy_list_sessions
proxy_query_session --session_id SESSION_ID --hostname_contains "api.example.com"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-mcp",
"version": "3.1.0",
"version": "3.2.0",
"description": "MCP server for HTTP/HTTPS MITM proxy via mockttp",
"type": "module",
"engines": {
Expand Down
4 changes: 4 additions & 0 deletions src/browser/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ function isCamoufoxTargetId(targetId: string): boolean {
return typeof targetId === "string" && targetId.startsWith("camoufox_");
}

export function isCamoufoxTarget(targetId: string): boolean {
return isCamoufoxTargetId(targetId);
}

async function ensureCamoufoxPage(entry: CamoufoxEntryWithDriver): Promise<Page> {
if (entry.page && !entry.page.isClosed()) return entry.page;
if (!entry.browser) {
Expand Down
1 change: 1 addition & 0 deletions src/interceptors/camoufox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export class CamoufoxInterceptor implements Interceptor {
humanize: options.humanize ?? null,
geoip,
block_webrtc: blockWebrtc,
main_world_eval: Boolean(params.main_world_eval),
...(options.os !== undefined ? { os: options.os } : {}),
...(options.locale !== undefined ? { locale: options.locale } : {}),
profileDir,
Expand Down
162 changes: 159 additions & 3 deletions src/tools/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { dirname, isAbsolute } from "node:path";
import { createHash } from "node:crypto";
import { proxyManager } from "../state.js";
import { truncateResult } from "../utils.js";
import { getEntry, getBrowserEntry, getPageForTarget } from "../browser/session.js";
import { getEntry, getBrowserEntry, getPageForTarget, isCamoufoxTarget } from "../browser/session.js";

function errorToString(e: unknown): string {
if (e instanceof Error) return e.message;
Expand Down Expand Up @@ -702,4 +702,160 @@ export function registerDevToolsTools(server: McpServer): void {
}
},
);

// ── JS execution / injection ───────────────────────────────────

server.tool(
"interceptor_browser_evaluate",
"Execute a JS file in the page and return its result. " +
"Source is loaded from `script_path` (absolute path). The file body is wrapped in an arrow " +
"function receiving `__args` (so the file may `return value;` directly and access the optional " +
"args object). " +
"Worlds: `isolated` (default, safe — page JS cannot see the call) or `main` (Camoufox-only via " +
"`mw:` prefix; REQUIRES `main_world_eval: true` at camoufox launch; fully observable from page). " +
"Cloakbrowser does not support main-world evaluate via Playwright — use `interceptor_browser_inject_init_script` for main-world patching there. " +
"Rate-limit on cloakbrowser before reCAPTCHA: each call emits CDP traffic that behavioural scorers count.",
{
target_id: z.string().describe("Target ID from interceptor_browser_launch or interceptor_camoufox_launch"),
script_path: z.string().describe("Absolute path to a .js file. File body is the function body; use `return` to send a value back."),
args: z.record(z.unknown()).optional().describe("Optional JSON-serialisable args object, available inside the script as `__args`."),
world: z.enum(["isolated", "main"]).optional().default("isolated")
.describe("`isolated` (default) or `main`. Main world only works on camoufox with `main_world_eval: true`."),
value_max_chars: z.number().optional().default(HARD_VALUE_CAP_CHARS)
.describe(`Max characters of the JSON-stringified return value (default: ${HARD_VALUE_CAP_CHARS}).`),
},
async ({ target_id, script_path, args, world, value_max_chars }) => {
try {
if (!isAbsolute(script_path)) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `script_path must be absolute: '${script_path}'` }) }] };
}
const source = await readFile(script_path, "utf-8");
const page = await getPageForTarget(target_id);
const isCamoufox = isCamoufoxTarget(target_id);

if (world === "main") {
if (!isCamoufox) {
return { content: [{ type: "text", text: JSON.stringify({
status: "error",
error: "world: 'main' is only supported on camoufox targets. On cloakbrowser, use interceptor_browser_inject_init_script for main-world patching.",
}) }] };
}
const entry = getEntry(target_id);
const mwEnabled = Boolean((entry.target.details as { main_world_eval?: boolean } | undefined)?.main_world_eval);
if (!mwEnabled) {
return { content: [{ type: "text", text: JSON.stringify({
status: "error",
error: "Camoufox target launched without main_world_eval=true; main-world evaluate is disabled. Relaunch with `main_world_eval: true`.",
}) }] };
}
}

const argsLiteral = JSON.stringify(args ?? {});
const fnExpr = `((__args) => { ${source}\n })(${argsLiteral})`;
const pageFunction = world === "main" && isCamoufox ? `mw:${fnExpr}` : fnExpr;

const result = await page.evaluate(pageFunction);
const serialised = result === undefined ? "" : JSON.stringify(result);
const capped = capValue(serialised, Math.max(0, Math.min(HARD_VALUE_CAP_CHARS, Math.trunc(value_max_chars ?? HARD_VALUE_CAP_CHARS))));

return {
content: [{
type: "text",
text: truncateResult({
status: "success",
target_id,
world,
backend: isCamoufox ? "camoufox" : "cloakbrowser",
value: capped.value,
value_length: capped.valueLength,
value_truncated: capped.truncated,
value_max_chars: capped.maxChars,
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);

server.tool(
"interceptor_browser_inject_init_script",
"Inject a JS file as an init script (Playwright `page.addInitScript`). " +
"Runs before any page script on every subsequent navigation/frame, in the isolated world. " +
"Safest stealth primitive on cloakbrowser — no DOM artifact, no `Function.toString` leak. " +
"On camoufox the script runs in the privileged Juggler scope and does NOT patch the page's main " +
"world (see camoufox#48); use the camoufox-add_init_script WebExtension at launch time for main-world patching. " +
"Does NOT affect the currently loaded document — navigate again to apply.",
{
target_id: z.string().describe("Target ID from interceptor_browser_launch or interceptor_camoufox_launch"),
script_path: z.string().describe("Absolute path to a .js file to inject before page scripts on every load."),
},
async ({ target_id, script_path }) => {
try {
if (!isAbsolute(script_path)) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `script_path must be absolute: '${script_path}'` }) }] };
}
const source = await readFile(script_path, "utf-8");
const page = await getPageForTarget(target_id);
await page.addInitScript({ content: source });
const isCamoufox = isCamoufoxTarget(target_id);
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
target_id,
backend: isCamoufox ? "camoufox" : "cloakbrowser",
bytes: source.length,
note: isCamoufox
? "Camoufox: init script runs in privileged Juggler scope and will NOT patch the page's main world (camoufox#48)."
: "Applies on next navigation/frame, not the current document.",
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);

server.tool(
"interceptor_browser_add_script_tag",
"Append a <script> element to the current page (Playwright `page.addScriptTag`). " +
"WARNING: injects a real DOM node visible to MutationObserver, document.scripts, and CSP. " +
"Avoid for anti-bot stealth — prefer interceptor_browser_inject_init_script (cloakbrowser) or " +
"interceptor_browser_evaluate with world='main' (camoufox + main_world_eval=true).",
{
target_id: z.string().describe("Target ID from interceptor_browser_launch or interceptor_camoufox_launch"),
script_path: z.string().describe("Absolute path to a .js file to inject as <script>."),
script_type: z.enum(["classic", "module"]).optional().default("classic")
.describe("`classic` (default) or `module`."),
},
async ({ target_id, script_path, script_type }) => {
try {
if (!isAbsolute(script_path)) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `script_path must be absolute: '${script_path}'` }) }] };
}
const source = await readFile(script_path, "utf-8");
const page = await getPageForTarget(target_id);
await page.addScriptTag({ content: source, type: script_type === "module" ? "module" : undefined });
return {
content: [{
type: "text",
text: JSON.stringify({
status: "success",
target_id,
backend: isCamoufoxTarget(target_id) ? "camoufox" : "cloakbrowser",
bytes: source.length,
script_type,
warning: "DOM-visible injection. Detectable by MutationObserver/document.scripts/CSP.",
}),
}],
};
} catch (e) {
return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] };
}
},
);
}
Loading
Loading