Skip to content

Commit ee1a8f7

Browse files
committed
feat(camoufox): expose fingerprint introspection
1 parent e588366 commit ee1a8f7

15 files changed

Lines changed: 304 additions & 93 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 3.3.2 — 2026-05-17
4+
5+
- **Camoufox OS default + introspection:** `interceptor_camoufox_launch` now
6+
defaults fingerprint generation to the host OS instead of Camoufox's upstream
7+
random OS list, while still allowing explicit `os` overrides. Launch/list/info
8+
responses now include a safe fingerprint summary (resolved OS, UA, platform,
9+
OSCPU, screen/window, WebGL, font/voice counts) without exposing raw Camoufox
10+
config or process environment.
11+
312
## 3.3.1 — 2026-05-17
413

514
- **Camoufox MCP parity:** `camoufox_*` targets now work with console and
@@ -24,7 +33,7 @@ Consequences for callers:
2433

2534
- `inject_init_script` patches NOW reach the page (`Object.defineProperty(navigator, 'webdriver', ...)` actually affects what site scripts see) — the camoufox#48 limitation no longer applies on this build. The trade: those patches are observable by anti-bot code via `Function.prototype.toString` and `window` enumeration.
2635
- `interceptor_browser_evaluate` mutations (writes to `window`, prototype patches) become observable to page scripts. Read-only evals stay safe.
27-
- `world: "main"` and `world: "isolated"` accept the same args for API compatibility but run in the same realm on cloverlabs/FF150. `mw:` prefix and `main_world_eval: true` launch flag are inert.
36+
- `world: "main"` and `world: "isolated"` accept the same script args for API compatibility but run in the same realm on cloverlabs/FF150. `main_world_eval: true` still gates explicit `world: "main"` calls; once enabled, the `mw:` prefix does not create a separate realm on this build.
2837

2938
Tool descriptions, README "Worlds and isolation" section, and `test/integration/browser-js-inject.test.ts` were updated to reflect the new behavior. The probe at `scripts/camoufox-world-probe.ts` re-verifies the model on any installed build.
3039

@@ -50,7 +59,7 @@ Tool descriptions, README "Worlds and isolation" section, and `test/integration/
5059

5160
### Notes
5261

53-
- Host requirements (only when using camoufox): Python 3 + `pip install "camoufox[geoip]"` + `python3 -m camoufox fetch` + NSS `certutil` (`libnss3-tools`/`nss-tools`/`brew install nss`). If `certutil` is missing the launcher still runs but the proxy CA is not trusted — HTTPS pages show cert errors and proxy traffic is still captured.
62+
- Original host requirements used the daijro/camoufox package line. Current Camoufox users should follow the cloverlabs/Firefox 150 install note in 3.3.0 above. NSS `certutil` (`libnss3-tools`/`nss-tools`/`brew install nss`) is still required for proxy CA trust.
5463
- No new npm dependencies. `playwright-core` (already a runtime dep for cloakbrowser) provides the `firefox.connect(wsUrl)` client.
5564
- All proxy-side capabilities — traffic capture, TLS fingerprint capture, rules, header injection, mocks, sessions, replay, upstream chaining, JA3 spoofing — apply to camoufox automatically because the proxy sits in front of it.
5665

DEMO.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ explain what *would* have happened and return to the menu. Never stop the demo o
2828
a failure. First `interceptor_browser_launch` call may take 30–60 s while the
2929
~200 MB stealth Chromium binary downloads.
3030

31-
**Tool prefix:** All tools are from the `proxy` MCP server — call them as
32-
`mcp__proxy__<tool_name>`.
31+
**Tool prefix:** All tools are from this MCP server. If configured with the
32+
README's `proxy-mcp` alias, call them as `mcp__proxy-mcp__<tool_name>`. If the
33+
local client aliases the server as `proxy`, call them as `mcp__proxy__<tool_name>`.
3334

3435
---
3536

README.md

Lines changed: 37 additions & 32 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.3.1",
3+
"version": "3.3.2",
44
"description": "MCP server for HTTP/HTTPS MITM proxy via mockttp",
55
"type": "module",
66
"engines": {

src/browser/session.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,14 @@ export function getEntry(targetId: string): BrowserTargetEntry | CamoufoxEntryWi
109109

110110
/**
111111
* Cloakbrowser-only entry getter. Use when the caller needs cloakbrowser
112-
* features that camoufox doesn't implement yet — `consoleBuffer` (event
113-
* recording) or pre-warmed `context` (synchronous cookie access). Camoufox
114-
* targets get a clear error instead of a deep type-mismatch.
112+
* internals rather than the shared page/context helpers. Camoufox targets get
113+
* a clear error instead of a deep type-mismatch.
115114
*/
116115
export function getBrowserEntry(targetId: string): BrowserTargetEntry {
117116
if (isCamoufoxTargetId(targetId)) {
118117
throw new Error(
119-
`Tool not yet supported on camoufox targets ('${targetId}'). Use cloakbrowser ` +
120-
`(interceptor_browser_launch) for console / cookie inspection until camoufox parity lands.`,
118+
`Internal cloakbrowser-only entry requested for camoufox target ('${targetId}'). ` +
119+
"Use the shared browser tools for camoufox targets.",
121120
);
122121
}
123122
const entry = getBrowserInterceptor().getEntry(targetId);

src/humanizer/engine.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
2-
* Humanizer engine — thin wrappers over cloakbrowser-patched Playwright.
2+
* Humanizer engine — thin wrappers over backend Playwright pages.
33
*
4-
* cloakbrowser's `humanize: true` already patches page.click / page.mouse.move /
5-
* page.mouse.click / page.keyboard.type / page.hover / page.type with Bezier
6-
* paths, realistic typing, and CDP-trusted Shift handling. This engine just
7-
* routes tool calls to those patched methods — no duplicate timing code.
4+
* The engine routes tool calls to page.mouse / page.keyboard / locator methods.
5+
* Backend-level humanization comes from the launched browser config
6+
* (cloakbrowser humanize patches or Camoufox options), not from a local timing
7+
* model in proxy-mcp.
88
*/
99

1010
import type { Page, Locator } from "playwright-core";

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function arg(name: string, fallback: string): string {
5555
/* ------------------------------------------------------------------ */
5656

5757
function createMcpServer(): McpServer {
58-
const server = new McpServer({ name: "proxy", version: "3.3.1" });
58+
const server = new McpServer({ name: "proxy", version: "3.3.2" });
5959

6060
initInterceptors();
6161

src/interceptors/camoufox.ts

Lines changed: 146 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import type {
3131
Interceptor, InterceptorMetadata, ActivateOptions, ActivateResult, ActiveTarget,
3232
} from "./types.js";
3333

34+
type CamoufoxOs = "windows" | "macos" | "linux";
35+
3436
export interface CamoufoxTargetEntry {
3537
target: ActiveTarget;
3638
process: ChildProcess;
@@ -48,6 +50,55 @@ const FORWARDED_PARAMS = [
4850

4951
type CamoufoxParam = (typeof FORWARDED_PARAMS)[number];
5052

53+
interface CamoufoxFingerprintSummary {
54+
os?: CamoufoxOs;
55+
requested_os?: unknown;
56+
os_source?: "caller" | "host_default" | "resolved";
57+
user_agent?: string;
58+
platform?: string;
59+
oscpu?: string;
60+
app_version?: string;
61+
locale?: unknown;
62+
accept_language?: string;
63+
timezone?: string;
64+
screen?: Record<string, unknown>;
65+
window?: Record<string, unknown>;
66+
webgl?: Record<string, unknown>;
67+
fonts_count?: number;
68+
voices_count?: number;
69+
}
70+
71+
interface WsHandshake {
72+
wsUrl: string;
73+
ts: number;
74+
fingerprint?: CamoufoxFingerprintSummary;
75+
}
76+
77+
export function hostFingerprintOs(platform: NodeJS.Platform = process.platform): CamoufoxOs | undefined {
78+
if (platform === "win32") return "windows";
79+
if (platform === "darwin") return "macos";
80+
if (platform === "linux") return "linux";
81+
return undefined;
82+
}
83+
84+
function firstString(value: unknown): string | undefined {
85+
return typeof value === "string" && value ? value : undefined;
86+
}
87+
88+
function inferOsFromFingerprint(fingerprint: CamoufoxFingerprintSummary | undefined): CamoufoxOs | undefined {
89+
if (fingerprint?.os) return fingerprint.os;
90+
const haystack = [
91+
fingerprint?.user_agent,
92+
fingerprint?.platform,
93+
fingerprint?.oscpu,
94+
fingerprint?.app_version,
95+
].filter(Boolean).join(" ").toLowerCase();
96+
if (haystack.includes("windows") || haystack.includes("win32")) return "windows";
97+
if (haystack.includes("macintosh") || haystack.includes("mac os") || haystack.includes("macintel")) return "macos";
98+
if (haystack.includes("linux") || haystack.includes("x11")) return "linux";
99+
return undefined;
100+
}
101+
51102
export class CamoufoxInterceptor implements Interceptor {
52103
readonly id = "camoufox";
53104
readonly name = "Camoufox (anti-detect Firefox via Playwright)";
@@ -84,12 +135,17 @@ export class CamoufoxInterceptor implements Interceptor {
84135
const pythonExe = typeof options.python_executable === "string" && options.python_executable
85136
? options.python_executable
86137
: "python3";
138+
const requestedOs = options.os;
139+
const defaultOs = requestedOs === undefined ? hostFingerprintOs() : undefined;
140+
const osForLaunch = requestedOs ?? defaultOs;
141+
const osSource = requestedOs === undefined ? "host_default" : "caller";
87142

88143
const params: Record<string, unknown> = {
89144
proxy: { server: `http://127.0.0.1:${proxyPort}` },
90145
headless,
91146
block_webrtc: blockWebrtc,
92147
geoip,
148+
...(osForLaunch !== undefined ? { os: osForLaunch } : {}),
93149
};
94150

95151
for (const key of FORWARDED_PARAMS as readonly CamoufoxParam[]) {
@@ -143,9 +199,9 @@ export class CamoufoxInterceptor implements Interceptor {
143199
},
144200
});
145201

146-
let wsUrl: string;
202+
let handshake: WsHandshake;
147203
try {
148-
wsUrl = await awaitWsEndpoint(proc, wsEndpointFile, DEFAULT_LAUNCH_TIMEOUT_MS);
204+
handshake = await awaitWsHandshake(proc, wsEndpointFile, DEFAULT_LAUNCH_TIMEOUT_MS);
149205
} catch (e) {
150206
try { proc.kill("SIGKILL"); } catch { /* already gone */ }
151207
await rm(launcherDir, { recursive: true, force: true }).catch(() => {});
@@ -155,20 +211,30 @@ export class CamoufoxInterceptor implements Interceptor {
155211

156212
const pid = typeof proc.pid === "number" ? proc.pid : 0;
157213
const targetId = `camoufox_${pid}_${Date.now()}`;
214+
const fingerprint: CamoufoxFingerprintSummary = {
215+
...(handshake.fingerprint ?? {}),
216+
requested_os: requestedOs ?? null,
217+
os_source: osSource,
218+
};
219+
const effectiveOs = inferOsFromFingerprint(fingerprint) ?? firstString(osForLaunch) ?? null;
220+
if (effectiveOs) fingerprint.os = effectiveOs as CamoufoxOs;
158221

159222
const details: Record<string, unknown> = {
160-
wsUrl,
223+
wsUrl: handshake.wsUrl,
161224
proxyPort,
162225
headless,
163226
humanize: options.humanize ?? null,
164227
geoip,
165228
block_webrtc: blockWebrtc,
166229
main_world_eval: Boolean(params.main_world_eval),
167-
...(options.os !== undefined ? { os: options.os } : {}),
230+
os: effectiveOs,
231+
requested_os: requestedOs ?? null,
232+
default_os: defaultOs ?? null,
233+
fingerprint,
168234
...(options.locale !== undefined ? { locale: options.locale } : {}),
169235
profileDir,
170236
certutil: profileDir !== null,
171-
playwright_connect: `await firefox.connect('${wsUrl}')`,
237+
playwright_connect: `await firefox.connect('${handshake.wsUrl}')`,
172238
};
173239

174240
const target: ActiveTarget = {
@@ -178,7 +244,7 @@ export class CamoufoxInterceptor implements Interceptor {
178244
details,
179245
};
180246

181-
const entry: CamoufoxTargetEntry = { target, process: proc, wsUrl, profileDir, launcherDir };
247+
const entry: CamoufoxTargetEntry = { target, process: proc, wsUrl: handshake.wsUrl, profileDir, launcherDir };
182248
this.launched.set(targetId, entry);
183249

184250
proc.once("exit", () => {
@@ -236,6 +302,7 @@ export class CamoufoxInterceptor implements Interceptor {
236302
name: this.name,
237303
description:
238304
"Launch camoufox (anti-detect Firefox) as a Playwright WS server with proxy + NSS CA trust. " +
305+
"Defaults fingerprint generation to the host OS and returns safe fingerprint introspection. " +
239306
"Drive the returned target_id through the same `interceptor_browser_*` and `humanizer_*` " +
240307
"tools as cloakbrowser. Requires Python + `pip install cloverlabs-camoufox[geoip]`.",
241308
isActivable: await this.isActivable(),
@@ -326,6 +393,67 @@ export function buildLauncherScript(
326393
"",
327394
`params = json.loads('${escapedParams}')`,
328395
"config = launch_options(**params)",
396+
"",
397+
"def _pick(src, key):",
398+
" return src.get(key) if isinstance(src, dict) and key in src else None",
399+
"",
400+
"def _count_list(value):",
401+
" return len(value) if isinstance(value, list) else None",
402+
"",
403+
"def _infer_os(cam):",
404+
" haystack = ' '.join(str(x or '') for x in [",
405+
" _pick(cam, 'navigator.userAgent'),",
406+
" _pick(cam, 'navigator.platform'),",
407+
" _pick(cam, 'navigator.oscpu'),",
408+
" _pick(cam, 'navigator.appVersion'),",
409+
" ]).lower()",
410+
" if 'windows' in haystack or 'win32' in haystack:",
411+
" return 'windows'",
412+
" if 'macintosh' in haystack or 'mac os' in haystack or 'macintel' in haystack:",
413+
" return 'macos'",
414+
" if 'linux' in haystack or 'x11' in haystack:",
415+
" return 'linux'",
416+
" return None",
417+
"",
418+
"def _fingerprint_summary(config):",
419+
" env = config.get('env') if isinstance(config, dict) else {}",
420+
" raw = env.get('CAMOU_CONFIG_1') if isinstance(env, dict) else None",
421+
" if not raw:",
422+
" return None",
423+
" try:",
424+
" cam = json.loads(raw)",
425+
" except Exception:",
426+
" return None",
427+
" return {",
428+
" 'os': _infer_os(cam),",
429+
" 'user_agent': _pick(cam, 'navigator.userAgent'),",
430+
" 'platform': _pick(cam, 'navigator.platform'),",
431+
" 'oscpu': _pick(cam, 'navigator.oscpu'),",
432+
" 'app_version': _pick(cam, 'navigator.appVersion'),",
433+
" 'locale': _pick(cam, 'locale:language') or _pick(cam, 'headers.Accept-Language'),",
434+
" 'accept_language': _pick(cam, 'headers.Accept-Language'),",
435+
" 'timezone': _pick(cam, 'timezone'),",
436+
" 'screen': {",
437+
" 'width': _pick(cam, 'screen.width'),",
438+
" 'height': _pick(cam, 'screen.height'),",
439+
" 'avail_width': _pick(cam, 'screen.availWidth'),",
440+
" 'avail_height': _pick(cam, 'screen.availHeight'),",
441+
" 'color_depth': _pick(cam, 'screen.colorDepth'),",
442+
" 'pixel_depth': _pick(cam, 'screen.pixelDepth'),",
443+
" },",
444+
" 'window': {",
445+
" 'outer_width': _pick(cam, 'window.outerWidth'),",
446+
" 'outer_height': _pick(cam, 'window.outerHeight'),",
447+
" },",
448+
" 'webgl': {",
449+
" 'vendor': _pick(cam, 'webGl:vendor'),",
450+
" 'renderer': _pick(cam, 'webGl:renderer'),",
451+
" },",
452+
" 'fonts_count': _count_list(_pick(cam, 'fonts')),",
453+
" 'voices_count': _count_list(_pick(cam, 'voices')),",
454+
" }",
455+
"",
456+
"fingerprint_summary = _fingerprint_summary(config)",
329457
"nodejs = get_nodejs()",
330458
"data = orjson.dumps(to_camel_case_dict(config))",
331459
"",
@@ -355,7 +483,7 @@ export function buildLauncherScript(
355483
" m = _WS_RE.search(_ANSI.sub('', line))",
356484
" if m:",
357485
" _ws_done = True",
358-
" payload = json.dumps({'wsUrl': m.group(1), 'ts': int(time.time())})",
486+
" payload = json.dumps({'wsUrl': m.group(1), 'ts': int(time.time()), 'fingerprint': fingerprint_summary})",
359487
" d = os.path.dirname(_WS_FILE) or '.'",
360488
" fd, tmp = tempfile.mkstemp(prefix='.ws-', dir=d)",
361489
" with os.fdopen(fd, 'w') as f:",
@@ -368,11 +496,6 @@ export function buildLauncherScript(
368496
].join("\n");
369497
}
370498

371-
interface WsHandshake {
372-
wsUrl: string;
373-
ts: number;
374-
}
375-
376499
/**
377500
* Poll `wsEndpointFile` until the python wrapper writes the handshake
378501
* JSON, or `proc` exits, or the timeout fires — whichever happens first.
@@ -383,11 +506,20 @@ interface WsHandshake {
383506
* wrapper's problem (it can update its own regex), and Node only
384507
* reads a structured file.
385508
*/
386-
export function awaitWsEndpoint(
509+
export async function awaitWsEndpoint(
387510
proc: ChildProcess,
388511
wsEndpointFile: string,
389512
timeoutMs: number,
390513
): Promise<string> {
514+
const handshake = await awaitWsHandshake(proc, wsEndpointFile, timeoutMs);
515+
return handshake.wsUrl;
516+
}
517+
518+
export function awaitWsHandshake(
519+
proc: ChildProcess,
520+
wsEndpointFile: string,
521+
timeoutMs: number,
522+
): Promise<WsHandshake> {
391523
return new Promise((resolve, reject) => {
392524
let settled = false;
393525
let stderrTail = "";
@@ -438,7 +570,7 @@ export function awaitWsEndpoint(
438570
if (typeof handshake.wsUrl === "string" && handshake.wsUrl.startsWith("ws://")) {
439571
settled = true;
440572
cleanup();
441-
resolve(handshake.wsUrl);
573+
resolve(handshake);
442574
return;
443575
}
444576
// File present but malformed — keep polling; the python wrapper

0 commit comments

Comments
 (0)