@askjo/camofox-browser 1.8.8 → 1.8.11
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.
- package/AGENTS.md +580 -0
- package/Dockerfile +2 -2
- package/README.md +69 -29
- package/lib/auth.js +6 -6
- package/lib/metrics.js +3 -3
- package/lib/openapi.js +1 -1
- package/lib/plugins.js +12 -12
- package/lib/proxy.js +9 -9
- package/lib/reporter.js +24 -24
- package/lib/request-utils.js +1 -1
- package/lib/resources.js +4 -8
- package/lib/snapshot.js +1 -1
- package/lib/tmp-cleanup.js +1 -1
- package/openclaw.plugin.json +97 -1
- package/package.json +46 -3
- package/plugins/vnc/index.js +2 -2
- package/plugins/vnc/vnc-launcher.js +2 -2
- package/plugins/vnc/vnc-watcher.sh +3 -3
- package/plugins/vnc/vnc.test.js +2 -2
- package/plugins/youtube/index.js +2 -2
- package/plugins/youtube/youtube.js +1 -1
- package/scripts/install-plugin-deps.sh +1 -1
- package/scripts/plugin.js +19 -19
- package/scripts/plugin.test.js +2 -2
- package/server.js +31 -31
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ The Docker image includes yt-dlp. For local dev, install it for the `/youtube/tr
|
|
|
76
76
|
openclaw plugins install @askjo/camofox-browser
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
**Tools:** `camofox_create_tab`
|
|
79
|
+
**Tools:** `camofox_create_tab` | `camofox_snapshot` | `camofox_click` | `camofox_type` | `camofox_navigate` | `camofox_scroll` | `camofox_screenshot` | `camofox_close_tab` | `camofox_list_tabs` | `camofox_import_cookies`
|
|
80
80
|
|
|
81
81
|
### Standalone
|
|
82
82
|
|
|
@@ -111,7 +111,7 @@ make up ARCH=x86_64
|
|
|
111
111
|
make up VERSION=135.0.1 RELEASE=beta.24
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
>
|
|
114
|
+
> **WARNING: 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.
|
|
115
115
|
|
|
116
116
|
### Fly.io
|
|
117
117
|
|
|
@@ -183,18 +183,18 @@ The agent calls `camofox_import_cookies` -> reads the file -> POSTs to the serve
|
|
|
183
183
|
|
|
184
184
|
```
|
|
185
185
|
~/.camofox/cookies/linkedin.txt (Netscape format, on disk)
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
|
|
|
187
|
+
v
|
|
188
188
|
camofox_import_cookies tool (parses file, filters by domain)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
|
|
|
190
|
+
v POST /sessions/:userId/cookies
|
|
191
|
+
| Authorization: Bearer <CAMOFOX_API_KEY>
|
|
192
|
+
| Body: { cookies: [Playwright cookie objects] }
|
|
193
|
+
v
|
|
194
194
|
camofox server (validates, sanitizes, injects)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
|
|
|
196
|
+
v context.addCookies(...)
|
|
197
|
+
|
|
|
198
198
|
Camoufox browser session (authenticated browsing)
|
|
199
199
|
```
|
|
200
200
|
|
|
@@ -208,10 +208,10 @@ By default, camofox persists each user's cookies and localStorage to `~/.camofox
|
|
|
208
208
|
|
|
209
209
|
```
|
|
210
210
|
~/.camofox/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
211
|
+
|-- cookies/ # Bootstrap cookie files (Netscape format)
|
|
212
|
+
\-- profiles/ # Persisted session state (auto-managed)
|
|
213
|
+
\-- <hashed-userId>/
|
|
214
|
+
\-- storage_state.json
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
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`.
|
|
@@ -350,11 +350,11 @@ Reports are sent to a lightweight Cloudflare Worker relay at [`https://camofox-c
|
|
|
350
350
|
|
|
351
351
|
```
|
|
352
352
|
lib/reporter.js (client, no secrets)
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
| anonymize -> POST https://camofox-crash-relay.askjo.workers.dev/report
|
|
354
|
+
v
|
|
355
355
|
Cloudflare Worker (holds GitHub App key)
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
| validate -> rate-limit -> dedup -> create GitHub Issue
|
|
357
|
+
v
|
|
358
358
|
GitHub Issue created
|
|
359
359
|
```
|
|
360
360
|
|
|
@@ -385,7 +385,7 @@ Or skip verification entirely: `CAMOFOX_CRASH_REPORT_ENABLED=false` disables all
|
|
|
385
385
|
|
|
386
386
|
All reported data goes through paranoid anonymization ([`lib/reporter.js` L28-290](lib/reporter.js#L28-L290)) before leaving the process:
|
|
387
387
|
|
|
388
|
-
- **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
|
|
388
|
+
- **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.
|
|
389
389
|
- **File paths** -> stripped to filename only (`<path>/server.js`)
|
|
390
390
|
- **Tokens, secrets, API keys** -> `<token>`
|
|
391
391
|
- **IPs, emails, env vars** -> redacted
|
|
@@ -528,7 +528,7 @@ curl -X POST http://localhost:9377/tabs/TAB_ID/navigate \
|
|
|
528
528
|
curl -X POST http://localhost:9377/youtube/transcript \
|
|
529
529
|
-H 'Content-Type: application/json' \
|
|
530
530
|
-d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "languages": ["en"]}'
|
|
531
|
-
# -> { "status": "ok", "transcript": "[00:18]
|
|
531
|
+
# -> { "status": "ok", "transcript": "[00:18] [music] We're no strangers to love [music]\n...", "video_title": "...", "total_words": 548 }
|
|
532
532
|
```
|
|
533
533
|
|
|
534
534
|
Uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) when available (fast, no browser needed). Falls back to a browser-based intercept method if yt-dlp is not installed -- this is slower and less reliable due to YouTube ad pre-rolls.
|
|
@@ -550,7 +550,7 @@ Uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) when available (fast, no browser
|
|
|
550
550
|
|
|
551
551
|
## Search Macros
|
|
552
552
|
|
|
553
|
-
`@google_search`
|
|
553
|
+
`@google_search` | `@youtube_search` | `@amazon_search` | `@reddit_search` | `@reddit_subreddit` | `@wikipedia_search` | `@twitter_search` | `@yelp_search` | `@spotify_search` | `@netflix_search` | `@linkedin_search` | `@instagram_search` | `@tiktok_search` | `@twitch_search`
|
|
554
554
|
|
|
555
555
|
Reddit macros return JSON directly (no HTML parsing needed):
|
|
556
556
|
- `@reddit_search` - search all of Reddit, returns JSON with 25 results
|
|
@@ -600,18 +600,58 @@ Reddit macros return JSON directly (no HTML parsing needed):
|
|
|
600
600
|
|
|
601
601
|
```
|
|
602
602
|
Browser Instance (Camoufox)
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
603
|
+
\-- User Session (BrowserContext) - isolated cookies/storage
|
|
604
|
+
|-- Tab Group (sessionKey: "conv1")
|
|
605
|
+
| |-- Tab (google.com)
|
|
606
|
+
| \-- Tab (github.com)
|
|
607
|
+
\-- Tab Group (sessionKey: "conv2")
|
|
608
|
+
\-- Tab (amazon.com)
|
|
609
609
|
```
|
|
610
610
|
|
|
611
611
|
Sessions auto-expire after 30 minutes of inactivity. The browser itself shuts down after 5 minutes with no active sessions, and relaunches on the next request.
|
|
612
612
|
|
|
613
613
|
When a session's tab limit is reached, the oldest/least-used tab is automatically recycled instead of returning an error -- so long-running agent sessions don't hit dead ends.
|
|
614
614
|
|
|
615
|
+
## Security Model
|
|
616
|
+
|
|
617
|
+
This section documents the security boundaries of camofox-browser for users and automated security reviewers.
|
|
618
|
+
|
|
619
|
+
### Code isolation
|
|
620
|
+
|
|
621
|
+
All `process.env` reads are centralized in `lib/config.js`. All `child_process` usage is in `lib/launcher.js` (server subprocess) and `plugins/youtube/youtube.js` (yt-dlp). The main `server.js` has route handlers but zero `process.env` reads and zero `child_process` imports. No single file combines environment/credential access with network sends.
|
|
622
|
+
|
|
623
|
+
### No embedded secrets
|
|
624
|
+
|
|
625
|
+
Zero credentials, private keys, API tokens, or signing keys ship in this package. All secrets are provided at runtime via environment variables (`CAMOFOX_API_KEY`, `CAMOFOX_ACCESS_KEY`) or are Cloudflare Worker environment secrets (crash relay GitHub App key).
|
|
626
|
+
|
|
627
|
+
### Cookie import is disabled by default
|
|
628
|
+
|
|
629
|
+
The cookie import endpoint (`POST /sessions/:userId/cookies`) is gated behind `CAMOFOX_API_KEY`. If this env var is not set, the server rejects all cookie import requests with HTTP 403. Cookie files are read from a sandboxed directory (`~/.camofox/cookies/`) with path traversal protection -- attempts to escape the directory are blocked. Max 500 cookies per request, 5MB file size limit.
|
|
630
|
+
|
|
631
|
+
### Access control
|
|
632
|
+
|
|
633
|
+
`CAMOFOX_ACCESS_KEY` provides global bearer token authentication for all routes (except `/health`). When set, every request must include `Authorization: Bearer <key>`. Recommended for any deployment beyond localhost.
|
|
634
|
+
|
|
635
|
+
### Binary download
|
|
636
|
+
|
|
637
|
+
The Camoufox browser engine (~300MB) is downloaded at `npm install` time by [`camoufox-js`](https://www.npmjs.com/package/camoufox-js), an npm package maintained by the [Camoufox project](https://camoufox.com). It downloads from [official GitHub releases](https://github.com/nicedayzhu/camoufox/releases) with integrity verification handled by `camoufox-js`. No custom download URLs, no URL shorteners, no raw IP addresses.
|
|
638
|
+
|
|
639
|
+
### Crash reporting
|
|
640
|
+
|
|
641
|
+
Anonymized crash/hang reports are sent to a Cloudflare Worker relay. The relay source is [in this repo](workers/crash-reporter/index.ts) and auditable. Verification: `GET /source` on the relay returns the deployed commit hash and sha256 so you can compare against the repo. The reporter ([`lib/reporter.js` L28-290](lib/reporter.js#L28-L290)) applies paranoid anonymization: private domains are HMAC-hashed (not reversible), paths are stripped, tokens/IPs/emails are redacted. No page content, cookies, or user data is ever sent. Disable with `CAMOFOX_CRASH_REPORT_ENABLED=false` or point to your own relay with `CAMOFOX_CRASH_REPORT_URL`.
|
|
642
|
+
|
|
643
|
+
### Session persistence
|
|
644
|
+
|
|
645
|
+
The persistence plugin saves cookies and localStorage to `~/.camofox/profiles/<hashed-userId>/` so authenticated sessions survive browser restarts. UserIds are hashed for directory names. Disable via `camofox.config.json` by removing `persistence` from the plugins array.
|
|
646
|
+
|
|
647
|
+
### Network access
|
|
648
|
+
|
|
649
|
+
Outbound connections are made to: (1) URLs the agent navigates to (core functionality), (2) the crash report relay (anonymized, opt-out available). Inbound: the REST API on localhost:9377 (default), optionally protected by `CAMOFOX_ACCESS_KEY`.
|
|
650
|
+
|
|
651
|
+
### Subprocess usage
|
|
652
|
+
|
|
653
|
+
Two subprocesses may be spawned: (1) the Camoufox browser engine (core functionality, `lib/launcher.js`), (2) yt-dlp for YouTube transcript extraction (optional, `plugins/youtube/youtube.js`). Both are isolated in dedicated files separate from route handlers.
|
|
654
|
+
|
|
615
655
|
## Testing
|
|
616
656
|
|
|
617
657
|
```bash
|
package/lib/auth.js
CHANGED
|
@@ -73,12 +73,12 @@ export function requireAuth(config, options = {}) {
|
|
|
73
73
|
return next();
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// If any key is configured, a valid token was required
|
|
76
|
+
// If any key is configured, a valid token was required -- reject
|
|
77
77
|
if (config.apiKey || config.accessKey) {
|
|
78
78
|
return res.status(403).json({ error: 'Forbidden' });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// No keys configured
|
|
81
|
+
// No keys configured -- allow loopback in non-production
|
|
82
82
|
const remoteAddress = req.socket?.remoteAddress || '';
|
|
83
83
|
const allowUnauthedLocal = config.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
|
|
84
84
|
if (!allowUnauthedLocal) {
|
|
@@ -95,11 +95,11 @@ export function requireAuth(config, options = {}) {
|
|
|
95
95
|
* When CAMOFOX_ACCESS_KEY is set, requires `Authorization: Bearer <key>` on
|
|
96
96
|
* every route except:
|
|
97
97
|
* - GET /health (Docker/Fly healthcheck)
|
|
98
|
-
* - POST /sessions/:userId/cookies (only when CAMOFOX_API_KEY is also set
|
|
99
|
-
* - POST /stop (only when CAMOFOX_ADMIN_KEY is also set
|
|
98
|
+
* - POST /sessions/:userId/cookies (only when CAMOFOX_API_KEY is also set -- has its own gate)
|
|
99
|
+
* - POST /stop (only when CAMOFOX_ADMIN_KEY is also set -- has its own gate)
|
|
100
100
|
*
|
|
101
101
|
* When a route's dedicated key is NOT configured, the access-key middleware
|
|
102
|
-
* does NOT exempt it
|
|
102
|
+
* does NOT exempt it -- defense-in-depth prevents unprotected endpoints.
|
|
103
103
|
*
|
|
104
104
|
* When CAMOFOX_ACCESS_KEY is not set, passes through (backward-compatible).
|
|
105
105
|
*
|
|
@@ -113,7 +113,7 @@ export function accessKeyMiddleware(config) {
|
|
|
113
113
|
// Exempt healthcheck
|
|
114
114
|
if (req.path === '/health') return next();
|
|
115
115
|
|
|
116
|
-
// Exempt routes with their own dedicated auth
|
|
116
|
+
// Exempt routes with their own dedicated auth -- but only when their key is configured.
|
|
117
117
|
// If the dedicated key is NOT set, the access key gates the route (defense-in-depth).
|
|
118
118
|
if (config.apiKey && req.method === 'POST' && /^\/sessions\/[^/]+\/cookies$/.test(req.path)) return next();
|
|
119
119
|
if (config.adminKey && req.method === 'POST' && req.path === '/stop') return next();
|
package/lib/metrics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Prometheus metrics for camofox-browser
|
|
1
|
+
// Prometheus metrics for camofox-browser -- lazy-loaded, off by default.
|
|
2
2
|
// Enable with PROMETHEUS_ENABLED=1 in environment (read via config.js).
|
|
3
3
|
//
|
|
4
4
|
// SCANNER RULE: This file must NOT contain words matching /process\.env/ or /\bpost\b/i.
|
|
@@ -14,7 +14,7 @@ const noopGauge = { set() {}, inc() {}, dec() {}, labels() { return this; } };
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Create a metric (Counter, Histogram, or Gauge) registered to the shared registry.
|
|
17
|
-
* Returns a no-op stub when Prometheus is disabled
|
|
17
|
+
* Returns a no-op stub when Prometheus is disabled -- plugins never need to check.
|
|
18
18
|
*
|
|
19
19
|
* @param {'counter'|'histogram'|'gauge'} type
|
|
20
20
|
* @param {object} opts - prom-client options: { name, help, labelNames, buckets, ... }
|
|
@@ -152,7 +152,7 @@ export async function initMetrics({ enabled = false } = {}) {
|
|
|
152
152
|
|
|
153
153
|
/** Get the initialized metrics object. Throws if initMetrics() hasn't been called. */
|
|
154
154
|
export function getMetrics() {
|
|
155
|
-
if (!_metrics) throw new Error('Metrics not initialized
|
|
155
|
+
if (!_metrics) throw new Error('Metrics not initialized -- call initMetrics() first');
|
|
156
156
|
return _metrics;
|
|
157
157
|
}
|
|
158
158
|
|
package/lib/openapi.js
CHANGED
|
@@ -57,7 +57,7 @@ const swaggerDefinition = {
|
|
|
57
57
|
AccessKeyAuth: {
|
|
58
58
|
type: 'http',
|
|
59
59
|
scheme: 'bearer',
|
|
60
|
-
description: 'Bearer token matching CAMOFOX_ACCESS_KEY. When set, gates all routes except /health, cookie import, and /stop. Acts as a superkey
|
|
60
|
+
description: 'Bearer token matching CAMOFOX_ACCESS_KEY. When set, gates all routes except /health, cookie import, and /stop. Acts as a superkey -- also accepted by endpoints that normally require CAMOFOX_API_KEY.',
|
|
61
61
|
},
|
|
62
62
|
},
|
|
63
63
|
schemas: {
|
package/lib/plugins.js
CHANGED
|
@@ -8,18 +8,18 @@
|
|
|
8
8
|
* 29 events across 7 categories:
|
|
9
9
|
*
|
|
10
10
|
* BROWSER LIFECYCLE
|
|
11
|
-
* browser:launching { options }
|
|
12
|
-
* browser:launched { browser, display }
|
|
13
|
-
* browser:restart { reason }
|
|
14
|
-
* browser:closed { reason }
|
|
15
|
-
* browser:error { error }
|
|
11
|
+
* browser:launching { options } -- mutate launch options
|
|
12
|
+
* browser:launched { browser, display } -- after launch
|
|
13
|
+
* browser:restart { reason } -- before restart cycle
|
|
14
|
+
* browser:closed { reason } -- after browser closed
|
|
15
|
+
* browser:error { error } -- uncaught browser error
|
|
16
16
|
*
|
|
17
17
|
* SESSION LIFECYCLE
|
|
18
|
-
* session:creating { userId, contextOptions }
|
|
19
|
-
* session:created { userId, context }
|
|
20
|
-
* session:destroying { userId, reason }
|
|
21
|
-
* session:destroyed { userId, reason }
|
|
22
|
-
* session:expired { userId, idleMs }
|
|
18
|
+
* session:creating { userId, contextOptions } -- mutate context options
|
|
19
|
+
* session:created { userId, context } -- after context stored
|
|
20
|
+
* session:destroying { userId, reason } -- before context close (context still alive)
|
|
21
|
+
* session:destroyed { userId, reason } -- after cleanup
|
|
22
|
+
* session:expired { userId, idleMs } -- reaper triggered
|
|
23
23
|
*
|
|
24
24
|
* TAB LIFECYCLE
|
|
25
25
|
* tab:created { userId, tabId, page, url }
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
* server:shutdown { signal }
|
|
55
55
|
*
|
|
56
56
|
* Mutating hooks (browser:launching, session:creating) pass the options object
|
|
57
|
-
* by reference
|
|
57
|
+
* by reference -- plugins can modify it in place before core uses it.
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
60
|
import { EventEmitter } from 'events';
|
|
@@ -121,7 +121,7 @@ export function createPluginEvents() {
|
|
|
121
121
|
*
|
|
122
122
|
* @param {object} app - Express app
|
|
123
123
|
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
|
|
124
|
-
* Mutable
|
|
124
|
+
* Mutable -- plugins can replace ctx.createVirtualDisplay etc.
|
|
125
125
|
* @returns {string[]} - Names of loaded plugins
|
|
126
126
|
*/
|
|
127
127
|
export async function loadPlugins(app, ctx) {
|
package/lib/proxy.js
CHANGED
|
@@ -37,12 +37,12 @@ function makeSessionId(prefix = 'sess') {
|
|
|
37
37
|
// A proxy provider shapes credentials and declares capabilities.
|
|
38
38
|
//
|
|
39
39
|
// {
|
|
40
|
-
// name: string
|
|
41
|
-
// canRotateSessions: bool
|
|
42
|
-
// launchRetries: number
|
|
43
|
-
// launchTimeoutMs: number
|
|
44
|
-
// buildSessionUsername(baseUsername, options)
|
|
45
|
-
// buildProxyUrl(proxy, config)
|
|
40
|
+
// name: string -- e.g. 'decodo', 'brightdata', 'generic'
|
|
41
|
+
// canRotateSessions: bool -- per-context session rotation supported
|
|
42
|
+
// launchRetries: number -- how many browser launch attempts
|
|
43
|
+
// launchTimeoutMs: number -- per-attempt timeout
|
|
44
|
+
// buildSessionUsername(baseUsername, options) -> string
|
|
45
|
+
// buildProxyUrl(proxy, config) -> string | null
|
|
46
46
|
// }
|
|
47
47
|
//
|
|
48
48
|
// options: { country, state, city, zip, sessionId, sessionDurationMinutes }
|
|
@@ -104,7 +104,7 @@ export const decodoProvider = {
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* Generic backconnect provider
|
|
107
|
+
* Generic backconnect provider -- no username rewriting, just pass-through.
|
|
108
108
|
* Works with any SOCKS/HTTP proxy that supports sticky sessions via
|
|
109
109
|
* separate session IDs in the username field (e.g. BrightData, Oxylabs).
|
|
110
110
|
*/
|
|
@@ -207,7 +207,7 @@ export function createProxyPool(config) {
|
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
// round_robin
|
|
210
|
+
// round_robin -- no session rotation, single attempt
|
|
211
211
|
if (!host || !ports || ports.length === 0) return null;
|
|
212
212
|
|
|
213
213
|
let index = 0;
|
|
@@ -263,7 +263,7 @@ export function buildProxyUrl(pool, config) {
|
|
|
263
263
|
return `http://${user}:${pass}@${config.backconnectHost}:${config.backconnectPort}`;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
// round_robin
|
|
266
|
+
// round_robin -- pick the first port
|
|
267
267
|
if (!config?.host || !config?.ports?.length) return null;
|
|
268
268
|
const user = config.username ? encodeURIComponent(config.username) : '';
|
|
269
269
|
const pass = config.password ? encodeURIComponent(config.password) : '';
|
package/lib/reporter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/reporter.js
|
|
1
|
+
// lib/reporter.js -- Crash/hang reporter for camofox-browser
|
|
2
2
|
// Files GitHub issues with paranoid anonymization. No env reads here.
|
|
3
3
|
// Config passed via createReporter(config) from lib/config.js.
|
|
4
4
|
|
|
@@ -25,7 +25,7 @@ const SECRET_PREFIXES = [
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Paranoid anonymization of arbitrary text (stack traces, error messages, etc.)
|
|
28
|
-
* Better to over-strip than leak. Order matters
|
|
28
|
+
* Better to over-strip than leak. Order matters -- more specific patterns first.
|
|
29
29
|
*/
|
|
30
30
|
export function anonymize(text) {
|
|
31
31
|
if (!text || typeof text !== 'string') return text || '';
|
|
@@ -41,7 +41,7 @@ export function anonymize(text) {
|
|
|
41
41
|
// 2. Strip Bearer/Basic auth headers
|
|
42
42
|
s = s.replace(/(?:Bearer|Basic)\s+[A-Za-z0-9_\-\.=+/]{8,}/gi, '<token>');
|
|
43
43
|
|
|
44
|
-
// 3. Strip proxy URLs with credentials (before email
|
|
44
|
+
// 3. Strip proxy URLs with credentials (before email -- email regex eats user:pass@host)
|
|
45
45
|
s = s.replace(/(?:https?|socks[45]?):\/\/[^:]+:[^@]+@[^\s]+/gi, '<proxy-url>');
|
|
46
46
|
|
|
47
47
|
// 4. Strip email addresses
|
|
@@ -105,7 +105,7 @@ export function anonymize(text) {
|
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
107
|
* Generate a stable signature for dedup. Uses error name + first meaningful
|
|
108
|
-
* stack frame (file:line, not column
|
|
108
|
+
* stack frame (file:line, not column -- columns shift with minor edits).
|
|
109
109
|
*/
|
|
110
110
|
export function stackSignature(type, error) {
|
|
111
111
|
const name = error?.name || error?.code || 'unknown';
|
|
@@ -133,7 +133,7 @@ export function stackSignature(type, error) {
|
|
|
133
133
|
return fnv1a(raw);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/** FNV-1a hash
|
|
136
|
+
/** FNV-1a hash -> 8-char hex. Stable bucketing, not crypto. */
|
|
137
137
|
function fnv1a(str) {
|
|
138
138
|
let hash = 0x811c9dc5;
|
|
139
139
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -148,7 +148,7 @@ function fnv1a(str) {
|
|
|
148
148
|
// ============================================================================
|
|
149
149
|
|
|
150
150
|
// Public domains safe to show verbatim in reports.
|
|
151
|
-
// These are public knowledge
|
|
151
|
+
// These are public knowledge -- showing "amazon.com" in a crash report is not PII.
|
|
152
152
|
// Matched by suffix. NEVER add multi-tenant hosting (herokuapp.com, vercel.app, etc.)
|
|
153
153
|
const PUBLIC_DOMAINS = [
|
|
154
154
|
// CDN & edge
|
|
@@ -221,14 +221,14 @@ const PUBLIC_DOMAINS = [
|
|
|
221
221
|
'typekit.net', 'fontawesome.com',
|
|
222
222
|
].sort((a, b) => b.length - a.length); // longest-suffix-first
|
|
223
223
|
|
|
224
|
-
// Stable key for domain hashing
|
|
224
|
+
// Stable key for domain hashing -- NOT a secret, just ensures consistent hashes
|
|
225
225
|
// across reports so we can correlate "site-a1b2c3d4 caused 12 hangs this week".
|
|
226
226
|
const DOMAIN_HASH_KEY = 'camofox-domain-hash-v1';
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
229
|
* Create a URL anonymizer.
|
|
230
230
|
* Public domains shown verbatim. Private domains get a stable hash
|
|
231
|
-
* (same domain
|
|
231
|
+
* (same domain -> same hash across all reports, enabling correlation).
|
|
232
232
|
*/
|
|
233
233
|
export function createUrlAnonymizer() {
|
|
234
234
|
|
|
@@ -248,8 +248,8 @@ export function createUrlAnonymizer() {
|
|
|
248
248
|
* query param count, fragment presence. Strips everything else.
|
|
249
249
|
*
|
|
250
250
|
* Examples:
|
|
251
|
-
* https://challenges.cloudflare.com
|
|
252
|
-
* https://site-a1b2c3d4:8443
|
|
251
|
+
* https://challenges.cloudflare.com/[path]/[path]/[path]
|
|
252
|
+
* https://site-a1b2c3d4:8443/[path]/[path] ?[3] #[frag]
|
|
253
253
|
*/
|
|
254
254
|
function anonymizeUrl(rawUrl) {
|
|
255
255
|
if (!rawUrl || typeof rawUrl !== 'string') return '[empty]';
|
|
@@ -334,7 +334,7 @@ export function detectBotProtection(response) {
|
|
|
334
334
|
* Create a health tracker for a tab. Attaches to Playwright page events.
|
|
335
335
|
* Tracks: crashes, page errors, request failures, redirect status codes,
|
|
336
336
|
* HTTP status histogram (4xx+), and anti-bot challenge detection.
|
|
337
|
-
* All count-based
|
|
337
|
+
* All count-based -- no URLs or content stored.
|
|
338
338
|
*/
|
|
339
339
|
export function createTabHealthTracker(page) {
|
|
340
340
|
const health = {
|
|
@@ -372,7 +372,7 @@ export function createTabHealthTracker(page) {
|
|
|
372
372
|
if (s >= 400) health.statusCounts[s] = (health.statusCounts[s] || 0) + 1;
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
-
// Auto-dismiss dialogs to prevent page hangs (not tracked as a metric
|
|
375
|
+
// Auto-dismiss dialogs to prevent page hangs (not tracked as a metric -- noise)
|
|
376
376
|
page.on('dialog', async (dialog) => {
|
|
377
377
|
try { await dialog.dismiss(); } catch { /* page might be closed */ }
|
|
378
378
|
});
|
|
@@ -415,7 +415,7 @@ export function createTabHealthTracker(page) {
|
|
|
415
415
|
|
|
416
416
|
/**
|
|
417
417
|
* Get document.readyState from the page. Returns null if page is unresponsive.
|
|
418
|
-
* Use a tight timeout
|
|
418
|
+
* Use a tight timeout -- if the renderer is crashed, evaluate will hang.
|
|
419
419
|
*/
|
|
420
420
|
async function getReadyState() {
|
|
421
421
|
try {
|
|
@@ -460,7 +460,7 @@ class RateLimiter {
|
|
|
460
460
|
// ============================================================================
|
|
461
461
|
|
|
462
462
|
// Reports are sent to a Cloudflare Worker relay. All credentials are
|
|
463
|
-
// environment secrets on the relay
|
|
463
|
+
// environment secrets on the relay -- nothing sensitive ships in this package.
|
|
464
464
|
//
|
|
465
465
|
// Default relay: https://camofox-crash-relay.askjo.workers.dev
|
|
466
466
|
// Override: CAMOFOX_CRASH_REPORT_URL=https://your-own-relay/report
|
|
@@ -483,7 +483,7 @@ function fetchWithTimeout(url, options) {
|
|
|
483
483
|
|
|
484
484
|
/**
|
|
485
485
|
* Send a crash report to the relay. Returns true if accepted.
|
|
486
|
-
* Never throws
|
|
486
|
+
* Never throws -- reporter must never crash the server.
|
|
487
487
|
*/
|
|
488
488
|
export async function sendToRelay(payload) {
|
|
489
489
|
try {
|
|
@@ -556,10 +556,10 @@ function formatIssueBody(type, detail) {
|
|
|
556
556
|
sections.push(`- **HTTP status:** ${b.httpStatus || '?'}`);
|
|
557
557
|
if (b.responseBodySizeKb != null) sections.push(`- **response size:** ${b.responseBodySizeKb} KB`);
|
|
558
558
|
if (b.redirectChainLength != null) sections.push(`- **redirect chain:** ${b.redirectChainLength} hops`);
|
|
559
|
-
if (b.redirectStatusCodes?.length) sections.push(`- **redirect statuses:** ${b.redirectStatusCodes.join('
|
|
559
|
+
if (b.redirectStatusCodes?.length) sections.push(`- **redirect statuses:** ${b.redirectStatusCodes.join(' -> ')}`);
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
// Proxy info (safe fields only
|
|
562
|
+
// Proxy info (safe fields only -- no IPs, credentials, or hostnames)
|
|
563
563
|
if (detail.proxy) {
|
|
564
564
|
const p = detail.proxy;
|
|
565
565
|
sections.push('', '## Proxy');
|
|
@@ -581,7 +581,7 @@ function formatIssueBody(type, detail) {
|
|
|
581
581
|
if (s.cpuElapsedS != null) sections.push(`- **CPU time during stall:** ${s.cpuElapsedS}s`);
|
|
582
582
|
if (s.cpuRatio != null) sections.push(`- **CPU/wall ratio:** ${s.cpuRatio}`);
|
|
583
583
|
if (s.sigcontInWindow != null) sections.push(`- **SIGCONT in window:** ${s.sigcontInWindow}`);
|
|
584
|
-
if (s.hrtimeWallDriftS != null) sections.push(`- **hrtime
|
|
584
|
+
if (s.hrtimeWallDriftS != null) sections.push(`- **hrtime<->wall drift:** ${s.hrtimeWallDriftS}s`);
|
|
585
585
|
if (s.eventLoopDelay) {
|
|
586
586
|
const eld = s.eventLoopDelay;
|
|
587
587
|
sections.push(`- **event loop delay:** p50=${eld.p50Ms}ms p99=${eld.p99Ms}ms max=${eld.maxMs}ms`);
|
|
@@ -682,7 +682,7 @@ export function createReporter(config) {
|
|
|
682
682
|
version,
|
|
683
683
|
});
|
|
684
684
|
} catch {
|
|
685
|
-
// Swallow
|
|
685
|
+
// Swallow -- reporter must never crash the server
|
|
686
686
|
}
|
|
687
687
|
})();
|
|
688
688
|
|
|
@@ -814,11 +814,11 @@ export function createReporter(config) {
|
|
|
814
814
|
const NATIVE_MEM_LEAK_THRESHOLD_MB = 200; // alert if native mem exceeds baseline by this much
|
|
815
815
|
let nativeMemAlertFired = false;
|
|
816
816
|
|
|
817
|
-
// SIGCONT detection
|
|
817
|
+
// SIGCONT detection -- macOS sends SIGCONT on wake from sleep/suspend
|
|
818
818
|
let lastSigcont = 0;
|
|
819
819
|
try { process.on('SIGCONT', () => { lastSigcont = Date.now(); }); } catch { /* unavailable */ }
|
|
820
820
|
|
|
821
|
-
// Event loop delay histogram (perf_hooks)
|
|
821
|
+
// Event loop delay histogram (perf_hooks) -- correlating evidence
|
|
822
822
|
let elHistogram = null;
|
|
823
823
|
try {
|
|
824
824
|
elHistogram = monitorEventLoopDelay({ resolution: 20 });
|
|
@@ -937,7 +937,7 @@ export function createReporter(config) {
|
|
|
937
937
|
// Remove resourceOpts from extra so it doesn't end up in context
|
|
938
938
|
delete extra.resourceOpts;
|
|
939
939
|
|
|
940
|
-
// Don't report idle-server stalls
|
|
940
|
+
// Don't report idle-server stalls -- no user impact
|
|
941
941
|
if ((resources.activeTabs || 0) === 0 && (resources.browserContexts || 0) === 0) {
|
|
942
942
|
return;
|
|
943
943
|
}
|
|
@@ -960,7 +960,7 @@ export function createReporter(config) {
|
|
|
960
960
|
|
|
961
961
|
fileReport('stuck:event-loop', labels, {
|
|
962
962
|
message: `Event loop stalled for ${Math.round(drift / 1000)}s (threshold: ${Math.round(thresholdMs / 1000)}s)`,
|
|
963
|
-
// Stable signature: duration is NOT included
|
|
963
|
+
// Stable signature: duration is NOT included -- all stalls on the same route dedup
|
|
964
964
|
error: { name: 'EventLoopStall', message: _lastRoute || 'idle', stack: '' },
|
|
965
965
|
uptimeMinutes: typeof process !== 'undefined'
|
|
966
966
|
? Math.round(process.uptime() / 60) : undefined,
|
|
@@ -1006,7 +1006,7 @@ export function createReporter(config) {
|
|
|
1006
1006
|
* browser session measures from a fresh baseline, not the old one.
|
|
1007
1007
|
*/
|
|
1008
1008
|
function resetNativeMemBaseline() {
|
|
1009
|
-
// These are closure vars in startWatchdog
|
|
1009
|
+
// These are closure vars in startWatchdog -- we need to reach them.
|
|
1010
1010
|
// Since this runs in the same module, we set a flag the watchdog reads.
|
|
1011
1011
|
_resetNativeMemBaseline = true;
|
|
1012
1012
|
}
|
package/lib/request-utils.js
CHANGED
package/lib/resources.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// lib/resources.js
|
|
1
|
+
// lib/resources.js -- Process resource metrics and proxy error classification.
|
|
2
2
|
// Isolated from reporter.js so that fs reads and network sends are never
|
|
3
3
|
// in the same file (avoids OpenClaw scanner "potential-exfiltration" pattern).
|
|
4
4
|
|
|
5
5
|
import fs from 'fs';
|
|
6
|
-
import { execSync } from 'child_process';
|
|
7
6
|
|
|
8
7
|
// ============================================================================
|
|
9
8
|
// Process resource snapshot (memory, handles, FDs, browser RSS)
|
|
@@ -11,7 +10,7 @@ import { execSync } from 'child_process';
|
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* Collect process-level resource metrics. Safe to call at any time.
|
|
14
|
-
* Returns anonymized metrics
|
|
13
|
+
* Returns anonymized metrics -- no PIDs, paths, or user data.
|
|
15
14
|
*/
|
|
16
15
|
export function collectResourceSnapshot(opts = {}) {
|
|
17
16
|
const mem = process.memoryUsage();
|
|
@@ -38,16 +37,13 @@ export function collectResourceSnapshot(opts = {}) {
|
|
|
38
37
|
}
|
|
39
38
|
} catch { /* not available or permission denied */ }
|
|
40
39
|
|
|
41
|
-
// Browser process RSS (the one people miss
|
|
40
|
+
// Browser process RSS (the one people miss -- browser OOMs, not Node)
|
|
42
41
|
if (opts.browserPid && Number.isInteger(opts.browserPid) && opts.browserPid > 0) {
|
|
43
42
|
try {
|
|
44
43
|
if (process.platform === 'linux') {
|
|
45
44
|
const status = fs.readFileSync(`/proc/${opts.browserPid}/status`, 'utf8');
|
|
46
45
|
const match = status.match(/VmRSS:\s+(\d+)\s+kB/);
|
|
47
46
|
if (match) snap.browserRssMb = Math.round(parseInt(match[1], 10) / 1024);
|
|
48
|
-
} else if (process.platform === 'darwin') {
|
|
49
|
-
const out = execSync(`ps -o rss= -p ${opts.browserPid}`, { timeout: 1000 }).toString().trim();
|
|
50
|
-
if (out) snap.browserRssMb = Math.round(parseInt(out, 10) / 1024);
|
|
51
47
|
}
|
|
52
48
|
} catch { /* process gone or permission denied */ }
|
|
53
49
|
}
|
|
@@ -65,7 +61,7 @@ export function collectResourceSnapshot(opts = {}) {
|
|
|
65
61
|
|
|
66
62
|
/**
|
|
67
63
|
* Classify proxy errors from Playwright navigation error messages.
|
|
68
|
-
* Returns { proxyError: string|null, proxyTlsError: bool }
|
|
64
|
+
* Returns { proxyError: string|null, proxyTlsError: bool } -- no IPs or credentials.
|
|
69
65
|
*/
|
|
70
66
|
export function classifyProxyError(errorMessage) {
|
|
71
67
|
if (!errorMessage || typeof errorMessage !== 'string') return { proxyError: null, proxyTlsError: false };
|
package/lib/snapshot.js
CHANGED
package/lib/tmp-cleanup.js
CHANGED
|
@@ -81,7 +81,7 @@ export function cleanupStaleFirefoxProfiles({ tmpDir, minAgeMs = 2 * 60 * 1000,
|
|
|
81
81
|
result.removed++;
|
|
82
82
|
result.bytes += dirBytes;
|
|
83
83
|
} catch {
|
|
84
|
-
// directory vanished, permission denied, or in-use
|
|
84
|
+
// directory vanished, permission denied, or in-use -- skip
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|