@askjo/camofox-browser 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/Dockerfile +17 -2
  2. package/README.md +138 -8
  3. package/camofox.config.json +18 -0
  4. package/lib/auth.js +71 -0
  5. package/lib/config.js +27 -1
  6. package/lib/cookies.js +38 -1
  7. package/lib/downloads.js +10 -2
  8. package/lib/extract.js +74 -0
  9. package/lib/inflight.js +16 -0
  10. package/lib/metrics.js +29 -0
  11. package/lib/openapi.js +100 -0
  12. package/lib/persistence.js +89 -0
  13. package/lib/plugins.js +175 -0
  14. package/lib/reporter.js +751 -0
  15. package/lib/tmp-cleanup.js +40 -0
  16. package/lib/tracing.js +137 -0
  17. package/openclaw.plugin.json +1 -1
  18. package/package.json +8 -2
  19. package/plugins/persistence/AGENTS.md +37 -0
  20. package/plugins/persistence/README.md +48 -0
  21. package/plugins/persistence/index.js +124 -0
  22. package/plugins/persistence/persistence.test.js +117 -0
  23. package/plugins/persistence/plugin.test.js +98 -0
  24. package/plugins/vnc/AGENTS.md +42 -0
  25. package/plugins/vnc/README.md +165 -0
  26. package/plugins/vnc/apt.txt +7 -0
  27. package/plugins/vnc/index.js +142 -0
  28. package/plugins/vnc/spawn.js +8 -0
  29. package/plugins/vnc/vnc-launcher.js +64 -0
  30. package/plugins/vnc/vnc-watcher.sh +82 -0
  31. package/plugins/vnc/vnc.test.js +204 -0
  32. package/plugins/youtube/AGENTS.md +25 -0
  33. package/plugins/youtube/apt.txt +1 -0
  34. package/plugins/youtube/index.js +206 -0
  35. package/plugins/youtube/post-install.sh +5 -0
  36. package/plugins/youtube/youtube.test.js +41 -0
  37. package/scripts/exec.js +8 -0
  38. package/scripts/generate-openapi.js +24 -0
  39. package/scripts/install-plugin-deps.sh +63 -0
  40. package/scripts/plugin.js +342 -0
  41. package/scripts/plugin.test.js +117 -0
  42. package/server.js +2124 -355
  43. /package/{lib → plugins/youtube}/youtube.js +0 -0
package/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM node:20-slim
1
+ FROM node:20-slim AS camofox-browser
2
2
 
3
3
  # Pinned Camoufox version for reproducible builds
4
4
  # Update these when upgrading Camoufox
@@ -36,6 +36,7 @@ RUN apt-get update && apt-get install -y \
36
36
  fontconfig \
37
37
  # Utils
38
38
  ca-certificates \
39
+ curl \
39
40
  unzip \
40
41
  # yt-dlp runtime dependency
41
42
  python3-minimal \
@@ -60,11 +61,25 @@ COPY package.json ./
60
61
  RUN npm install --production
61
62
 
62
63
  COPY server.js ./
64
+ COPY camofox.config.json ./
63
65
  COPY lib/ ./lib/
66
+ COPY plugins/ ./plugins/
67
+ COPY scripts/ ./scripts/
68
+
69
+ # Install default plugin dependencies (apt packages + post-install hooks)
70
+ RUN scripts/install-plugin-deps.sh
64
71
 
65
72
  ENV NODE_ENV=production
66
- ENV CAMOFOX_PORT=3000
73
+ ENV CAMOFOX_PORT=9377
67
74
 
68
75
  EXPOSE 9377
69
76
 
70
77
  CMD ["sh", "-c", "node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js"]
78
+
79
+ # Optional: rebuild plugin deps after adding third-party plugins
80
+ # Usage: docker build --target with-plugins -t camofox-browser .
81
+ FROM camofox-browser AS with-plugins
82
+ COPY plugins/ ./plugins/
83
+ COPY camofox.config.json ./
84
+ COPY scripts/install-plugin-deps.sh /tmp/install-plugin-deps.sh
85
+ RUN /tmp/install-plugin-deps.sh && rm /tmp/install-plugin-deps.sh
package/README.md CHANGED
@@ -3,20 +3,24 @@
3
3
  <h1>camofox-browser</h1>
4
4
  <p><strong>Anti-detection browser server for AI agents, powered by Camoufox</strong></p>
5
5
  <p>
6
- <a href="https://github.com/jo-inc/camofox-browser/actions"><img src="https://img.shields.io/badge/build-passing-brightgreen" alt="Build" /></a>
7
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License" /></a>
8
- <a href="https://camoufox.com"><img src="https://img.shields.io/badge/engine-Camoufox-red" alt="Camoufox" /></a>
9
- <a href="https://hub.docker.com"><img src="https://img.shields.io/badge/docker-ready-blue" alt="Docker" /></a>
6
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
7
+ <a href="https://github.com/jo-inc/camofox-browser/stargazers"><img src="https://img.shields.io/github/stars/jo-inc/camofox-browser" alt="GitHub stars" /></a>
8
+ <a href="https://www.npmjs.com/package/camofox-browser"><img src="https://img.shields.io/npm/v/camofox-browser" alt="npm version" /></a>
9
+ <a href="https://github.com/jo-inc/camofox-browser/commits"><img src="https://img.shields.io/github/last-commit/jo-inc/camofox-browser" alt="GitHub last commit" /></a>
10
10
  </p>
11
11
  <p>
12
12
  Standing on the mighty shoulders of <a href="https://camoufox.com">Camoufox</a> - a Firefox fork with fingerprint spoofing at the C++ level.
13
- <br/><br/>
14
- The same engine behind <a href="https://askjo.ai?ref=camofox">Jo</a> — an AI assistant that doesn't need you to babysit it. Runs half on your Mac, half on a dedicated cloud machine that only you use. Available on macOS, Telegram, and WhatsApp. <a href="https://askjo.ai?ref=camofox">Try the beta free →</a>
15
13
  </p>
16
14
  </div>
17
15
 
18
16
  <br/>
19
17
 
18
+ > <a href="https://askjo.ai?ref=camofox"><img src="jo-logo.png" alt="Jo" width="80" height="80" align="left" /></a>
19
+ >
20
+ > Built by the team behind <a href="https://askjo.ai?ref=camofox"><strong>jo — a personal AI agent</strong></a> that runs half on your Mac, half on a dedicated cloud machine just for you — with zero maintenance needed. Available on macOS, Telegram, WhatsApp, and email. <a href="https://askjo.ai?ref=camofox">Try the beta free →</a>
21
+
22
+ <br/>
23
+
20
24
  ```bash
21
25
  git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser
22
26
  npm install && npm start
@@ -50,6 +54,11 @@ This project wraps that engine in a REST API built for agents: accessibility sna
50
54
  - **Download Capture** - capture browser downloads and fetch them via API (optional inline base64)
51
55
  - **DOM Image Extraction** - list `<img>` src/alt and optionally return inline data URLs
52
56
  - **Deploy Anywhere** - Docker, Fly.io, Railway
57
+ - **VNC Interactive Login** - log into sites visually via noVNC, export storage state for agent reuse
58
+ - **OpenAPI Docs** - auto-generated spec at [`/openapi.json`](http://localhost:9377/openapi.json) and interactive docs at [`/docs`](http://localhost:9377/docs)
59
+ - **Structured Extract** - `POST /tabs/:tabId/extract` with a JSON Schema that maps properties to snapshot refs via `x-ref`
60
+ - **Session Tracing** - opt-in per-session Playwright trace capture (screenshots + DOM snapshots + network) with API endpoints to list, fetch, and delete trace zips
61
+ - **Crash Reporter** - automatic [anonymized crash/hang reporting](lib/reporter.js#L28-L290) via GitHub Issues. Identifies which sites cause failures and common failure patterns. Private domains are HMAC-hashed, paths/params stripped, tokens/IPs redacted. Opt-out with `CAMOFOX_CRASH_REPORT_ENABLED=false`.
53
62
 
54
63
  ## Optional Dependencies
55
64
 
@@ -102,11 +111,11 @@ make up ARCH=x86_64
102
111
  make up VERSION=135.0.1 RELEASE=beta.24
103
112
  ```
104
113
 
105
- Note: `make fetch` (or `make build`) must be run first the Dockerfile expects pre-downloaded binaries in `dist/`.
114
+ > **⚠️ Do not run `docker build` directly.** The Dockerfile uses bind mounts to pull pre-downloaded binaries from `dist/`. Always use `make up` (or `make fetch` then `make build`) — it downloads the binaries first.
106
115
 
107
116
  ### Fly.io / Railway
108
117
 
109
- `fly.toml` and `railway.toml` are included. Deploy with `fly deploy` or connect the repo to Railway.
118
+ `railway.toml` is included. For Fly.io or other remote CI, you'll need a Dockerfile that downloads binaries at build time instead of using bind mounts — see [jo-browser](https://github.com/jo-inc/jo-browser) for an example.
110
119
 
111
120
  ## Usage
112
121
 
@@ -178,6 +187,62 @@ Camoufox browser session (authenticated browsing)
178
187
  - Max 500 cookies per request, 5MB file size limit
179
188
  - Cookie objects are sanitized to an allowlist of Playwright fields
180
189
 
190
+ ### Session Persistence
191
+
192
+ By default, camofox persists each user's cookies and localStorage to `~/.camofox/profiles/`. Sessions survive browser restarts — log in once (via cookies or VNC), and subsequent sessions restore the authenticated state automatically.
193
+
194
+ ```
195
+ ~/.camofox/
196
+ ├── cookies/ # Bootstrap cookie files (Netscape format)
197
+ └── profiles/ # Persisted session state (auto-managed)
198
+ └── <hashed-userId>/
199
+ └── storage_state.json
200
+ ```
201
+
202
+ Override the directory with `CAMOFOX_PROFILE_DIR` or set `"profileDir"` in the persistence plugin config. To disable persistence, set `"persistence": { "enabled": false }` in `camofox.config.json`.
203
+
204
+ ### Session Tracing
205
+
206
+ Capture a Playwright trace of every action in a session: page screenshots, DOM snapshots, network requests, and console output. Output is a single `.zip` file you can open in Playwright's built-in Trace Viewer.
207
+
208
+ Opt-in per session by passing `trace: true` when opening the first tab:
209
+
210
+ ```bash
211
+ curl -X POST http://localhost:9377/tabs \
212
+ -H 'Content-Type: application/json' \
213
+ -d '{"userId":"agent1","sessionKey":"task1","url":"https://example.com","trace":true}'
214
+ ```
215
+
216
+ The trace is written when the session closes. Close the session to flush it, then list, fetch, and view:
217
+
218
+ ```bash
219
+ # Close the session to flush the trace
220
+ curl -X DELETE http://localhost:9377/sessions/agent1
221
+
222
+ # List trace files
223
+ curl http://localhost:9377/sessions/agent1/traces
224
+ # {"traces":[{"filename":"trace-2026-04-18T04-05-00-...zip","sizeBytes":42810,"createdAt":...}]}
225
+
226
+ # Download (Content-Type: application/zip)
227
+ curl http://localhost:9377/sessions/agent1/traces/trace-2026-04-18T04-05-00-abc.zip > session.zip
228
+
229
+ # View it in Playwright's Trace Viewer
230
+ npx playwright show-trace session.zip
231
+
232
+ # Delete
233
+ curl -X DELETE http://localhost:9377/sessions/agent1/traces/trace-2026-04-18T04-05-00-abc.zip
234
+ ```
235
+
236
+ Why traces instead of video: Camoufox is Firefox-based, and Playwright's `recordVideo` is Chromium-only. Traces work on Firefox and give you more than video (network + DOM + console + screenshots).
237
+
238
+ Tracing cannot be toggled on an existing session. `DELETE /sessions/:userId` first if you need to change the flag.
239
+
240
+ Storage defaults to `~/.camofox/traces/<hashed-userId>/` and is swept on server startup:
241
+
242
+ - `CAMOFOX_TRACES_DIR` - base directory (default: `~/.camofox/traces`)
243
+ - `CAMOFOX_TRACES_MAX_BYTES` - max size per trace, removed at next startup if exceeded (default: 50MB)
244
+ - `CAMOFOX_TRACES_TTL_HOURS` - traces older than this are removed at next startup (default: 24)
245
+
181
246
  #### Standalone server usage
182
247
 
183
248
  ```bash
@@ -247,6 +312,60 @@ When a proxy is configured:
247
312
  - Browser fingerprint (language, timezone, coordinates) is consistent with the proxy location
248
313
  - Without a proxy, defaults to `en-US`, `America/Los_Angeles`, San Francisco coordinates
249
314
 
315
+ ### Crash Reporter
316
+
317
+ Browser automation fails in ways that are hard to predict — Cloudflare challenges, site redesigns breaking selectors, redirect loops, dialog storms, renderer crashes. The scope is wide and the failure modes are diverse. Without telemetry, the only signal is "it didn't work."
318
+
319
+ The crash reporter gives us structured data on *which sites fail*, *how they fail*, and *how often*, so we can prioritize fixes for the patterns that actually affect users. It files GitHub Issues automatically when:
320
+
321
+ - **Uncaught exceptions** crash the process
322
+ - **Event loop stalls** exceed 5 seconds (watchdog detection)
323
+ - **Frustration patterns** — 3+ consecutive failures (timeout, dead context, navigation abort) on the same tab
324
+
325
+ Each report includes the failure type, stack trace, tab health counters (HTTP status histogram, console errors, request failures, redirect depth), and the target URL — all anonymized.
326
+
327
+ #### Privacy
328
+
329
+ All reported data goes through paranoid anonymization ([`lib/reporter.js` L28–290](lib/reporter.js#L28-L290)) before leaving the process:
330
+
331
+ - **URLs** — well-known public domains (Google, Amazon, Reddit, Cloudflare, etc.) are shown verbatim so we can identify which sites cause problems. Private/unknown domains are replaced with a stable HMAC hash (`site-a1b2c3d4`) — same hash across reports for correlation, but not reversible to the original domain. Path segments become `•/•/•` (depth only). Query params become `?[3]` (count only). No keys, values, or path content is ever included.
332
+ - **File paths** → stripped to filename only (`<path>/server.js`)
333
+ - **Tokens, secrets, API keys** → `<token>`
334
+ - **IPs, emails, env vars** → redacted
335
+ - **Docker/Fly machine IDs** → `<id>`
336
+ - **Tab health** — pure counters (crash count, error count, status code histogram). No page content, no URLs, no user data.
337
+
338
+ Duplicate issues are detected by stack signature and get a `+1` comment instead of a new issue.
339
+
340
+ Uses a dedicated GitHub App ([Camofox Crash/Stuck Reporter](https://github.com/apps/camofox-crash-stuck-reporter)) with issues-only permissions — no PAT or configuration required.
341
+
342
+ ```bash
343
+ # Disable crash reporting
344
+ export CAMOFOX_CRASH_REPORT_ENABLED=false
345
+
346
+ # Report to a different repo (default: jo-inc/camofox-browser)
347
+ export CAMOFOX_CRASH_REPORT_REPO=your-org/your-repo
348
+
349
+ # Adjust rate limit (default: 10 per hour)
350
+ export CAMOFOX_CRASH_REPORT_RATE_LIMIT=5
351
+ ```
352
+
353
+ #### Reporting to your own repo
354
+
355
+ By default, reports go to `jo-inc/camofox-browser`. To file issues in your own repo instead, create a GitHub App:
356
+
357
+ 1. Go to **Settings → Developer settings → GitHub Apps → New GitHub App**
358
+ 2. Set permissions: **Repository → Issues → Read & Write**. Uncheck **Webhook → Active**.
359
+ 3. Click **Generate a private key** — downloads a `.pem` file
360
+ 4. Install the app on your target repo (Install App → select repo)
361
+ 5. Note your **App ID** (number on the app's settings page) and **Installation ID** (from the URL after installing: `github.com/settings/installations/{id}`)
362
+ 6. Base64-encode the private key and split it into two halves:
363
+ ```bash
364
+ base64 < your-app.pem | tr -d '\n' | fold -w $(($(base64 < your-app.pem | tr -d '\n' | wc -c) / 2)) | head -2
365
+ ```
366
+ 7. Replace `_GH_APP_ID`, `_GH_INSTALL_ID`, `_K_A`, and `_K_B` in `lib/reporter.js` with your values
367
+ 8. Set `CAMOFOX_CRASH_REPORT_REPO=your-org/your-repo`
368
+
250
369
  ### Structured Logging
251
370
 
252
371
  All log output is JSON (one object per line) for easy parsing by log aggregators:
@@ -346,6 +465,7 @@ Uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) when available (fast, no browser
346
465
  | Method | Endpoint | Description |
347
466
  |--------|----------|-------------|
348
467
  | `POST` | `/sessions/:userId/cookies` | Add cookies to a user session (Playwright cookie objects) |
468
+ | `GET` | `/sessions/:userId/storage_state` | Export cookies + localStorage ([VNC plugin](plugins/vnc/)) |
349
469
 
350
470
  ## Search Macros
351
471
 
@@ -364,6 +484,10 @@ Reddit macros return JSON directly (no HTML parsing needed):
364
484
  | `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | - |
365
485
  | `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | - |
366
486
  | `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
487
+ | `CAMOFOX_PROFILE_DIR` | Directory for persisted session profiles | `~/.camofox/profiles` |
488
+ | `CAMOFOX_TRACES_DIR` | Directory for session trace zips | `~/.camofox/traces` |
489
+ | `CAMOFOX_TRACES_MAX_BYTES` | Max size per trace, removed on next startup if exceeded | `52428800` (50MB) |
490
+ | `CAMOFOX_TRACES_TTL_HOURS` | Traces older than this are swept on startup | `24` |
367
491
  | `MAX_SESSIONS` | Max concurrent browser sessions | `50` |
368
492
  | `MAX_TABS_PER_SESSION` | Max tabs per session | `10` |
369
493
  | `SESSION_TIMEOUT_MS` | Session inactivity timeout | `1800000` (30min) |
@@ -382,6 +506,12 @@ Reddit macros return JSON directly (no HTML parsing needed):
382
506
  | `PROXY_COUNTRY` | Target country for proxy geo-targeting | - |
383
507
  | `PROXY_STATE` | Target state/region for proxy geo-targeting | - |
384
508
  | `TAB_INACTIVITY_MS` | Close tabs idle longer than this | `300000` (5min) |
509
+ | `CAMOFOX_CRASH_REPORT_ENABLED` | Enable anonymized crash/hang reporter (`false` to disable) | `true` |
510
+ | `CAMOFOX_CRASH_REPORT_REPO` | GitHub repo for issue reports | `jo-inc/camofox-browser` |
511
+ | `CAMOFOX_CRASH_REPORT_RATE_LIMIT` | Max reports per hour | `10` |
512
+ | `ENABLE_VNC` | Enable VNC plugin for interactive browser access (`1`) | - |
513
+ | `VNC_PASSWORD` | Password for VNC access (recommended in production) | - |
514
+ | `NOVNC_PORT` | noVNC web UI port | `6080` |
385
515
 
386
516
  ## Architecture
387
517
 
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "camofox-browser",
3
+ "name": "Camofox Browser",
4
+ "version": "1.6.0",
5
+ "plugins": {
6
+ "youtube": { "enabled": true },
7
+ "persistence": { "enabled": true },
8
+ "vnc": { "resolution": "1920x1080" }
9
+ },
10
+ "crashReporter": {
11
+ "appId": "3503870",
12
+ "installationId": "127089401",
13
+ "repo": "jo-inc/camofox-browser",
14
+ "userAgent": "camofox-crash-reporter",
15
+ "keyA": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb2dJQkFBS0NBUUVBMDRQRDBWWkNRb1pYdkpCNmYwUHdGRk1scEVzbjF2blFYaHJNSkxlSDdBWElmdGhCCmtpbElUb0xNeWFCVU9aWEdUbXNNYnE2MEd5R1p4RGFQVSt4bGRQdmc2Y2QrUElUNmxSbjB2TCsrd2FNT0llMDQKUEdrallSWW1YZzhqZHFCNEhQcmlKbkQwa2srU0EwVUxBYm5jUHVhTitKNEVFYjBDZUZQRUpzNEJqd0dvcENZMwpaM1VBS2ZBVjh5Slpzc3JVM0lvVlBzckc4dHVnRFhya3JTc2Y0K1Jmdm5lejRibGk1YXJKQ1h3cXJPVE0xRERNCk9mUFhOMENQMWNIWW1XL1p0TVNiRlpsbFRobVdMb05NUXNNdlFEZ3lmWjRXbG9rL1doaWVhTWttWFB6YzFuUkkKMVltNCs0YzZVS2kzOURFSS9MODA5RUZ6OHU0K2lnN1dzaEdsZHdJREFRQUJBb0lCQVFDcC8xQWw4cjhrZXBjUApqY3QyZCtNQVl1ZHhDWnFHbEplYzJzclNnOU94cGVCRDJvbXc4SThWMHRqSEFKNVEvZ2k1UkI1azR2TU1qMC9uCnZMWXJqR2Jxdy9vN3lzT3gzbXNMNVNXbmdqRE5yc0NRRWZuTnkrN01mQ0h3SFJpeW9qeUhoamkzRHJmeTFCTVYKbjZzK0F1UjZoWkQ3amZ6VlNPVXdVcHJuV1ZFMVhuaVBaZmZSd29BQmxEQi92Zm9wWWZpKzhKaXpYQTNVaytlMwp1TEdIZHc2bzJlUkp1bW90RFBubWtsVkVSKy8vcUZpa1BqTGJBSHprNGRTM3hkMWF1ZEFUNU5hczdiUkhGeXFoCkt2eGk2blM4cTAzanBIS0JMQ0hsN212Y1JQUWhYek1XcHVQblVsNGpvRFVDQ0RITG5CcWNmZ3FjTnlzOGhuSkIKaFpueE5pbGhBb0dCQVBKNm04QXo5KytnbzZjMGoxNnFsSXk3V3NyU0liSW80bXZSUEtRc0lnQUxOYTB3TXluNgozWms3NHlTdWJVaWY5c0pwM0pCNFEzdlBqVm",
16
+ "keyB": "9pSHBDaVZ3ZEttNFFLeC9mMVJITUtCTzVDOFlHK0VsaTlUSWllCkVjWFJMN2VyUmpValIvNjJoZVAvVG96ZXdPckxYbHFlVEEySEJ2S2RJbTFrL2ozNnlWNFVUdm1SQW9HQkFOOVAKSkRpWCt4Y083RnMwOUxxWlYxRFVhK1Rja1RQR1BMMVdlelBEeHc0M0lLVERyL2F1RWs0Mm1pK0NYY0huQk5CagpDT0Y3bEVhRTVENzdVVlZFL3JxR0E3dGdLVER3M2N5cDhQb2x3T1IwK01LUmh5MFpjeG1wcFhlb1dlZm9LVEJaCmtyQjlRbXZrZTMvdWhHUWNqemt3M2dxcE1QRmdFdFlNUUdBY3RtcUhBb0dCQUtobGNnbFhqaGJERHlTdUlldHkKdDl2TXVjOGxnL1ZBNDQ1Ukw3WXNXQ2lEb0hGNGllL2JvMDRxQXlPVVo1MEtTc3JWempJZTgyN213NW9YRy9jQwpaMEpQRkJYdGp0YXJaVEFuZ3lrZElMQWtHb1c2WVk1M2lJeERMTXAzamppVkdnalJKY2NqcForN2kyc0VkYkNsClF0Z2FNRDhKMWNEM1pJSVN5d29sUEh1aEFuOHYrZERPVjlpYUc1cXIvYlNXWWx0Z0FrTXI2RGRKNkUwa1lIQVgKcnZnVlRNSzJvMVFxcXp0RHlYZFd2YXRtL1RzTGlqdGVOaTZrOStnUm4relpaUGxWR1hXenkvVU5qcklZUm1wLwpVNTBkZUFQNXlVcEJaalpVVFI0L2x1dTU1eWJ5UEV4SG5xR21qRy84REVKbFA3MkZpL29vVURFenFuQmhqRUJJClplTExBb0dBWUQxSkp2S0d5U2NtZWVCU0J3TTRlSUF2b3RCMWR2L0NvaThDNVR2Z1F6QWRySE1BTGhIbDNtbVEKajJ3ZnpPOFlEbW45ZmdreDVvVTMyWjJNQTEyVS9mQklJLzVFYWFLK045MVVJanlFUHd1ZU42emR3c0MwT0NXWQovTVYyaFI3dlk1bmN5SUxubG1yRHNRU3htTzIrZmFhUlAxQUVtUit5aUpOUUw2ZlE1RGc9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg=="
17
+ }
18
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared auth middleware for camofox-browser.
3
+ *
4
+ * Extracts the duplicated auth pattern from cookie/storage_state endpoints
5
+ * into a reusable Express middleware factory.
6
+ *
7
+ * Policy:
8
+ * - If CAMOFOX_API_KEY is set, require Bearer token match (timing-safe).
9
+ * - If not set and NODE_ENV !== production, allow loopback (127.0.0.1 / ::1).
10
+ * - Otherwise, reject.
11
+ */
12
+
13
+ import crypto from 'crypto';
14
+
15
+ /**
16
+ * Timing-safe string comparison.
17
+ */
18
+ function timingSafeCompare(a, b) {
19
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
20
+ const bufA = Buffer.from(a);
21
+ const bufB = Buffer.from(b);
22
+ if (bufA.length !== bufB.length) {
23
+ // Compare against self to burn constant time, then return false
24
+ crypto.timingSafeEqual(bufA, bufA);
25
+ return false;
26
+ }
27
+ return crypto.timingSafeEqual(bufA, bufB);
28
+ }
29
+
30
+ /**
31
+ * Check if an address is loopback.
32
+ */
33
+ function isLoopbackAddress(address) {
34
+ if (!address) return false;
35
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
36
+ }
37
+
38
+ /**
39
+ * Create an Express middleware that enforces API key auth.
40
+ *
41
+ * @param {object} config - Must have { apiKey, nodeEnv }
42
+ * @param {object} [options]
43
+ * @param {string} [options.errorMessage] - Custom error message when rejecting unauthenticated requests
44
+ * @returns {function} Express middleware (req, res, next)
45
+ */
46
+ export function requireAuth(config, options = {}) {
47
+ const errorMessage = options.errorMessage ||
48
+ 'This endpoint requires CAMOFOX_API_KEY except for loopback requests in non-production environments.';
49
+
50
+ return (req, res, next) => {
51
+ if (config.apiKey) {
52
+ const auth = String(req.headers['authorization'] || '');
53
+ const match = auth.match(/^Bearer\s+(.+)$/i);
54
+ if (!match || !timingSafeCompare(match[1], config.apiKey)) {
55
+ return res.status(403).json({ error: 'Forbidden' });
56
+ }
57
+ return next();
58
+ }
59
+
60
+ const remoteAddress = req.socket?.remoteAddress || '';
61
+ const allowUnauthedLocal = config.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
62
+ if (!allowUnauthedLocal) {
63
+ return res.status(403).json({ error: errorMessage });
64
+ }
65
+
66
+ next();
67
+ };
68
+ }
69
+
70
+ // Re-export utilities so server.js can still use them directly
71
+ export { timingSafeCompare, isLoopbackAddress };
package/lib/config.js CHANGED
@@ -5,9 +5,22 @@
5
5
  * flag plugin.ts or server.js for env-harvesting (env + network in same file).
6
6
  */
7
7
 
8
- import { join } from 'path';
8
+ import { join, dirname } from 'path';
9
+ import { readFileSync } from 'fs';
10
+ import { fileURLToPath } from 'url';
9
11
  import os from 'os';
10
12
 
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const ROOT_DIR = join(__dirname, '..');
15
+
16
+ /** Read crashReporter section from camofox.config.json (if present). */
17
+ function readCrashReporterConfig() {
18
+ try {
19
+ const raw = readFileSync(join(ROOT_DIR, 'camofox.config.json'), 'utf-8');
20
+ return JSON.parse(raw).crashReporter || {};
21
+ } catch { return {}; }
22
+ }
23
+
11
24
  /**
12
25
  * Parse PROXY_PORTS env var into an array of port numbers.
13
26
  * Supports range ("10001-10010") or comma-separated ("10001,10002,10003").
@@ -46,6 +59,10 @@ function loadConfig() {
46
59
  adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
47
60
  apiKey: process.env.CAMOFOX_API_KEY || '',
48
61
  cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
62
+ profileDir: process.env.CAMOFOX_PROFILE_DIR || join(os.homedir(), '.camofox', 'profiles'),
63
+ tracesDir: process.env.CAMOFOX_TRACES_DIR || join(os.homedir(), '.camofox', 'traces'),
64
+ tracesMaxBytes: parseInt(process.env.CAMOFOX_TRACES_MAX_BYTES || String(50 * 1024 * 1024), 10),
65
+ tracesTtlHours: parseInt(process.env.CAMOFOX_TRACES_TTL_HOURS || '24', 10),
49
66
  handlerTimeoutMs: parseInt(process.env.HANDLER_TIMEOUT_MS) || 30000,
50
67
  maxConcurrentPerUser: parseInt(process.env.MAX_CONCURRENT_PER_USER) || 3,
51
68
  sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS) || 600000,
@@ -81,6 +98,9 @@ function loadConfig() {
81
98
  CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
82
99
  CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
83
100
  CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
101
+ CAMOFOX_TRACES_DIR: process.env.CAMOFOX_TRACES_DIR,
102
+ CAMOFOX_TRACES_MAX_BYTES: process.env.CAMOFOX_TRACES_MAX_BYTES,
103
+ CAMOFOX_TRACES_TTL_HOURS: process.env.CAMOFOX_TRACES_TTL_HOURS,
84
104
  PROXY_STRATEGY: process.env.PROXY_STRATEGY,
85
105
  PROXY_PROVIDER: process.env.PROXY_PROVIDER,
86
106
  PROXY_HOST: process.env.PROXY_HOST,
@@ -96,6 +116,12 @@ function loadConfig() {
96
116
  PROXY_ZIP: process.env.PROXY_ZIP,
97
117
  PROXY_SESSION_DURATION_MINUTES: process.env.PROXY_SESSION_DURATION_MINUTES,
98
118
  },
119
+ // Crash reporter (opt-in, uses embedded GitHub App credentials)
120
+ // Crash reporter (opt-in, credentials from camofox.config.json)
121
+ crashReportEnabled: process.env.CAMOFOX_CRASH_REPORT_ENABLED !== 'false',
122
+ crashReportRepo: process.env.CAMOFOX_CRASH_REPORT_REPO,
123
+ crashReportRateLimit: parseInt(process.env.CAMOFOX_CRASH_REPORT_RATE_LIMIT, 10) || 10,
124
+ crashReporterConfig: readCrashReporterConfig(),
99
125
  };
100
126
  }
101
127
 
package/lib/cookies.js CHANGED
@@ -79,4 +79,41 @@ async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes
79
79
  }));
80
80
  }
81
81
 
82
- export { parseNetscapeCookieFile, readCookieFile };
82
+ /**
83
+ * Import all cookies from the default bootstrap cookie file into a Playwright context.
84
+ * Intended for first-run session seeding before any persistent storage state exists.
85
+ * Missing file is treated as a no-op.
86
+ * @param {object} opts
87
+ * @param {string} opts.cookiesDir - Base directory for cookie files
88
+ * @param {object} opts.context - Playwright BrowserContext
89
+ * @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
90
+ * @param {object} [opts.logger=console] - Logger with warn()
91
+ * @returns {Promise<{imported: number, source: string|null}>}
92
+ */
93
+ async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
94
+ if (!cookiesDir || !context) {
95
+ return { imported: 0, source: null };
96
+ }
97
+
98
+ const resolved = path.resolve(cookiesDir, cookiesPath);
99
+
100
+ try {
101
+ const cookies = await readCookieFile({ cookiesDir, cookiesPath });
102
+ if (cookies.length === 0) {
103
+ return { imported: 0, source: resolved };
104
+ }
105
+ await context.addCookies(cookies);
106
+ return { imported: cookies.length, source: resolved };
107
+ } catch (err) {
108
+ if (err?.code === 'ENOENT') {
109
+ return { imported: 0, source: null };
110
+ }
111
+ logger?.warn?.('failed to import bootstrap cookies', {
112
+ cookiesPath: resolved,
113
+ error: err?.message || String(err),
114
+ });
115
+ return { imported: 0, source: resolved };
116
+ }
117
+ }
118
+
119
+ export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
package/lib/downloads.js CHANGED
@@ -60,7 +60,7 @@ async function clearSessionDownloads(session) {
60
60
  await Promise.all(tasks);
61
61
  }
62
62
 
63
- function attachDownloadListener(tabState, tabId, log) {
63
+ function attachDownloadListener(tabState, tabId, log, pluginEvents, userId) {
64
64
  if (tabState.downloadListenerAttached) return;
65
65
  tabState.downloadListenerAttached = true;
66
66
 
@@ -69,6 +69,11 @@ function attachDownloadListener(tabState, tabId, log) {
69
69
  const suggestedFilename = sanitizeFilename(download.suggestedFilename?.() || `download-${downloadId}.bin`);
70
70
  const filePath = path.join(os.tmpdir(), `camofox-download-${downloadId}-${suggestedFilename}`);
71
71
 
72
+ const url = String(download.url?.() || '').trim();
73
+ if (pluginEvents) {
74
+ pluginEvents.emit('tab:download:start', { userId: userId || null, tabId, filename: suggestedFilename, url });
75
+ }
76
+
72
77
  let failure = null;
73
78
  let bytes = null;
74
79
 
@@ -86,7 +91,6 @@ function attachDownloadListener(tabState, tabId, log) {
86
91
  failure = reportedFailure;
87
92
  }
88
93
 
89
- const url = String(download.url?.() || '').trim();
90
94
  if (url) {
91
95
  tabState.visitedUrls.add(url);
92
96
  }
@@ -104,6 +108,10 @@ function attachDownloadListener(tabState, tabId, log) {
104
108
  failure,
105
109
  });
106
110
 
111
+ if (pluginEvents && !failure) {
112
+ pluginEvents.emit('tab:download:complete', { userId: userId || null, tabId, filename: suggestedFilename, path: filePath, size: bytes });
113
+ }
114
+
107
115
  await trimTabDownloads(tabState);
108
116
  log('info', 'download captured', {
109
117
  tabId, downloadId, suggestedFilename, mimeType, bytes,
package/lib/extract.js ADDED
@@ -0,0 +1,74 @@
1
+ const SUPPORTED_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'object', 'null']);
2
+
3
+ export function validateSchema(schema) {
4
+ if (!schema || typeof schema !== 'object') {
5
+ return { ok: false, error: 'schema must be an object' };
6
+ }
7
+ if (schema.type !== 'object') {
8
+ return { ok: false, error: 'top-level schema must have type: object' };
9
+ }
10
+ if (!schema.properties || typeof schema.properties !== 'object') {
11
+ return { ok: false, error: 'schema must have a properties object' };
12
+ }
13
+ for (const [prop, def] of Object.entries(schema.properties)) {
14
+ if (!def || typeof def !== 'object') {
15
+ return { ok: false, error: `property "${prop}" must be an object` };
16
+ }
17
+ if (def.type && !SUPPORTED_TYPES.has(def.type)) {
18
+ return { ok: false, error: `property "${prop}" has unsupported type "${def.type}"` };
19
+ }
20
+ }
21
+ return { ok: true };
22
+ }
23
+
24
+ function coerceValue(raw, type) {
25
+ if (raw == null) return null;
26
+ if (type === 'string' || !type) return String(raw).trim();
27
+ if (type === 'number') {
28
+ const n = parseFloat(String(raw).replace(/[^0-9.eE+-]/g, ''));
29
+ return Number.isFinite(n) ? n : null;
30
+ }
31
+ if (type === 'integer') {
32
+ const n = parseInt(String(raw).replace(/[^0-9-]/g, ''), 10);
33
+ return Number.isFinite(n) ? n : null;
34
+ }
35
+ if (type === 'boolean') {
36
+ const s = String(raw).toLowerCase().trim();
37
+ if (s === 'true' || s === 'yes' || s === '1') return true;
38
+ if (s === 'false' || s === 'no' || s === '0') return false;
39
+ return null;
40
+ }
41
+ return raw;
42
+ }
43
+
44
+ function extractFromRef(refs, refId) {
45
+ const info = refs.get(refId);
46
+ if (!info) return null;
47
+ return info.name || null;
48
+ }
49
+
50
+ export function extractDeterministic({ schema, refs }) {
51
+ const check = validateSchema(schema);
52
+ if (!check.ok) throw new Error(check.error);
53
+
54
+ const result = {};
55
+ for (const [prop, def] of Object.entries(schema.properties)) {
56
+ const refId = def['x-ref'];
57
+
58
+ let value = null;
59
+ if (refId) {
60
+ value = extractFromRef(refs, refId);
61
+ if (value != null && def.type && def.type !== 'object') {
62
+ value = coerceValue(value, def.type);
63
+ }
64
+ }
65
+
66
+ if (value == null && Array.isArray(schema.required) && schema.required.includes(prop)) {
67
+ throw new Error(`required property "${prop}" could not be extracted (x-ref=${refId || 'n/a'})`);
68
+ }
69
+
70
+ result[prop] = value;
71
+ }
72
+
73
+ return result;
74
+ }
@@ -0,0 +1,16 @@
1
+ async function coalesceInflight(map, key, factory) {
2
+ const existing = map.get(key);
3
+ if (existing) return existing;
4
+
5
+ const promise = (async () => {
6
+ try {
7
+ return await factory();
8
+ } finally {
9
+ map.delete(key);
10
+ }
11
+ })();
12
+ map.set(key, promise);
13
+ return promise;
14
+ }
15
+
16
+ export { coalesceInflight };
package/lib/metrics.js CHANGED
@@ -12,6 +12,27 @@ const noopCounter = { inc() {}, labels() { return this; } };
12
12
  const noopHistogram = { observe() {}, startTimer() { return () => {}; }, labels() { return this; } };
13
13
  const noopGauge = { set() {}, inc() {}, dec() {}, labels() { return this; } };
14
14
 
15
+ /**
16
+ * Create a metric (Counter, Histogram, or Gauge) registered to the shared registry.
17
+ * Returns a no-op stub when Prometheus is disabled — plugins never need to check.
18
+ *
19
+ * @param {'counter'|'histogram'|'gauge'} type
20
+ * @param {object} opts - prom-client options: { name, help, labelNames, buckets, ... }
21
+ * @returns {object} The metric instance or a no-op stub
22
+ */
23
+ export async function createMetric(type, opts) {
24
+ if (!_register) {
25
+ if (type === 'histogram') return noopHistogram;
26
+ if (type === 'gauge') return noopGauge;
27
+ return noopCounter;
28
+ }
29
+ const client = (await import('prom-client')).default;
30
+ const MetricClass = type === 'histogram' ? client.Histogram
31
+ : type === 'gauge' ? client.Gauge
32
+ : client.Counter;
33
+ return new MetricClass({ ...opts, registers: [_register] });
34
+ }
35
+
15
36
  function buildNoopMetrics() {
16
37
  return {
17
38
  requestsTotal: noopCounter,
@@ -24,6 +45,7 @@ function buildNoopMetrics() {
24
45
  tabsRecycledTotal: noopCounter,
25
46
  requestDuration: noopHistogram,
26
47
  pageLoadDuration: noopHistogram,
48
+ snapshotBytes: noopHistogram,
27
49
  activeTabsGauge: noopGauge,
28
50
  tabLockQueueDepth: noopGauge,
29
51
  memoryUsageBytes: noopGauge,
@@ -93,6 +115,13 @@ async function buildRealMetrics() {
93
115
  buckets: [0.5, 1, 2, 5, 10, 20, 30, 60],
94
116
  registers: [_register],
95
117
  }),
118
+ snapshotBytes: new client.Histogram({
119
+ name: 'camofox_snapshot_bytes',
120
+ help: 'Size of accessibility tree snapshots in bytes (before windowing)',
121
+ labelNames: ['type'],
122
+ buckets: [1000, 5000, 10000, 25000, 50000, 80000, 120000, 200000, 500000],
123
+ registers: [_register],
124
+ }),
96
125
  activeTabsGauge: new client.Gauge({
97
126
  name: 'camofox_active_tabs',
98
127
  help: 'Current number of open browser tabs',