Skip to content

Commit ee20694

Browse files
yfe404claude
andauthored
feat(camoufox): drive targets via interceptor_browser_*; file-based ws handshake; v3.1.0 (#17)
Camoufox targets are now first-class citizens of the interceptor_browser_* / humanizer_* tool surface. Callers don't have to reach for `firefox.connect` themselves; passing a `camoufox_*` target_id through navigate / snapshot / screenshot / click / scroll just works, same as cloakbrowser. Headline changes: - session.ts: lazy `firefox.connect(wsUrl)` + cached Browser/Context/Page on the camoufox entry. `getPageForTarget` is now async and dispatches on target_id prefix. Callers in humanizer engine + devtools tools updated. New `getBrowserEntry` for the cloakbrowser-only paths (consoleBuffer, context.cookies) — camoufox callers get a clear "not yet supported" error instead of a deep type-mismatch. - File-based ws-endpoint handshake: replaces the stdout WS_REGEX with an atomic JSON drop at `${launcherDir}/ws-endpoint.json`. The python wrapper around `camoufox.server.launch_server` pipes the underlying Playwright Node child's stdout, strips ANSI, and writes the URL once; Node side polls the file. ANSI / log-level / locale changes upstream no longer affect Node's parser. - firefox_user_prefs forwarded through `FORWARDED_PARAMS`, with the proxy CA path auto-setting `security.enterprise_roots.enabled = true` when `trust_proxy_cert` is true. Without it, hardened-Firefox builds ignore the imported NSS CA and HTTPS pages return SEC_ERROR_UNKNOWN_ISSUER. - Launcher cwd hardened: spawn cwd is the launcher temp dir, AND a `package.json` with `{\"type\":\"commonjs\"}` lands there before spawn. This isolates camoufox's CommonJS launcher from any stale ancestor `package.json` (notably `/tmp/package.json` from other tools) that would otherwise force Node to load `launchServer.js` as ESM. - `interceptor_browser_close` now closes camoufox targets too, dispatching the deactivate to the right interceptor based on target_id prefix. - `proxy_check_fingerprint_runtime` now returns a `runtimes[]` array per backend — keeps the contract forward-compatible if more backends land. - `proxy_list_traffic` exposes `count` alongside `total` for the historical alias. Tests: - Unit suite rewired around the file-handshake contract (5 new cases: resolve via file, stderr-tail capture on early exit, ANSI noise on stderr is irrelevant to the resolve path, etc.). - Integration: new test that drives the camoufox target via \`interceptor_browser_navigate\` (skipped pending an upstream Playwright fix for proxy propagation through \`BrowserServer.newContext\`; the mscdirect-actor end-to-end smoke covers the production path). - mcp-server preflight + HAR-replay tests: schema fixes (runtimes array, \`count\` alias). Out of band: - /tmp/package.json poisoning fix is now baked into the launcher (cwd + cjs marker). Operator hosts no longer need to keep /tmp clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c4ad7a commit ee20694

10 files changed

Lines changed: 489 additions & 134 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-mcp",
3-
"version": "3.0.0",
3+
"version": "3.1.0",
44
"description": "MCP server for HTTP/HTTPS MITM proxy via mockttp",
55
"type": "module",
66
"engines": {

src/browser/session.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,108 @@
11
/**
22
* Shared helpers for resolving a Playwright Page from a browser interceptor target ID.
33
* Used by humanizer and browser tools so they don't each re-walk the interceptor map.
4+
*
5+
* Resolves both cloakbrowser ("browser_*") and camoufox ("camoufox_*") targets.
6+
* Camoufox entries don't carry a Page eagerly — the interceptor returns a WS
7+
* endpoint and stops there. We connect lazily on first use via
8+
* `firefox.connect(wsUrl)` and cache the Browser/Context/Page on the entry, so
9+
* every `interceptor_browser_*` and `humanizer_*` tool call works identically
10+
* across both engines.
411
*/
512

6-
import type { Page } from "playwright-core";
13+
import { firefox, type Browser, type BrowserContext, type Page } from "playwright-core";
714
import { interceptorManager } from "../interceptors/manager.js";
815
import type { BrowserInterceptor, BrowserTargetEntry } from "../interceptors/browser.js";
16+
import type { CamoufoxInterceptor, CamoufoxTargetEntry } from "../interceptors/camoufox.js";
17+
18+
interface CamoufoxDriverHandle {
19+
browser?: Browser;
20+
context?: BrowserContext;
21+
page?: Page;
22+
}
23+
type CamoufoxEntryWithDriver = CamoufoxTargetEntry & CamoufoxDriverHandle;
924

1025
function getBrowserInterceptor(): BrowserInterceptor {
1126
const it = interceptorManager.get("browser") as BrowserInterceptor | undefined;
1227
if (!it) throw new Error("Browser interceptor not registered.");
1328
return it;
1429
}
1530

16-
export function getEntry(targetId: string): BrowserTargetEntry {
31+
function getCamoufoxInterceptor(): CamoufoxInterceptor | undefined {
32+
return interceptorManager.get("camoufox") as CamoufoxInterceptor | undefined;
33+
}
34+
35+
function isCamoufoxTargetId(targetId: string): boolean {
36+
return typeof targetId === "string" && targetId.startsWith("camoufox_");
37+
}
38+
39+
async function ensureCamoufoxPage(entry: CamoufoxEntryWithDriver): Promise<Page> {
40+
if (entry.page && !entry.page.isClosed()) return entry.page;
41+
if (!entry.browser) {
42+
entry.browser = await firefox.connect(entry.wsUrl);
43+
}
44+
let ctx = entry.browser.contexts()[0];
45+
if (!ctx) {
46+
// BrowserServer + persistent_context: the persistent context lives
47+
// server-side and `Browser.contexts()` from a fresh `firefox.connect()`
48+
// returns empty. New contexts created here do NOT inherit the
49+
// launch-level proxy, so we have to wire the MITM proxy explicitly
50+
// or the firefox process reaches the internet directly and bypasses
51+
// capture. Pull the port back out of the entry details (set at
52+
// activate() time).
53+
const proxyPort = (entry.target.details as { proxyPort?: number } | undefined)?.proxyPort;
54+
ctx = await entry.browser.newContext({
55+
ignoreHTTPSErrors: true,
56+
...(proxyPort ? { proxy: { server: `http://127.0.0.1:${proxyPort}` } } : {}),
57+
});
58+
}
59+
let page = ctx.pages()[0];
60+
if (!page) {
61+
page = await ctx.newPage();
62+
}
63+
entry.context = ctx;
64+
entry.page = page;
65+
return page;
66+
}
67+
68+
export function getEntry(targetId: string): BrowserTargetEntry | CamoufoxEntryWithDriver {
69+
if (isCamoufoxTargetId(targetId)) {
70+
const cam = getCamoufoxInterceptor();
71+
const entry = cam?.getEntry(targetId) as CamoufoxEntryWithDriver | undefined;
72+
if (!entry) throw new Error(`Browser target '${targetId}' not found. Is it still running?`);
73+
return entry;
74+
}
75+
const entry = getBrowserInterceptor().getEntry(targetId);
76+
if (!entry) throw new Error(`Browser target '${targetId}' not found. Is it still running?`);
77+
return entry;
78+
}
79+
80+
/**
81+
* Cloakbrowser-only entry getter. Use when the caller needs cloakbrowser
82+
* features that camoufox doesn't implement yet — `consoleBuffer` (event
83+
* recording) or pre-warmed `context` (synchronous cookie access). Camoufox
84+
* targets get a clear error instead of a deep type-mismatch.
85+
*/
86+
export function getBrowserEntry(targetId: string): BrowserTargetEntry {
87+
if (isCamoufoxTargetId(targetId)) {
88+
throw new Error(
89+
`Tool not yet supported on camoufox targets ('${targetId}'). Use cloakbrowser ` +
90+
`(interceptor_browser_launch) for console / cookie inspection until camoufox parity lands.`,
91+
);
92+
}
1793
const entry = getBrowserInterceptor().getEntry(targetId);
1894
if (!entry) throw new Error(`Browser target '${targetId}' not found. Is it still running?`);
1995
return entry;
2096
}
2197

22-
export function getPageForTarget(targetId: string): Page {
98+
export async function getPageForTarget(targetId: string): Promise<Page> {
2399
const entry = getEntry(targetId);
24-
if (entry.page.isClosed()) {
100+
if (isCamoufoxTargetId(targetId)) {
101+
return ensureCamoufoxPage(entry as CamoufoxEntryWithDriver);
102+
}
103+
const browserEntry = entry as BrowserTargetEntry;
104+
if (browserEntry.page.isClosed()) {
25105
throw new Error(`Page for browser target '${targetId}' is closed.`);
26106
}
27-
return entry.page;
107+
return browserEntry.page;
28108
}

src/humanizer/engine.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class HumanizerEngine {
8181
x: number,
8282
y: number,
8383
): Promise<{ totalMs: number; eventsDispatched: number }> {
84-
const page = getPageForTarget(targetId);
84+
const page = await getPageForTarget(targetId);
8585
const start = Date.now();
8686
await page.mouse.move(x, y);
8787
const state = getMouseState(targetId);
@@ -98,7 +98,7 @@ class HumanizerEngine {
9898
timeoutMs?: number;
9999
} = {},
100100
): Promise<{ totalMs: number; eventsDispatched: number; clickedAt: Point; resolvedBy: string }> {
101-
const page = getPageForTarget(targetId);
101+
const page = await getPageForTarget(targetId);
102102
const button = opts.button ?? "left";
103103
const clickCount = opts.clickCount ?? 1;
104104
const timeout = opts.timeoutMs ?? 15_000;
@@ -139,7 +139,7 @@ class HumanizerEngine {
139139
text: string,
140140
opts: { delayMs?: number } = {},
141141
): Promise<{ totalMs: number; eventsDispatched: number; charsTyped: number }> {
142-
const page = getPageForTarget(targetId);
142+
const page = await getPageForTarget(targetId);
143143
const start = Date.now();
144144
await page.keyboard.type(text, opts.delayMs !== undefined ? { delay: opts.delayMs } : undefined);
145145
return {
@@ -154,7 +154,7 @@ class HumanizerEngine {
154154
deltaY: number,
155155
deltaX?: number,
156156
): Promise<{ totalMs: number; eventsDispatched: number }> {
157-
const page = getPageForTarget(targetId);
157+
const page = await getPageForTarget(targetId);
158158
const start = Date.now();
159159
await page.mouse.wheel(deltaX ?? 0, deltaY);
160160
return { totalMs: Date.now() - start, eventsDispatched: 1 };
@@ -165,7 +165,7 @@ class HumanizerEngine {
165165
durationMs: number,
166166
intensity: "subtle" | "normal" = "subtle",
167167
): Promise<{ totalMs: number; eventsDispatched: number }> {
168-
const page = getPageForTarget(targetId);
168+
const page = await getPageForTarget(targetId);
169169
const state = getMouseState(targetId);
170170
const start = Date.now();
171171
let eventsDispatched = 0;

0 commit comments

Comments
 (0)