diff --git a/.gitignore b/.gitignore index ac6e5a8..9a7fac2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .proxy-mcp/ .playwright-mcp/ *.tgz +.mcp.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9113f04..f309456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 2.0.0 + +### Breaking Changes + +- **Browser stack swap: `chrome-launcher` + CDP → `cloakbrowser` + Playwright.** Stealth-patched Chromium with source-level C++ fingerprint patches replaces the hand-rolled stealth script + `chrome-devtools-mcp` sidecar. `humanize: true` on by default. +- **Tools renamed.** All `interceptor_chrome_*` tools are now `interceptor_browser_*`. The 14 `interceptor_chrome_devtools_*` tools are collapsed onto 9 Playwright-driven equivalents: + - `interceptor_chrome_launch` → `interceptor_browser_launch` + - `interceptor_chrome_close` → `interceptor_browser_close` + - `interceptor_chrome_navigate` → `interceptor_browser_navigate` + - `interceptor_chrome_devtools_{snapshot,screenshot,list_console,list_cookies,get_cookie,list_storage_keys,get_storage_value,list_network_fields,get_network_field}` → `interceptor_browser_*` +- **Tools removed.** `interceptor_chrome_cdp_info`, `interceptor_chrome_devtools_{pull_sidecar,attach,detach,navigate,list_network}` are gone. There is no CDP surface and no session-binding step — tools take `target_id` directly. Network listing is now sourced from MITM proxy capture (always on). +- **Resources renamed.** `proxy://chrome/primary` → `proxy://browser/primary`, `proxy://chrome/targets` → `proxy://browser/targets`. `proxy://chrome/devtools/sessions` and the `proxy://chrome/{target_id}/cdp` template are removed. +- **Tool count: 77 → 71.** + +### New Features + +- **Locator-based `humanizer_click`.** No more guessing pixel coordinates. Accepts `selector` (CSS/XPath), `role` + `name`, `text`, or `label`. Auto-waits for visible + enabled + stable + in-view before clicking. Falls back to raw `x, y` if no locator is given. +- **ARIA snapshots.** `interceptor_browser_snapshot` returns a YAML-formatted role tree (via Playwright `locator.ariaSnapshot`), purpose-built for LLM page understanding. +- **Buffered console logging.** `interceptor_browser_list_console` reads from a per-target in-memory buffer populated by Playwright's `page.on("console", ...)` — no session binding needed. + +### Dependencies + +- Added: `cloakbrowser@^0.3.24`, `playwright-core@^1.59`. +- Removed: `chrome-launcher`, `chrome-devtools-mcp` (dynamic). +- Node requirement raised to `>=20` (cloakbrowser). + +### Migration + +- Replace `interceptor_chrome_launch` calls with `interceptor_browser_launch` (drop `browser` variant arg; cloakbrowser is the only browser). +- Replace the attach → call → detach pattern from the old sidecar flow with direct `target_id` parameters. +- CDP-specific fields in `details` (`port`, `cdpHttpUrl`, etc.) are gone; targets expose `url`, `headless`, `humanize`, etc. +- Custom stealth script injection is redundant — cloakbrowser handles it at the C++ level. + ## 1.2.0 ### New Features diff --git a/DEMO.md b/DEMO.md index 2239ed4..622fd0b 100644 --- a/DEMO.md +++ b/DEMO.md @@ -23,8 +23,10 @@ You are running an interactive demo of **proxy-mcp** for the user's team. 5. **When the user picks cleanup** (or all demos are done), run the Cleanup sequence and deliver the Finale summary. -**Error handling:** If a step fails (e.g. Chrome not installed), explain what *would* -have happened and return to the menu. Never stop the demo on a failure. +**Error handling:** If a step fails (e.g. cloakbrowser binary not yet downloaded), +explain what *would* have happened and return to the menu. Never stop the demo on +a failure. First `interceptor_browser_launch` call may take 30–60 s while the +~200 MB stealth Chromium binary downloads. **Tool prefix:** All tools are from the `proxy` MCP server — call them as `mcp__proxy__`. @@ -37,7 +39,7 @@ Present these options to the user: | # | Demo | One-liner | |---|------|-----------| -| A | **Chrome Interception** | Launch Chrome through the proxy, capture & inspect HTTPS traffic | +| A | **Browser Interception** | Launch cloakbrowser through the proxy, capture & inspect HTTPS traffic | | B | **Mock API Responses** | Return fake JSON for any URL pattern, test with curl | | C | **Header Injection** | Add custom headers to all requests in real-time | | D | **Body Modification** | Find-and-replace inside response bodies in-flight | @@ -61,51 +63,47 @@ Then present the menu. --- -## Demo A: Chrome Interception +## Demo A: Browser Interception -**Say:** "Launching Chrome with proxy flags and certificate trust auto-configured — -zero manual setup." +**Say:** "Launching cloakbrowser (stealth Chromium) with proxy flags, CA trust, +and humanize mode all auto-configured — zero manual setup." **Steps:** 1. Call `interceptor_list` with `{}` — Show available interceptors -2. Call `interceptor_chrome_launch` with `{"url": "https://example.com"}` - — Chrome launches with --proxy-server and SPKI cert trust flags +2. Call `interceptor_browser_launch` with `{"url": "https://example.com"}` + — cloakbrowser launches with proxy + SPKI cert trust, Playwright-driven -3. Call `interceptor_chrome_cdp_info` with `{"target_id": "", "include_targets": false}` - — Show CDP endpoints for Playwright/DevTools attachment +3. Call `interceptor_browser_navigate` with + `{"target_id": "", "url": "https://example.com", "wait_for_proxy_capture": true}` + — Navigate via Playwright `page.goto` with proxy-capture verification -4. Call `interceptor_chrome_devtools_attach` with `{"target_id": ""}` - — Start a bound chrome-devtools-mcp sidecar session tied to this exact Chrome instance + Optionally mention: current page state is exposed as an MCP resource at + `proxy://browser/primary`. -5. Call `interceptor_chrome_devtools_navigate` with - `{"devtools_session_id":"","url":"https://example.com","wait_for_proxy_capture":true}` - — Navigate with cross-instance safety and proxy-capture verification +4. Call `interceptor_browser_snapshot` with `{"target_id": ""}` + — ARIA role tree snapshot (great for LLM reasoning) - Optionally mention: the same info is also available as an MCP resource at `proxy://chrome/primary` - (and per-target via the `proxy://chrome/{target_id}/cdp` resource template). - -6. Wait 4 seconds (`sleep 4` via Bash) for the page to load - -7. Call `proxy_list_traffic` with `{"limit": 20}` +5. Call `proxy_list_traffic` with `{"limit": 20}` — Show captured HTTPS exchanges -8. Call `proxy_search_traffic` with `{"query": "example.com", "limit": 5}` +6. Call `proxy_search_traffic` with `{"query": "example.com", "limit": 5}` — Search the captured traffic -9. Pick the **first exchange ID** from results, then call +7. Pick the **first exchange ID** from results, then call `proxy_get_exchange` with `{"exchange_id": ""}` — Full request/response deep-dive **Say:** "We captured {count} HTTPS exchanges from one page load. You get full headers, sizes, timing, TLS fingerprints, and body previews (preview size is -capped). Chrome trusted our CA via the SPKI fingerprint flag, so no certificate -warnings." +capped). The browser trusted our CA via the SPKI fingerprint flag, so no +certificate warnings. Source-level stealth patches mean `navigator.webdriver` +is false and ja3n/ja4 match real Chrome." -**If Chrome is not available:** Explain that the interceptor also supports Chromium, -Brave, and Edge. Fall back to spawning curl instead: +**If cloakbrowser binary isn't ready:** First launch takes 30–60 s to download. +Fall back to spawning curl instead: - Call `interceptor_spawn` with `{"command": "curl", "args": ["-s", "https://example.com"]}` - Wait 2 seconds, then call `proxy_list_traffic` with `{"limit": 10}` - Pick an exchange and call `proxy_get_exchange` @@ -285,7 +283,7 @@ Return to menu. **Say:** "Cleaning up — shutting down all interceptors and stopping the proxy." 1. Call `interceptor_deactivate_all` with `{}` - — Kill all Chrome instances and spawned processes + — Close all browser instances and spawned processes 2. Call `proxy_clear_traffic` with `{}` — Wipe captured traffic @@ -322,4 +320,4 @@ After cleanup, deliver this summary: - Request forwarding and connection dropping - Per-host proxy routing -**Stats:** 73 tools, 8 resources, 4 resource templates, 5 interceptor types. +**Stats:** 71 tools, 6 resources, 3 resource templates, 5 interceptor types. diff --git a/README.md b/README.md index c0401e2..1700eca 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # proxy-mcp -proxy-mcp is an MCP server that runs an explicit HTTP/HTTPS MITM proxy (L7). It captures requests/responses, lets you modify traffic in-flight (headers/bodies/mock/forward/drop), supports upstream proxy chaining, and records TLS fingerprints for connections to the proxy (JA3/JA4) plus optional upstream server JA3S. It also ships "interceptors" to route Chrome, CLI tools, Docker containers, and Android devices/apps through the proxy. +proxy-mcp is an MCP server that runs an explicit HTTP/HTTPS MITM proxy (L7). It captures requests/responses, lets you modify traffic in-flight (headers/bodies/mock/forward/drop), supports upstream proxy chaining, and records TLS fingerprints for connections to the proxy (JA3/JA4) plus optional upstream server JA3S. Ships "interceptors" to route a stealth browser (cloakbrowser, source-patched Chromium), CLI tools, Docker containers, and Android devices/apps through the proxy, plus Playwright-driven browser automation with locator-based click, typing, scroll, and ARIA snapshots. -81 tools + 8 resources + 4 resource templates. Built on [mockttp](https://github.com/httptoolkit/mockttp). +71 tools + 6 resources + 3 resource templates. Built on [mockttp](https://github.com/httptoolkit/mockttp) and [cloakbrowser](https://cloakbrowser.dev/). ## Table of Contents @@ -18,10 +18,10 @@ proxy-mcp is an MCP server that runs an explicit HTTP/HTTPS MITM proxy (L7). It - [Traffic Capture](#traffic-capture-4) - [Modification Shortcuts](#modification-shortcuts-3) - [TLS Fingerprinting](#tls-fingerprinting-9) - - [Interceptors](#interceptors-18) - - [DevTools Bridge](#devtools-bridge-14) + - [Interceptors](#interceptors-17) + - [Browser DevTools-equivalents](#browser-devtools-equivalents-9) - [Sessions](#sessions-13) - - [Humanizer](#humanizer--cdp-input-5) + - [Humanizer](#humanizer--playwright-input-5) - [Resources](#resources) - [Usage Example](#usage-example) - [Architecture](#architecture) @@ -124,22 +124,23 @@ Use the returned `port` and endpoint `http://127.0.0.1:`. ### 2) Browser setup (recommended: interceptor) -Use the Chrome interceptor so proxy flags and cert trust are configured automatically: +Use the browser interceptor so proxy flags and cert trust are configured automatically. Launches [cloakbrowser](https://cloakbrowser.dev/) — a stealth-patched Chromium with source-level C++ fingerprint patches and humanize mode on by default: ```bash -interceptor_chrome_launch --url "https://example.com" +interceptor_browser_launch --url "https://example.com" ``` -Then bind DevTools safely to that same target: +Drive the page with Playwright-backed tools (no CDP, no sidecar — `target_id` is all you need): ```bash -interceptor_chrome_devtools_attach --target_id "chrome_" -interceptor_chrome_devtools_navigate --devtools_session_id "devtools_" --url "https://apify.com" +interceptor_browser_navigate --target_id "browser_" --url "https://apify.com" +interceptor_browser_snapshot --target_id "browser_" +interceptor_browser_screenshot --target_id "browser_" --file_path "/tmp/shot.png" ``` ### 3) Browser setup (manual fallback) -If launching Chrome manually, pass proxy flag yourself: +If launching a browser manually, pass the proxy flag yourself: ```bash google-chrome --proxy-server="http://127.0.0.1:" @@ -209,16 +210,10 @@ proxy_search_traffic --query "example.com" ``` Common issues: -- Traffic from the wrong browser instance (fix: use `interceptor_chrome_devtools_attach`) +- Traffic from the wrong browser instance (fix: always pass `target_id` from `interceptor_browser_launch`) - HTTPS cert trust missing on target - `NO_PROXY` bypassing expected hosts -- `chrome-devtools-mcp` not installed (`ENOENT`): `interceptor_chrome_devtools_attach` falls back to navigation-only mode. Install `chrome-devtools-mcp` for full snapshot/network/console/screenshot support. - -Pull/install sidecar directly from MCP: - -```bash -interceptor_chrome_devtools_pull_sidecar --version "0.2.2" -``` +- First launch is slow: cloakbrowser downloads a ~200 MB stealth Chromium binary on first use (cached afterwards) ### 7) HAR import + replay @@ -253,9 +248,9 @@ Note: imported HAR entries (and entries created by `proxy_replay_session`) do no - Can add, overwrite, or delete HTTP headers; outgoing header **order** can be controlled via fingerprint spoofing - Returns its own CA certificate — does **not** expose upstream server certificate chains -### TLS ClientHello Passthrough (Chrome via interceptor) +### TLS ClientHello Passthrough (browser via interceptor) -When Chrome is launched via `interceptor_chrome_launch`, proxy-mcp forwards Chrome's **original TLS ClientHello** to the upstream server for document loads and same-origin sub-resource requests. The target server sees an authentic Chrome TLS fingerprint — not the proxy's. +When cloakbrowser is launched via `interceptor_browser_launch`, proxy-mcp forwards the browser's **original TLS ClientHello** to the upstream server for document loads and same-origin sub-resource requests. The target server sees an authentic Chrome TLS fingerprint — not the proxy's. This is a key difference from typical MITM proxies (mitmproxy, Charles, Fiddler) which re-terminate TLS with their own fingerprint, making MITM trivially detectable by anti-bot systems via JA3/JA4 analysis. @@ -273,55 +268,35 @@ proxy_list_tls_fingerprints --hostname_filter "example.com" | Traffic source | TLS behavior | Action needed | |---|---|---| -| Chrome via `interceptor_chrome_launch` (document loads, same-origin) | Chrome's native ClientHello forwarded (passthrough) | None — fingerprint is authentic | -| Chrome via `interceptor_chrome_launch` (cross-origin sub-resources, when spoof active) | Re-issued via impit with spoofed TLS | `proxy_set_fingerprint_spoof` with a browser preset | +| cloakbrowser via `interceptor_browser_launch` (document loads, same-origin) | Browser's native ClientHello forwarded (passthrough) | None — fingerprint is authentic | +| cloakbrowser via `interceptor_browser_launch` (cross-origin sub-resources, when spoof active) | Re-issued via impit with spoofed TLS | `proxy_set_fingerprint_spoof` with a browser preset | | Non-browser clients (curl, Python, `interceptor_spawn`) | Proxy's own TLS | `proxy_set_fingerprint_spoof` or `proxy_set_ja3_spoof` required | | HAR replay (`proxy_replay_session`) | Proxy's own TLS | `proxy_set_fingerprint_spoof` required | -### Pairs well with CDP/Playwright +### Built on cloakbrowser + Playwright -Use CDP/Playwright for browser internals (DOM, JS execution, localStorage, cookie jar), and proxy-mcp for wire-level capture/manipulation + replay. They complement each other: +Browser automation uses [cloakbrowser](https://cloakbrowser.dev/) — a stealth-patched Chromium with source-level C++ fingerprint patches — driven via Playwright. There is no CDP surface, no sidecar, no hand-rolled stealth script. One `target_id` from `interceptor_browser_launch` is everything downstream tools need. -| Capability | CDP / Playwright | proxy-mcp | -|---|---|---| -| See/modify DOM, run JS in page | Yes | No | -| Read cookies, localStorage, sessionStorage | Yes (browser internals) | Yes for proxy-launched Chrome via DevTools Bridge list/get tools; for any client, sees Cookie/Set-Cookie headers on the wire | -| Capture HTTP request/response bodies | Yes for browser requests (protocol/size/streaming caveats) | Body previews only (4 KB cap, 1000-entry ring buffer) | -| Modify requests in-flight (headers, body, mock, drop) | Via route/intercept handlers | Yes (declarative rules, hot-reload) | -| Upstream proxy chaining (geo, auth) | Single browser via `--proxy-server` | Global + per-host upstreams across all clients (SOCKS4/5, HTTP, HTTPS, PAC) | -| TLS fingerprint capture (JA3/JA4/JA3S) | No | Yes | -| JA3 + HTTP/2 fingerprint spoofing | No | Proxy-side only (impit re-issues matching requests with spoofed TLS 1.3, HTTP/2 frames, and header order; does not alter the client's TLS handshake) | -| Intercept non-browser traffic (curl, Python, Android apps) | No | Yes (interceptors) | -| Human-like mouse/keyboard/scroll input | Via Playwright `page.mouse`/`page.keyboard` (instant, detectable timing) | Yes — CDP humanizer with Bezier curves, Fitts's law, WPM typing, eased scrolling | - -A typical combo: launch Chrome via `interceptor_chrome_launch` (routes through proxy automatically), drive pages with Playwright/CDP, and use proxy-mcp to capture the wire traffic, inject headers, or spoof JA3 — all in the same session. For behavioral realism, use `humanizer_*` tools instead of Playwright's instant `page.click()`/`page.type()` — they dispatch human-like CDP `Input.*` events with natural timing curves. +| 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`) | +| 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) | +| Upstream proxy chaining (geo, auth) | Global + per-host upstreams across all clients (SOCKS4/5, HTTP, HTTPS, PAC) | +| TLS fingerprint capture (JA3/JA4/JA3S) | Yes | +| JA3 + HTTP/2 fingerprint spoofing | Proxy-side (impit re-issues matching requests with spoofed TLS 1.3, HTTP/2 frames, and header order) | +| Intercept non-browser traffic (curl, Python, Android apps) | Yes (interceptors) | +| Human-like mouse/keyboard/scroll input | `humanizer_*` tools: Bezier curves + Fitts's law for mouse, WPM + bigram + typo model for typing, eased wheel scroll — layered on top of cloakbrowser's built-in humanize mode | +| Locator-based interaction | `humanizer_click` accepts CSS/XPath selector, ARIA role + name, visible text, or form label — no pixel guessing | -**Attach Playwright to proxy-launched Chrome:** +**Standard flow:** 1. Call `proxy_start` -2. Call `interceptor_chrome_launch` -3. Read `proxy://chrome/primary` (or call `interceptor_chrome_cdp_info`) to get `cdp.httpUrl` (Playwright) and `cdp.browserWebSocketDebuggerUrl` (raw CDP clients) -4. In Playwright: - ```ts - import { chromium } from "playwright"; - const browser = await chromium.connectOverCDP("http://127.0.0.1:"); - ``` - -**Proxy-safe built-in CDP flow (single-instance safe):** - -1. Call `proxy_start` -2. Call `interceptor_chrome_launch` -3. Call `interceptor_chrome_devtools_attach` with that `target_id` -4. Call `interceptor_chrome_devtools_navigate` with `devtools_session_id` -5. Call `proxy_search_traffic --query ""` to confirm capture - -**Human-like input flow (bypasses bot detection):** - -1. Call `proxy_start` -2. Optionally enable fingerprint spoofing: `proxy_set_fingerprint_spoof --preset chrome_136` -3. Call `interceptor_chrome_launch --url "https://example.com"` (stealth mode auto-enabled when spoofing) -4. Use `humanizer_move` / `humanizer_click` / `humanizer_type` / `humanizer_scroll` with the `target_id` -5. Use `humanizer_idle` between actions to maintain natural presence +2. Optionally enable outbound fingerprint spoofing for cross-origin sub-resources: `proxy_set_fingerprint_spoof --preset chrome_136` +3. Call `interceptor_browser_launch --url "https://example.com"` +4. Drive the page: `interceptor_browser_navigate`, `interceptor_browser_snapshot`, `humanizer_click --selector "..."`, `humanizer_type --text "..."` +5. Inspect traffic: `proxy_search_traffic --query ""` ## Tools Reference @@ -396,9 +371,9 @@ proxy_test_rule_match --mode exchange --exchange_id "ex_abc123" | `proxy_list_fingerprint_presets` | List available browser fingerprint presets (e.g. `chrome_131`, `chrome_136`, `chrome_136_linux`, `firefox_133`) | | `proxy_check_fingerprint_runtime` | Check fingerprint spoofing backend readiness | -Fingerprint spoofing works by re-issuing the request from the proxy via impit (native Rust TLS/HTTP2 impersonation via rustls). TLS 1.3 and HTTP/2 fingerprints (SETTINGS, WINDOW_UPDATE, PRIORITY frames) match real browsers by construction. The origin server sees the proxy's spoofed TLS, HTTP/2, and header order — not the original client's. When a `user_agent` is set (including via presets), proxy-mcp also normalizes Chromium UA Client Hints headers (`sec-ch-ua*`) to match the spoofed User-Agent (forwarding contradictory hints is a common bot signal). **Chrome browser exception:** when Chrome is launched via `interceptor_chrome_launch`, document loads and same-origin requests use Chrome's native TLS (no impit), preserving fingerprint consistency for bot detection challenges. Only cross-origin sub-resource requests are re-issued with spoofed TLS. Non-browser clients (curl, spawn, HAR replay) get full TLS + UA spoofing on all requests. Use `proxy_set_fingerprint_spoof` with a browser preset for one-command setup. `proxy_set_ja3_spoof` is kept for backward compatibility but custom JA3 strings are ignored (the preset's impit browser target is used instead). JA4 fingerprints are captured (read-only) but spoofing is not supported. +Fingerprint spoofing works by re-issuing the request from the proxy via impit (native Rust TLS/HTTP2 impersonation via rustls). TLS 1.3 and HTTP/2 fingerprints (SETTINGS, WINDOW_UPDATE, PRIORITY frames) match real browsers by construction. The origin server sees the proxy's spoofed TLS, HTTP/2, and header order — not the original client's. When a `user_agent` is set (including via presets), proxy-mcp also normalizes Chromium UA Client Hints headers (`sec-ch-ua*`) to match the spoofed User-Agent (forwarding contradictory hints is a common bot signal). **Browser exception:** when cloakbrowser is launched via `interceptor_browser_launch`, document loads and same-origin requests use the browser's native TLS (no impit), preserving fingerprint consistency for bot detection challenges. Only cross-origin sub-resource requests are re-issued with spoofed TLS. Non-browser clients (curl, spawn, HAR replay) get full TLS + UA spoofing on all requests. Use `proxy_set_fingerprint_spoof` with a browser preset for one-command setup. `proxy_set_ja3_spoof` is kept for backward compatibility but custom JA3 strings are ignored (the preset's impit browser target is used instead). JA4 fingerprints are captured (read-only) but spoofing is not supported. -### Interceptors (18) +### Interceptors (17) Interceptors configure targets (browsers, processes, devices, containers) to route their traffic through the proxy automatically. @@ -410,18 +385,15 @@ Interceptors configure targets (browsers, processes, devices, containers) to rou | `interceptor_status` | Detailed status of a specific interceptor | | `interceptor_deactivate_all` | Emergency cleanup: kill all active interceptors across all types | -#### Chrome (4) +#### Browser (3) | Tool | Description | |------|-------------| -| `interceptor_chrome_launch` | Launch Chrome/Chromium/Brave/Edge with proxy flags and SPKI cert trust | -| `interceptor_chrome_cdp_info` | Get CDP endpoints (HTTP + WebSocket) and tab targets for a launched Chrome | -| `interceptor_chrome_navigate` | Navigate a tab via the launched Chrome target's CDP page WebSocket and verify proxy capture | -| `interceptor_chrome_close` | Close a Chrome instance by target ID | - -Launches with isolated temp profile, auto-cleaned on close. Supports `chrome`, `chromium`, `brave`, `edge`. +| `interceptor_browser_launch` | Launch cloakbrowser (stealth Chromium) with proxy flags, SPKI cert trust, built-in humanize mode | +| `interceptor_browser_navigate` | Navigate the bound page via Playwright `page.goto` and verify proxy capture | +| `interceptor_browser_close` | Close a browser instance by target ID | -When fingerprint spoofing is active (`proxy_set_fingerprint_spoof`), Chrome launches in **stealth mode**: chrome-launcher's default flags that create detectable artifacts (e.g. `--disable-extensions` removing `chrome.runtime`) are replaced with a curated minimal set, and anti-detection patches are injected via CDP before any page scripts run. This covers `navigator.webdriver`, `chrome.runtime` presence, `Permissions.query`, and Error stack sanitization. Chrome keeps its **real User-Agent** (no UA override) so that bot detection JS (Kasada, Akamai) sees browser capabilities matching the actual Chrome version. Same-origin sub-resource requests also bypass impit to maintain TLS fingerprint consistency within each domain — only cross-origin requests are re-issued with spoofed TLS. +Stealth is source-level: cloakbrowser ships 48+ C++ patches so ja3n/ja4/akamai match real Chrome, `navigator.webdriver` is false, audio/canvas/WebGL fingerprints match real hardware. No JS stealth injection needed. First launch downloads a ~200 MB Chromium binary (cached afterwards). #### Terminal / Process (2) @@ -462,29 +434,23 @@ 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. -### DevTools Bridge (14) +### Browser DevTools-equivalents (9) -Proxy-safe wrappers around a managed `chrome-devtools-mcp` sidecar, bound to a specific `interceptor_chrome_launch` target. +Playwright-driven tools for the browser target. Each takes a `target_id` directly — no session binding, no sidecar. | Tool | Description | |------|-------------| -| `interceptor_chrome_devtools_pull_sidecar` | Install/pull `chrome-devtools-mcp` so full DevTools bridge actions are available | -| `interceptor_chrome_devtools_attach` | Start a bound DevTools sidecar session for one Chrome interceptor target | -| `interceptor_chrome_devtools_navigate` | Navigate via bound DevTools session and verify matching proxy traffic | -| `interceptor_chrome_devtools_snapshot` | Get accessibility snapshot from bound DevTools session | -| `interceptor_chrome_devtools_list_network` | List network requests from bound DevTools session | -| `interceptor_chrome_devtools_list_console` | List console messages from bound DevTools session | -| `interceptor_chrome_devtools_screenshot` | Capture screenshot from bound DevTools session | -| `interceptor_chrome_devtools_list_cookies` | Token-efficient cookie listing with filters, pagination, and truncated value previews | -| `interceptor_chrome_devtools_get_cookie` | Get one cookie by `cookie_id` (value is capped to keep output bounded) | -| `interceptor_chrome_devtools_list_storage_keys` | Token-efficient localStorage/sessionStorage key listing with pagination and value previews | -| `interceptor_chrome_devtools_get_storage_value` | Get one storage value by `item_id` | -| `interceptor_chrome_devtools_list_network_fields` | Token-efficient header field listing from proxy-captured traffic since session creation | -| `interceptor_chrome_devtools_get_network_field` | Get one full header field value by `field_id` | -| `interceptor_chrome_devtools_detach` | Close one bound DevTools sidecar session | - -Note: image payloads from DevTools responses are redacted from MCP output to avoid pushing large base64 blobs into context. -If `file_path` is provided for screenshot and sidecar returns the image inline, proxy-mcp writes it to disk in the wrapper. +| `interceptor_browser_snapshot` | ARIA/role YAML snapshot of the page (or selector subtree) — optimized for LLM page reasoning | +| `interceptor_browser_screenshot` | Screenshot. Writes to `file_path` if provided; otherwise reports byte count only | +| `interceptor_browser_list_console` | Buffered console messages since launch, with type/text filters and pagination | +| `interceptor_browser_list_cookies` | Cookie listing with filters, pagination, truncated value previews | +| `interceptor_browser_get_cookie` | Get one cookie by `cookie_id` (value is capped to keep output bounded) | +| `interceptor_browser_list_storage_keys` | localStorage/sessionStorage key listing with value previews | +| `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` | + +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. ### Sessions (13) @@ -510,19 +476,19 @@ Persistent, queryable on-disk capture for long runs and post-crash analysis. Note on `proxy_start` with `persistence_enabled: true`: this auto-creates a session. A subsequent `proxy_session_start()` call returns the existing active session instead of failing — no need to stop and re-start. -### Humanizer — CDP Input (5) +### Humanizer — Playwright Input (5) -Human-like browser input via Chrome DevTools Protocol. Dispatches `Input.*` events with realistic timing, Bezier mouse paths, and natural keystroke delays. Binds to `target_id` (Chrome interceptor target) — manages its own persistent CdpSession per target, independent of the DevTools Bridge sidecar. +Human-like browser input via Playwright `page.mouse` / `page.keyboard`, layered on top of cloakbrowser's built-in humanize mode. Binds to `target_id` from `interceptor_browser_launch`. | Tool | Description | |------|-------------| | `humanizer_move` | Move mouse along a Bezier curve with Fitts's law velocity scaling and eased timing | -| `humanizer_click` | Move to element (CSS selector) or coordinates, then click with human-like timing. Supports left/right/middle button and multi-click | +| `humanizer_click` | Click a locator (`selector` / `role` + `name` / `text` / `label`) or raw `x,y`. Auto-waits for visible + enabled + stable + in-view before clicking | | `humanizer_type` | Type text with per-character delays modeled on WPM, bigram frequency, shift penalty, word pauses, and optional typo injection | | `humanizer_scroll` | Scroll with easeInOutQuad acceleration/deceleration via multiple wheel events | | `humanizer_idle` | Simulate idle behavior with mouse micro-jitter and occasional micro-scrolls to defeat idle detection | -All tools require `target_id` from a prior `interceptor_chrome_launch`. The engine maintains tracked mouse position across calls, so `humanizer_move` followed by `humanizer_click` produces a continuous path. +All tools require `target_id` from a prior `interceptor_browser_launch`. The engine maintains tracked mouse position across calls, so `humanizer_move` followed by `humanizer_click` produces a continuous path. **Behavioral details:** - **Mouse paths**: Cubic Bezier curves with random control points, Fitts's law distance/size scaling, optional overshoot + correction arc @@ -538,11 +504,9 @@ All tools require `target_id` from a prior `interceptor_chrome_launch`. The engi | `proxy://ca-cert` | CA certificate PEM | | `proxy://traffic/summary` | Traffic stats: method/status breakdown, top hostnames, TLS fingerprint stats | | `proxy://interceptors` | All interceptor metadata and activation status | -| `proxy://chrome/devtools/sessions` | Active DevTools sidecar sessions bound to Chrome target IDs | | `proxy://sessions` | Persistent session catalog + runtime persistence status | -| `proxy://chrome/primary` | CDP endpoints for the most recently launched Chrome instance | -| `proxy://chrome/targets` | CDP endpoints + tab targets for active Chrome instances | -| `proxy://chrome/{target_id}/cdp` | CDP endpoints for a specific Chrome instance (resource template) | +| `proxy://browser/primary` | Current page URL/title for the most recently launched browser instance | +| `proxy://browser/targets` | Current page state for all active browser instances | | `proxy://sessions/{session_id}/summary` | Aggregate stats for one recorded session (resource template) | | `proxy://sessions/{session_id}/timeline` | Time-bucketed request/error timeline (resource template) | | `proxy://sessions/{session_id}/findings` | Top errors/slow exchanges/host error rates (resource template) | @@ -560,7 +524,7 @@ proxy_session_start --capture_profile full --session_name "reverse-run-1" # Install CA cert on device (proxy_get_ca_cert) # Or use interceptors to auto-configure targets: -interceptor_chrome_launch # Launch Chrome with proxy +interceptor_browser_launch # Launch stealth browser with proxy interceptor_spawn --command curl --args '["https://example.com"]' # Spawn proxied process interceptor_android_activate --serial DEVICE_SERIAL # Android device @@ -579,17 +543,17 @@ proxy_search_traffic --query "error" # TLS fingerprinting proxy_list_tls_fingerprints # See unique JA3/JA4 fingerprints -proxy_set_ja3_spoof --ja3 "771,4865-..." # Spoof outgoing JA3 +proxy_set_ja3_spoof --ja3 "771,4865-..." # Spoof outgoing JA3 (for non-browser clients) proxy_set_fingerprint_spoof --preset chrome_136 --host_patterns '["example.com"]' # Full fingerprint spoof -interceptor_chrome_launch --url "https://example.com" # With spoof active → stealth mode auto-enabled proxy_list_fingerprint_presets # Available browser presets -# Human-like browser interaction (requires interceptor_chrome_launch target) -humanizer_move --target_id "chrome_" --x 500 --y 300 -humanizer_click --target_id "chrome_" --selector "#login-button" -humanizer_type --target_id "chrome_" --text "user@example.com" --wpm 45 -humanizer_scroll --target_id "chrome_" --delta_y 300 -humanizer_idle --target_id "chrome_" --duration_ms 2000 --intensity subtle +# Human-like browser interaction (requires interceptor_browser_launch target) +humanizer_move --target_id "browser_" --x 500 --y 300 +humanizer_click --target_id "browser_" --selector "#login-button" +humanizer_click --target_id "browser_" --role "button" --name "Sign in" +humanizer_type --target_id "browser_" --text "user@example.com" --wpm 45 +humanizer_scroll --target_id "browser_" --delta_y 300 +humanizer_idle --target_id "browser_" --duration_ms 2000 --intensity subtle # Query/export recorded session proxy_list_sessions @@ -606,7 +570,8 @@ proxy_export_har --session_id SESSION_ID - **TLS capture**: Client JA3/JA4 from mockttp socket metadata; server JA3S via `tls.connect` monkey-patch - **TLS spoofing**: impit (native Rust TLS/HTTP2 impersonation via rustls); in-process, no container needed - **Interceptors**: Managed by `InterceptorManager`, each type registers independently -- **Humanizer**: Singleton `HumanizerEngine` with persistent `CdpSession` per Chrome target, tracks mouse position across calls. Pure TypeScript — no external deps (Bezier paths, Fitts's law, bigram timing all computed internally) +- **Browser**: cloakbrowser (stealth Chromium, ~200 MB binary auto-downloaded on first launch) driven via Playwright `BrowserContext` / `Page` +- **Humanizer**: Singleton engine using Playwright's `page.mouse` / `page.keyboard`. Custom timing layer (Bezier paths, Fitts's law, bigram typing) feeds Playwright — sits on top of cloakbrowser's built-in `humanize: true` ## Testing @@ -614,7 +579,7 @@ proxy_export_har --session_id SESSION_ID npm test # All tests (unit + integration) npm run test:unit # Unit tests only npm run test:integration # Integration tests -npm run test:e2e # E2E fingerprint tests (requires Chrome + internet) +npm run test:e2e # E2E fingerprint tests (requires cloakbrowser + internet) ``` ## Credits @@ -626,7 +591,8 @@ npm run test:e2e # E2E fingerprint tests (requires Chrome + internet) | [mockttp](https://github.com/httptoolkit/mockttp) | MITM proxy engine, rule system, CA generation | | [impit](https://github.com/yfe404/impit) | Native TLS/HTTP2 fingerprint impersonation (Rust via NAPI-RS) | | [frida-js](https://github.com/AeonLucid/frida-js) | Pure-JS Frida client for Android instrumentation | -| [chrome-launcher](https://github.com/nicolo-ribaudo/chrome-launcher) | Chrome/Chromium process management | +| [cloakbrowser](https://cloakbrowser.dev/) | Stealth-patched Chromium with source-level C++ fingerprint patches | +| [playwright-core](https://playwright.dev/) | Browser automation API driving cloakbrowser | | [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | MCP server framework | ### Vendored Frida Scripts diff --git a/ROADMAP.md b/ROADMAP.md index ce0cad6..e087805 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,10 +28,9 @@ These are implemented and in the repo now: - `proxy://sessions/{session_id}/summary` - `proxy://sessions/{session_id}/timeline` - `proxy://sessions/{session_id}/findings` -- CDP discovery resources: - - `proxy://chrome/primary` - - `proxy://chrome/{target_id}/cdp` - - `proxy://chrome/targets` +- Browser discovery resources: + - `proxy://browser/primary` + - `proxy://browser/targets` ## Roadmap Principles @@ -162,22 +161,12 @@ Acceptance criteria: Effort: M Dependencies: replay engine, session indexing -### 6) CDP Convenience Bridge -Problem solved: -- Eliminate manual CDP attach translation for agent workflows. - -Planned interface additions: -- Tool: `proxy_chrome_attach_info` - -Functional requirements: -- Return attach-ready payload (HTTP URL + WS URL + snippet) -- Resolve from `proxy://chrome/primary` by default - -Acceptance criteria: -- Agent can attach Playwright from single tool call +### 6) ~~CDP Convenience Bridge~~ — OBSOLETED in 2.0.0 -Effort: S -Dependencies: current CDP resources (already shipped) +Superseded by the cloakbrowser + Playwright rewrite. The browser interceptor +now exposes a Playwright `Page` directly — no external CDP attach is needed. +If external CDP/Playwright attach becomes a use case again, a future tool +could launch cloakbrowser with `--remote-debugging-port` in `args`. ## Phase 3 (Reliability + Scale) diff --git a/package-lock.json b/package-lock.json index 57a0e71..1b740c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,12 @@ "version": "1.2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "chrome-launcher": "^1.1.2", + "cloakbrowser": "^0.3.24", "dockerode": "^4.0.4", "frida-js": "^0.4.0", "impit": "^0.13.0", "mockttp": "^3.17.0", + "playwright-core": "^1.59.1", "zod": "^3.25.0" }, "bin": { @@ -693,6 +694,18 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -1196,24 +1209,6 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, - "node_modules/chrome-launcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", - "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^2.0.1" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.cjs" - }, - "engines": { - "node": ">=12.13.0" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1228,6 +1223,41 @@ "node": ">=12" } }, + "node_modules/cloakbrowser": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/cloakbrowser/-/cloakbrowser-0.3.24.tgz", + "integrity": "sha512-RS1qLlL9EmzFezv+cwxZjlIsM8LxaVyk4+n3yzdHtN06Hz3bXPZoGEbajzpYomfbyUsPrLiCyI1T8xs9QAaPrw==", + "license": "MIT", + "dependencies": { + "tar": "^7.0.0" + }, + "bin": { + "cloakbrowser": "dist/cli.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "mmdb-lib": ">=2.0.0", + "playwright-core": ">=1.40.0", + "puppeteer-core": ">=21.0.0", + "socks-proxy-agent": ">=10.0.0" + }, + "peerDependenciesMeta": { + "mmdb-lib": { + "optional": true + }, + "playwright-core": { + "optional": true + }, + "puppeteer-core": { + "optional": true + }, + "socks-proxy-agent": { + "optional": true + } + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1721,18 +1751,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -2526,21 +2544,6 @@ "node": ">= 0.10" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2556,18 +2559,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2616,16 +2607,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/lighthouse-logger": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", - "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.4.1", - "marky": "^1.2.2" - } - }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -2659,12 +2640,6 @@ "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", "license": "MIT" }, - "node_modules/marky": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", - "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "license": "Apache-2.0" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2756,6 +2731,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3102,6 +3089,43 @@ "node": ">= 0.8.0" } }, + "node_modules/mockttp/node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/mockttp/node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mockttp/node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/mockttp/node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3398,6 +3422,18 @@ "node": ">=16.20.0" } }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -3805,20 +3841,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3957,6 +3979,22 @@ "node": ">=0.10.0" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -3985,6 +4023,15 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -4254,6 +4301,15 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index c8551a3..2682aeb 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "proxy-mcp", - "version": "1.2.0", + "version": "2.0.0", "description": "MCP server for HTTP/HTTPS MITM proxy via mockttp", "type": "module", + "engines": { + "node": ">=20" + }, "main": "dist/index.js", "bin": { "proxy-mcp": "dist/index.js" @@ -22,11 +25,12 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "chrome-launcher": "^1.1.2", + "cloakbrowser": "^0.3.24", "dockerode": "^4.0.4", "frida-js": "^0.4.0", "impit": "^0.13.0", "mockttp": "^3.17.0", + "playwright-core": "^1.59.1", "zod": "^3.25.0" }, "devDependencies": { diff --git a/scripts/smoke-browser.ts b/scripts/smoke-browser.ts new file mode 100644 index 0000000..b3ec60a --- /dev/null +++ b/scripts/smoke-browser.ts @@ -0,0 +1,108 @@ +/** + * Smoke test for the cloakbrowser + Playwright swap. + * + * Starts the mockttp proxy, launches cloakbrowser via the BrowserInterceptor, + * navigates to stealth-check sites, verifies traffic capture and + * navigator.webdriver == false. First run downloads the ~200 MB cloakbrowser + * binary (cached afterwards). + * + * Run with: node --import tsx/esm scripts/smoke-browser.ts + */ + +import { proxyManager } from "../src/state.js"; +import { interceptorManager } from "../src/interceptors/manager.js"; +import { initInterceptors } from "../src/interceptors/init.js"; +import { getPageForTarget } from "../src/browser/session.js"; + +function line(label = ""): void { + console.log(`\n── ${label} ${"─".repeat(Math.max(0, 60 - label.length))}`); +} + +async function main() { + initInterceptors(); + + line("1) start proxy"); + const start = await proxyManager.start(0, {}); + console.log(` port=${start.port} fp=${start.cert.fingerprint.slice(0, 32)}…`); + + line("2) launch cloakbrowser (first run downloads ~200 MB binary)"); + const launchResult = await interceptorManager.activate("browser", { + proxyPort: start.port, + certPem: start.cert.cert, + certFingerprint: start.cert.fingerprint, + url: "about:blank", + headless: false, + humanize: true, + }); + const targetId = launchResult.targetId; + console.log(` target_id=${targetId}`); + console.log(` details=`, launchResult.details); + + const page = getPageForTarget(targetId); + + line("3) navigate to bot.sannysoft.com"); + try { + const resp = await page.goto("https://bot.sannysoft.com/", { + waitUntil: "domcontentloaded", + timeout: 45_000, + }); + console.log(` http_status=${resp?.status() ?? "?"}`); + console.log(` url=${page.url()}`); + console.log(` title=${await page.title().catch(() => "?")}`); + } catch (e) { + console.log(` goto failed: ${(e as Error).message}`); + } + + // Give stealth panels time to run + await new Promise((r) => setTimeout(r, 3000)); + + line("4) check stealth properties"); + const stealth = await page.evaluate(() => ({ + webdriver: navigator.webdriver, + userAgent: navigator.userAgent, + chromeRuntime: typeof (window as unknown as { chrome?: { runtime?: unknown } }).chrome?.runtime, + platform: navigator.platform, + hardwareConcurrency: navigator.hardwareConcurrency, + languages: navigator.languages, + })).catch((e) => ({ error: (e as Error).message })); + console.log(" ", JSON.stringify(stealth, null, 2)); + + line("5) proxy capture"); + const traffic = proxyManager.getTraffic(); + console.log(` total_exchanges=${traffic.length}`); + const hosts = new Map(); + for (const t of traffic) { + hosts.set(t.request.hostname, (hosts.get(t.request.hostname) ?? 0) + 1); + } + const top = [...hosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); + for (const [h, n] of top) console.log(` ${n.toString().padStart(4)} ${h}`); + + line("6) TLS fingerprints captured"); + const withTls = traffic.filter((t) => t.tls?.client?.ja3Fingerprint).slice(0, 3); + for (const t of withTls) { + console.log(` ${t.request.hostname}`); + console.log(` ja3=${t.tls?.client?.ja3Fingerprint?.slice(0, 40)}…`); + console.log(` ja4=${t.tls?.client?.ja4Fingerprint ?? "?"}`); + } + + line("7) locator-based click test (duckduckgo search box)"); + try { + await page.goto("https://duckduckgo.com/", { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.locator('input[name="q"]').waitFor({ state: "visible", timeout: 10_000 }); + console.log(` search box located + waited for visible ✓`); + } catch (e) { + console.log(` locator test failed: ${(e as Error).message}`); + } + + line("8) shutdown"); + await interceptorManager.deactivate("browser", targetId); + await proxyManager.stop(); + console.log(" done"); + + setTimeout(() => process.exit(0), 500); +} + +main().catch((e) => { + console.error("FATAL:", e); + process.exit(1); +}); diff --git a/src/browser/session.ts b/src/browser/session.ts new file mode 100644 index 0000000..7b97496 --- /dev/null +++ b/src/browser/session.ts @@ -0,0 +1,28 @@ +/** + * Shared helpers for resolving a Playwright Page from a browser interceptor target ID. + * Used by humanizer and browser tools so they don't each re-walk the interceptor map. + */ + +import type { Page } from "playwright-core"; +import { interceptorManager } from "../interceptors/manager.js"; +import type { BrowserInterceptor, BrowserTargetEntry } from "../interceptors/browser.js"; + +function getBrowserInterceptor(): BrowserInterceptor { + const it = interceptorManager.get("browser") as BrowserInterceptor | undefined; + if (!it) throw new Error("Browser interceptor not registered."); + return it; +} + +export function getEntry(targetId: string): BrowserTargetEntry { + const entry = getBrowserInterceptor().getEntry(targetId); + if (!entry) throw new Error(`Browser target '${targetId}' not found. Is it still running?`); + return entry; +} + +export function getPageForTarget(targetId: string): Page { + const entry = getEntry(targetId); + if (entry.page.isClosed()) { + throw new Error(`Page for browser target '${targetId}' is closed.`); + } + return entry.page; +} diff --git a/src/cdp-utils.ts b/src/cdp-utils.ts deleted file mode 100644 index 44451b6..0000000 --- a/src/cdp-utils.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * CDP (Chrome DevTools Protocol) helpers. - * - * Used to expose ready-to-use endpoints for attaching Playwright/CDP clients - * to a Chrome instance launched by proxy-mcp. - */ - -export interface FetchJsonOptions { - timeoutMs?: number; -} - -interface CdpCommandErrorPayload { - code?: number; - message?: string; - data?: unknown; -} - -interface CdpCommandResponse { - id?: number; - result?: Record; - error?: CdpCommandErrorPayload; -} - -interface MinimalWebSocketEvent { - data?: unknown; -} - -interface MinimalWebSocket { - addEventListener(type: string, listener: (event: unknown) => void): void; - removeEventListener(type: string, listener: (event: unknown) => void): void; - close(code?: number, reason?: string): void; - send(data: string): void; -} - -interface MinimalWebSocketCtor { - new(url: string): MinimalWebSocket; -} - -export interface SendCdpCommandOptions { - timeoutMs?: number; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function errorToString(e: unknown): string { - if (e instanceof Error) return e.message; - if (typeof e === "string") return e; - try { - return JSON.stringify(e); - } catch { - return String(e); - } -} - -function getWebSocketCtor(): MinimalWebSocketCtor { - const ctor = (globalThis as unknown as { WebSocket?: unknown }).WebSocket; - if (typeof ctor !== "function") { - throw new Error("WebSocket is not available in this Node runtime. Use Node.js 22+."); - } - return ctor as MinimalWebSocketCtor; -} - -function messageDataToString(event: unknown): string { - const data = (event as MinimalWebSocketEvent | undefined)?.data ?? event; - - if (typeof data === "string") return data; - if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8"); - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8"); - } - if (data === null || data === undefined) return ""; - return String(data); -} - -export function getCdpBaseUrl(port: number): string { - return `http://127.0.0.1:${port}`; -} - -export function getCdpVersionUrl(port: number): string { - return `${getCdpBaseUrl(port)}/json/version`; -} - -export function getCdpTargetsUrl(port: number): string { - return `${getCdpBaseUrl(port)}/json/list`; -} - -export async function fetchJson(url: string, opts: FetchJsonOptions = {}): Promise { - const timeoutMs = opts.timeoutMs ?? 1000; - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const res = await fetch(url, { - method: "GET", - headers: { "accept": "application/json" }, - signal: controller.signal, - }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`HTTP ${res.status} from ${url}${text ? `: ${text.slice(0, 200)}` : ""}`); - } - - return await res.json() as T; - } finally { - clearTimeout(timer); - } -} - -export async function getCdpVersion(port: number, opts: FetchJsonOptions = {}): Promise> { - return await fetchJson>(getCdpVersionUrl(port), opts); -} - -export async function getCdpTargets(port: number, opts: FetchJsonOptions = {}): Promise>> { - return await fetchJson>>(getCdpTargetsUrl(port), opts); -} - -export interface WaitForCdpOptions { - timeoutMs?: number; - intervalMs?: number; - requestTimeoutMs?: number; -} - -export async function waitForCdpVersion(port: number, opts: WaitForCdpOptions = {}): Promise> { - const timeoutMs = opts.timeoutMs ?? 3000; - const intervalMs = opts.intervalMs ?? 200; - const requestTimeoutMs = opts.requestTimeoutMs ?? Math.min(1000, timeoutMs); - - const startedAt = Date.now(); - let lastErr: unknown; - - while (Date.now() - startedAt < timeoutMs) { - try { - return await getCdpVersion(port, { timeoutMs: requestTimeoutMs }); - } catch (e) { - lastErr = e; - await sleep(intervalMs); - } - } - - throw new Error(`CDP not responding at ${getCdpVersionUrl(port)} within ${timeoutMs}ms${lastErr ? `: ${errorToString(lastErr)}` : ""}`); -} - -/** - * Send a single CDP command to a target WebSocket endpoint and wait for its reply. - * Useful for deterministic one-shot actions (e.g. Page.navigate) from MCP tools. - */ -// ── Persistent CDP session ─────────────────────────────────────────── - -interface PendingCommand { - resolve: (result: Record) => void; - reject: (err: Error) => void; - method: string; - timer: ReturnType; -} - -/** - * Persistent WebSocket connection to a CDP target. - * - * Unlike `sendCdpCommand()` (fire-and-forget, one WS per command), CdpSession - * keeps the socket open so session-scoped CDP domains like `Emulation` remain - * active for the browser tab's lifetime. - */ -export class CdpSession { - private _ws: MinimalWebSocket; - private _closed = false; - private _nextId = 1; - private _pending = new Map(); - - private constructor(ws: MinimalWebSocket) { - this._ws = ws; - - ws.addEventListener("message", this._onMessage); - ws.addEventListener("close", this._onClose); - ws.addEventListener("error", this._onError); - } - - /** Open a persistent CDP session to `wsUrl`. */ - static async open(wsUrl: string, opts?: { timeoutMs?: number }): Promise { - const timeoutMs = opts?.timeoutMs ?? 5000; - const WS = getWebSocketCtor(); - const ws = new WS(wsUrl); - - return new Promise((resolve, reject) => { - let done = false; - const timer = setTimeout(() => { - if (done) return; - done = true; - try { ws.close(); } catch { /* */ } - reject(new Error(`CdpSession: connection to ${wsUrl} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - - const onOpen = (): void => { - if (done) return; - done = true; - clearTimeout(timer); - ws.removeEventListener("open", onOpen); - ws.removeEventListener("error", onErr); - resolve(new CdpSession(ws)); - }; - - const onErr = (): void => { - if (done) return; - done = true; - clearTimeout(timer); - ws.removeEventListener("open", onOpen); - ws.removeEventListener("error", onErr); - reject(new Error(`CdpSession: failed to connect to ${wsUrl}`)); - }; - - ws.addEventListener("open", onOpen); - ws.addEventListener("error", onErr); - }); - } - - get closed(): boolean { - return this._closed; - } - - /** Send a CDP command and wait for its response. */ - async send( - method: string, - params?: Record, - opts?: { timeoutMs?: number }, - ): Promise> { - if (this._closed) throw new Error("CdpSession is closed"); - - const timeoutMs = opts?.timeoutMs ?? 5000; - const id = this._nextId++; - - return new Promise>((resolve, reject) => { - const timer = setTimeout(() => { - this._pending.delete(id); - reject(new Error(`CdpSession: timeout waiting for '${method}' (id=${id}) after ${timeoutMs}ms`)); - }, timeoutMs); - - this._pending.set(id, { resolve, reject, method, timer }); - - try { - this._ws.send(JSON.stringify({ id, method, ...(params ? { params } : {}) })); - } catch (e) { - this._pending.delete(id); - clearTimeout(timer); - reject(e instanceof Error ? e : new Error(String(e))); - } - }); - } - - /** Cleanly close the session. */ - close(): void { - if (this._closed) return; - this._closed = true; - - // Reject all pending commands - for (const [id, pending] of this._pending) { - clearTimeout(pending.timer); - pending.reject(new Error(`CdpSession closed while '${pending.method}' (id=${id}) was pending`)); - } - this._pending.clear(); - - this._ws.removeEventListener("message", this._onMessage); - this._ws.removeEventListener("close", this._onClose); - this._ws.removeEventListener("error", this._onError); - - try { this._ws.close(1000, "CdpSession.close"); } catch { /* */ } - } - - // ── Internal event handlers (arrow fns for stable `this`) ── - - private _onMessage = (event: unknown): void => { - let payload: CdpCommandResponse; - try { - payload = JSON.parse(messageDataToString(event)) as CdpCommandResponse; - } catch { - return; // ignore non-JSON frames (CDP events) - } - - if (payload.id == null) return; // CDP event, not a command response - const pending = this._pending.get(payload.id); - if (!pending) return; - - this._pending.delete(payload.id); - clearTimeout(pending.timer); - - if (payload.error) { - const msg = payload.error.message || "Unknown CDP error"; - pending.reject(new Error(`CDP ${pending.method} failed: ${msg}`)); - } else { - pending.resolve(payload.result ?? {}); - } - }; - - private _onClose = (): void => { - if (!this._closed) { - this._closed = true; - for (const [, pending] of this._pending) { - clearTimeout(pending.timer); - pending.reject(new Error(`CdpSession WebSocket closed unexpectedly`)); - } - this._pending.clear(); - } - }; - - private _onError = (): void => { - // Error is typically followed by close; just mark closed - if (!this._closed) { - this._closed = true; - for (const [, pending] of this._pending) { - clearTimeout(pending.timer); - pending.reject(new Error(`CdpSession WebSocket error`)); - } - this._pending.clear(); - } - }; -} - -/** - * Send a single CDP command to a target WebSocket endpoint and wait for its reply. - * Useful for deterministic one-shot actions (e.g. Page.navigate) from MCP tools. - */ -export async function sendCdpCommand( - wsUrl: string, - method: string, - params?: Record, - opts: SendCdpCommandOptions = {}, -): Promise> { - const timeoutMs = opts.timeoutMs ?? 3000; - const WS = getWebSocketCtor(); - const commandId = Math.floor(Math.random() * 1_000_000_000); - - return await new Promise>((resolve, reject) => { - const socket = new WS(wsUrl); - let done = false; - let timer: ReturnType | null = null; - - const finishOk = (result: Record): void => { - if (done) return; - done = true; - cleanup(); - try { socket.close(1000, "done"); } catch { /* ignore */ } - resolve(result); - }; - - const finishErr = (err: unknown): void => { - if (done) return; - done = true; - cleanup(); - try { socket.close(1000, "error"); } catch { /* ignore */ } - reject(err instanceof Error ? err : new Error(errorToString(err))); - }; - - const onOpen = (): void => { - try { - socket.send(JSON.stringify({ - id: commandId, - method, - ...(params ? { params } : {}), - })); - } catch (e) { - finishErr(e); - } - }; - - const onMessage = (event: unknown): void => { - let payload: CdpCommandResponse; - try { - payload = JSON.parse(messageDataToString(event)) as CdpCommandResponse; - } catch { - // Ignore unrelated/invalid frames. - return; - } - if (payload.id !== commandId) return; - if (payload.error) { - const msg = payload.error.message || "Unknown CDP error"; - finishErr(new Error(`CDP ${method} failed: ${msg}`)); - return; - } - finishOk(payload.result ?? {}); - }; - - const onError = (): void => { - finishErr(new Error(`WebSocket error while sending CDP command '${method}' to ${wsUrl}`)); - }; - - const onClose = (): void => { - if (!done) { - finishErr(new Error(`WebSocket closed before CDP command '${method}' completed`)); - } - }; - - const cleanup = (): void => { - socket.removeEventListener("open", onOpen); - socket.removeEventListener("message", onMessage); - socket.removeEventListener("error", onError); - socket.removeEventListener("close", onClose); - if (timer) clearTimeout(timer); - timer = null; - }; - - socket.addEventListener("open", onOpen); - socket.addEventListener("message", onMessage); - socket.addEventListener("error", onError); - socket.addEventListener("close", onClose); - - timer = setTimeout(() => { - finishErr(new Error(`Timeout waiting for CDP '${method}' response after ${timeoutMs}ms`)); - }, timeoutMs); - }); -} diff --git a/src/devtools/bridge.ts b/src/devtools/bridge.ts deleted file mode 100644 index 574a038..0000000 --- a/src/devtools/bridge.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { createRequire } from "node:module"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { getCdpTargets, sendCdpCommand } from "../cdp-utils.js"; -import { resolveToolMap } from "./tool-map.js"; -import type { DevToolsAction, DevToolsResolvedToolMap, DevToolsSessionSnapshot } from "./types.js"; - -interface DevToolsSessionInternal extends DevToolsSessionSnapshot { - transport: StdioClientTransport | null; - client: Client | null; - stderrTail: string[]; -} - -const SESSION_ID_PREFIX = "devtools"; -const STDERR_TAIL_LINES = 30; - -let resolvedSidecarBinPath: string | null = null; - -function errorToString(e: unknown): string { - if (e instanceof Error) return e.message; - if (typeof e === "string") return e; - try { - return JSON.stringify(e); - } catch { - return String(e); - } -} - -function pushStderrTail(lines: string[], chunk: unknown): void { - const text = typeof chunk === "string" ? chunk : String(chunk); - for (const line of text.split(/\r?\n/)) { - if (!line.trim()) continue; - lines.push(line); - } - if (lines.length > STDERR_TAIL_LINES) { - lines.splice(0, lines.length - STDERR_TAIL_LINES); - } -} - -function resolveChromeDevtoolsMcpBin(): string { - if (resolvedSidecarBinPath) return resolvedSidecarBinPath; - - const require = createRequire(import.meta.url); - const pkgPath = require.resolve("chrome-devtools-mcp/package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { - bin?: string | Record; - }; - - let relBinPath: string | null = null; - if (typeof pkg.bin === "string") { - relBinPath = pkg.bin; - } else if (pkg.bin && typeof pkg.bin === "object") { - const direct = pkg.bin["chrome-devtools-mcp"]; - if (typeof direct === "string") { - relBinPath = direct; - } else { - const firstString = Object.values(pkg.bin).find((v): v is string => typeof v === "string"); - relBinPath = firstString ?? null; - } - } - - if (!relBinPath) { - throw new Error("Unable to resolve chrome-devtools-mcp binary from package metadata."); - } - - resolvedSidecarBinPath = resolve(dirname(pkgPath), relBinPath); - return resolvedSidecarBinPath; -} - -export function resetSidecarResolutionCache(): void { - resolvedSidecarBinPath = null; -} - -export function getLocalSidecarStatus(): { available: boolean; binPath: string | null; error: string | null } { - try { - const binPath = resolveChromeDevtoolsMcpBin(); - return { available: true, binPath, error: null }; - } catch (e) { - return { available: false, binPath: null, error: errorToString(e) }; - } -} - -function resolveSidecarLaunch(browserUrl: string): { - command: string; - args: string[]; - mode: "local-package" | "path-command"; -} { - try { - const binPath = resolveChromeDevtoolsMcpBin(); - return { - command: process.execPath, - args: [binPath, "--browserUrl", browserUrl], - mode: "local-package", - }; - } catch { - // Fallback for environments where dependency install is not available yet. - return { - command: "chrome-devtools-mcp", - args: ["--browserUrl", browserUrl], - mode: "path-command", - }; - } -} - -function toPublicSession(session: DevToolsSessionInternal): DevToolsSessionSnapshot { - return { - id: session.id, - targetId: session.targetId, - browserUrl: session.browserUrl, - mode: session.mode, - createdAt: session.createdAt, - lastUsedAt: session.lastUsedAt, - sidecarPid: session.sidecarPid, - tools: session.tools, - }; -} - -function getPortFromBrowserUrl(browserUrl: string): number { - let u: URL; - try { - u = new URL(browserUrl); - } catch { - throw new Error(`Invalid browser URL '${browserUrl}'`); - } - const port = Number(u.port); - if (!Number.isFinite(port) || port <= 0) { - throw new Error(`Browser URL '${browserUrl}' does not contain a valid port`); - } - return port; -} - -async function navigateWithNativeFallback(browserUrl: string, args: Record): Promise { - const url = typeof args.url === "string" ? args.url : null; - if (!url) { - throw new Error("Missing 'url' for native-fallback navigate action."); - } - - const port = getPortFromBrowserUrl(browserUrl); - const targets = await getCdpTargets(port, { timeoutMs: 2000 }); - const pageTargets = targets.filter((t) => t.type === "page"); - if (pageTargets.length === 0) { - throw new Error("No page targets available for native-fallback navigation."); - } - - const selected = pageTargets.find((t) => { - const tUrl = typeof t.url === "string" ? t.url.toLowerCase() : ""; - return tUrl.length > 0 && !tUrl.startsWith("devtools://") && !tUrl.startsWith("chrome://"); - }) ?? pageTargets[0]; - - const ws = selected.webSocketDebuggerUrl; - if (typeof ws !== "string" || ws.length === 0) { - throw new Error("Selected page target has no webSocketDebuggerUrl for native-fallback navigation."); - } - - const cdpResult = await sendCdpCommand( - ws, - "Page.navigate", - { url }, - { timeoutMs: 5000 }, - ); - - return { - mode: "native-fallback", - selected_page_target_id: selected.id ?? null, - selected_page_url: selected.url ?? null, - cdpResult, - }; -} - -export class DevToolsBridge { - private sessions = new Map(); - - async createSession(targetId: string, browserUrl: string): Promise { - const sessionId = `${SESSION_ID_PREFIX}_${randomUUID()}`; - const stderrTail: string[] = []; - const launch = resolveSidecarLaunch(browserUrl); - - const transport = new StdioClientTransport({ - command: launch.command, - args: launch.args, - stderr: "pipe", - cwd: process.cwd(), - }); - - const stderr = transport.stderr as NodeJS.ReadableStream | null; - if (stderr && typeof stderr.on === "function") { - stderr.on("data", (chunk: unknown) => pushStderrTail(stderrTail, chunk)); - } - - const client = new Client({ - name: "proxy-devtools-bridge-client", - version: "1.0.0", - }); - - try { - await client.connect(transport); - const listed = await client.listTools(); - const availableNames = listed.tools.map((t) => t.name); - const tools: DevToolsResolvedToolMap = resolveToolMap(availableNames); - - const session: DevToolsSessionInternal = { - id: sessionId, - targetId, - browserUrl, - mode: "sidecar", - createdAt: Date.now(), - lastUsedAt: Date.now(), - sidecarPid: transport.pid, - tools, - client, - transport, - stderrTail, - }; - - this.sessions.set(sessionId, session); - transport.onclose = () => { - this.sessions.delete(sessionId); - }; - - return toPublicSession(session); - } catch (e) { - await client.close().catch(() => {}); - await transport.close().catch(() => {}); - - const errText = errorToString(e); - if (/ENOENT/.test(errText)) { - const fallbackSession: DevToolsSessionInternal = { - id: sessionId, - targetId, - browserUrl, - mode: "native-fallback", - createdAt: Date.now(), - lastUsedAt: Date.now(), - sidecarPid: null, - tools: null, - client: null, - transport: null, - stderrTail, - }; - this.sessions.set(sessionId, fallbackSession); - return toPublicSession(fallbackSession); - } - - const tail = stderrTail.length > 0 ? ` stderr: ${stderrTail.join(" | ")}` : ""; - throw new Error(`Failed to start chrome-devtools-mcp sidecar (${launch.mode}): ${errText}.${tail}`); - } - } - - getSession(sessionId: string): DevToolsSessionSnapshot | null { - const session = this.sessions.get(sessionId); - if (!session) return null; - return toPublicSession(session); - } - - listSessions(): DevToolsSessionSnapshot[] { - return [...this.sessions.values()] - .map(toPublicSession) - .sort((a, b) => b.createdAt - a.createdAt); - } - - async callAction( - sessionId: string, - action: DevToolsAction, - args: Record, - ): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`DevTools session '${sessionId}' not found.`); - } - - session.lastUsedAt = Date.now(); - - if (session.mode === "native-fallback") { - if (action !== "navigate") { - throw new Error( - "This DevTools session is in native-fallback mode (chrome-devtools-mcp binary not found). " + - "Only navigation is available. Install chrome-devtools-mcp and re-attach for full DevTools actions.", - ); - } - return await navigateWithNativeFallback(session.browserUrl, args); - } - - if (!session.client || !session.tools) { - throw new Error("DevTools sidecar session is not available."); - } - - const toolName = session.tools[action]; - return await session.client.callTool({ - name: toolName, - arguments: args, - }); - } - - async closeSession(sessionId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) return false; - this.sessions.delete(sessionId); - if (session.client) { - await session.client.close().catch(() => {}); - } - if (session.transport) { - await session.transport.close().catch(() => {}); - } - return true; - } - - async closeSessionsByTarget(targetId: string): Promise { - const ids = [...this.sessions.values()] - .filter((s) => s.targetId === targetId) - .map((s) => s.id); - for (const id of ids) { - await this.closeSession(id); - } - return ids.length; - } - - async closeAllSessions(): Promise { - const ids = [...this.sessions.keys()]; - for (const id of ids) { - await this.closeSession(id); - } - return ids.length; - } -} - -export const devToolsBridge = new DevToolsBridge(); diff --git a/src/devtools/tool-map.ts b/src/devtools/tool-map.ts deleted file mode 100644 index 3c3aa4f..0000000 --- a/src/devtools/tool-map.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { DevToolsAction, DevToolsResolvedToolMap } from "./types.js"; - -const CANDIDATES: Record = { - navigate: ["navigate_page", "browser_navigate"], - snapshot: ["take_snapshot", "browser_snapshot"], - listNetwork: ["list_network_requests", "browser_network_requests"], - listConsole: ["list_console_messages", "browser_console_messages"], - screenshot: ["take_screenshot", "browser_take_screenshot"], -}; - -function pickCandidate(candidates: string[], available: Set): string | null { - for (const candidate of candidates) { - if (available.has(candidate)) return candidate; - } - return null; -} - -export function resolveToolMap(availableTools: string[]): DevToolsResolvedToolMap { - const available = new Set(availableTools); - - const resolved = { - navigate: pickCandidate(CANDIDATES.navigate, available), - snapshot: pickCandidate(CANDIDATES.snapshot, available), - listNetwork: pickCandidate(CANDIDATES.listNetwork, available), - listConsole: pickCandidate(CANDIDATES.listConsole, available), - screenshot: pickCandidate(CANDIDATES.screenshot, available), - }; - - const missing: string[] = []; - if (!resolved.navigate) missing.push("navigate"); - if (!resolved.snapshot) missing.push("snapshot"); - if (!resolved.listNetwork) missing.push("listNetwork"); - if (!resolved.listConsole) missing.push("listConsole"); - if (!resolved.screenshot) missing.push("screenshot"); - - if (missing.length > 0) { - const preview = availableTools.slice(0, 30).join(", "); - throw new Error( - `chrome-devtools-mcp is missing required tools: ${missing.join(", ")}. ` + - `Available tools (first 30): ${preview || "(none)"}`, - ); - } - - return { - navigate: resolved.navigate!, - snapshot: resolved.snapshot!, - listNetwork: resolved.listNetwork!, - listConsole: resolved.listConsole!, - screenshot: resolved.screenshot!, - }; -} diff --git a/src/devtools/types.ts b/src/devtools/types.ts deleted file mode 100644 index 07eef60..0000000 --- a/src/devtools/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type DevToolsAction = - | "navigate" - | "snapshot" - | "listNetwork" - | "listConsole" - | "screenshot"; - -export interface DevToolsResolvedToolMap { - navigate: string; - snapshot: string; - listNetwork: string; - listConsole: string; - screenshot: string; -} - -export interface DevToolsSessionSnapshot { - id: string; - targetId: string; - browserUrl: string; - mode: "sidecar" | "native-fallback"; - createdAt: number; - lastUsedAt: number; - sidecarPid: number | null; - tools: DevToolsResolvedToolMap | null; -} diff --git a/src/humanizer/engine.ts b/src/humanizer/engine.ts index f7a5dc9..2a82cc3 100644 --- a/src/humanizer/engine.ts +++ b/src/humanizer/engine.ts @@ -1,14 +1,20 @@ /** - * CDP dispatch engine for human-like browser input. + * Playwright-backed humanizer engine. * - * Singleton that manages persistent CdpSession per Chrome target, tracks - * mouse position across calls, and dispatches Input.* events through CDP. + * Replaces the former CDP-based engine. Uses the cloakbrowser-launched + * Playwright Page for each target. cloakbrowser's `humanize: true` already + * patches input dispatch at the C++ layer; this engine layers custom per-call + * timing profiles (WPM + bigram + typo, Bezier paths, eased scroll) on top. + * + * humanizer_click supports locator-first targeting (selector | role+name | + * text | label) so callers no longer need to guess pixel coordinates — the + * locator auto-waits for visible+enabled+stable+in-view before dispatching. */ -import { CdpSession, getCdpTargets } from "../cdp-utils.js"; -import { interceptorManager } from "../interceptors/manager.js"; +import type { Page, Locator } from "playwright-core"; +import { getPageForTarget } from "../browser/session.js"; import { generatePath, addRandomOffset, type Point } from "./path.js"; -import { calculateKeyDelays, calculateScrollSteps, type TypingProfile, type ScrollOptions } from "./timing.js"; +import { calculateKeyDelays, calculateScrollSteps, type TypingProfile } from "./timing.js"; // ── Helpers ────────────────────────────────────────────────────────── @@ -20,175 +26,75 @@ function rand(min: number, max: number): number { return min + Math.random() * (max - min); } -function errorToString(e: unknown): string { - if (e instanceof Error) return e.message; - if (typeof e === "string") return e; - try { return JSON.stringify(e); } catch { return String(e); } -} - -// ── CDP key code mapping ───────────────────────────────────────────── - -interface KeyDef { - key: string; - code: string; - keyCode: number; - text?: string; +function isUpperCase(ch: string): boolean { + return ch !== ch.toLowerCase() && ch === ch.toUpperCase(); } -const SPECIAL_KEYS: Record = { - Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 }, - Tab: { key: "Tab", code: "Tab", keyCode: 9, text: "\t" }, - Enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" }, - Shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 }, - Escape: { key: "Escape", code: "Escape", keyCode: 27 }, - " ": { key: " ", code: "Space", keyCode: 32, text: " " }, -}; - -function charToKeyDef(ch: string): KeyDef { - if (SPECIAL_KEYS[ch]) return SPECIAL_KEYS[ch]; - - const lower = ch.toLowerCase(); - const isUpper = ch !== lower && ch === ch.toUpperCase(); - - // Letters - if (lower >= "a" && lower <= "z") { - return { - key: ch, - code: `Key${lower.toUpperCase()}`, - keyCode: lower.charCodeAt(0) - 32, // 'a' → 65 - text: ch, - }; - } - - // Digits - if (ch >= "0" && ch <= "9") { - return { - key: ch, - code: `Digit${ch}`, - keyCode: ch.charCodeAt(0), - text: ch, - }; - } +// ── Mouse position tracking ────────────────────────────────────────── - // Punctuation / other — use generic mapping - return { - key: ch, - code: "", - keyCode: ch.charCodeAt(0), - text: ch, - }; +interface MouseState { + x: number; + y: number; } -// ── Target resolution ──────────────────────────────────────────────── +const mouseStates = new Map(); -async function getChromeTargetPort(targetId: string): Promise { - const chrome = interceptorManager.get("chrome"); - if (!chrome) throw new Error("Chrome interceptor not registered."); - - const meta = await chrome.getMetadata(); - const target = meta.activeTargets.find((t) => t.id === targetId); - if (!target) throw new Error(`Chrome target '${targetId}' not found. Is it still running?`); - - const details = target.details as Record; - const port = details?.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - throw new Error(`Chrome target '${targetId}' has no valid CDP port.`); +function getMouseState(targetId: string): MouseState { + let state = mouseStates.get(targetId); + if (!state) { + state = { x: 0, y: 0 }; + mouseStates.set(targetId, state); } - return port; + return state; } -function targetUrlIsUserPage(url: unknown): boolean { - if (typeof url !== "string") return false; - const lower = url.toLowerCase(); - return lower.length > 0 && !lower.startsWith("devtools://") && !lower.startsWith("chrome://"); +function clearMouseState(targetId: string): void { + mouseStates.delete(targetId); } -async function getPageWsUrl(port: number): Promise { - const targets = await getCdpTargets(port, { timeoutMs: 2000 }); - const pages = targets.filter((t) => t.type === "page"); - if (pages.length === 0) throw new Error("No page targets available."); +// ── Locator resolution ─────────────────────────────────────────────── - const selected = pages.find((t) => targetUrlIsUserPage(t.url)) ?? pages[0]; - const wsUrl = selected.webSocketDebuggerUrl; - if (typeof wsUrl !== "string" || !wsUrl) { - throw new Error("Page target has no webSocketDebuggerUrl."); - } - return wsUrl; +export interface ClickTarget { + selector?: string; + role?: string; + name?: string; + text?: string; + label?: string; + x?: number; + y?: number; } -// ── Bounding rect resolution ───────────────────────────────────────── - -interface BoundingRect { - x: number; - y: number; - width: number; - height: number; +function resolveLocator(page: Page, opts: ClickTarget): Locator | null { + if (opts.selector) return page.locator(opts.selector); + if (opts.role) { + // Playwright requires role to be a known AriaRole; we accept any string. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return page.getByRole(opts.role as any, opts.name ? { name: opts.name } : undefined); + } + if (opts.text) return page.getByText(opts.text); + if (opts.label) return page.getByLabel(opts.label); + return null; } -async function resolveSelectorBounds( - session: CdpSession, - selector: string, -): Promise { - const result = await session.send("Runtime.evaluate", { - expression: `(() => { - const el = document.querySelector(${JSON.stringify(selector)}); - if (!el) return { error: "Element not found: ${selector.replace(/"/g, '\\"')}" }; - const r = el.getBoundingClientRect(); - return { x: r.x, y: r.y, width: r.width, height: r.height }; - })()`, - returnByValue: true, - awaitPromise: false, - }); - - const remote = result.result as Record | undefined; - const value = remote?.value as Record | undefined; - if (!value || value.error) { - throw new Error(typeof value?.error === "string" ? value.error : `Failed to resolve selector: ${selector}`); +async function resolveCenter(locator: Locator): Promise<{ center: Point; box: { width: number; height: number } }> { + await locator.waitFor({ state: "visible", timeout: 15_000 }); + await locator.scrollIntoViewIfNeeded({ timeout: 5_000 }).catch(() => { /* non-fatal */ }); + const box = await locator.boundingBox({ timeout: 5_000 }); + if (!box) { + throw new Error("Locator has no bounding box (element not rendered or zero-size)."); } - return { - x: Number(value.x), - y: Number(value.y), - width: Number(value.width), - height: Number(value.height), + center: { x: box.x + box.width / 2, y: box.y + box.height / 2 }, + box: { width: box.width, height: box.height }, }; } // ── Engine ─────────────────────────────────────────────────────────── -interface TargetState { - session: CdpSession; - mouseX: number; - mouseY: number; -} - class HumanizerEngine { - private _targets = new Map(); - - /** Get or create a persistent CdpSession for a target. */ - async getSession(targetId: string): Promise { - const existing = this._targets.get(targetId); - if (existing && !existing.session.closed) return existing; - - // Clean up stale entry - if (existing) this._targets.delete(targetId); - - const port = await getChromeTargetPort(targetId); - const wsUrl = await getPageWsUrl(port); - const session = await CdpSession.open(wsUrl); - - const state: TargetState = { session, mouseX: 0, mouseY: 0 }; - this._targets.set(targetId, state); - return state; - } - - /** Close the CdpSession for a target. */ + /** Drop tracked mouse state when a target is closed. */ closeSession(targetId: string): void { - const state = this._targets.get(targetId); - if (state) { - state.session.close(); - this._targets.delete(targetId); - } + clearMouseState(targetId); } // ── Mouse movement ───────────────────────────────────────────── @@ -199,38 +105,27 @@ class HumanizerEngine { y: number, durationMs?: number, ): Promise<{ totalMs: number; eventsDispatched: number }> { - const state = await this.getSession(targetId); - const from: Point = { x: state.mouseX, y: state.mouseY }; + const page = getPageForTarget(targetId); + const state = getMouseState(targetId); + const from: Point = { x: state.x, y: state.y }; const to: Point = { x, y }; - const path = generatePath(from, to, { - baseDurationMs: durationMs ?? 600, - }); + const path = generatePath(from, to, { baseDurationMs: durationMs ?? 600 }); let eventsDispatched = 0; for (let i = 0; i < path.points.length; i++) { const pt = path.points[i]; - - // Wait inter-point delay if (i > 0) { const delay = path.timestamps[i] - path.timestamps[i - 1]; if (delay > 0) await sleep(delay); } - - await state.session.send("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: pt.x, - y: pt.y, - button: "none", - buttons: 0, - }); + await page.mouse.move(pt.x, pt.y); eventsDispatched++; } - // Update tracked position - const lastPt = path.points[path.points.length - 1]; - state.mouseX = lastPt.x; - state.mouseY = lastPt.y; + const last = path.points[path.points.length - 1]; + state.x = last.x; + state.y = last.y; return { totalMs: path.totalMs, eventsDispatched }; } @@ -239,80 +134,57 @@ class HumanizerEngine { async click( targetId: string, - opts: { - selector?: string; - x?: number; - y?: number; + opts: ClickTarget & { button?: "left" | "right" | "middle"; clickCount?: number; moveDurationMs?: number; } = {}, - ): Promise<{ totalMs: number; eventsDispatched: number; clickedAt: Point }> { - const state = await this.getSession(targetId); + ): Promise<{ totalMs: number; eventsDispatched: number; clickedAt: Point; resolvedBy: string }> { + const page = getPageForTarget(targetId); const button = opts.button ?? "left"; const clickCount = opts.clickCount ?? 1; let targetX: number; let targetY: number; + let resolvedBy: string; - if (opts.selector) { - const bounds = await resolveSelectorBounds(state.session, opts.selector); - const center: Point = { - x: bounds.x + bounds.width / 2, - y: bounds.y + bounds.height / 2, - }; - const offset = addRandomOffset(center, bounds); + const locator = resolveLocator(page, opts); + if (locator) { + const { center, box } = await resolveCenter(locator); + const offset = addRandomOffset(center, box); targetX = offset.x; targetY = offset.y; + resolvedBy = opts.selector ? "selector" + : opts.role ? "role" + : opts.text ? "text" + : "label"; } else if (opts.x !== undefined && opts.y !== undefined) { targetX = opts.x; targetY = opts.y; + resolvedBy = "coords"; } else { - throw new Error("Either selector or x+y coordinates are required."); + throw new Error("Provide one of: selector, role (+ name), text, label, or x+y coordinates."); } - // Move to target const moveResult = await this.moveMouse(targetId, targetX, targetY, opts.moveDurationMs); let eventsDispatched = moveResult.eventsDispatched; let totalMs = moveResult.totalMs; - const cdpButton = button === "right" ? "right" : button === "middle" ? "middle" : "left"; - const buttons = button === "right" ? 2 : button === "middle" ? 4 : 1; - - // Small pause before clicking (human hesitation) const preClickDelay = Math.round(rand(30, 80)); await sleep(preClickDelay); totalMs += preClickDelay; for (let c = 0; c < clickCount; c++) { - // mousePressed - await state.session.send("Input.dispatchMouseEvent", { - type: "mousePressed", - x: targetX, - y: targetY, - button: cdpButton, - buttons, - clickCount: c + 1, - }); + await page.mouse.down({ button }); eventsDispatched++; - // Brief hold const holdMs = Math.round(rand(40, 100)); await sleep(holdMs); totalMs += holdMs; - // mouseReleased - await state.session.send("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: targetX, - y: targetY, - button: cdpButton, - buttons: 0, - clickCount: c + 1, - }); + await page.mouse.up({ button }); eventsDispatched++; - // Inter-click pause for multi-click if (c < clickCount - 1) { const interClickMs = Math.round(rand(50, 120)); await sleep(interClickMs); @@ -320,7 +192,7 @@ class HumanizerEngine { } } - return { totalMs, eventsDispatched, clickedAt: { x: targetX, y: targetY } }; + return { totalMs, eventsDispatched, clickedAt: { x: targetX, y: targetY }, resolvedBy }; } // ── Typing ───────────────────────────────────────────────────── @@ -330,84 +202,39 @@ class HumanizerEngine { text: string, profile: TypingProfile = {}, ): Promise<{ totalMs: number; eventsDispatched: number; charsTyped: number }> { - const state = await this.getSession(targetId); + const page = getPageForTarget(targetId); const keyDelays = calculateKeyDelays(text, profile); let totalMs = 0; let eventsDispatched = 0; for (const { key, delayMs } of keyDelays) { - // Wait before keystroke await sleep(delayMs); totalMs += delayMs; - const keyDef = charToKeyDef(key); - const needsShift = key !== key.toLowerCase() && key === key.toUpperCase() && key.length === 1; - - // Shift down if needed - if (needsShift) { - await state.session.send("Input.dispatchKeyEvent", { - type: "keyDown", - key: "Shift", - code: "ShiftLeft", - windowsVirtualKeyCode: 16, - nativeVirtualKeyCode: 16, - modifiers: 8, // shift modifier - }); + if (key === "Backspace") { + await page.keyboard.press("Backspace"); eventsDispatched++; - } - - // keyDown (no text — character insertion happens via the char event) - await state.session.send("Input.dispatchKeyEvent", { - type: "keyDown", - key: keyDef.key, - code: keyDef.code, - windowsVirtualKeyCode: keyDef.keyCode, - nativeVirtualKeyCode: keyDef.keyCode, - ...(needsShift ? { modifiers: 8 } : {}), - }); - eventsDispatched++; - - // char event for text-producing keys - if (keyDef.text) { - await state.session.send("Input.dispatchKeyEvent", { - type: "char", - key: keyDef.key, - code: keyDef.code, - text: keyDef.text, - unmodifiedText: keyDef.text, - ...(needsShift ? { modifiers: 8 } : {}), - }); + } else if (key === " ") { + await page.keyboard.press("Space"); + eventsDispatched++; + } else if (key.length === 1) { + // Shift is handled automatically by Playwright's keyboard.type for single chars. + if (isUpperCase(key)) { + await page.keyboard.press(`Shift+${key.toLowerCase()}`); + } else { + await page.keyboard.press(key); + } + eventsDispatched++; + } else { + // Named key (Tab, Enter, etc.) + await page.keyboard.press(key); eventsDispatched++; } - // Brief key hold const holdMs = Math.round(rand(20, 60)); await sleep(holdMs); totalMs += holdMs; - - // keyUp - await state.session.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: keyDef.key, - code: keyDef.code, - windowsVirtualKeyCode: keyDef.keyCode, - nativeVirtualKeyCode: keyDef.keyCode, - ...(needsShift ? { modifiers: 8 } : {}), - }); - eventsDispatched++; - - // Shift up if needed - if (needsShift) { - await state.session.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: "Shift", - code: "ShiftLeft", - windowsVirtualKeyCode: 16, - nativeVirtualKeyCode: 16, - }); - eventsDispatched++; - } } return { totalMs, eventsDispatched, charsTyped: text.length }; @@ -421,29 +248,17 @@ class HumanizerEngine { deltaX?: number, durationMs?: number, ): Promise<{ totalMs: number; eventsDispatched: number }> { - const state = await this.getSession(targetId); - const scrollSteps = calculateScrollSteps({ - deltaY, - deltaX, - durationMs: durationMs ?? 400, - }); + const page = getPageForTarget(targetId); + const steps = calculateScrollSteps({ deltaY, deltaX, durationMs: durationMs ?? 400 }); let totalMs = 0; let eventsDispatched = 0; - for (const step of scrollSteps) { + for (const step of steps) { await sleep(step.delayMs); totalMs += step.delayMs; - await state.session.send("Input.dispatchMouseEvent", { - type: "mouseWheel", - x: state.mouseX, - y: state.mouseY, - deltaX: step.deltaX, - deltaY: step.deltaY, - button: "none", - buttons: 0, - }); + await page.mouse.wheel(step.deltaX, step.deltaY); eventsDispatched++; } @@ -457,7 +272,8 @@ class HumanizerEngine { durationMs: number, intensity: "subtle" | "normal" = "subtle", ): Promise<{ totalMs: number; eventsDispatched: number }> { - const state = await this.getSession(targetId); + const page = getPageForTarget(targetId); + const state = getMouseState(targetId); const start = Date.now(); let eventsDispatched = 0; @@ -473,41 +289,21 @@ class HumanizerEngine { ); await sleep(waitMs); elapsed = Date.now() - start; - if (elapsed >= durationMs) break; - // Random action: micro mouse jitter or micro scroll if (Math.random() < scrollChance) { - // Micro scroll const microDelta = Math.round(rand(-20, 20)); if (microDelta !== 0) { - await state.session.send("Input.dispatchMouseEvent", { - type: "mouseWheel", - x: state.mouseX, - y: state.mouseY, - deltaX: 0, - deltaY: microDelta, - button: "none", - buttons: 0, - }); + await page.mouse.wheel(0, microDelta); eventsDispatched++; } } else { - // Mouse jitter - const jx = Math.round(state.mouseX + rand(-jitterRadius, jitterRadius)); - const jy = Math.round(state.mouseY + rand(-jitterRadius, jitterRadius)); - - await state.session.send("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: jx, - y: jy, - button: "none", - buttons: 0, - }); + const jx = Math.round(state.x + rand(-jitterRadius, jitterRadius)); + const jy = Math.round(state.y + rand(-jitterRadius, jitterRadius)); + await page.mouse.move(jx, jy); + state.x = jx; + state.y = jy; eventsDispatched++; - - state.mouseX = jx; - state.mouseY = jy; } } @@ -515,5 +311,4 @@ class HumanizerEngine { } } -/** Singleton humanizer engine instance. */ export const humanizerEngine = new HumanizerEngine(); diff --git a/src/index.ts b/src/index.ts index f9489b6..aa5e2f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ * Proxy MCP Server — entry point. * * HTTPS MITM proxy via mockttp with lifecycle/rules/traffic/TLS/interceptors/session tools and resources. + * Browser automation via cloakbrowser (stealth Chromium, Playwright API). * Tools organized into 10 modules: * lifecycle, upstream, rules, traffic, modification, tls, interceptors, devtools, sessions, humanizer * @@ -51,7 +52,7 @@ function arg(name: string, fallback: string): string { /* ------------------------------------------------------------------ */ function createMcpServer(): McpServer { - const server = new McpServer({ name: "proxy", version: "1.0.0" }); + const server = new McpServer({ name: "proxy", version: "2.0.0" }); initInterceptors(); diff --git a/src/interceptors/browser.ts b/src/interceptors/browser.ts new file mode 100644 index 0000000..2d1d53b --- /dev/null +++ b/src/interceptors/browser.ts @@ -0,0 +1,169 @@ +/** + * Browser interceptor — launch cloakbrowser (stealth Chromium) via Playwright. + * + * Replaces the former chrome-launcher + CDP stack. cloakbrowser ships + * source-level C++ fingerprint patches, so no JS stealth injection is needed. + * Humanize mode (humanize: true) handles realistic mouse/keyboard at the + * browser level — our humanizer tools still run per-call timing profiles on top. + */ + +import type { + Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget, +} from "./types.js"; +import type { Browser, BrowserContext, Page, ConsoleMessage } from "playwright-core"; + +export interface ConsoleEntry { + type: string; + text: string; + location: string; + timestamp: number; +} + +export interface BrowserTargetEntry { + target: ActiveTarget; + browser: Browser; + context: BrowserContext; + page: Page; + consoleBuffer: ConsoleEntry[]; +} + +const CONSOLE_BUFFER_MAX = 500; + +export class BrowserInterceptor implements Interceptor { + readonly id = "browser"; + readonly name = "Browser (cloakbrowser stealth Chromium)"; + + private launched = new Map(); + private _activable: boolean | null = null; + + async isActivable(): Promise { + if (this._activable !== null) return this._activable; + try { + await import("cloakbrowser"); + await import("playwright-core"); + this._activable = true; + } catch { + this._activable = false; + } + return this._activable; + } + + getEntry(targetId: string): BrowserTargetEntry | undefined { + return this.launched.get(targetId); + } + + listEntries(): BrowserTargetEntry[] { + return [...this.launched.values()]; + } + + async activate(options: ActivateOptions): Promise { + const { proxyPort, certFingerprint } = options; + const url = typeof options.url === "string" ? options.url : undefined; + const headless = typeof options.headless === "boolean" ? options.headless : false; + const humanize = options.humanize !== false; + const humanPreset = options.humanPreset === "careful" ? "careful" : "default"; + const timezone = typeof options.timezone === "string" ? options.timezone : undefined; + const locale = typeof options.locale === "string" ? options.locale : undefined; + const viewport = options.viewport as { width: number; height: number } | undefined; + + const { launchContext } = await import("cloakbrowser"); + + const args = [ + `--ignore-certificate-errors-spki-list=${certFingerprint}`, + "--proxy-bypass-list=<-loopback>", + "--disable-quic", + ]; + + const context = await launchContext({ + headless, + proxy: { server: `http://127.0.0.1:${proxyPort}` }, + args, + humanize, + humanPreset, + ...(timezone ? { timezone } : {}), + ...(locale ? { locale } : {}), + ...(viewport ? { viewport } : {}), + }); + + const browser = context.browser(); + if (!browser) { + await context.close().catch(() => {}); + throw new Error("cloakbrowser launchContext returned a context without a browser handle."); + } + + const page = await context.newPage(); + + const consoleBuffer: ConsoleEntry[] = []; + page.on("console", (msg: ConsoleMessage) => { + const loc = msg.location(); + consoleBuffer.push({ + type: msg.type(), + text: msg.text(), + location: loc.url ? `${loc.url}:${loc.lineNumber ?? 0}:${loc.columnNumber ?? 0}` : "", + timestamp: Date.now(), + }); + if (consoleBuffer.length > CONSOLE_BUFFER_MAX) { + consoleBuffer.splice(0, consoleBuffer.length - CONSOLE_BUFFER_MAX); + } + }); + + if (url) { + try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + } catch { + // Non-fatal: caller can retry via interceptor_browser_navigate. + } + } + + const pid = typeof process.pid === "number" ? process.pid : 0; + const targetId = `browser_${pid}_${Date.now()}`; + + const target: ActiveTarget = { + id: targetId, + description: `cloakbrowser (headless=${headless})`, + activatedAt: Date.now(), + details: { + proxyPort, + url: url ?? "about:blank", + headless, + humanize, + humanPreset, + ...(timezone ? { timezone } : {}), + ...(locale ? { locale } : {}), + }, + }; + + this.launched.set(targetId, { target, browser, context, page, consoleBuffer }); + + return { targetId, details: target.details }; + } + + async deactivate(targetId: string): Promise { + const entry = this.launched.get(targetId); + if (!entry) { + throw new Error(`No browser instance with target ID '${targetId}'`); + } + + try { await entry.context.close(); } catch { /* best effort */ } + try { await entry.browser.close(); } catch { /* already gone */ } + + this.launched.delete(targetId); + } + + async deactivateAll(): Promise { + const ids = [...this.launched.keys()]; + for (const id of ids) { + try { await this.deactivate(id); } catch { /* best effort */ } + } + } + + async getMetadata(): Promise { + return { + id: this.id, + name: this.name, + description: "Launch cloakbrowser (stealth Chromium) with proxy + SPKI certificate trust. Humanize mode on by default. Driven via Playwright.", + isActivable: await this.isActivable(), + activeTargets: [...this.launched.values()].map((l) => l.target), + }; + } +} diff --git a/src/interceptors/chrome.ts b/src/interceptors/chrome.ts deleted file mode 100644 index 85bfda0..0000000 --- a/src/interceptors/chrome.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Chrome interceptor — launch Chrome/Chromium with proxy flags. - * - * Uses chrome-launcher (dynamic import) to find and launch Chrome with: - * - --proxy-server pointing at our MITM proxy - * - --ignore-certificate-errors-spki-list with our CA fingerprint - * - --remote-debugging-address=127.0.0.1 (avoid exposing CDP on LAN) - * - Isolated temp profile (auto-cleaned on close) - * - * Supports Chrome, Chromium, Brave, and Edge. - */ - -import type { Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget } from "./types.js"; -import { - getCdpTargetsUrl, getCdpTargets, getCdpVersion, getCdpVersionUrl, getCdpBaseUrl, - waitForCdpVersion, CdpSession, -} from "../cdp-utils.js"; - -interface LaunchedBrowser { - target: ActiveTarget; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chrome: any; // ChromeLauncher instance - pid: number; - cdpSession: CdpSession | null; -} - -/** - * Stealth script injected via Page.addScriptToEvaluateOnNewDocument when - * fingerprint spoofing is active. Runs before ANY page JavaScript (including - * bot-detection sensors like Akamai). - */ -const STEALTH_SCRIPT = ` -// 1. Ensure chrome.runtime exists with expected shape. -// Without --disable-extensions this should already be present, but -// belt-and-suspenders in case the extension system is slow to init. -if (window.chrome && !window.chrome.runtime) { - window.chrome.runtime = { id: undefined }; -} - -// 2. Patch Permissions.query for 'notifications' check. -// CDP automation can cause this to reject abnormally. -// Uses native toString() disguise to avoid Function.prototype.toString detection. -(function() { - const origQuery = Permissions.prototype.query; - const patchedQuery = function query(params) { - if (params && params.name === 'notifications') { - return Promise.resolve({ state: Notification.permission }); - } - return origQuery.call(this, params); - }; - // Make toString() return native-looking string - const nativeToString = 'function query() { [native code] }'; - patchedQuery.toString = () => nativeToString; - Object.defineProperty(patchedQuery, 'name', { value: 'query' }); - Object.defineProperty(patchedQuery, 'length', { value: origQuery.length }); - Permissions.prototype.query = patchedQuery; -})(); - -// 3. Set navigator.webdriver to false matching Chrome's real descriptor. -// Real Chrome uses configurable:true — using configurable:false is -// a known automation detection signal. -Object.defineProperty(navigator, 'webdriver', { - get: () => false, - configurable: true, -}); - -// 4. Clean CDP-injected artifacts from Error stacks. -(function() { - const origGetStack = Object.getOwnPropertyDescriptor(Error.prototype, 'stack'); - if (origGetStack && origGetStack.get) { - Object.defineProperty(Error.prototype, 'stack', { - get: function() { - const stack = origGetStack.get.call(this); - if (typeof stack === 'string') { - return stack - .replace(/\\n\\s+at\\s+Object\\.InjectedScript\\..+/g, '') - .replace(/\\n\\s+at\\s+[\\w.]*evaluate[\\w.]*\\s+\\(eval\\b.+/g, ''); - } - return stack; - }, - configurable: true, - }); - } -})(); -`; - -/** - * Minimal, stealth-safe Chrome flags used when fingerprint spoofing is active. - * Deliberately omits chrome-launcher defaults that create detectable artifacts: - * --disable-extensions → removes chrome.runtime (primary Akamai check) - * --disable-sync → detectable via sync API - * --disable-default-apps → removes default extension pages - * --mute-audio → detectable via AudioContext state - * --metrics-recording-only → subtly detectable - */ -const STEALTH_BASE_FLAGS = [ - "--no-first-run", - "--no-default-browser-check", - "--password-store=basic", - "--disable-background-timer-throttling", - "--disable-backgrounding-occluded-windows", - "--disable-renderer-backgrounding", - "--disable-hang-monitor", - "--disable-ipc-flooding-protection", - "--disable-prompt-on-repost", - "--disable-client-side-phishing-detection", - "--disable-component-update", -]; - -export class ChromeInterceptor implements Interceptor { - readonly id = "chrome"; - readonly name = "Chrome / Chromium Browser"; - - private launched = new Map(); - private _activable: boolean | null = null; - - async isActivable(): Promise { - if (this._activable !== null) return this._activable; - try { - await import("chrome-launcher"); - this._activable = true; - } catch { - this._activable = false; - } - return this._activable; - } - - async activate(options: ActivateOptions): Promise { - const { proxyPort, certFingerprint } = options; - const url = options.url as string | undefined; - const browser = options.browser as string | undefined; - const incognito = options.incognito as boolean | undefined; - - const chromeLauncher = await import("chrome-launcher"); - - // Stealth mode: minimal flags + stealth script, but NO User-Agent override. - // Chrome keeps its real UA so bot sensors see capabilities matching the - // actual browser version. - const stealthMode = !!options.stealthMode; - - // In stealth mode, start from a curated minimal flag set to avoid - // detectable artifacts (e.g. --disable-extensions removes chrome.runtime). - // Otherwise, chrome-launcher's defaults are used. - const flags = stealthMode - ? [ - ...STEALTH_BASE_FLAGS, - `--proxy-server=http://127.0.0.1:${proxyPort}`, - `--ignore-certificate-errors-spki-list=${certFingerprint}`, - "--proxy-bypass-list=<-loopback>", - "--remote-debugging-address=127.0.0.1", - "--disable-quic", - ] - : [ - `--proxy-server=http://127.0.0.1:${proxyPort}`, - `--ignore-certificate-errors-spki-list=${certFingerprint}`, - "--proxy-bypass-list=<-loopback>", - "--remote-debugging-address=127.0.0.1", - "--disable-quic", - ]; - - if (incognito) { - flags.push("--incognito"); - } - - // Resolve browser path for non-standard Chrome variants - let chromePath: string | undefined; - if (browser) { - const b = browser.toLowerCase(); - const candidates: Record = { - chromium: ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"], - brave: ["/usr/bin/brave-browser", "/usr/bin/brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], - edge: ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], - }; - const paths = candidates[b]; - if (paths) { - const { accessSync } = await import("node:fs"); - for (const p of paths) { - try { accessSync(p); chromePath = p; break; } catch { /* try next */ } - } - } - // "chrome" or unknown — let chrome-launcher find it - } - - // When in stealth mode, launch to about:blank first so the CDP stealth - // script is injected before the real page loads any scripts. - const needsCdpSetup = stealthMode; - const launchUrl = needsCdpSetup ? "about:blank" : (url ?? "about:blank"); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const launchOptions: any = { - chromeFlags: flags, - startingUrl: launchUrl, - ...(stealthMode ? { ignoreDefaultFlags: true } : {}), - }; - - if (chromePath) { - launchOptions.chromePath = chromePath; - } - - const chrome = await chromeLauncher.launch(launchOptions); - - const targetId = `chrome_${chrome.pid}`; - const cdpHttpUrl = getCdpBaseUrl(chrome.port); - const cdpVersionUrl = getCdpVersionUrl(chrome.port); - const cdpTargetsUrl = getCdpTargetsUrl(chrome.port); - - // Best-effort: CDP is usually ready when chrome-launcher returns, but don't block launch on it. - let browserWebSocketDebuggerUrl: string | null = null; - try { - const version = await getCdpVersion(chrome.port, { timeoutMs: 500 }); - const ws = version.webSocketDebuggerUrl; - if (typeof ws === "string" && ws.length > 0) { - browserWebSocketDebuggerUrl = ws; - } - } catch { - // Ignore and let users call interceptor_chrome_cdp_info for retries - } - - // ── CDP stealth setup ── - let cdpSession: CdpSession | null = null; - - if (needsCdpSetup) { - try { - // Wait for CDP to be fully ready - await waitForCdpVersion(chrome.port, { timeoutMs: 5000 }); - - // Find the first page target's WebSocket URL - const targets = await getCdpTargets(chrome.port, { timeoutMs: 2000 }); - const pageTarget = targets.find( - (t) => t.type === "page" && typeof t.webSocketDebuggerUrl === "string", - ); - - if (pageTarget) { - const pageWsUrl = pageTarget.webSocketDebuggerUrl as string; - cdpSession = await CdpSession.open(pageWsUrl, { timeoutMs: 3000 }); - - // Inject stealth patches before any page script runs - await cdpSession.send("Page.addScriptToEvaluateOnNewDocument", { - source: STEALTH_SCRIPT, - }); - - // Navigate via the same persistent session so the stealth script - // isn't disrupted by a second DevTools connection. - if (url) { - await cdpSession.send("Page.navigate", { url }, { timeoutMs: 5000 }); - } - } - } catch { - // CDP setup failed — Chrome still launched with stealth flags. - if (cdpSession && !cdpSession.closed) { - cdpSession.close(); - cdpSession = null; - } - } - } - - const target: ActiveTarget = { - id: targetId, - description: `${browser ?? "chrome"} (PID ${chrome.pid})`, - activatedAt: Date.now(), - details: { - pid: chrome.pid, - port: chrome.port, - browser: browser ?? "chrome", - proxyPort, - url: url ?? "about:blank", - incognito: incognito ?? false, - cdpHttpUrl, - cdpVersionUrl, - cdpTargetsUrl, - browserWebSocketDebuggerUrl, - ...(stealthMode ? { stealthMode: true } : {}), - }, - }; - - this.launched.set(targetId, { target, chrome, pid: chrome.pid, cdpSession }); - - return { - targetId, - details: target.details, - }; - } - - async deactivate(targetId: string): Promise { - const entry = this.launched.get(targetId); - if (!entry) { - throw new Error(`No Chrome instance with target ID '${targetId}'`); - } - - // Close the persistent CDP session before killing Chrome - if (entry.cdpSession && !entry.cdpSession.closed) { - try { entry.cdpSession.close(); } catch { /* best effort */ } - } - - try { - await entry.chrome.kill(); - } catch { - // Force kill via process signal - try { - process.kill(entry.pid, "SIGKILL"); - } catch { - // Already dead - } - } - - this.launched.delete(targetId); - } - - async deactivateAll(): Promise { - const ids = [...this.launched.keys()]; - for (const id of ids) { - try { - await this.deactivate(id); - } catch { - // Best effort - } - } - } - - async getMetadata(): Promise { - return { - id: this.id, - name: this.name, - description: "Launch Chrome/Chromium/Brave/Edge with proxy flags and SPKI certificate trust. Isolated temp profile, auto-cleaned on close.", - isActivable: await this.isActivable(), - activeTargets: [...this.launched.values()].map((l) => l.target), - }; - } -} diff --git a/src/interceptors/init.ts b/src/interceptors/init.ts index 0b41758..b753901 100644 --- a/src/interceptors/init.ts +++ b/src/interceptors/init.ts @@ -5,14 +5,14 @@ import { interceptorManager } from "./manager.js"; import { TerminalInterceptor } from "./terminal.js"; -import { ChromeInterceptor } from "./chrome.js"; +import { BrowserInterceptor } from "./browser.js"; import { AndroidAdbInterceptor } from "./android-adb.js"; import { AndroidFridaInterceptor } from "./android-frida.js"; import { DockerInterceptor } from "./docker.js"; export function initInterceptors(): void { interceptorManager.register(new TerminalInterceptor()); - interceptorManager.register(new ChromeInterceptor()); + interceptorManager.register(new BrowserInterceptor()); interceptorManager.register(new AndroidAdbInterceptor()); interceptorManager.register(new AndroidFridaInterceptor()); interceptorManager.register(new DockerInterceptor()); diff --git a/src/resources.ts b/src/resources.ts index e881ee1..f87e350 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -5,8 +5,7 @@ import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { proxyManager } from "./state.js"; import { interceptorManager } from "./interceptors/manager.js"; -import { getCdpBaseUrl, getCdpTargets, getCdpTargetsUrl, getCdpVersion, getCdpVersionUrl, waitForCdpVersion } from "./cdp-utils.js"; -import { devToolsBridge } from "./devtools/bridge.js"; +import type { BrowserInterceptor } from "./interceptors/browser.js"; export function registerResources(server: McpServer): void { server.resource( @@ -132,23 +131,6 @@ export function registerResources(server: McpServer): void { }, ); - server.resource( - "proxy_chrome_devtools_sessions", - "proxy://chrome/devtools/sessions", - async (uri) => { - const sessions = devToolsBridge.listSessions(); - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - count: sessions.length, - sessions, - }, null, 2), - }], - }; - }, - ); - server.resource( "proxy_sessions", "proxy://sessions", @@ -288,227 +270,53 @@ export function registerResources(server: McpServer): void { }, ); - // Dynamic per-Chrome CDP bundle (resource template) - const chromeCdpTemplate = new ResourceTemplate( - "proxy://chrome/{target_id}/cdp", - { - list: async () => { - const chrome = interceptorManager.get("chrome"); - if (!chrome) return { resources: [] }; - const meta = await chrome.getMetadata(); - - return { - resources: meta.activeTargets.map((t) => ({ - uri: `proxy://chrome/${t.id}/cdp`, - name: `Chrome CDP (${t.id})`, - description: t.description, - })), - }; - }, - complete: { - target_id: async (value) => { - const chrome = interceptorManager.get("chrome"); - if (!chrome) return []; - const meta = await chrome.getMetadata(); - return meta.activeTargets - .map((t) => t.id) - .filter((id) => id.startsWith(value)); - }, - }, - }, - ); - - server.resource( - "proxy_chrome_cdp", - chromeCdpTemplate, - async (uri, variables) => { - const proxy = { - running: proxyManager.isRunning(), - port: proxyManager.getPort(), - certFingerprint: proxyManager.getCert()?.fingerprint ?? null, - }; - - try { - const targetId = typeof variables.target_id === "string" ? variables.target_id : null; - if (!targetId) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: "Missing target_id in URI template." } }, null, 2), - }], - }; - } - - const chrome = interceptorManager.get("chrome"); - if (!chrome) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: "Chrome interceptor not registered." } }, null, 2), - }], - }; - } - - const meta = await chrome.getMetadata(); - const target = meta.activeTargets.find((t) => t.id === targetId); - if (!target) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: `Chrome target '${targetId}' not found. Is it still running?` } }, null, 2), - }], - }; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = target.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: `Chrome target '${targetId}' has no valid CDP port.` } }, null, 2), - }], - }; - } - - const httpUrl = getCdpBaseUrl(port); - const versionUrl = getCdpVersionUrl(port); - const targetsUrl = getCdpTargetsUrl(port); - - let version: Record | null = null; - let versionError: string | null = null; - let cdpTargets: Array> | null = null; - - try { - version = await waitForCdpVersion(port, { timeoutMs: 3000, intervalMs: 200, requestTimeoutMs: 800 }); - } catch (e) { - versionError = e instanceof Error ? e.message : String(e); - } - - try { - cdpTargets = await getCdpTargets(port, { timeoutMs: 1500 }); - } catch { - // Best effort only - } - - const ws = version?.webSocketDebuggerUrl; - - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ - proxy, - chrome: { - target, - cdp: { - httpUrl, - versionUrl, - targetsUrl, - version, - browserWebSocketDebuggerUrl: typeof ws === "string" ? ws : null, - ...(versionError ? { versionError } : {}), - }, - cdpTargets, - }, - }, null, 2), - }], - }; - } catch (e) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: e instanceof Error ? e.message : String(e) } }, null, 2), - }], - }; - } - }, - ); - - // Most recently activated Chrome instance (fixed resource) + // Most recently activated browser instance (fixed resource) server.resource( - "proxy_chrome_primary", - "proxy://chrome/primary", + "proxy_browser_primary", + "proxy://browser/primary", async (uri) => { - const chrome = interceptorManager.get("chrome"); + const browser = interceptorManager.get("browser") as BrowserInterceptor | undefined; const proxy = { running: proxyManager.isRunning(), port: proxyManager.getPort(), certFingerprint: proxyManager.getCert()?.fingerprint ?? null, }; - if (!chrome) { + if (!browser) { return { contents: [{ uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: "Chrome interceptor not registered.", primary: null } }, null, 2), + text: JSON.stringify({ proxy, browser: { error: "Browser interceptor not registered.", primary: null } }, null, 2), }], }; } - const meta = await chrome.getMetadata(); + const meta = await browser.getMetadata(); const primary = [...meta.activeTargets].sort((a, b) => b.activatedAt - a.activatedAt)[0]; if (!primary) { return { contents: [{ uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: "No active Chrome targets. Launch one with interceptor_chrome_launch.", primary: null } }, null, 2), + text: JSON.stringify({ proxy, browser: { error: "No active browser targets. Launch one with interceptor_browser_launch.", primary: null } }, null, 2), }], }; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = primary.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - return { - contents: [{ - uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: `Primary Chrome target '${primary.id}' has no valid CDP port.`, primary: null } }, null, 2), - }], - }; - } - - const httpUrl = getCdpBaseUrl(port); - const versionUrl = getCdpVersionUrl(port); - const targetsUrl = getCdpTargetsUrl(port); - - let version: Record | null = null; - let versionError: string | null = null; - let cdpTargets: Array> | null = null; - - try { - version = await waitForCdpVersion(port, { timeoutMs: 3000, intervalMs: 200, requestTimeoutMs: 800 }); - } catch (e) { - versionError = e instanceof Error ? e.message : String(e); - } - - try { - cdpTargets = await getCdpTargets(port, { timeoutMs: 1500 }); - } catch { - // Best effort only - } - - const ws = version?.webSocketDebuggerUrl; + const entry = browser.getEntry(primary.id); + const pageInfo = entry && !entry.page.isClosed() + ? { currentUrl: entry.page.url(), title: await entry.page.title().catch(() => "") } + : null; return { contents: [{ uri: uri.href, text: JSON.stringify({ proxy, - chrome: { + browser: { primaryTargetId: primary.id, target: primary, - cdp: { - httpUrl, - versionUrl, - targetsUrl, - version, - browserWebSocketDebuggerUrl: typeof ws === "string" ? ws : null, - ...(versionError ? { versionError } : {}), - }, - cdpTargets, + page: pageInfo, }, }, null, 2), }], @@ -517,64 +325,33 @@ export function registerResources(server: McpServer): void { ); server.resource( - "proxy_chrome_targets", - "proxy://chrome/targets", + "proxy_browser_targets", + "proxy://browser/targets", async (uri) => { - const chrome = interceptorManager.get("chrome"); + const browser = interceptorManager.get("browser") as BrowserInterceptor | undefined; const proxy = { running: proxyManager.isRunning(), port: proxyManager.getPort(), certFingerprint: proxyManager.getCert()?.fingerprint ?? null, }; - if (!chrome) { + if (!browser) { return { contents: [{ uri: uri.href, - text: JSON.stringify({ proxy, chrome: { error: "Chrome interceptor not registered.", targets: [] } }, null, 2), + text: JSON.stringify({ proxy, browser: { error: "Browser interceptor not registered.", targets: [] } }, null, 2), }], }; } - const meta = await chrome.getMetadata(); + const meta = await browser.getMetadata(); const targets = await Promise.all(meta.activeTargets.map(async (t) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = t.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - return { target: t, cdp: null, cdpTargets: null }; - } - - const httpUrl = getCdpBaseUrl(port); - const versionUrl = getCdpVersionUrl(port); - const targetsUrl = getCdpTargetsUrl(port); - - let version: Record | null = null; - let cdpTargets: Array> | null = null; - - try { - version = await getCdpVersion(port, { timeoutMs: 500 }); - } catch { - // Best effort only - } - - try { - cdpTargets = await getCdpTargets(port, { timeoutMs: 500 }); - } catch { - // Best effort only - } - - return { - target: t, - cdp: { - httpUrl, - versionUrl, - targetsUrl, - browserWebSocketDebuggerUrl: typeof version?.webSocketDebuggerUrl === "string" ? version.webSocketDebuggerUrl : null, - }, - cdpTargets, - }; + const entry = browser.getEntry(t.id); + const pageInfo = entry && !entry.page.isClosed() + ? { currentUrl: entry.page.url(), title: await entry.page.title().catch(() => "") } + : null; + return { target: t, page: pageInfo }; })); return { @@ -582,7 +359,7 @@ export function registerResources(server: McpServer): void { uri: uri.href, text: JSON.stringify({ proxy, - chrome: { + browser: { interceptorId: meta.id, interceptorName: meta.name, activeCount: meta.activeTargets.length, diff --git a/src/tools/devtools.ts b/src/tools/devtools.ts index bbd6bb2..e4da308 100644 --- a/src/tools/devtools.ts +++ b/src/tools/devtools.ts @@ -1,77 +1,39 @@ /** - * DevTools bridge tools — proxy-safe wrappers around chrome-devtools-mcp sidecar. + * Browser DevTools-equivalent MCP tools — Playwright-driven. * - * These tools enforce binding to a specific Chrome interceptor target_id so - * CDP actions and proxy capture always refer to the same browser instance. + * Replaces the former chrome-devtools-mcp sidecar + CDP bridge. Each tool + * takes a browser target_id (from interceptor_browser_launch) and drives + * the bound Playwright Page directly. + * + * Tools exposed: + * interceptor_browser_snapshot — a11y tree + * interceptor_browser_screenshot — screenshot, optional save + * interceptor_browser_list_console — buffered console messages + * interceptor_browser_list_cookies — paginated cookie list + * interceptor_browser_get_cookie — full cookie by cookie_id + * interceptor_browser_list_storage_keys — local/session storage keys + * interceptor_browser_get_storage_value — full storage value by item_id + * interceptor_browser_list_network_fields — header fields from proxy traffic + * interceptor_browser_get_network_field — full header value by field_id + * + * Network listing intentionally sources from proxyManager — the MITM is + * the single source of truth for captured HTTP, and it works independently + * of whether the browser emitted a CDP event. */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawn } from "node:child_process"; +import { dirname } from "node:path"; import { createHash } from "node:crypto"; -import { interceptorManager } from "../interceptors/manager.js"; -import { getCdpBaseUrl, getCdpTargets, sendCdpCommand } from "../cdp-utils.js"; import { proxyManager } from "../state.js"; import { truncateResult } from "../utils.js"; -import { devToolsBridge, getLocalSidecarStatus, resetSidecarResolutionCache } from "../devtools/bridge.js"; +import { getEntry, getPageForTarget } from "../browser/session.js"; function errorToString(e: unknown): string { if (e instanceof Error) return e.message; if (typeof e === "string") return e; - try { - return JSON.stringify(e); - } catch { - return String(e); - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function normalizeHostname(url: string): string | null { - try { - return new URL(url).hostname.toLowerCase(); - } catch { - return null; - } -} - -function estimateBase64Bytes(data: string): number { - const len = data.length; - const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; - return Math.max(0, Math.floor((len * 3) / 4) - padding); -} - -function sanitizeDevToolsPayload(payload: unknown): unknown { - if (Array.isArray(payload)) { - return payload.map((item) => sanitizeDevToolsPayload(item)); - } - if (!payload || typeof payload !== "object") { - return payload; - } - - const obj = payload as Record; - if (obj.type === "image" && typeof obj.data === "string") { - const bytes = estimateBase64Bytes(obj.data); - const { data, ...rest } = obj; - return { - ...rest, - dataRedacted: true, - redactionReason: "Omitted base64 image payload to keep MCP context small.", - approximateImageBytes: bytes, - }; - } - - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - out[k] = sanitizeDevToolsPayload(v); - } - return out; + try { return JSON.stringify(e); } catch { return String(e); } } const DEFAULT_VALUE_MAX_CHARS = 256; @@ -117,38 +79,17 @@ function capValue(value: string, maxChars: number): { value: string; valueLength return { value: value.slice(0, effectiveMax) + "...", valueLength, truncated: true, maxChars: effectiveMax }; } -function cookieStableId(cookie: Record): string { +function cookieStableId(cookie: { name?: string; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; sameSite?: string; partitionKey?: string }): string { const parts = [ - typeof cookie.name === "string" ? cookie.name : "", - typeof cookie.domain === "string" ? cookie.domain : "", - typeof cookie.path === "string" ? cookie.path : "", + cookie.name ?? "", + cookie.domain ?? "", + cookie.path ?? "", String(!!cookie.secure), String(!!cookie.httpOnly), - typeof cookie.sameSite === "string" ? cookie.sameSite : "", - typeof cookie.partitionKey === "string" ? cookie.partitionKey : "", + cookie.sameSite ?? "", + cookie.partitionKey ?? "", ]; - const hash = createHash("sha1").update(parts.join("|"), "utf8").digest("hex"); - return `ck_${hash}`; -} - -function targetUrlIsUserPage(url: unknown): boolean { - if (typeof url !== "string") return false; - const tUrl = url.toLowerCase(); - return tUrl.length > 0 && !tUrl.startsWith("devtools://") && !tUrl.startsWith("chrome://"); -} - -function pickCdpPageTarget(targets: Array>): { url: string; wsUrl: string } { - const pages = targets.filter((t) => t.type === "page"); - if (pages.length === 0) { - throw new Error("No page targets available for this Chrome instance."); - } - const selected = pages.find((t) => targetUrlIsUserPage(t.url)) ?? pages[0]; - const url = typeof selected.url === "string" ? selected.url : ""; - const wsUrl = typeof selected.webSocketDebuggerUrl === "string" ? selected.webSocketDebuggerUrl : ""; - if (!wsUrl) { - throw new Error("Selected page target has no webSocketDebuggerUrl."); - } - return { url, wsUrl }; + return `ck_${createHash("sha1").update(parts.join("|"), "utf8").digest("hex")}`; } function getOriginFromUrl(url: string): string | null { @@ -161,345 +102,32 @@ function getOriginFromUrl(url: string): string | null { } } -async function getCdpPageEndpoint(targetId: string): Promise<{ pageUrl: string; wsUrl: string; port: number }> { - const port = await getChromeTargetPort(targetId); - const targets = await getCdpTargets(port, { timeoutMs: 2000 }); - const { url: pageUrl, wsUrl } = pickCdpPageTarget(targets); - return { pageUrl, wsUrl, port }; -} - -async function cdpGetCookies(wsUrl: string, pageUrl?: string): Promise>> { - const attempts: Array<{ method: string; params?: Record }> = [ - { method: "Storage.getCookies" }, - { method: "Network.getAllCookies" }, - ]; - if (pageUrl) { - attempts.push({ method: "Network.getCookies", params: { urls: [pageUrl] } }); - } - - let lastErr: unknown = null; - for (const attempt of attempts) { - try { - const result = await sendCdpCommand(wsUrl, attempt.method, attempt.params); - const cookies = (result as Record).cookies; - if (Array.isArray(cookies)) { - return cookies.filter((c): c is Record => !!c && typeof c === "object"); - } - lastErr = new Error(`CDP ${attempt.method} returned no cookies array.`); - } catch (e) { - lastErr = e; - } - } - - throw new Error(`Unable to fetch cookies via CDP.${lastErr ? ` Last error: ${errorToString(lastErr)}` : ""}`); -} - -async function cdpEvaluateValue( - wsUrl: string, - expression: string, -): Promise { - const result = await sendCdpCommand(wsUrl, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true, - }); - - const err = (result as Record).exceptionDetails; - if (err) { - throw new Error(`Runtime.evaluate failed: ${JSON.stringify(err)}`); - } - - const remote = (result as Record).result as Record | undefined; - return (remote?.value as T); -} - -interface InlineImagePayload { - data: string; - mimeType?: string; -} - -function findInlineImagePayload(payload: unknown): InlineImagePayload | null { - const stack: unknown[] = [payload]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === null || current === undefined) continue; - - if (Array.isArray(current)) { - for (const item of current) stack.push(item); - continue; - } - if (typeof current !== "object") continue; - - const obj = current as Record; - if (obj.type === "image" && typeof obj.data === "string" && obj.data.length > 0) { - return { - data: obj.data, - ...(typeof obj.mimeType === "string" ? { mimeType: obj.mimeType } : {}), - }; - } - for (const value of Object.values(obj)) { - stack.push(value); - } - } - return null; -} - -async function persistScreenshotIfRequested( - devtoolsResult: unknown, - filePath?: string, -): Promise> { - if (!filePath) return {}; - - try { - if (existsSync(filePath)) { - return { - screenshot: { - requestedFilePath: filePath, - saved: true, - savedBy: "sidecar", - }, - }; - } - - const inline = findInlineImagePayload(devtoolsResult); - if (!inline) { - return { - screenshot: { - requestedFilePath: filePath, - saved: false, - warning: "No inline image payload was returned by DevTools sidecar.", - }, - }; - } - - const bytes = Buffer.from(inline.data, "base64"); - if (bytes.length === 0 && inline.data.length > 0) { - return { - screenshot: { - requestedFilePath: filePath, - saved: false, - error: "Inline image payload could not be decoded from base64.", - }, - }; - } - - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, bytes); - - return { - screenshot: { - requestedFilePath: filePath, - saved: true, - savedBy: "proxy-wrapper", - bytesWritten: bytes.length, - ...(inline.mimeType ? { mimeType: inline.mimeType } : {}), - }, - }; - } catch (e) { - return { - screenshot: { - requestedFilePath: filePath, - saved: false, - error: errorToString(e), - }, - }; - } -} - -function findProjectRoot(): string { - const start = dirname(fileURLToPath(import.meta.url)); - let dir = start; - for (let i = 0; i < 10; i++) { - if (existsSync(join(dir, "package.json"))) { - return dir; - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - return process.cwd(); -} - -async function runInstallCommand( - command: string, - args: string[], - cwd: string, - timeoutMs: number, -): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }> { - return await new Promise((resolve) => { - const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); - - let stdout = ""; - let stderr = ""; - let resolved = false; - let timedOut = false; - - const done = (exitCode: number | null): void => { - if (resolved) return; - resolved = true; - resolve({ exitCode, stdout: stdout.trim(), stderr: stderr.trim(), timedOut }); - }; - - child.stdout?.on("data", (d: unknown) => { - stdout += String(d); - }); - child.stderr?.on("data", (d: unknown) => { - stderr += String(d); - }); - - child.on("error", (e) => { - stderr += `\n${String(e)}`; - done(-1); - }); - child.on("close", (code) => done(code)); - - setTimeout(() => { - if (!resolved) { - timedOut = true; - child.kill("SIGTERM"); - } - }, timeoutMs); - }); -} - -async function getChromeTargetPort(targetId: string): Promise { - const chrome = interceptorManager.get("chrome"); - if (!chrome) { - throw new Error("Chrome interceptor not registered."); - } - - const meta = await chrome.getMetadata(); - const target = meta.activeTargets.find((t) => t.id === targetId); - if (!target) { - throw new Error(`Chrome target '${targetId}' not found. Is it still running?`); - } - - // Chrome interceptor stores CDP port in details.port - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = target.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - throw new Error(`Chrome target '${targetId}' has no valid CDP port.`); - } - - return port; -} - -async function ensureSessionTargetIsAlive(sessionId: string): Promise<{ targetId: string }> { - const session = devToolsBridge.getSession(sessionId); - if (!session) { - throw new Error(`DevTools session '${sessionId}' not found.`); - } - - try { - await getChromeTargetPort(session.targetId); - } catch (e) { - await devToolsBridge.closeSession(sessionId).catch(() => {}); - throw new Error(`Bound Chrome target is no longer available: ${errorToString(e)}`); - } - - return { targetId: session.targetId }; -} - export function registerDevToolsTools(server: McpServer): void { - server.tool( - "interceptor_chrome_devtools_pull_sidecar", - "Install/pull chrome-devtools-mcp sidecar locally so full DevTools bridge actions are available.", - { - version: z.string().optional().default("0.2.2").describe("Sidecar version to install"), - timeout_ms: z.number().optional().default(180000).describe("Install timeout in milliseconds"), - save_exact: z.boolean().optional().default(false) - .describe("When true, persist to package.json with --save-exact (default false uses --no-save)"), - }, - async ({ version, timeout_ms, save_exact }) => { - try { - const cwd = findProjectRoot(); - const spec = `chrome-devtools-mcp@${version}`; - const args = save_exact - ? ["install", "--save-exact", spec] - : ["install", "--no-save", spec]; - - const before = getLocalSidecarStatus(); - const result = await runInstallCommand("npm", args, cwd, timeout_ms); - resetSidecarResolutionCache(); - const after = getLocalSidecarStatus(); - - const ok = result.exitCode === 0 && !result.timedOut && after.available; - return { - content: [{ - type: "text", - text: truncateResult({ - status: ok ? "success" : "error", - installed: ok, - version, - mode: save_exact ? "save-exact" : "no-save", - cwd, - before, - after, - command: `npm ${args.join(" ")}`, - exitCode: result.exitCode, - timedOut: result.timedOut, - stdoutTail: result.stdout.slice(-1500), - stderrTail: result.stderr.slice(-1500), - ...(ok - ? {} - : { - hint: - "Install failed or sidecar not resolvable. Check network/npm registry access and rerun this tool.", - }), - }), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); + // ── snapshot ────────────────────────────────────────────────── server.tool( - "interceptor_chrome_devtools_attach", - "Start a chrome-devtools-mcp sidecar session bound to a specific interceptor_chrome_launch target_id.", + "interceptor_browser_snapshot", + "Take an ARIA accessibility snapshot of the bound page (YAML-formatted role tree). " + + "Great for LLM-driven page understanding without parsing HTML.", { - target_id: z.string().describe("Target ID from interceptor_chrome_launch"), - include_targets: z.boolean().optional().default(false).describe("Include current CDP tab targets in output (default: false)"), - timeout_ms: z.number().optional().default(1500).describe("Timeout when fetching optional CDP target list"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), + selector: z.string().optional().default("body").describe("Root selector to snapshot (default: 'body')"), + mode: z.enum(["default", "ai"]).optional().default("default").describe("Snapshot mode — 'ai' adds ref attributes for locator reuse"), }, - async ({ target_id, include_targets, timeout_ms }) => { + async ({ target_id, selector, mode }) => { try { - const port = await getChromeTargetPort(target_id); - const browserUrl = getCdpBaseUrl(port); - const session = await devToolsBridge.createSession(target_id, browserUrl); - - let cdpTargets: Array> | null = null; - let targetsError: string | null = null; - - if (include_targets) { - try { - cdpTargets = await getCdpTargets(port, { timeoutMs: timeout_ms }); - } catch (e) { - targetsError = errorToString(e); - } - } - + const page = getPageForTarget(target_id); + const snapshot = await page.locator(selector).ariaSnapshot({ mode }); return { content: [{ type: "text", text: truncateResult({ status: "success", - session, - ...(session.mode === "native-fallback" - ? { - warning: - "chrome-devtools-mcp binary was not found. Session is running in native-fallback mode: " + - "navigation works; cookie/storage list/get tools still work via direct CDP; " + - "snapshot/network/console/screenshot require installing chrome-devtools-mcp.", - } - : {}), - cdp: { - httpUrl: browserUrl, - }, - cdpTargets, - ...(targetsError ? { targetsError } : {}), + target_id, + url: page.url(), + title: await page.title().catch(() => ""), + root: selector, + snapshot, }), }], }; @@ -509,147 +137,44 @@ export function registerDevToolsTools(server: McpServer): void { }, ); - server.tool( - "interceptor_chrome_devtools_detach", - "Close a chrome-devtools-mcp sidecar session by session ID.", - { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - }, - async ({ devtools_session_id }) => { - try { - const closed = await devToolsBridge.closeSession(devtools_session_id); - if (!closed) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }), - }], - }; - } - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "success", message: `DevTools session ${devtools_session_id} closed.` }), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); + // ── screenshot ──────────────────────────────────────────────── server.tool( - "interceptor_chrome_devtools_navigate", - "Navigate the bound Chrome session via chrome-devtools-mcp and verify matching host traffic was captured by proxy-mcp.", + "interceptor_browser_screenshot", + "Take a screenshot of the bound page. Saves to file_path if provided; otherwise reports byte count without embedding the image.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - url: z.string().describe("Destination URL"), - wait_for_proxy_capture: z.boolean().optional().default(true) - .describe("Wait for matching proxy traffic after navigate (default: true)"), - timeout_ms: z.number().optional().default(5000).describe("Max wait for navigate response and optional proxy verification"), - poll_interval_ms: z.number().optional().default(200).describe("Polling interval while waiting for proxy capture"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), + file_path: z.string().optional().describe("Optional path to save screenshot"), + format: z.enum(["png", "jpeg"]).optional().default("png").describe("Image format (default: png)"), + full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page"), + quality: z.number().optional().describe("JPEG quality 0-100 (ignored for png)"), }, - async ({ devtools_session_id, url, wait_for_proxy_capture, timeout_ms, poll_interval_ms }) => { + async ({ target_id, file_path, format, full_page, quality }) => { try { - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const beforeCount = proxyManager.getTraffic().length; - const devtoolsResult = await devToolsBridge.callAction( - devtools_session_id, - "navigate", - { type: "url", url }, - ); - - const destinationHost = normalizeHostname(url); - let matchedExchangeIds: string[] = []; - let sawAnyNewTraffic = false; - let waitedMs = 0; - - if (wait_for_proxy_capture) { - const startedAt = Date.now(); - while (Date.now() - startedAt <= timeout_ms) { - const delta = proxyManager.getTraffic().slice(beforeCount); - if (delta.length > 0) sawAnyNewTraffic = true; - - if (destinationHost) { - const matches = delta - .filter((x) => { - const host = x.request.hostname.toLowerCase(); - return host === destinationHost || host.endsWith(`.${destinationHost}`); - }) - .map((x) => x.id); - if (matches.length > 0) { - matchedExchangeIds = matches; - break; - } - } else if (delta.length > 0) { - matchedExchangeIds = delta.map((x) => x.id); - break; - } - - await sleep(Math.max(50, poll_interval_ms)); - waitedMs = Date.now() - startedAt; - } - } - - const delta = proxyManager.getTraffic().slice(beforeCount); - const response: Record = { - status: "success", - devtools_session_id, - target_id: targetId, - url, - devtoolsResult: sanitizeDevToolsPayload(devtoolsResult), - traffic: { - beforeCount, - afterCount: beforeCount + delta.length, - deltaCount: delta.length, - destinationHost, - matchedHostExchangeCount: matchedExchangeIds.length, - matchedHostExchangeIds: matchedExchangeIds, - waitedMs, - }, - }; + const page = getPageForTarget(target_id); + const buffer = await page.screenshot({ + type: format, + fullPage: full_page, + ...(format === "jpeg" && quality !== undefined ? { quality } : {}), + }); - if (wait_for_proxy_capture && destinationHost && matchedExchangeIds.length === 0) { - response.warning = sawAnyNewTraffic - ? `Navigation succeeded but no '${destinationHost}' traffic was captured within ${timeout_ms}ms.` - : `No new proxy traffic observed within ${timeout_ms}ms after navigation.`; + let saved = false; + if (file_path) { + await mkdir(dirname(file_path), { recursive: true }); + await writeFile(file_path, buffer); + saved = true; } - return { - content: [{ - type: "text", - text: truncateResult(response), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); - - server.tool( - "interceptor_chrome_devtools_snapshot", - "Take an accessibility snapshot from the bound Chrome DevTools session.", - { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - verbose: z.boolean().optional().default(false).describe("Include full a11y tree details"), - }, - async ({ devtools_session_id, verbose }) => { - try { - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const devtoolsResult = await devToolsBridge.callAction( - devtools_session_id, - "snapshot", - { verbose }, - ); return { content: [{ type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, - devtoolsResult: sanitizeDevToolsPayload(devtoolsResult), + target_id, + format, + full_page, + bytes: buffer.length, + ...(file_path ? { file_path, saved } : {}), }), }], }; @@ -659,75 +184,46 @@ export function registerDevToolsTools(server: McpServer): void { }, ); - server.tool( - "interceptor_chrome_devtools_list_network", - "List network requests from the bound Chrome DevTools session.", - { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - include_preserved_requests: z.boolean().optional().default(false) - .describe("Include requests preserved over the last navigations"), - resource_types: z.array(z.string()).optional().describe("Filter by resource types"), - page_idx: z.number().optional().describe("Page number (0-based)"), - page_size: z.number().optional().describe("Page size"), - }, - async ({ devtools_session_id, include_preserved_requests, resource_types, page_idx, page_size }) => { - try { - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const args: Record = { - includePreservedRequests: include_preserved_requests, - }; - if (resource_types && resource_types.length > 0) args.resourceTypes = resource_types; - if (page_idx !== undefined) args.pageIdx = page_idx; - if (page_size !== undefined) args.pageSize = page_size; - - const devtoolsResult = await devToolsBridge.callAction(devtools_session_id, "listNetwork", args); - return { - content: [{ - type: "text", - text: truncateResult({ - status: "success", - devtools_session_id, - target_id: targetId, - devtoolsResult: sanitizeDevToolsPayload(devtoolsResult), - }), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); + // ── console ─────────────────────────────────────────────────── server.tool( - "interceptor_chrome_devtools_list_console", - "List console messages from the bound Chrome DevTools session.", + "interceptor_browser_list_console", + "List console messages buffered since the browser was launched. Types: log, info, warning, error, debug, etc.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - include_preserved_messages: z.boolean().optional().default(false) - .describe("Include messages preserved over the last navigations"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), types: z.array(z.string()).optional().describe("Filter by console message types"), - page_idx: z.number().optional().describe("Page number (0-based)"), - page_size: z.number().optional().describe("Page size"), + text_filter: z.string().optional().describe("Filter by text substring"), + offset: z.number().optional().default(0).describe("Offset into results (default: 0)"), + limit: z.number().optional().default(DEFAULT_LIST_LIMIT).describe("Max messages to return (default: 50, max: 500)"), }, - async ({ devtools_session_id, include_preserved_messages, types, page_idx, page_size }) => { + async ({ target_id, types, text_filter, offset, limit }) => { try { - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const args: Record = { - includePreservedMessages: include_preserved_messages, - }; - if (types && types.length > 0) args.types = types; - if (page_idx !== undefined) args.pageIdx = page_idx; - if (page_size !== undefined) args.pageSize = page_size; + const entry = getEntry(target_id); + let msgs = entry.consoleBuffer; + if (types && types.length > 0) { + const set = new Set(types.map((t) => t.toLowerCase())); + msgs = msgs.filter((m) => set.has(m.type.toLowerCase())); + } + if (text_filter) { + const needle = text_filter.toLowerCase(); + msgs = msgs.filter((m) => m.text.toLowerCase().includes(needle)); + } + const total = msgs.length; + const o = normalizeOffset(offset); + const l = normalizeLimit(limit); + const page = msgs.slice(o, o + l); - const devtoolsResult = await devToolsBridge.callAction(devtools_session_id, "listConsole", args); return { content: [{ type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, - devtoolsResult: sanitizeDevToolsPayload(devtoolsResult), + target_id, + total, + offset: o, + limit: l, + showing: page.length, + messages: page, }), }], }; @@ -737,50 +233,13 @@ export function registerDevToolsTools(server: McpServer): void { }, ); - server.tool( - "interceptor_chrome_devtools_screenshot", - "Take a screenshot using the bound Chrome DevTools session.", - { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - file_path: z.string().optional().describe("Optional path to save screenshot"), - format: z.enum(["png", "jpeg", "webp"]).optional().describe("Image format"), - full_page: z.boolean().optional().default(false).describe("Capture the full page"), - quality: z.number().optional().describe("Compression quality for jpeg/webp"), - }, - async ({ devtools_session_id, file_path, format, full_page, quality }) => { - try { - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const args: Record = {}; - if (file_path) args.filePath = file_path; - if (format) args.format = format; - if (full_page) args.fullPage = true; - if (quality !== undefined) args.quality = quality; - - const devtoolsResult = await devToolsBridge.callAction(devtools_session_id, "screenshot", args); - const screenshot = await persistScreenshotIfRequested(devtoolsResult, file_path); - return { - content: [{ - type: "text", - text: truncateResult({ - status: "success", - devtools_session_id, - target_id: targetId, - devtoolsResult: sanitizeDevToolsPayload(devtoolsResult), - ...screenshot, - }), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); + // ── cookies ─────────────────────────────────────────────────── server.tool( - "interceptor_chrome_devtools_list_cookies", - "List browser cookies for the bound Chrome session with pagination and truncated values by default.", + "interceptor_browser_list_cookies", + "List cookies from the browser context with pagination and truncated value previews.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), url_filter: z.string().optional().describe("Filter cookies by domain/path substring"), domain_filter: z.string().optional().describe("Filter cookies by domain substring"), name_filter: z.string().optional().describe("Filter cookies by name substring"), @@ -790,44 +249,28 @@ export function registerDevToolsTools(server: McpServer): void { .describe("Max characters for cookie value previews (default: 256)"), sort: z.enum(["name", "domain", "expires"]).optional().default("name").describe("Sort order (default: name)"), }, - async ({ devtools_session_id, url_filter, domain_filter, name_filter, offset, limit, value_max_chars, sort }) => { + async ({ target_id, url_filter, domain_filter, name_filter, offset, limit, value_max_chars, sort }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const { pageUrl, wsUrl } = await getCdpPageEndpoint(targetId); - const cookies = await cdpGetCookies(wsUrl, pageUrl); + const entry = getEntry(target_id); + const cookies = await entry.context.cookies(); const urlNeedle = url_filter?.toLowerCase(); const domainNeedle = domain_filter?.toLowerCase(); const nameNeedle = name_filter?.toLowerCase(); const filtered = cookies.filter((c) => { - const name = typeof c.name === "string" ? c.name : ""; - const domain = typeof c.domain === "string" ? c.domain : ""; - const path = typeof c.path === "string" ? c.path : ""; - if (urlNeedle && !`${domain}${path}`.toLowerCase().includes(urlNeedle)) return false; - if (domainNeedle && !domain.toLowerCase().includes(domainNeedle)) return false; - if (nameNeedle && !name.toLowerCase().includes(nameNeedle)) return false; + if (urlNeedle && !`${c.domain}${c.path}`.toLowerCase().includes(urlNeedle)) return false; + if (domainNeedle && !c.domain.toLowerCase().includes(domainNeedle)) return false; + if (nameNeedle && !c.name.toLowerCase().includes(nameNeedle)) return false; return true; }); const sorted = filtered.sort((a, b) => { - const aName = typeof a.name === "string" ? a.name : ""; - const bName = typeof b.name === "string" ? b.name : ""; - const aDomain = typeof a.domain === "string" ? a.domain : ""; - const bDomain = typeof b.domain === "string" ? b.domain : ""; - const aExpires = typeof a.expires === "number" ? a.expires : 0; - const bExpires = typeof b.expires === "number" ? b.expires : 0; - switch (sort) { - case "domain": return aDomain.localeCompare(bDomain) || aName.localeCompare(bName); - case "expires": return aExpires - bExpires || aDomain.localeCompare(bDomain) || aName.localeCompare(bName); + case "domain": return a.domain.localeCompare(b.domain) || a.name.localeCompare(b.name); + case "expires": return (a.expires ?? 0) - (b.expires ?? 0) || a.domain.localeCompare(b.domain) || a.name.localeCompare(b.name); case "name": - default: return aName.localeCompare(bName) || aDomain.localeCompare(bDomain); + default: return a.name.localeCompare(b.name) || a.domain.localeCompare(b.domain); } }); @@ -839,20 +282,16 @@ export function registerDevToolsTools(server: McpServer): void { const valueCap = Math.max(0, Math.min(HARD_VALUE_CAP_CHARS, Math.trunc(value_max_chars ?? DEFAULT_VALUE_MAX_CHARS))); const summaries = page.map((c) => { - const name = typeof c.name === "string" ? c.name : ""; - const domain = typeof c.domain === "string" ? c.domain : ""; - const path = typeof c.path === "string" ? c.path : ""; - const value = typeof c.value === "string" ? c.value : ""; - const capped = capValue(value, valueCap); + const capped = capValue(c.value, valueCap); return { cookie_id: cookieStableId(c), - name, - domain, - path, - expires: typeof c.expires === "number" ? c.expires : null, - httpOnly: typeof c.httpOnly === "boolean" ? c.httpOnly : null, - secure: typeof c.secure === "boolean" ? c.secure : null, - sameSite: typeof c.sameSite === "string" ? c.sameSite : null, + name: c.name, + domain: c.domain, + path: c.path, + expires: c.expires ?? null, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite ?? null, value_preview: capped.value, value_length: capped.valueLength, value_truncated: capped.truncated, @@ -864,8 +303,7 @@ export function registerDevToolsTools(server: McpServer): void { type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, + target_id, total, offset: o, limit: l, @@ -881,50 +319,31 @@ export function registerDevToolsTools(server: McpServer): void { ); server.tool( - "interceptor_chrome_devtools_get_cookie", + "interceptor_browser_get_cookie", "Get one cookie by cookie_id with full value (subject to a hard cap to keep output bounded).", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - cookie_id: z.string().describe("cookie_id from interceptor_chrome_devtools_list_cookies"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), + cookie_id: z.string().describe("cookie_id from interceptor_browser_list_cookies"), value_max_chars: z.number().optional().default(HARD_VALUE_CAP_CHARS) .describe(`Max characters for cookie value (default: ${HARD_VALUE_CAP_CHARS})`), }, - async ({ devtools_session_id, cookie_id, value_max_chars }) => { + async ({ target_id, cookie_id, value_max_chars }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const { pageUrl, wsUrl } = await getCdpPageEndpoint(targetId); - const cookies = await cdpGetCookies(wsUrl, pageUrl); - + const entry = getEntry(target_id); + const cookies = await entry.context.cookies(); const found = cookies.find((c) => cookieStableId(c) === cookie_id) ?? null; if (!found) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `Cookie '${cookie_id}' not found. Re-run list tool.` }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Cookie '${cookie_id}' not found. Re-run list tool.` }) }] }; } - - const value = typeof found.value === "string" ? found.value : ""; - const capped = capValue(value, Math.max(0, Math.min(HARD_VALUE_CAP_CHARS, Math.trunc(value_max_chars ?? HARD_VALUE_CAP_CHARS)))); - + const capped = capValue(found.value, 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", - devtools_session_id, - target_id: targetId, + target_id, cookie_id, - cookie: { - ...found, - value: capped.value, - }, + cookie: { ...found, value: capped.value }, value_length: capped.valueLength, value_truncated: capped.truncated, value_max_chars: capped.maxChars, @@ -937,11 +356,13 @@ export function registerDevToolsTools(server: McpServer): void { }, ); + // ── storage ─────────────────────────────────────────────────── + server.tool( - "interceptor_chrome_devtools_list_storage_keys", + "interceptor_browser_list_storage_keys", "List localStorage/sessionStorage keys for the current origin with pagination and truncated value previews.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), storage_type: z.enum(["local", "session"]).describe("Storage type"), origin: z.string().optional().describe("Optional origin override (must match current page origin)"), key_filter: z.string().optional().describe("Filter by key substring"), @@ -950,80 +371,46 @@ export function registerDevToolsTools(server: McpServer): void { value_max_chars: z.number().optional().default(DEFAULT_VALUE_MAX_CHARS) .describe("Max characters for storage value previews (default: 256)"), }, - async ({ devtools_session_id, storage_type, origin, key_filter, offset, limit, value_max_chars }) => { + async ({ target_id, storage_type, origin, key_filter, offset, limit, value_max_chars }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const { pageUrl, wsUrl } = await getCdpPageEndpoint(targetId); - + const page = getPageForTarget(target_id); + const pageUrl = page.url(); const currentOrigin = getOriginFromUrl(pageUrl); if (!currentOrigin) { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `No http(s) origin available for current page URL: '${pageUrl}'` }) }] }; } if (origin && origin !== currentOrigin) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `origin '${origin}' does not match current origin '${currentOrigin}'. Navigate first.` }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `origin '${origin}' does not match current origin '${currentOrigin}'. Navigate first.` }) }] }; } const previewLen = Math.max(0, Math.min(HARD_VALUE_CAP_CHARS, Math.trunc(value_max_chars ?? DEFAULT_VALUE_MAX_CHARS))); const keyNeedle = (key_filter ?? "").toLowerCase(); - const stType = storage_type; - - const expr = `(() => { - try { - const storageType = ${JSON.stringify(stType)}; - const keyFilter = ${JSON.stringify(keyNeedle)}; - const maxChars = ${previewLen}; - const storage = storageType === "local" ? localStorage : sessionStorage; - const out = []; - for (let i = 0; i < storage.length; i++) { - const k = storage.key(i); - if (typeof k !== "string") continue; - if (keyFilter && !k.toLowerCase().includes(keyFilter)) continue; - const raw = storage.getItem(k); - const v = typeof raw === "string" ? raw : ""; - out.push({ key: k, valuePreview: maxChars > 0 ? v.slice(0, maxChars) : "", valueLength: v.length }); - } - out.sort((a, b) => a.key.localeCompare(b.key)); - return out; - } catch (e) { - return { error: String(e && e.message ? e.message : e) }; - } -})()`; - - const result = await cdpEvaluateValue(wsUrl, expr) as unknown; - if (result && typeof result === "object" && !Array.isArray(result) && "error" in (result as Record)) { - const err = (result as Record).error; - throw new Error(`Storage evaluation error: ${typeof err === "string" ? err : JSON.stringify(err)}`); - } - if (!Array.isArray(result)) { - throw new Error("Unexpected storage evaluation result."); - } - const items = result - .filter((x): x is Record => !!x && typeof x === "object") - .map((x) => ({ - key: typeof x.key === "string" ? x.key : "", - valuePreview: typeof x.valuePreview === "string" ? x.valuePreview : "", - valueLength: typeof x.valueLength === "number" ? x.valueLength : 0, - })) - .filter((x) => x.key.length > 0); + const items = await page.evaluate( + ({ stType, keyFilter, maxChars }) => { + const storage = stType === "local" ? localStorage : sessionStorage; + const out: { key: string; valuePreview: string; valueLength: number }[] = []; + for (let i = 0; i < storage.length; i++) { + const k = storage.key(i); + if (typeof k !== "string") continue; + if (keyFilter && !k.toLowerCase().includes(keyFilter)) continue; + const raw = storage.getItem(k); + const v = typeof raw === "string" ? raw : ""; + out.push({ key: k, valuePreview: maxChars > 0 ? v.slice(0, maxChars) : "", valueLength: v.length }); + } + out.sort((a, b) => a.key.localeCompare(b.key)); + return out; + }, + { stType: storage_type, keyFilter: keyNeedle, maxChars: previewLen }, + ); const total = items.length; const o = normalizeOffset(offset); const l = normalizeLimit(limit); - const page = items.slice(o, o + l); + const pageItems = items.slice(o, o + l); - const summaries = page.map((x) => ({ - item_id: `st.${stType}.${toBase64UrlUtf8(currentOrigin)}.${toBase64UrlUtf8(x.key)}`, + const summaries = pageItems.map((x) => ({ + item_id: `st.${storage_type}.${toBase64UrlUtf8(currentOrigin)}.${toBase64UrlUtf8(x.key)}`, key: x.key, value_preview: x.valuePreview, value_length: x.valueLength, @@ -1035,10 +422,9 @@ export function registerDevToolsTools(server: McpServer): void { type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, + target_id, origin: currentOrigin, - storage_type: stType, + storage_type, total, offset: o, limit: l, @@ -1054,23 +440,18 @@ export function registerDevToolsTools(server: McpServer): void { ); server.tool( - "interceptor_chrome_devtools_get_storage_value", + "interceptor_browser_get_storage_value", "Get one localStorage/sessionStorage value by item_id.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), storage_type: z.enum(["local", "session"]).describe("Storage type"), - item_id: z.string().describe("item_id from interceptor_chrome_devtools_list_storage_keys"), + item_id: z.string().describe("item_id from interceptor_browser_list_storage_keys"), origin: z.string().optional().describe("Optional origin override (must match current page origin)"), value_max_chars: z.number().optional().default(HARD_VALUE_CAP_CHARS) .describe(`Max characters for returned value (default: ${HARD_VALUE_CAP_CHARS})`), }, - async ({ devtools_session_id, storage_type, item_id, origin, value_max_chars }) => { + async ({ target_id, storage_type, item_id, origin, value_max_chars }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - const parts = item_id.split("."); if (parts.length !== 4 || parts[0] !== "st") { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Invalid item_id '${item_id}'` }) }] }; @@ -1082,73 +463,46 @@ export function registerDevToolsTools(server: McpServer): void { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `item_id storage_type '${itemType}' does not match requested '${storage_type}'` }) }] }; } - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); - const { pageUrl, wsUrl } = await getCdpPageEndpoint(targetId); - + const page = getPageForTarget(target_id); + const pageUrl = page.url(); const currentOrigin = getOriginFromUrl(pageUrl); if (!currentOrigin) { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `No http(s) origin available for current page URL: '${pageUrl}'` }) }] }; } if (origin && origin !== currentOrigin) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `origin '${origin}' does not match current origin '${currentOrigin}'. Navigate first.` }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `origin '${origin}' does not match current origin '${currentOrigin}'. Navigate first.` }) }] }; } if (itemOrigin !== currentOrigin) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `item_id origin '${itemOrigin}' does not match current origin '${currentOrigin}'. Navigate first.` }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `item_id origin '${itemOrigin}' does not match current origin '${currentOrigin}'. Navigate first.` }) }] }; } const maxChars = Math.max(0, Math.min(HARD_VALUE_CAP_CHARS, Math.trunc(value_max_chars ?? HARD_VALUE_CAP_CHARS))); - const stType = storage_type; - - const expr = `(() => { - try { - const storageType = ${JSON.stringify(stType)}; - const key = ${JSON.stringify(itemKey)}; - const maxChars = ${maxChars}; - const storage = storageType === "local" ? localStorage : sessionStorage; - const raw = storage.getItem(key); - const v = typeof raw === "string" ? raw : ""; - const valueLength = v.length; - const truncated = maxChars > 0 && valueLength > maxChars; - const value = maxChars > 0 ? (truncated ? v.slice(0, maxChars) : v) : v; - return { key, value, valueLength, truncated }; - } catch (e) { - return { error: String(e && e.message ? e.message : e) }; - } -})()`; - - const result = await cdpEvaluateValue(wsUrl, expr) as unknown; - if (!result || typeof result !== "object") { - throw new Error("Unexpected storage evaluation result."); - } - const obj = result as Record; - if (obj.error) { - throw new Error(`Storage evaluation error: ${typeof obj.error === "string" ? obj.error : JSON.stringify(obj.error)}`); - } + const result = await page.evaluate( + ({ stType, key, maxChars: mc }) => { + const storage = stType === "local" ? localStorage : sessionStorage; + const raw = storage.getItem(key); + const v = typeof raw === "string" ? raw : ""; + const valueLength = v.length; + const truncated = mc > 0 && valueLength > mc; + const value = mc > 0 ? (truncated ? v.slice(0, mc) : v) : v; + return { key, value, valueLength, truncated }; + }, + { stType: storage_type, key: itemKey, maxChars }, + ); return { content: [{ type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, + target_id, origin: currentOrigin, - storage_type: stType, + storage_type, item_id, - key: obj.key ?? itemKey, - value: typeof obj.value === "string" ? obj.value : "", - value_length: typeof obj.valueLength === "number" ? obj.valueLength : null, - value_truncated: typeof obj.truncated === "boolean" ? obj.truncated : null, + key: result.key, + value: result.value, + value_length: result.valueLength, + value_truncated: result.truncated, value_max_chars: maxChars, }), }], @@ -1159,11 +513,13 @@ export function registerDevToolsTools(server: McpServer): void { }, ); + // ── network fields (from MITM proxy capture) ─────────────────── + server.tool( - "interceptor_chrome_devtools_list_network_fields", - "List request/response header fields from proxy-captured traffic since the DevTools session was created, with pagination and truncation.", + "interceptor_browser_list_network_fields", + "List request/response header fields from proxy-captured traffic since the browser was launched, with pagination and truncation.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), direction: z.enum(["request", "response", "both"]).optional().default("both").describe("Header direction (default: both)"), header_name_filter: z.string().optional().describe("Filter by header name substring"), method_filter: z.string().optional().describe("Filter by HTTP method"), @@ -1175,15 +531,11 @@ export function registerDevToolsTools(server: McpServer): void { value_max_chars: z.number().optional().default(DEFAULT_VALUE_MAX_CHARS) .describe("Max characters for header value previews (default: 256)"), }, - async ({ devtools_session_id, direction, header_name_filter, method_filter, url_filter, status_filter, hostname_filter, offset, limit, value_max_chars }) => { + async ({ target_id, direction, header_name_filter, method_filter, url_filter, status_filter, hostname_filter, offset, limit, value_max_chars }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); + const entry = getEntry(target_id); + const since = entry.target.activatedAt; - const since = session.createdAt; let traffic = proxyManager.getTraffic().filter((t) => t.timestamp >= since); if (method_filter) { @@ -1255,21 +607,20 @@ export function registerDevToolsTools(server: McpServer): void { const total = rows.length; const o = normalizeOffset(offset); const l = normalizeLimit(limit); - const page = rows.slice(o, o + l); + const pageRows = rows.slice(o, o + l); return { content: [{ type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, + target_id, since_ts: since, total, offset: o, limit: l, - showing: page.length, - fields: page, + showing: pageRows.length, + fields: pageRows, }), }], }; @@ -1280,21 +631,18 @@ export function registerDevToolsTools(server: McpServer): void { ); server.tool( - "interceptor_chrome_devtools_get_network_field", + "interceptor_browser_get_network_field", "Get one full header field value from proxy-captured traffic by field_id.", { - devtools_session_id: z.string().describe("Session ID from interceptor_chrome_devtools_attach"), - field_id: z.string().describe("field_id from interceptor_chrome_devtools_list_network_fields"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), + field_id: z.string().describe("field_id from interceptor_browser_list_network_fields"), value_max_chars: z.number().optional().default(HARD_VALUE_CAP_CHARS) .describe(`Max characters for returned value (default: ${HARD_VALUE_CAP_CHARS})`), }, - async ({ devtools_session_id, field_id, value_max_chars }) => { + async ({ target_id, field_id, value_max_chars }) => { try { - const session = devToolsBridge.getSession(devtools_session_id); - if (!session) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `DevTools session '${devtools_session_id}' not found.` }) }] }; - } - const { targetId } = await ensureSessionTargetIsAlive(devtools_session_id); + const entry = getEntry(target_id); + const since = entry.target.activatedAt; const parts = field_id.split("."); if (parts.length !== 4 || parts[0] !== "nf") { @@ -1308,8 +656,8 @@ export function registerDevToolsTools(server: McpServer): void { if (!exchange) { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Exchange '${exchangeId}' not found in capture buffer.` }) }] }; } - if (exchange.timestamp < session.createdAt) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: "field_id refers to an exchange older than this DevTools session." }) }] }; + if (exchange.timestamp < since) { + return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: "field_id refers to an exchange older than this browser session." }) }] }; } let value: string | null = null; @@ -1320,7 +668,6 @@ export function registerDevToolsTools(server: McpServer): void { } else { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Invalid field direction '${dir}'` }) }] }; } - if (value === null) { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Header '${headerName}' not found on ${dir}.` }) }] }; } @@ -1332,8 +679,7 @@ export function registerDevToolsTools(server: McpServer): void { type: "text", text: truncateResult({ status: "success", - devtools_session_id, - target_id: targetId, + target_id, field_id, exchange_id: exchangeId, direction: dir, diff --git a/src/tools/humanizer.ts b/src/tools/humanizer.ts index 64eae6e..7c97fee 100644 --- a/src/tools/humanizer.ts +++ b/src/tools/humanizer.ts @@ -1,9 +1,9 @@ /** - * Humanizer MCP tools — human-like browser input via CDP. + * Humanizer MCP tools — human-like browser input via Playwright. * - * Dispatches realistic mouse, keyboard, and scroll events through the - * Chrome DevTools Protocol. Binds to target_id (Chrome interceptor target), - * not devtools_session_id — the humanizer manages its own CdpSession. + * Bound to a browser interceptor target_id (from interceptor_browser_launch). + * humanizer_click supports locator-first targeting (selector | role+name | + * text | label) so callers no longer need to guess pixel coordinates. */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -24,7 +24,7 @@ export function registerHumanizerTools(server: McpServer): void { "Move mouse along a human-like Bezier curve to target coordinates. " + "Uses Fitts's law velocity scaling and eased timing profile.", { - target_id: z.string().describe("Chrome target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Browser target ID from interceptor_browser_launch"), x: z.number().describe("Destination X coordinate"), y: z.number().describe("Destination Y coordinate"), duration_ms: z.number().optional().default(600) @@ -41,20 +41,12 @@ export function registerHumanizerTools(server: McpServer): void { target_id, action: "move", destination: { x, y }, - stats: { - total_ms: result.totalMs, - events_dispatched: result.eventsDispatched, - }, + stats: { total_ms: result.totalMs, events_dispatched: result.eventsDispatched }, }), }], }; } catch (e) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", target_id, action: "move", error: errorToString(e) }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", target_id, action: "move", error: errorToString(e) }) }] }; } }, ); @@ -63,13 +55,18 @@ export function registerHumanizerTools(server: McpServer): void { server.tool( "humanizer_click", - "Move to an element (by CSS selector) or coordinates, then click with human-like timing. " + - "Supports left/right/middle button and multi-click (double-click, etc.).", + "Click an element using Playwright locators — no need to guess pixel coordinates. " + + "Auto-waits for visible + enabled + stable + in-view before clicking. Pass one of: " + + "selector (CSS/XPath), role + optional name, text, label, or raw x+y coords as fallback.", { - target_id: z.string().describe("Chrome target ID from interceptor_chrome_launch"), - selector: z.string().optional().describe("CSS selector to click (resolved via getBoundingClientRect)"), - x: z.number().optional().describe("X coordinate (used if selector is not provided)"), - y: z.number().optional().describe("Y coordinate (used if selector is not provided)"), + target_id: z.string().describe("Browser target ID from interceptor_browser_launch"), + selector: z.string().optional().describe("CSS or XPath selector (e.g. 'button.submit', '//button[@id=\"go\"]')"), + role: z.string().optional().describe("ARIA role (e.g. 'button', 'link', 'textbox')"), + name: z.string().optional().describe("Accessible name; used with role (e.g. 'Sign in')"), + text: z.string().optional().describe("Visible text to match (e.g. 'Accept cookies')"), + label: z.string().optional().describe("Form-field label text (e.g. 'Email address')"), + x: z.number().optional().describe("X coordinate fallback when no locator is given"), + y: z.number().optional().describe("Y coordinate fallback when no locator is given"), button: z.enum(["left", "right", "middle"]).optional().default("left") .describe("Mouse button (default: left)"), click_count: z.number().optional().default(1) @@ -77,10 +74,14 @@ export function registerHumanizerTools(server: McpServer): void { move_duration_ms: z.number().optional().default(600) .describe("Base duration for mouse movement (default: 600)"), }, - async ({ target_id, selector, x, y, button, click_count, move_duration_ms }) => { + async ({ target_id, selector, role, name, text, label, x, y, button, click_count, move_duration_ms }) => { try { const result = await humanizerEngine.click(target_id, { selector, + role, + name, + text, + label, x, y, button, @@ -94,23 +95,16 @@ export function registerHumanizerTools(server: McpServer): void { status: "success", target_id, action: "click", + resolved_by: result.resolvedBy, clicked_at: result.clickedAt, button, click_count, - stats: { - total_ms: result.totalMs, - events_dispatched: result.eventsDispatched, - }, + stats: { total_ms: result.totalMs, events_dispatched: result.eventsDispatched }, }), }], }; } catch (e) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", target_id, action: "click", error: errorToString(e) }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", target_id, action: "click", error: errorToString(e) }) }] }; } }, ); @@ -123,7 +117,7 @@ export function registerHumanizerTools(server: McpServer): void { "Models per-character delays based on WPM, bigram frequency, shift penalty, " + "word boundary pauses, and optional typo injection with backspace correction.", { - target_id: z.string().describe("Chrome target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Browser target ID from interceptor_browser_launch"), text: z.string().describe("Text to type"), wpm: z.number().optional().default(40) .describe("Typing speed in words per minute (default: 40)"), @@ -132,10 +126,7 @@ export function registerHumanizerTools(server: McpServer): void { }, async ({ target_id, text, wpm, error_rate }) => { try { - const result = await humanizerEngine.typeText(target_id, text, { - wpm, - errorRate: error_rate, - }); + const result = await humanizerEngine.typeText(target_id, text, { wpm, errorRate: error_rate }); return { content: [{ type: "text", @@ -156,12 +147,7 @@ export function registerHumanizerTools(server: McpServer): void { }], }; } catch (e) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", target_id, action: "type", error: errorToString(e) }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", target_id, action: "type", error: errorToString(e) }) }] }; } }, ); @@ -173,7 +159,7 @@ export function registerHumanizerTools(server: McpServer): void { "Scroll with natural acceleration/deceleration using easeInOutQuad velocity distribution. " + "Dispatches multiple wheel events to simulate human scroll behavior.", { - target_id: z.string().describe("Chrome target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Browser target ID from interceptor_browser_launch"), delta_y: z.number().describe("Vertical scroll delta in pixels (positive = scroll down)"), delta_x: z.number().optional().default(0) .describe("Horizontal scroll delta in pixels (default: 0)"), @@ -191,20 +177,12 @@ export function registerHumanizerTools(server: McpServer): void { target_id, action: "scroll", delta: { x: delta_x, y: delta_y }, - stats: { - total_ms: result.totalMs, - events_dispatched: result.eventsDispatched, - }, + stats: { total_ms: result.totalMs, events_dispatched: result.eventsDispatched }, }), }], }; } catch (e) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", target_id, action: "scroll", error: errorToString(e) }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", target_id, action: "scroll", error: errorToString(e) }) }] }; } }, ); @@ -216,7 +194,7 @@ export function registerHumanizerTools(server: McpServer): void { "Simulate idle behavior with mouse micro-jitter and occasional micro-scrolls. " + "Keeps the page 'alive' to avoid idle detection by bot-detection scripts.", { - target_id: z.string().describe("Chrome target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Browser target ID from interceptor_browser_launch"), duration_ms: z.number().describe("How long to simulate idle behavior in ms"), intensity: z.enum(["subtle", "normal"]).optional().default("subtle") .describe("Idle intensity: 'subtle' (±3px jitter) or 'normal' (±8px jitter, more scrolls)"), @@ -233,20 +211,12 @@ export function registerHumanizerTools(server: McpServer): void { action: "idle", requested_ms: duration_ms, intensity, - stats: { - total_ms: result.totalMs, - events_dispatched: result.eventsDispatched, - }, + stats: { total_ms: result.totalMs, events_dispatched: result.eventsDispatched }, }), }], }; } catch (e) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", target_id, action: "idle", error: errorToString(e) }), - }], - }; + return { content: [{ type: "text", text: JSON.stringify({ status: "error", target_id, action: "idle", error: errorToString(e) }) }] }; } }, ); diff --git a/src/tools/interceptors.ts b/src/tools/interceptors.ts index a240f14..712da37 100644 --- a/src/tools/interceptors.ts +++ b/src/tools/interceptors.ts @@ -1,9 +1,9 @@ /** - * Interceptor tools — 18 MCP tools for auto-attaching to Chrome, Android, Docker, and processes. + * Interceptor tools — MCP tools for auto-attaching to Browser, Android, Docker, and processes. * * Organized into 6 groups: * Discovery (3): list, status, deactivate_all - * Chrome (4): launch, cdp_info, navigate, close + * Browser (3): launch, navigate, close * Terminal (2): spawn, kill * Android ADB (4): devices, setup, activate, deactivate * Android Frida (3): apps, attach, detach @@ -17,8 +17,7 @@ import { interceptorManager } from "../interceptors/manager.js"; import type { TerminalInterceptor } from "../interceptors/terminal.js"; import type { AndroidAdbInterceptor } from "../interceptors/android-adb.js"; import type { AndroidFridaInterceptor } from "../interceptors/android-frida.js"; -import { getCdpBaseUrl, getCdpTargets, getCdpTargetsUrl, getCdpVersionUrl, sendCdpCommand, waitForCdpVersion } from "../cdp-utils.js"; -import { devToolsBridge } from "../devtools/bridge.js"; +import { getPageForTarget } from "../browser/session.js"; import { truncateResult } from "../utils.js"; /** Robust error-to-string — handles Error, plain objects (e.g. DBus errors), and primitives. */ @@ -26,7 +25,6 @@ function errorToString(e: unknown): string { if (e instanceof Error) return e.message; if (typeof e === "string") return e; if (e && typeof e === "object") { - // DBus/frida-js errors are often plain objects with message/name/description fields const obj = e as Record; if (obj.message) return String(obj.message); if (obj.description) return String(obj.description); @@ -36,7 +34,6 @@ function errorToString(e: unknown): string { return String(e); } -/** Helper — require proxy running and return port + cert info. */ function requireProxy(): { proxyPort: number; certPem: string; certFingerprint: string } { if (!proxyManager.isRunning()) { throw new Error("Proxy is not running. Start it first with proxy_start."); @@ -71,7 +68,7 @@ export function registerInterceptorTools(server: McpServer): void { server.tool( "interceptor_list", - "List all interceptors with their availability and active targets. Shows Chrome, Terminal, Android ADB, Android Frida, and Docker interceptors.", + "List all interceptors with their availability and active targets. Shows Browser, Terminal, Android ADB, Android Frida, and Docker interceptors.", {}, async () => { try { @@ -96,7 +93,7 @@ export function registerInterceptorTools(server: McpServer): void { "interceptor_status", "Get detailed status of a specific interceptor, including all active targets and their details.", { - interceptor_id: z.string().describe("Interceptor ID (e.g., 'chrome', 'terminal', 'android-adb', 'android-frida', 'docker')"), + interceptor_id: z.string().describe("Interceptor ID (e.g., 'browser', 'terminal', 'android-adb', 'android-frida', 'docker')"), }, async ({ interceptor_id }) => { try { @@ -119,11 +116,10 @@ export function registerInterceptorTools(server: McpServer): void { server.tool( "interceptor_deactivate_all", - "Kill ALL active interceptors across all types. Emergency cleanup — stops all Chrome instances, kills spawned processes, removes ADB tunnels, detaches Frida, cleans Docker.", + "Kill ALL active interceptors across all types. Emergency cleanup — stops all browser instances, kills spawned processes, removes ADB tunnels, detaches Frida, cleans Docker.", {}, async () => { try { - await devToolsBridge.closeAllSessions().catch(() => {}); await interceptorManager.deactivateAll(); return { content: [{ @@ -138,31 +134,39 @@ export function registerInterceptorTools(server: McpServer): void { ); // ────────────────────────────────────────── - // Chrome (4 tools) + // Browser (3 tools) // ────────────────────────────────────────── server.tool( - "interceptor_chrome_launch", - "Launch Chrome/Chromium with proxy flags and SPKI certificate trust. Uses isolated temp profile. Traffic automatically flows through the MITM proxy.", + "interceptor_browser_launch", + "Launch cloakbrowser (stealth Chromium) with proxy flags and SPKI certificate trust. Built-in source-level fingerprint patches + humanize mode. Driven via Playwright — locator-based tools replace CDP.", { url: z.string().optional().describe("URL to open (default: about:blank)"), - browser: z.enum(["chrome", "chromium", "brave", "edge"]).optional().default("chrome") - .describe("Browser variant to launch"), - incognito: z.boolean().optional().default(false).describe("Launch in incognito mode"), - }, - async ({ url, browser, incognito }) => { + headless: z.boolean().optional().default(false).describe("Run headless (default: false)"), + humanize: z.boolean().optional().default(true).describe("Enable cloakbrowser's humanize mode (default: true)"), + human_preset: z.enum(["default", "careful"]).optional().default("default") + .describe("Human behavior preset"), + timezone: z.string().optional().describe("IANA timezone, e.g. 'America/New_York'"), + locale: z.string().optional().describe("BCP 47 locale, e.g. 'en-US'"), + viewport_width: z.number().optional().describe("Viewport width in px"), + viewport_height: z.number().optional().describe("Viewport height in px"), + }, + async ({ url, headless, humanize, human_preset, timezone, locale, viewport_width, viewport_height }) => { try { const proxyInfo = requireProxy(); + const viewport = (viewport_width && viewport_height) + ? { width: viewport_width, height: viewport_height } + : undefined; - // Always launch in stealth mode: minimal flags + CDP stealth patches. - // Chrome keeps its real UA so in-page bot sensors see capabilities that - // match the actual browser version. - const result = await interceptorManager.activate("chrome", { + const result = await interceptorManager.activate("browser", { ...proxyInfo, url, - browser, - incognito, - stealthMode: true, + headless, + humanize, + humanPreset: human_preset, + timezone, + locale, + viewport, }); return { content: [{ @@ -177,182 +181,28 @@ export function registerInterceptorTools(server: McpServer): void { ); server.tool( - "interceptor_chrome_cdp_info", - "Get CDP endpoints (HTTP + WebSocket) and tab targets for a Chrome instance launched by interceptor_chrome_launch. Useful for attaching Playwright/DevTools.", + "interceptor_browser_navigate", + "Navigate the browser target's page via Playwright and optionally wait for matching host traffic to be captured by the proxy.", { - target_id: z.string().describe("Target ID from interceptor_chrome_launch"), - include_targets: z.boolean().optional().default(true).describe("Include /json/list targets (default: true)"), - timeout_ms: z.number().optional().default(3000).describe("Total time to wait for CDP readiness (default: 3000ms)"), - retry_interval_ms: z.number().optional().default(200).describe("Retry interval while waiting for CDP (default: 200ms)"), - }, - async ({ target_id, include_targets, timeout_ms, retry_interval_ms }) => { - try { - const chrome = interceptorManager.get("chrome"); - if (!chrome) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: "Chrome interceptor not registered." }) }] }; - } - - const meta = await chrome.getMetadata(); - const target = meta.activeTargets.find((t) => t.id === target_id); - if (!target) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `Chrome target '${target_id}' not found. Is it still running?` }), - }], - }; - } - - // Chrome interceptor stores CDP port in details.port - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = target.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Chrome target '${target_id}' has no valid CDP port.` }) }] }; - } - - const httpUrl = getCdpBaseUrl(port); - const versionUrl = getCdpVersionUrl(port); - const targetsUrl = getCdpTargetsUrl(port); - - const version = await waitForCdpVersion(port, { - timeoutMs: timeout_ms, - intervalMs: retry_interval_ms, - requestTimeoutMs: Math.min(1000, Math.max(100, retry_interval_ms)), - }); - const ws = version.webSocketDebuggerUrl; - - let targets: Array> | null = null; - let targetsError: string | null = null; - if (include_targets) { - try { - targets = await getCdpTargets(port, { timeoutMs: 1500 }); - } catch (e) { - targetsError = errorToString(e); - } - } - - return { - content: [{ - type: "text", - text: truncateResult({ - status: "success", - target_id, - cdp: { - httpUrl, - versionUrl, - targetsUrl, - version, - browserWebSocketDebuggerUrl: typeof ws === "string" ? ws : null, - }, - targets, - ...(targetsError ? { targetsError } : {}), - }), - }], - }; - } catch (e) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; - } - }, - ); - - server.tool( - "interceptor_chrome_navigate", - "Navigate a tab in a specific Chrome instance launched by interceptor_chrome_launch using that instance's CDP target WebSocket. Prevents cross-instance mistakes when proxy capture is required.", - { - target_id: z.string().describe("Target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), url: z.string().describe("Destination URL"), - page_target_id: z.string().optional().describe("Optional page target ID from interceptor_chrome_cdp_info targets"), + wait_until: z.enum(["load", "domcontentloaded", "networkidle", "commit"]).optional().default("domcontentloaded") + .describe("Playwright wait condition (default: domcontentloaded)"), wait_for_proxy_capture: z.boolean().optional().default(true) .describe("Wait for matching proxy traffic after navigate (default: true)"), - timeout_ms: z.number().optional().default(5000).describe("Max wait for CDP response and proxy capture (default: 5000ms)"), + timeout_ms: z.number().optional().default(5000).describe("Max wait for navigation and proxy capture (default: 5000ms)"), poll_interval_ms: z.number().optional().default(200).describe("Polling interval while waiting for proxy capture (default: 200ms)"), }, - async ({ target_id, url, page_target_id, wait_for_proxy_capture, timeout_ms, poll_interval_ms }) => { + async ({ target_id, url, wait_until, wait_for_proxy_capture, timeout_ms, poll_interval_ms }) => { try { - const chrome = interceptorManager.get("chrome"); - if (!chrome) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: "Chrome interceptor not registered." }) }] }; - } - - const meta = await chrome.getMetadata(); - const target = meta.activeTargets.find((t) => t.id === target_id); - if (!target) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `Chrome target '${target_id}' not found. Is it still running?` }), - }], - }; - } - - // Chrome interceptor stores CDP port in details.port - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const details: any = target.details ?? {}; - const port = details.port; - if (typeof port !== "number" || !Number.isFinite(port) || port <= 0) { - return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: `Chrome target '${target_id}' has no valid CDP port.` }) }] }; - } + const page = getPageForTarget(target_id); + const beforeCount = proxyManager.getTraffic().length; - await waitForCdpVersion(port, { - timeoutMs: timeout_ms, - intervalMs: Math.max(50, Math.min(500, poll_interval_ms)), - requestTimeoutMs: Math.min(1000, Math.max(100, poll_interval_ms)), + const response = await page.goto(url, { + waitUntil: wait_until, + timeout: timeout_ms, }); - const cdpTargets = await getCdpTargets(port, { timeoutMs: Math.min(timeout_ms, 2000) }); - const pageTargets = cdpTargets.filter((t) => t.type === "page"); - if (pageTargets.length === 0) { - return { - content: [{ - type: "text", - text: JSON.stringify({ status: "error", error: `No page targets available on Chrome target '${target_id}'.` }), - }], - }; - } - - let selectedTarget: Record | undefined; - if (page_target_id) { - selectedTarget = pageTargets.find((t) => t.id === page_target_id); - if (!selectedTarget) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - status: "error", - error: `Page target '${page_target_id}' not found on Chrome target '${target_id}'.`, - }), - }], - }; - } - } else { - selectedTarget = pageTargets.find((t) => { - const tUrl = typeof t.url === "string" ? t.url.toLowerCase() : ""; - return tUrl.length > 0 && !tUrl.startsWith("devtools://") && !tUrl.startsWith("chrome://"); - }) ?? pageTargets[0]; - } - - const pageTargetWs = selectedTarget.webSocketDebuggerUrl; - if (typeof pageTargetWs !== "string" || pageTargetWs.length === 0) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - status: "error", - error: `Selected page target has no webSocketDebuggerUrl on Chrome target '${target_id}'.`, - }), - }], - }; - } - - const beforeCount = proxyManager.getTraffic().length; - const cdpResult = await sendCdpCommand( - pageTargetWs, - "Page.navigate", - { url }, - { timeoutMs: timeout_ms }, - ); - const destinationHost = normalizeHostname(url); let matchedExchangeIds: string[] = []; let sawAnyNewTraffic = false; @@ -362,9 +212,8 @@ export function registerInterceptorTools(server: McpServer): void { const startedAt = Date.now(); while (Date.now() - startedAt <= timeout_ms) { const delta = proxyManager.getTraffic().slice(beforeCount); - if (delta.length > 0) { - sawAnyNewTraffic = true; - } + if (delta.length > 0) sawAnyNewTraffic = true; + if (destinationHost) { const matches = delta .filter((x) => { @@ -386,13 +235,12 @@ export function registerInterceptorTools(server: McpServer): void { } const delta = proxyManager.getTraffic().slice(beforeCount); - const response: Record = { + const payload: Record = { status: "success", target_id, url, - selected_page_target_id: selectedTarget.id ?? null, - selected_page_url: selectedTarget.url ?? null, - cdpResult, + http_status: response?.status() ?? null, + final_url: page.url(), traffic: { beforeCount, afterCount: beforeCount + delta.length, @@ -405,17 +253,12 @@ export function registerInterceptorTools(server: McpServer): void { }; if (wait_for_proxy_capture && destinationHost && matchedExchangeIds.length === 0) { - response.warning = sawAnyNewTraffic + payload.warning = sawAnyNewTraffic ? `Navigation succeeded but no '${destinationHost}' traffic was captured within ${timeout_ms}ms.` : `No new proxy traffic observed within ${timeout_ms}ms after navigation.`; } - return { - content: [{ - type: "text", - text: truncateResult(response), - }], - }; + return { content: [{ type: "text", text: truncateResult(payload) }] }; } catch (e) { return { content: [{ type: "text", text: JSON.stringify({ status: "error", error: errorToString(e) }) }] }; } @@ -423,19 +266,18 @@ export function registerInterceptorTools(server: McpServer): void { ); server.tool( - "interceptor_chrome_close", - "Close a Chrome instance launched by interceptor_chrome_launch.", + "interceptor_browser_close", + "Close a browser instance launched by interceptor_browser_launch.", { - target_id: z.string().describe("Target ID from interceptor_chrome_launch"), + target_id: z.string().describe("Target ID from interceptor_browser_launch"), }, async ({ target_id }) => { try { - await devToolsBridge.closeSessionsByTarget(target_id).catch(() => {}); - await interceptorManager.deactivate("chrome", target_id); + await interceptorManager.deactivate("browser", target_id); return { content: [{ type: "text", - text: JSON.stringify({ status: "success", message: `Chrome instance ${target_id} closed.` }), + text: JSON.stringify({ status: "success", message: `Browser instance ${target_id} closed.` }), }], }; } catch (e) { @@ -487,7 +329,6 @@ export function registerInterceptorTools(server: McpServer): void { }, async ({ target_id }) => { try { - // Get output before killing const terminal = interceptorManager.get("terminal") as TerminalInterceptor | undefined; const output = terminal?.getProcessOutput(target_id); diff --git a/src/tools/lifecycle.ts b/src/tools/lifecycle.ts index 5f9d214..c481a15 100644 --- a/src/tools/lifecycle.ts +++ b/src/tools/lifecycle.ts @@ -6,7 +6,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { proxyManager } from "../state.js"; import { getLocalIP } from "../utils.js"; -import { devToolsBridge } from "../devtools/bridge.js"; export function registerLifecycleTools(server: McpServer): void { server.tool( @@ -66,7 +65,6 @@ export function registerLifecycleTools(server: McpServer): void { {}, async () => { try { - await devToolsBridge.closeAllSessions().catch(() => {}); await proxyManager.stop(); return { content: [{ diff --git a/test/e2e/fingerprint-spoofing.test.ts b/test/e2e/fingerprint-spoofing.test.ts index 42f3c1b..4711ffe 100644 --- a/test/e2e/fingerprint-spoofing.test.ts +++ b/test/e2e/fingerprint-spoofing.test.ts @@ -1,16 +1,18 @@ /** * E2E fingerprint spoofing tests — success metrics for issue #2. * - * Requirements: Chrome/Chromium, internet access. + * Requirements: cloakbrowser binary, internet access. * - * These tests launch the full MCP server, start a proxy, enable fingerprint - * spoofing via impit, launch Chrome, and navigate to real sites - * to verify TLS/HTTP2 fingerprint fidelity. + * Launches the full MCP server, starts a proxy, enables fingerprint spoofing + * (for impit-based outbound), launches cloakbrowser via the browser + * interceptor, and drives the bound Playwright Page to verify TLS/HTTP2 + * fingerprint fidelity at real sites. */ import { describe, it, before, after } from "node:test"; import assert from "node:assert/strict"; +import type { Page } from "playwright-core"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -26,8 +28,7 @@ import { registerDevToolsTools } from "../../src/tools/devtools.js"; import { registerSessionTools } from "../../src/tools/sessions.js"; import { registerResources } from "../../src/resources.js"; import { initInterceptors } from "../../src/interceptors/init.js"; - -import { CdpSession, getCdpTargets, waitForCdpVersion } from "../../src/cdp-utils.js"; +import { getPageForTarget } from "../../src/browser/session.js"; // ── Helpers ── @@ -44,13 +45,11 @@ function sleep(ms: number): Promise { describe("E2E Fingerprint Spoofing", () => { let client: Client; let proxyPort: number; - let chromeTargetId: string; - let cdpPort: number; - let cdpSession: CdpSession; + let browserTargetId: string; + let page: Page; let browserleaksData: Record | null = null; before(async () => { - // Set up MCP server + client const server = new McpServer({ name: "proxy-e2e", version: "1.0.0" }); initInterceptors(); registerLifecycleTools(server); @@ -75,8 +74,9 @@ describe("E2E Fingerprint Spoofing", () => { ); assert.equal(startRes.status, "success", `proxy_start failed: ${JSON.stringify(startRes)}`); proxyPort = startRes.port as number; + void proxyPort; - // Enable fingerprint spoofing with chrome_131 preset + // Enable outbound fingerprint spoofing (impit) with chrome_131 preset const spoofRes = parseToolResult( await client.callTool({ name: "proxy_set_fingerprint_spoof", @@ -85,66 +85,50 @@ describe("E2E Fingerprint Spoofing", () => { ); assert.equal(spoofRes.status, "success", `fingerprint spoof failed: ${JSON.stringify(spoofRes)}`); - // Launch Chrome - const chromeRes = parseToolResult( + // Launch cloakbrowser via the browser interceptor + const browserRes = parseToolResult( await client.callTool({ - name: "interceptor_chrome_launch", - arguments: { url: "about:blank" }, + name: "interceptor_browser_launch", + arguments: { url: "about:blank", headless: false, humanize: true }, }) as { content: Array<{ text: string }> }, ); - assert.equal(chromeRes.status, "success", `chrome launch failed: ${JSON.stringify(chromeRes)}`); - chromeTargetId = chromeRes.targetId as string; - const details = chromeRes.details as Record; - cdpPort = details.port as number; - - // Wait for CDP and open a persistent session - await waitForCdpVersion(cdpPort, { timeoutMs: 10_000 }); - const targets = await getCdpTargets(cdpPort); - const pageTarget = targets.find((t) => t.type === "page") as Record | undefined; - assert.ok(pageTarget, "No page target found"); - const wsUrl = pageTarget.webSocketDebuggerUrl as string; - cdpSession = await CdpSession.open(wsUrl, { timeoutMs: 10_000 }); - - // Brief warm-up: navigate to a simple HTTPS page to establish connections. - // impit is in-process — no Docker cold-start needed. - cdpSession.send("Page.navigate", { url: "https://httpbin.org/get" }, { timeoutMs: 30_000 }).catch(() => {}); - await sleep(5_000); + assert.equal(browserRes.status, "success", `browser launch failed: ${JSON.stringify(browserRes)}`); + browserTargetId = browserRes.targetId as string; + + page = getPageForTarget(browserTargetId); + + // Warm-up navigation + try { + await page.goto("https://httpbin.org/get", { waitUntil: "domcontentloaded", timeout: 30_000 }); + } catch { /* non-fatal */ } + await sleep(2_000); await client.callTool({ name: "proxy_clear_traffic", arguments: {} }); }); after(async () => { - // Cleanup - try { cdpSession?.close(); } catch { /* */ } try { - if (chromeTargetId) { + if (browserTargetId) { await client.callTool({ - name: "interceptor_chrome_close", - arguments: { target_id: chromeTargetId }, + name: "interceptor_browser_close", + arguments: { target_id: browserTargetId }, }); } } catch { /* */ } try { await client.callTool({ name: "proxy_clear_ja3_spoof", arguments: {} }); } catch { /* */ } try { await client.callTool({ name: "proxy_stop", arguments: {} }); } catch { /* */ } try { await client.close(); } catch { /* */ } - // impit's native Rust connection pool keeps the event loop alive (no close() API). - // Force exit after cleanup since all assertions have already run. + // impit's native Rust pool keeps the event loop alive; force exit. setTimeout(() => process.exit(0), 1_000); }); // ── Test 1: Barnes & Noble ── it("Barnes & Noble loads with spoofed fingerprint", { timeout: 120_000 }, async () => { - // Navigate to about:blank first to clear previous page state - await cdpSession.send("Page.navigate", { url: "about:blank" }, { timeoutMs: 5_000 }).catch(() => {}); + await page.goto("about:blank").catch(() => {}); await sleep(500); - // Step 1: Initial navigation — Akamai may return 403 with sensor JS challenge. - // The sensor script runs in Chrome, POSTs validation data, and solves the _abck cookie. - cdpSession.send("Page.navigate", { url: "https://www.barnesandnoble.com/" }, { timeoutMs: 60_000 }).catch(() => {}); + page.goto("https://www.barnesandnoble.com/", { timeout: 60_000 }).catch(() => {}); - // Poll until we see B&N traffic with a response. - // Use url_filter (not hostname_filter) because req.hostname can be empty - // for HTTPS requests handled via beforeRequest synthetic responses. let exchanges: Array> = []; for (let i = 0; i < 30; i++) { await sleep(2_000); @@ -160,21 +144,14 @@ describe("E2E Fingerprint Spoofing", () => { assert.ok(exchanges.length > 0, "No traffic captured for barnesandnoble.com"); - // Check if the first response was 403 (Akamai challenge) const firstDoc = exchanges.find((e) => typeof e.status === "number"); assert.ok(firstDoc, "No completed exchange found"); const firstStatus = firstDoc.status as number; if (firstStatus === 403) { - // Akamai challenge: wait for sensor JS to execute and solve the _abck cookie, - // then retry navigation. - await sleep(15_000); // Sensor script needs time to run + POST validation - - // Clear traffic for clean retry observation + await sleep(15_000); await client.callTool({ name: "proxy_clear_traffic", arguments: {} }); - - // Step 2: Retry navigation — Chrome should now have a solved _abck cookie - cdpSession.send("Page.navigate", { url: "https://www.barnesandnoble.com/" }, { timeoutMs: 60_000 }).catch(() => {}); + page.goto("https://www.barnesandnoble.com/", { timeout: 60_000 }).catch(() => {}); let retryExchanges: Array> = []; for (let i = 0; i < 30; i++) { @@ -193,18 +170,12 @@ describe("E2E Fingerprint Spoofing", () => { const retryDoc = retryExchanges.find((e) => typeof e.status === "number"); assert.ok(retryDoc, "No completed retry exchange found"); const retryStatus = retryDoc.status as number; - assert.ok(retryStatus >= 200 && retryStatus < 400, `Retry got ${retryStatus}, expected 2xx/3xx (Akamai challenge may not be solvable through proxy)`); + assert.ok(retryStatus >= 200 && retryStatus < 400, `Retry got ${retryStatus}, expected 2xx/3xx`); } else { - // Direct success — no Akamai challenge assert.ok(firstStatus >= 200 && firstStatus < 400, `Expected 2xx/3xx status, got ${firstStatus}`); } - // Verify page title doesn't indicate blocking - const evalResult = await cdpSession.send("Runtime.evaluate", { - expression: "document.title", - returnByValue: true, - }, { timeoutMs: 5_000 }); - const title = ((evalResult.result as Record)?.value as string || "").toLowerCase(); + const title = (await page.title().catch(() => "")).toLowerCase(); assert.ok(!title.includes("access denied"), `Page title indicates blocking: ${title}`); assert.ok(!title.includes("blocked"), `Page title indicates blocking: ${title}`); }); @@ -212,13 +183,11 @@ describe("E2E Fingerprint Spoofing", () => { // ── Test 2: Reddit ── it("Reddit loads without 403", { timeout: 90_000 }, async () => { - // Clear traffic from previous test await client.callTool({ name: "proxy_clear_traffic", arguments: {} }); - cdpSession.send("Page.navigate", { url: "https://www.reddit.com/" }, { timeoutMs: 60_000 }).catch(() => {}); + page.goto("https://www.reddit.com/", { timeout: 60_000 }).catch(() => {}); await sleep(20_000); - // proxy_search_traffic returns summaries: { id, url, status, ... } const trafficRes = parseToolResult( await client.callTool({ name: "proxy_search_traffic", @@ -237,27 +206,19 @@ describe("E2E Fingerprint Spoofing", () => { assert.ok(status >= 200 && status < 400, `Expected 2xx/3xx, got ${status}`); } - // Verify page body doesn't indicate blocking - const evalResult = await cdpSession.send("Runtime.evaluate", { - expression: "document.body?.innerText?.substring(0, 500) || ''", - returnByValue: true, - }, { timeoutMs: 5_000 }); - const bodyText = ((evalResult.result as Record)?.value as string || "").toLowerCase(); + const bodyText = ( + await page.evaluate(() => document.body?.innerText?.substring(0, 500) || "").catch(() => "") + ).toLowerCase(); assert.ok(!bodyText.includes("blocked by network security"), `Page body indicates blocking`); }); // ── Test 3: browserleaks TLS data ── it("browserleaks TLS JSON has JA3 data", { timeout: 60_000 }, async () => { - cdpSession.send("Page.navigate", { url: "https://tls.browserleaks.com/json" }, { timeoutMs: 30_000 }).catch(() => {}); + page.goto("https://tls.browserleaks.com/json", { timeout: 30_000 }).catch(() => {}); await sleep(8_000); - // Extract JSON from the page body - const evalResult = await cdpSession.send("Runtime.evaluate", { - expression: "document.body?.innerText || ''", - returnByValue: true, - }, { timeoutMs: 5_000 }); - const bodyText = (evalResult.result as Record)?.value as string || ""; + const bodyText = await page.evaluate(() => document.body?.innerText || "").catch(() => ""); let tlsData: Record; try { @@ -266,10 +227,7 @@ describe("E2E Fingerprint Spoofing", () => { assert.fail(`Failed to parse browserleaks JSON: ${bodyText.substring(0, 200)}`); } - // Assert JA3 hash is present assert.ok(tlsData.ja3_hash || tlsData.ja3Hash || tlsData.ja3, "No JA3 hash in browserleaks data"); - - // Store for test 4 browserleaksData = tlsData; }); @@ -278,7 +236,6 @@ describe("E2E Fingerprint Spoofing", () => { it("TLS 1.3 is negotiated (JA4 contains t13)", { timeout: 10_000 }, async () => { assert.ok(browserleaksData, "browserleaks data not available (test 3 must pass first)"); - // Look for JA4 fingerprint containing t13 (TLS 1.3 indicator) const ja4 = (browserleaksData.ja4 || browserleaksData.ja4_hash || browserleaksData.ja4Hash || "") as string; assert.ok(ja4, "No JA4 fingerprint in browserleaks data"); assert.ok(ja4.includes("t13"), `JA4 does not indicate TLS 1.3: ${ja4}`); @@ -287,16 +244,10 @@ describe("E2E Fingerprint Spoofing", () => { // ── Test 5: BrowserScan bot detection ── it("BrowserScan bot detection passes", { timeout: 90_000 }, async () => { - cdpSession.send("Page.navigate", { url: "https://www.browserscan.net/bot-detection" }, { timeoutMs: 60_000 }).catch(() => {}); - // Bot detection JS needs time to execute + page.goto("https://www.browserscan.net/bot-detection", { timeout: 60_000 }).catch(() => {}); await sleep(20_000); - // Check navigator.webdriver - const evalResult = await cdpSession.send("Runtime.evaluate", { - expression: "navigator.webdriver", - returnByValue: true, - }, { timeoutMs: 5_000 }); - const webdriver = (evalResult.result as Record)?.value; + const webdriver = await page.evaluate(() => navigator.webdriver).catch(() => null); assert.equal(webdriver, false, `navigator.webdriver is ${webdriver}, expected false`); }); }); diff --git a/test/integration/mcp-server.test.ts b/test/integration/mcp-server.test.ts index 2189500..b2c9bfd 100644 --- a/test/integration/mcp-server.test.ts +++ b/test/integration/mcp-server.test.ts @@ -54,7 +54,7 @@ describe("MCP Server Integration", () => { if (cleanup) await cleanup(); }); - it("lists all 76 tools", async () => { + it("lists all 71 tools", async () => { const { client, cleanup: c } = await createTestSetup(); cleanup = c; @@ -81,28 +81,23 @@ describe("MCP Server Integration", () => { assert.ok(names.includes("interceptor_list")); assert.ok(names.includes("interceptor_status")); assert.ok(names.includes("interceptor_deactivate_all")); - assert.ok(names.includes("interceptor_chrome_launch")); - assert.ok(names.includes("interceptor_chrome_cdp_info")); - assert.ok(names.includes("interceptor_chrome_navigate")); + assert.ok(names.includes("interceptor_browser_launch")); + assert.ok(names.includes("interceptor_browser_navigate")); + assert.ok(names.includes("interceptor_browser_close")); assert.ok(names.includes("interceptor_spawn")); assert.ok(names.includes("interceptor_android_devices")); assert.ok(names.includes("interceptor_frida_apps")); assert.ok(names.includes("interceptor_docker_attach")); - // DevTools bridge tools - assert.ok(names.includes("interceptor_chrome_devtools_attach")); - assert.ok(names.includes("interceptor_chrome_devtools_pull_sidecar")); - assert.ok(names.includes("interceptor_chrome_devtools_navigate")); - assert.ok(names.includes("interceptor_chrome_devtools_snapshot")); - assert.ok(names.includes("interceptor_chrome_devtools_list_network")); - assert.ok(names.includes("interceptor_chrome_devtools_list_console")); - assert.ok(names.includes("interceptor_chrome_devtools_screenshot")); - assert.ok(names.includes("interceptor_chrome_devtools_list_cookies")); - assert.ok(names.includes("interceptor_chrome_devtools_get_cookie")); - assert.ok(names.includes("interceptor_chrome_devtools_list_storage_keys")); - assert.ok(names.includes("interceptor_chrome_devtools_get_storage_value")); - assert.ok(names.includes("interceptor_chrome_devtools_list_network_fields")); - assert.ok(names.includes("interceptor_chrome_devtools_get_network_field")); - assert.ok(names.includes("interceptor_chrome_devtools_detach")); + // Browser DevTools-equivalent tools (Playwright-driven) + assert.ok(names.includes("interceptor_browser_snapshot")); + assert.ok(names.includes("interceptor_browser_screenshot")); + assert.ok(names.includes("interceptor_browser_list_console")); + assert.ok(names.includes("interceptor_browser_list_cookies")); + assert.ok(names.includes("interceptor_browser_get_cookie")); + assert.ok(names.includes("interceptor_browser_list_storage_keys")); + assert.ok(names.includes("interceptor_browser_get_storage_value")); + assert.ok(names.includes("interceptor_browser_list_network_fields")); + assert.ok(names.includes("interceptor_browser_get_network_field")); // Session persistence tools assert.ok(names.includes("proxy_session_start")); assert.ok(names.includes("proxy_session_stop")); @@ -122,7 +117,7 @@ describe("MCP Server Integration", () => { assert.ok(names.includes("proxy_list_fingerprint_presets")); assert.ok(names.includes("proxy_check_fingerprint_runtime")); assert.ok(names.includes("proxy_search_session_bodies")); - assert.equal(names.length, 77); + assert.equal(names.length, 71); }); it("start/status/stop lifecycle via MCP", async (t) => { @@ -206,9 +201,8 @@ describe("MCP Server Integration", () => { assert.ok(uris.includes("proxy://ca-cert")); assert.ok(uris.includes("proxy://traffic/summary")); assert.ok(uris.includes("proxy://interceptors")); - assert.ok(uris.includes("proxy://chrome/primary")); - assert.ok(uris.includes("proxy://chrome/targets")); - assert.ok(uris.includes("proxy://chrome/devtools/sessions")); + assert.ok(uris.includes("proxy://browser/primary")); + assert.ok(uris.includes("proxy://browser/targets")); assert.ok(uris.includes("proxy://sessions")); }); @@ -219,7 +213,6 @@ describe("MCP Server Integration", () => { const { resourceTemplates } = await client.listResourceTemplates(); const templates = resourceTemplates.map((t) => t.uriTemplate); - assert.ok(templates.includes("proxy://chrome/{target_id}/cdp")); assert.ok(templates.includes("proxy://sessions/{session_id}/summary")); assert.ok(templates.includes("proxy://sessions/{session_id}/timeline")); assert.ok(templates.includes("proxy://sessions/{session_id}/findings")); diff --git a/test/unit/cdp-utils.test.ts b/test/unit/cdp-utils.test.ts deleted file mode 100644 index 0272b3d..0000000 --- a/test/unit/cdp-utils.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import http from "node:http"; -import { fetchJson, getCdpTargets, getCdpVersion, waitForCdpVersion } from "../../src/cdp-utils.js"; - -async function withServer( - t: { skip: (reason?: string) => void }, - handler: http.RequestListener, - fn: (port: number) => Promise, -): Promise { - const server = http.createServer(handler); - - let port: number | null = null; - try { - port = await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Unexpected server address")); - return; - } - resolve(addr.port); - }); - }); - } catch (e: any) { - // In restricted sandboxes, listening sockets may be forbidden. - if (e && (e.code === "EPERM" || e.code === "EACCES")) { - t.skip("listen() not permitted in this environment"); - return; - } - throw e; - } - - try { - await fn(port); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } -} - -describe("cdp-utils", () => { - it("fetches CDP version and targets JSON", async (t) => { - await withServer(t, (req, res) => { - if (!req.url) return void res.writeHead(400).end(); - - if (req.url === "/json/version") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ Browser: "TestChrome/1.0", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/browser/abc" })); - return; - } - - if (req.url === "/json/list") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify([{ id: "page_1", type: "page", url: "https://example.com", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/1" }])); - return; - } - - res.writeHead(404).end(); - }, async (port) => { - const version = await getCdpVersion(port, { timeoutMs: 500 }); - assert.equal(version.Browser, "TestChrome/1.0"); - - const targets = await getCdpTargets(port, { timeoutMs: 500 }); - assert.equal(targets.length, 1); - assert.equal(targets[0].id, "page_1"); - }); - }); - - it("waitForCdpVersion retries until /json/version returns 200", async (t) => { - let calls = 0; - await withServer(t, (req, res) => { - if (req.url !== "/json/version") return void res.writeHead(404).end(); - calls++; - if (calls < 3) { - res.writeHead(500, { "content-type": "text/plain" }); - res.end("not ready"); - return; - } - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ Browser: "ReadyChrome/1.0" })); - }, async (port) => { - const version = await waitForCdpVersion(port, { timeoutMs: 2000, intervalMs: 50, requestTimeoutMs: 200 }); - assert.equal(version.Browser, "ReadyChrome/1.0"); - assert.ok(calls >= 3); - }); - }); - - it("fetchJson enforces timeouts", async (t) => { - await withServer(t, (req, res) => { - if (req.url !== "/slow") return void res.writeHead(404).end(); - setTimeout(() => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - }, 200); - }, async (port) => { - await assert.rejects( - () => fetchJson(`http://127.0.0.1:${port}/slow`, { timeoutMs: 20 }), - /aborted|Abort|timeout|fetch failed/i, - ); - }); - }); -}); - diff --git a/test/unit/devtools-tool-map.test.ts b/test/unit/devtools-tool-map.test.ts deleted file mode 100644 index adf30ab..0000000 --- a/test/unit/devtools-tool-map.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolveToolMap } from "../../src/devtools/tool-map.js"; - -describe("devtools tool map", () => { - it("resolves canonical chrome-devtools-mcp names", () => { - const map = resolveToolMap([ - "navigate_page", - "take_snapshot", - "list_network_requests", - "list_console_messages", - "take_screenshot", - ]); - - assert.equal(map.navigate, "navigate_page"); - assert.equal(map.snapshot, "take_snapshot"); - assert.equal(map.listNetwork, "list_network_requests"); - assert.equal(map.listConsole, "list_console_messages"); - assert.equal(map.screenshot, "take_screenshot"); - }); - - it("resolves browser_* fallback names", () => { - const map = resolveToolMap([ - "browser_navigate", - "browser_snapshot", - "browser_network_requests", - "browser_console_messages", - "browser_take_screenshot", - ]); - - assert.equal(map.navigate, "browser_navigate"); - assert.equal(map.snapshot, "browser_snapshot"); - assert.equal(map.listNetwork, "browser_network_requests"); - assert.equal(map.listConsole, "browser_console_messages"); - assert.equal(map.screenshot, "browser_take_screenshot"); - }); - - it("throws when required tools are missing", () => { - assert.throws( - () => resolveToolMap(["navigate_page"]), - /missing required tools/i, - ); - }); -});