@the-forge-flow/camoufox-pi
PI extension for stealth web search and URL fetching via Camoufox
Package details
Install @the-forge-flow/camoufox-pi from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@the-forge-flow/camoufox-pi- Package
@the-forge-flow/camoufox-pi- Version
0.2.1- Published
- Apr 13, 2026
- Downloads
- 93/mo · 23/wk
- Author
- the-forge-flow-ai
- License
- MIT
- Types
- extension
- Size
- 234.7 KB
- Dependencies
- 3 dependencies · 4 peers
Pi manifest JSON
{
"extensions": [
"./dist/index.js"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
What it does
PI extension that wraps Camoufox — a Firefox fork patched at the C++ level for anti-fingerprint resistance — to give the coding agent a stealth-capable web search and URL fetcher. For sites that block conventional headless browsers (Cloudflare, DataDome, PerimeterX, Turnstile, Google's bot wall, LinkedIn, etc.).
Sibling to @the-forge-flow/lightpanda-pi. Where lightpanda-pi is the fast/light choice for cooperative targets, camoufox-pi is the choice when sites actively block bots. Camoufox patches fingerprint surfaces inside SpiderMonkey / Gecko C++, before JavaScript can observe them — fundamentally more robust than runtime JS-injection approaches like puppeteer-extra-plugin-stealth. Independent benchmarks report ~100% bypass rate vs ~33% for Playwright-Chromium.
Features
tff-fetch_url— fetch a URL via stealth Firefox and return HTMLtff-search_web— web search via DuckDuckGo (Google lands in a follow-up slice)- Stealth properties — C++-level fingerprint spoofing, patched canvas/WebGL, Juggler (Firefox remote) protocol — not CDP
- SSRF protection — private IP ranges, link-local, loopback, cloud metadata, and CGNAT blocked pre-navigation AND on every redirect hop and subframe request
- Scheme allow-list — only
http:/https:accepted at the tool boundary;file:,javascript:,data:,chrome://rejected - Response size caps — UTF-8-safe truncation at
max_bytes(default 2 MiB, max 50 MiB) withtruncatedflag isolate: trueopt-in — one-shot browser context per call, no cookie/storage bleed- Lazy binary download — ~500 MB Camoufox binary fetched on first use, not install
Trade-offs vs lightpanda-pi
| lightpanda-pi | camoufox-pi | |
|---|---|---|
| RSS per session | ~50 MB | 200–1300 MB |
| Binary size | few MB | 300–700 MB (lazy download on first use) |
| Cold start | instant | 1–3 s |
| Cooperative sites | yes | yes |
| WAF-protected sites | no | yes |
| Canvas/WebGL | not rendered | spoofed (except post-2026-03 regression — see SPEC §17) |
| TLS/JA3 fingerprint | libcurl | Firefox (unspoofed — use proxy if target fingerprints TLS) |
Pick one based on target profile. They can coexist but share no runtime.
Requirements
- Node.js >= 22.5.0
- PI (
piCLI) installed - ~500 MB disk space for the Camoufox binary (lazy downloaded on first use)
- macOS or Linux (Windows untested)
Installation
# From npm (recommended)
pi install npm:@the-forge-flow/camoufox-pi
# Project-local only
pi install -l npm:@the-forge-flow/camoufox-pi
# From GitHub (tracks main)
pi install git:github.com/MonsieurBarti/camoufox-pi
# Pin a version
pi install npm:@the-forge-flow/camoufox-pi@0.1.0
Then reload PI with /reload (or restart it). First tool call downloads the Camoufox binary (~500 MB, one-time).
Usage
Tools
| Tool | Description | Key parameters |
|---|---|---|
tff-fetch_url |
Fetch a URL via stealth Firefox, return HTML/markdown with optional selector/screenshot | url, timeout_ms, max_bytes, isolate, render_mode, wait_for_selector, selector, format, screenshot |
tff-search_web |
Web search (DuckDuckGo) | query, max_results, timeout_ms, isolate |
tff-fetch_url parameters:
render_mode:"static"|"render"(default) |"render-and-wait"— page wait strategy.wait_for_selector: CSS selector; waits for element visibility. Only valid withrender_mode: "render-and-wait".selector: CSS selector; returnsouterHTMLof first match. No-match raises error.format:"html"(default) |"markdown"— body format. Markdown drops HTML to save tokens.screenshot:{ full_page?, format? ("png"|"jpeg"), quality? (1-100, jpeg only) }— base64 image in response. Images > 10 MiB rejected.
tff-fetch_url returns: { url, finalUrl, status, html, markdown?, screenshot?, bytes, truncated }. truncated: true means the response exceeded max_bytes and was cut at a UTF-8-safe boundary.
tff-search_web returns { engine, query, results[], atLimit } where each result is { title, url, snippet, rank }. atLimit means results.length === max_results — could mean DDG had more, or exactly that many. No ground-truth "has_more" signal is available from the engine.
Security
- Scheme allow-list. Only
http:andhttps:accepted at the tool boundary.file:,javascript:,data:,chrome://and similar are rejected before any navigation. - SSRF protection. Per-hop validation: the caller-supplied URL is checked pre-navigation, and every document-type request the browser issues (main-frame initial, redirect hop, subframe navigation) is re-checked via a Playwright route handler. Any unsafe hop — loopback, RFC1918, link-local, cloud metadata 169.254.169.254, CGNAT, IPv6 ULAs — aborts the whole call with
ssrf_blocked { hop: "initial" | "redirect" | "subframe", url, reason }. Sub-resources (images, scripts, XHR) are not intercepted. - Response truncation. Bodies exceeding
max_bytesare cut at a UTF-8-safe byte boundary and flaggedtruncated: true. Default 2 MiB, max 50 MiB. - Untrusted content. The
tff-fetch_urlprompt guidelines explicitly warn the LLM that fetched HTML is UNTRUSTED and must not be executed, eval'd, or treated as authoritative instructions. isolate: truefor sensitive fetches — freshBrowserContextper call, no cookie/storage reuse with the shared session context.
Configuration
v0.1.0 does not load a config file. All configuration is per-call via tool parameters. A layered config (project-local + user-global + env + fs.watch reload) lands in a later slice.
Defaults baked into DEFAULT_CONFIG:
| Field | Default | Description |
|---|---|---|
timeoutMs |
30000 |
Per-navigation timeout (ms), overridable via timeout_ms |
maxBytes |
2097152 |
2 MiB response cap for fetch_url, overridable via max_bytes |
defaultEngine |
"duckduckgo" |
Only valid value in v0.1.0 |
Programmatic API (library mode)
@the-forge-flow/camoufox-pi can be imported directly from non-PI code (TFF daemon, scripts, CI harnesses). This is an off-label integration path — PI itself does not officially document cross-extension imports — but the client is PI-agnostic by design and has no PI runtime dependency.
import { createClient } from "@the-forge-flow/camoufox-pi";
const client = createClient();
// Optional: wait for ready up-front (factory is lazy; first op would block otherwise).
await client.ensureReady();
const { html, status } = await client.fetchUrl("https://example.com", {
signal: AbortSignal.timeout(30_000),
});
const { results } = await client.search("claude code", {
signal: AbortSignal.timeout(30_000),
maxResults: 10,
});
await client.close();
createClient(opts?)
opts.config?: Partial<CamoufoxConfig>— shallow-merged overDEFAULT_CONFIG.opts.launcher?: Launcher— swap in a custom launcher (tests inject a fake).
Returns a CamoufoxClient synchronously. ensureReady() is fired in the background; the first op awaits the in-flight promise.
client.checkHealth({ probe? })
Lightweight snapshot (default):
{
status: "launching" | "ready" | "failed" | "closed",
browserConnected: boolean,
browserVersion: string | null,
launchedAt: number | null,
uptimeMs: number | null,
lastError: CamoufoxError | null,
}
Active probe ({ probe: true }) adds probe: { ok, roundTripMs, error } by opening and closing an about:blank page with a fixed 2 s timeout. Probe failure does NOT mutate client state.
Events
client.events is a typed EventEmitter with five events:
| Event | Payload | Emitted when |
|---|---|---|
browser_launch |
{ spanId, browserVersion, durationMs } |
Launch completes successfully |
binary_download_progress |
{ bytesDownloaded, bytesTotal } |
camoufox-js downloads the binary (first launch only) |
fetch_url |
{ spanId, url, finalUrl, status, bytes, truncated, isolate, durationMs, renderMode, usedWaitForSelector, usedSelector, format, screenshotBytes } |
fetchUrl() resolves |
search |
{ spanId, engine, query, maxResults, resultCount, atLimit, durationMs } |
search() resolves |
error |
{ spanId, op, error: CamoufoxError } |
Any op throws — fired BEFORE throw |
spanId is an 8-char hex string minted per op. The error event always fires before the CamoufoxErrorBox reaches the caller's catch. A listener that throws is caught (console.error) and does NOT mask the original error, and other listeners on the same event still run. Async listener rejections are swallowed-and-logged too.
Event reference (inside PI)
When the extension is loaded by PI, CamoufoxService.attach(pi) bridges every client event to pi.events under the camoufox: prefix. Other PI extensions subscribe idiomatically:
pi.events.on("camoufox:fetch_url", (e) => {
console.log(`fetch_url ${e.url} → ${e.status} (${e.durationMs} ms)`);
});
Binary-download progress additionally drives pi.ui.setStatus("camoufox:binary", …) for a footer status line during the ~500 MB first-use download.
Architecture
┌─────────────────────┐
│ PI host process │
│ └─ loads extension │
└─────────┬────────────┘
│ session_start
▼
┌──────────────────────────────────────────────────────┐
│ camoufox-pi extension (in PI process) │
│ │
│ CamoufoxService (singleton) │
│ └─ CamoufoxClient │
│ ├─ one Browser │
│ ├─ one BrowserContext (cookies persist) │
│ └─ Launcher (camoufox-js, isolated) │
│ │
│ Tools: tff-fetch_url / tff-search_web ─┐ │
│ ▼ │
│ CamoufoxClient.navigate │
└──────────────────────────────────────────────────────┘
│ Playwright (Juggler)
▼
┌──────────────────────┐
│ Camoufox process │
│ (patched Firefox) │
└──────────────────────┘
Launcher isolation. src/client/launcher.ts is the only file that imports camoufox-js. Every other file uses the Launcher interface. This keeps the third-party Node wrapper (Apify's port of Python-official Camoufox) swappable — a future official binding, patchright, or a Python subprocess slots in with a one-file change.
Fake-launcher test seam. tests/helpers/fake-launcher.ts injects a stub BrowserContext so every unit test runs without downloading the ~500 MB binary or spawning a real Firefox.
Key components in src/:
| File | Purpose |
|---|---|
src/index.ts |
Extension factory — session lifecycle, tool/command/hook registration |
src/services/camoufox-service.ts |
Singleton service owning the CamoufoxClient, kicks off ensureReady() from session_start |
src/client/camoufox-client.ts |
Lifecycle + navigate + fetchUrl + search + close |
src/client/fetch-pipeline.ts |
fetchUrl helpers — wait-strategy, selector waits, DOM slicing, HTML→markdown, screenshot capture, opts validator |
src/client/launcher.ts |
Launcher interface + RealLauncher (sole camoufox-js importer) |
src/client/signal.ts |
combineSignals(external, timeoutMs) — turn-signal + timeout composition |
src/errors.ts |
CamoufoxError discriminated union (incl. ssrf_blocked { hop, url, reason }) + CamoufoxErrorBox + mapPlaywrightError |
src/security/ssrf.ts |
Private-IP + link-local + cloud-metadata blocklist (IPv4/IPv6 literal + DNS-resolved) |
src/security/redirect-guard.ts |
Per-hop SSRF guard via page.route — intercepts every document-type navigation (main-frame initial, redirect, subframe) and aborts unsafe targets |
src/search/adapters/duckduckgo.ts |
DOM-query SERP parser against html.duckduckgo.com |
src/tools/fetch-url.ts |
tff-fetch_url tool definition |
src/tools/search-web.ts |
tff-search_web tool definition |
src/tools/formats.ts |
TypeBox format: "uri" scheme allow-list hook |
src/tools/types.ts |
ToolDefinition structural interface |
src/types.ts |
CamoufoxConfig + DEFAULT_CONFIG |
Development
bun install # install deps
bun run test # vitest once
bun run test:watch # vitest watch mode
bun run test:coverage # v8 coverage
bun run lint # biome check
bun run lint:fix # auto-fix
bun run build # tsc → dist/
bun run typecheck # type-only check
Pre-commit hooks (lefthook) run biome, typecheck, and tests in parallel.
Commit messages must follow Conventional Commits — enforced by commitlint.
Known limitations
v0.1.0 is the foundational slice. The following are deliberately deferred to later slices:
- DuckDuckGo only. Google / Brave / Bing adapters land in follow-up slices; Google requires stealth tuning that deserves its own slice.
- No retries.
network,timeout,playwright_disconnected, andbrowser_crashedsurface as errors — no exponential backoff. - No cache. No in-memory LRU, no on-disk cache, no request coalescing.
- No blocked-detection. CF/DataDome/PerimeterX challenge pages return as HTTP 200 with challenge HTML; no structured
{ type: "blocked" }yet. - No observability. No metrics, no event-bus events, no span IDs.
binary_download_progressnot emitted (onlyconsole.debug). - No config layering. No config file, no env vars, no
fs.watchreload. Per-call params andDEFAULT_CONFIGonly. - TLS/JA3 fingerprint not spoofed. Camoufox inherits Firefox's ClientHello. Targets that fingerprint TLS (aggressive DataDome, Akamai Bot Manager tier 3) will still detect. Mitigation deferred to a proxy-integration slice.
- Sticky launch failure. A failed
ensureReady()marks the client permanently failed. Retrying requires reconstructing the service. Auto-reconnect lands in the retry-and-reconnect slice. - Third-party Node wrapper. Upstream Camoufox endorses only the Python wrapper.
camoufox-js(Apify, MPL-2.0) is the Node port; launcher isolation keeps it swappable.
Detailed design and deferred-feature landing plan live in local-only docs/ (not published).
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing) - Commit with conventional commits (
git commit -m "feat: add something") - Push to the branch (
git push origin feature/amazing) - Open a Pull Request
License
MIT