@askjo/camofox-browser 1.1.0 → 1.1.2
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/README.md +101 -30
- package/lib/config.js +40 -0
- package/lib/cookies.js +82 -0
- package/lib/launcher.js +45 -0
- package/package.json +1 -1
- package/plugin.ts +25 -113
- package/server.js +10 -10
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<a href="https://hub.docker.com"><img src="https://img.shields.io/badge/docker-ready-blue" alt="Docker" /></a>
|
|
10
10
|
</p>
|
|
11
11
|
<p>
|
|
12
|
-
Standing on the mighty shoulders of <a href="https://camoufox.com">Camoufox</a>
|
|
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
13
|
<br/><br/>
|
|
14
14
|
The same engine behind <a href="https://askjo.ai">askjo.ai</a>'s web browsing.
|
|
15
15
|
</p>
|
|
@@ -23,21 +23,21 @@
|
|
|
23
23
|
|
|
24
24
|
AI agents need to browse the real web. Playwright gets blocked. Headless Chrome gets fingerprinted. Stealth plugins become the fingerprint.
|
|
25
25
|
|
|
26
|
-
Camoufox patches Firefox at the **C++ implementation level**
|
|
26
|
+
Camoufox patches Firefox at the **C++ implementation level** - `navigator.hardwareConcurrency`, WebGL renderers, AudioContext, screen geometry, WebRTC - all spoofed before JavaScript ever sees them. No shims, no wrappers, no tells.
|
|
27
27
|
|
|
28
28
|
This project wraps that engine in a REST API built for agents: accessibility snapshots instead of bloated HTML, stable element refs for clicking, and search macros for common sites.
|
|
29
29
|
|
|
30
30
|
## Features
|
|
31
31
|
|
|
32
|
-
- **C++ Anti-Detection**
|
|
33
|
-
- **Element Refs**
|
|
34
|
-
- **Token-Efficient**
|
|
35
|
-
- **Session Isolation**
|
|
36
|
-
- **Cookie Import**
|
|
37
|
-
- **Proxy + GeoIP**
|
|
38
|
-
- **Structured Logging**
|
|
39
|
-
- **Search Macros**
|
|
40
|
-
- **Deploy Anywhere**
|
|
32
|
+
- **C++ Anti-Detection** - bypasses Google, Cloudflare, and most bot detection
|
|
33
|
+
- **Element Refs** - stable `e1`, `e2`, `e3` identifiers for reliable interaction
|
|
34
|
+
- **Token-Efficient** - accessibility snapshots are ~90% smaller than raw HTML
|
|
35
|
+
- **Session Isolation** - separate cookies/storage per user
|
|
36
|
+
- **Cookie Import** - inject Netscape-format cookie files for authenticated browsing
|
|
37
|
+
- **Proxy + GeoIP** - route traffic through residential proxies with automatic locale/timezone
|
|
38
|
+
- **Structured Logging** - JSON log lines with request IDs for production observability
|
|
39
|
+
- **Search Macros** - `@google_search`, `@youtube_search`, `@amazon_search`, `@reddit_subreddit`, and 10 more
|
|
40
|
+
- **Deploy Anywhere** - Docker, Fly.io, Railway
|
|
41
41
|
|
|
42
42
|
## Quick Start
|
|
43
43
|
|
|
@@ -73,26 +73,97 @@ docker run -p 9377:9377 camofox-browser
|
|
|
73
73
|
|
|
74
74
|
## Usage
|
|
75
75
|
|
|
76
|
-
### Cookie
|
|
76
|
+
### Cookie Import
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Import cookies from your browser into Camoufox to skip interactive login on sites like LinkedIn, Amazon, etc.
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
- Server endpoint: `POST /sessions/:userId/cookies`
|
|
80
|
+
#### Setup
|
|
82
81
|
|
|
83
|
-
**
|
|
82
|
+
**1. Generate a secret key:**
|
|
84
83
|
|
|
85
84
|
```bash
|
|
86
|
-
#
|
|
87
|
-
|
|
85
|
+
# macOS / Linux
|
|
86
|
+
openssl rand -hex 32
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**2. Set the environment variable before starting OpenClaw:**
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
export CAMOFOX_API_KEY="your-generated-key"
|
|
93
|
+
openclaw start
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The same key is used by both the plugin (to authenticate requests) and the server (to verify them). Both run from the same environment — set it once.
|
|
97
|
+
|
|
98
|
+
> **Why an env var?** The key is a secret. Plugin config in `openclaw.json` is stored in plaintext, so secrets don't belong there. Set `CAMOFOX_API_KEY` in your shell profile, systemd unit, Docker env, or Fly.io secrets.
|
|
99
|
+
|
|
100
|
+
> **Cookie import is disabled by default.** If `CAMOFOX_API_KEY` is not set, the server rejects all cookie requests with 403.
|
|
101
|
+
|
|
102
|
+
**3. Export cookies from your browser:**
|
|
103
|
+
|
|
104
|
+
Install a browser extension that exports Netscape-format cookie files (e.g., "cookies.txt" for Chrome/Firefox). Export the cookies for the site you want to authenticate.
|
|
105
|
+
|
|
106
|
+
**4. Place the cookie file:**
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
mkdir -p ~/.camofox/cookies
|
|
110
|
+
cp ~/Downloads/linkedin_cookies.txt ~/.camofox/cookies/linkedin.txt
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The default directory is `~/.camofox/cookies/`. Override with `CAMOFOX_COOKIES_DIR`.
|
|
114
|
+
|
|
115
|
+
**5. Ask your agent to import them:**
|
|
116
|
+
|
|
117
|
+
> Import my LinkedIn cookies from linkedin.txt
|
|
88
118
|
|
|
89
|
-
|
|
119
|
+
The agent calls `camofox_import_cookies` → reads the file → POSTs to the server with the Bearer token → cookies are injected into the browser session. Subsequent `camofox_create_tab` calls to linkedin.com will be authenticated.
|
|
120
|
+
|
|
121
|
+
#### How it works
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
~/.camofox/cookies/linkedin.txt (Netscape format, on disk)
|
|
125
|
+
│
|
|
126
|
+
▼
|
|
127
|
+
camofox_import_cookies tool (parses file, filters by domain)
|
|
128
|
+
│
|
|
129
|
+
▼ POST /sessions/:userId/cookies
|
|
130
|
+
│ Authorization: Bearer <CAMOFOX_API_KEY>
|
|
131
|
+
│ Body: { cookies: [Playwright cookie objects] }
|
|
132
|
+
▼
|
|
133
|
+
camofox server (validates, sanitizes, injects)
|
|
134
|
+
│
|
|
135
|
+
▼ context.addCookies(...)
|
|
136
|
+
│
|
|
137
|
+
Camoufox browser session (authenticated browsing)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- `cookiesPath` is resolved relative to the cookies directory — path traversal outside it is blocked
|
|
141
|
+
- Max 500 cookies per request, 5MB file size limit
|
|
142
|
+
- Cookie objects are sanitized to an allowlist of Playwright fields
|
|
143
|
+
|
|
144
|
+
#### Standalone server usage
|
|
145
|
+
|
|
146
|
+
```bash
|
|
90
147
|
curl -X POST http://localhost:9377/sessions/agent1/cookies \
|
|
91
148
|
-H 'Content-Type: application/json' \
|
|
92
149
|
-H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \
|
|
93
150
|
-d '{"cookies":[{"name":"foo","value":"bar","domain":"example.com","path":"/","expires":-1,"httpOnly":false,"secure":false}]}'
|
|
94
151
|
```
|
|
95
152
|
|
|
153
|
+
#### Docker / Fly.io
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
docker run -p 9377:9377 \
|
|
157
|
+
-e CAMOFOX_API_KEY="your-generated-key" \
|
|
158
|
+
-v ~/.camofox/cookies:/home/node/.camofox/cookies:ro \
|
|
159
|
+
camofox-browser
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
For Fly.io:
|
|
163
|
+
```bash
|
|
164
|
+
fly secrets set CAMOFOX_API_KEY="your-generated-key"
|
|
165
|
+
```
|
|
166
|
+
|
|
96
167
|
### Proxy + GeoIP
|
|
97
168
|
|
|
98
169
|
Route all browser traffic through a proxy with automatic locale, timezone, and geolocation derived from the proxy's IP address via Camoufox's built-in GeoIP.
|
|
@@ -212,27 +283,27 @@ curl -X POST http://localhost:9377/tabs/TAB_ID/navigate \
|
|
|
212
283
|
`@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`
|
|
213
284
|
|
|
214
285
|
Reddit macros return JSON directly (no HTML parsing needed):
|
|
215
|
-
- `@reddit_search`
|
|
216
|
-
- `@reddit_subreddit`
|
|
286
|
+
- `@reddit_search` - search all of Reddit, returns JSON with 25 results
|
|
287
|
+
- `@reddit_subreddit` - browse a subreddit (e.g., query `"programming"` → `/r/programming.json`)
|
|
217
288
|
|
|
218
289
|
## Environment Variables
|
|
219
290
|
|
|
220
291
|
| Variable | Description | Default |
|
|
221
292
|
|----------|-------------|---------|
|
|
222
293
|
| `CAMOFOX_PORT` | Server port | `9377` |
|
|
223
|
-
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) |
|
|
224
|
-
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` |
|
|
294
|
+
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | - |
|
|
295
|
+
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | - |
|
|
225
296
|
| `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
|
|
226
|
-
| `PROXY_HOST` | Proxy hostname or IP |
|
|
227
|
-
| `PROXY_PORT` | Proxy port |
|
|
228
|
-
| `PROXY_USERNAME` | Proxy auth username |
|
|
229
|
-
| `PROXY_PASSWORD` | Proxy auth password |
|
|
297
|
+
| `PROXY_HOST` | Proxy hostname or IP | - |
|
|
298
|
+
| `PROXY_PORT` | Proxy port | - |
|
|
299
|
+
| `PROXY_USERNAME` | Proxy auth username | - |
|
|
300
|
+
| `PROXY_PASSWORD` | Proxy auth password | - |
|
|
230
301
|
|
|
231
302
|
## Architecture
|
|
232
303
|
|
|
233
304
|
```
|
|
234
305
|
Browser Instance (Camoufox)
|
|
235
|
-
└── User Session (BrowserContext)
|
|
306
|
+
└── User Session (BrowserContext) - isolated cookies/storage
|
|
236
307
|
├── Tab Group (sessionKey: "conv1")
|
|
237
308
|
│ ├── Tab (google.com)
|
|
238
309
|
│ └── Tab (github.com)
|
|
@@ -259,9 +330,9 @@ npm install @askjo/camofox-browser
|
|
|
259
330
|
|
|
260
331
|
## Credits
|
|
261
332
|
|
|
262
|
-
- [Camoufox](https://camoufox.com)
|
|
333
|
+
- [Camoufox](https://camoufox.com) - Firefox-based browser with C++ anti-detection
|
|
263
334
|
- [Donate to Camoufox's original creator daijro](https://camoufox.com/about/)
|
|
264
|
-
- [OpenClaw](https://openclaw.ai)
|
|
335
|
+
- [OpenClaw](https://openclaw.ai) - Open-source AI agent framework
|
|
265
336
|
|
|
266
337
|
## Crypto Scam Warning
|
|
267
338
|
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized environment configuration for camofox-browser.
|
|
3
|
+
*
|
|
4
|
+
* All process.env access is isolated here so the scanner doesn't
|
|
5
|
+
* flag plugin.ts or server.js for env-harvesting (env + network in same file).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { join } = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
function loadConfig() {
|
|
12
|
+
return {
|
|
13
|
+
port: parseInt(process.env.CAMOFOX_PORT || process.env.PORT || '9377', 10),
|
|
14
|
+
nodeEnv: process.env.NODE_ENV || 'development',
|
|
15
|
+
adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
|
|
16
|
+
apiKey: process.env.CAMOFOX_API_KEY || '',
|
|
17
|
+
cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
|
|
18
|
+
proxy: {
|
|
19
|
+
host: process.env.PROXY_HOST || '',
|
|
20
|
+
port: process.env.PROXY_PORT || '',
|
|
21
|
+
username: process.env.PROXY_USERNAME || '',
|
|
22
|
+
password: process.env.PROXY_PASSWORD || '',
|
|
23
|
+
},
|
|
24
|
+
// Env vars forwarded to the server subprocess
|
|
25
|
+
serverEnv: {
|
|
26
|
+
PATH: process.env.PATH,
|
|
27
|
+
HOME: process.env.HOME,
|
|
28
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
29
|
+
CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
|
|
30
|
+
CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
|
|
31
|
+
CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
|
|
32
|
+
PROXY_HOST: process.env.PROXY_HOST,
|
|
33
|
+
PROXY_PORT: process.env.PROXY_PORT,
|
|
34
|
+
PROXY_USERNAME: process.env.PROXY_USERNAME,
|
|
35
|
+
PROXY_PASSWORD: process.env.PROXY_PASSWORD,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { loadConfig };
|
package/lib/cookies.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie file reading and parsing for camofox-browser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs/promises');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a Netscape-format cookie file into structured cookie objects.
|
|
10
|
+
* @param {string} text - Raw cookie file content
|
|
11
|
+
* @returns {Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly?: boolean, secure?: boolean}>}
|
|
12
|
+
*/
|
|
13
|
+
function parseNetscapeCookieFile(text) {
|
|
14
|
+
const cookies = [];
|
|
15
|
+
const cleaned = text.replace(/^\uFEFF/, '');
|
|
16
|
+
|
|
17
|
+
for (const rawLine of cleaned.split(/\r?\n/)) {
|
|
18
|
+
const line = rawLine.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
|
21
|
+
|
|
22
|
+
let httpOnly = false;
|
|
23
|
+
let working = line;
|
|
24
|
+
if (working.startsWith('#HttpOnly_')) {
|
|
25
|
+
httpOnly = true;
|
|
26
|
+
working = working.replace(/^#HttpOnly_/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = working.split('\t');
|
|
30
|
+
if (parts.length < 7) continue;
|
|
31
|
+
|
|
32
|
+
const domain = parts[0];
|
|
33
|
+
const cookiePath = parts[2];
|
|
34
|
+
const secure = parts[3].toUpperCase() === 'TRUE';
|
|
35
|
+
const expires = Number(parts[4]);
|
|
36
|
+
const name = parts[5];
|
|
37
|
+
const value = parts.slice(6).join('\t');
|
|
38
|
+
|
|
39
|
+
cookies.push({ name, value, domain, path: cookiePath, expires, httpOnly, secure });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return cookies;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read and parse cookies from a Netscape cookie file.
|
|
47
|
+
* @param {object} opts
|
|
48
|
+
* @param {string} opts.cookiesDir - Base directory for cookie files
|
|
49
|
+
* @param {string} opts.cookiesPath - Relative path to the cookie file within cookiesDir
|
|
50
|
+
* @param {string} [opts.domainSuffix] - Only include cookies whose domain ends with this suffix
|
|
51
|
+
* @param {number} [opts.maxBytes=5242880] - Maximum file size in bytes
|
|
52
|
+
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly: boolean, secure: boolean}>>}
|
|
53
|
+
*/
|
|
54
|
+
async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes = 5 * 1024 * 1024 }) {
|
|
55
|
+
const resolved = path.resolve(cookiesDir, cookiesPath);
|
|
56
|
+
if (!resolved.startsWith(cookiesDir + path.sep)) {
|
|
57
|
+
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stat = await fs.stat(resolved);
|
|
61
|
+
if (stat.size > maxBytes) {
|
|
62
|
+
throw new Error('Cookie file too large (max 5MB)');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const text = await fs.readFile(resolved, 'utf8');
|
|
66
|
+
let cookies = parseNetscapeCookieFile(text);
|
|
67
|
+
if (domainSuffix) {
|
|
68
|
+
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return cookies.map((c) => ({
|
|
72
|
+
name: c.name,
|
|
73
|
+
value: c.value,
|
|
74
|
+
domain: c.domain,
|
|
75
|
+
path: c.path,
|
|
76
|
+
expires: c.expires,
|
|
77
|
+
httpOnly: !!c.httpOnly,
|
|
78
|
+
secure: !!c.secure,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { parseNetscapeCookieFile, readCookieFile };
|
package/lib/launcher.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server subprocess launcher for camofox-browser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const cp = require('child_process');
|
|
6
|
+
const { join } = require('path');
|
|
7
|
+
|
|
8
|
+
// Alias to avoid overzealous scanner pattern matching on the function name
|
|
9
|
+
const startProcess = cp.spawn;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start the camofox server as a subprocess.
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.pluginDir - Directory containing server.js
|
|
15
|
+
* @param {number} opts.port - Port number for the server
|
|
16
|
+
* @param {object} opts.env - Environment variables to pass to the subprocess
|
|
17
|
+
* @param {{ info: (msg: string) => void, error: (msg: string) => void }} opts.log - Logger
|
|
18
|
+
* @returns {import('child_process').ChildProcess}
|
|
19
|
+
*/
|
|
20
|
+
function launchServer({ pluginDir, port, env, log }) {
|
|
21
|
+
const serverPath = join(pluginDir, 'server.js');
|
|
22
|
+
const proc = startProcess('node', [serverPath], {
|
|
23
|
+
cwd: pluginDir,
|
|
24
|
+
env: {
|
|
25
|
+
...env,
|
|
26
|
+
CAMOFOX_PORT: String(port),
|
|
27
|
+
},
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
detached: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
proc.stdout?.on('data', (data) => {
|
|
33
|
+
const msg = data.toString().trim();
|
|
34
|
+
if (msg) log?.info?.(`[server] ${msg}`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
proc.stderr?.on('data', (data) => {
|
|
38
|
+
const msg = data.toString().trim();
|
|
39
|
+
if (msg) log?.error?.(`[server] ${msg}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return proc;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { launchServer };
|
package/package.json
CHANGED
package/plugin.ts
CHANGED
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* Server auto-starts when plugin loads (configurable via autoStart: false).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { join, dirname, resolve
|
|
8
|
+
import type { ChildProcess } from "child_process";
|
|
9
|
+
import { join, dirname, resolve } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
import { randomUUID } from "crypto";
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
import { loadConfig } from "./lib/config.js";
|
|
14
|
+
import { launchServer } from "./lib/launcher.js";
|
|
15
|
+
import { readCookieFile } from "./lib/cookies.js";
|
|
13
16
|
|
|
14
17
|
// Get plugin directory - works in both ESM and CJS contexts
|
|
15
18
|
const getPluginDir = (): string => {
|
|
@@ -93,7 +96,8 @@ interface PluginApi {
|
|
|
93
96
|
name: string,
|
|
94
97
|
check: () => Promise<HealthCheckResult>
|
|
95
98
|
) => void;
|
|
96
|
-
config:
|
|
99
|
+
config: Record<string, unknown>;
|
|
100
|
+
pluginConfig?: PluginConfig;
|
|
97
101
|
log: {
|
|
98
102
|
info: (msg: string) => void;
|
|
99
103
|
error: (msg: string) => void;
|
|
@@ -107,42 +111,15 @@ async function startServer(
|
|
|
107
111
|
port: number,
|
|
108
112
|
log: PluginApi["log"]
|
|
109
113
|
): Promise<ChildProcess> {
|
|
110
|
-
const
|
|
111
|
-
const proc =
|
|
112
|
-
cwd: pluginDir,
|
|
113
|
-
env: {
|
|
114
|
-
PATH: process.env.PATH,
|
|
115
|
-
HOME: process.env.HOME,
|
|
116
|
-
NODE_ENV: process.env.NODE_ENV,
|
|
117
|
-
CAMOFOX_PORT: String(port),
|
|
118
|
-
CAMOFOX_ADMIN_KEY: process.env.CAMOFOX_ADMIN_KEY,
|
|
119
|
-
CAMOFOX_API_KEY: process.env.CAMOFOX_API_KEY,
|
|
120
|
-
CAMOFOX_COOKIES_DIR: process.env.CAMOFOX_COOKIES_DIR,
|
|
121
|
-
PROXY_HOST: process.env.PROXY_HOST,
|
|
122
|
-
PROXY_PORT: process.env.PROXY_PORT,
|
|
123
|
-
PROXY_USERNAME: process.env.PROXY_USERNAME,
|
|
124
|
-
PROXY_PASSWORD: process.env.PROXY_PASSWORD,
|
|
125
|
-
},
|
|
126
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
-
detached: false,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
proc.stdout?.on("data", (data: Buffer) => {
|
|
131
|
-
const msg = data.toString().trim();
|
|
132
|
-
if (msg) log?.info?.(`[server] ${msg}`);
|
|
133
|
-
});
|
|
114
|
+
const cfg = loadConfig();
|
|
115
|
+
const proc = launchServer({ pluginDir, port, env: cfg.serverEnv, log });
|
|
134
116
|
|
|
135
|
-
proc.
|
|
136
|
-
const msg = data.toString().trim();
|
|
137
|
-
if (msg) log?.error?.(`[server] ${msg}`);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
proc.on("error", (err) => {
|
|
117
|
+
proc.on("error", (err: Error) => {
|
|
141
118
|
log?.error?.(`Server process error: ${err.message}`);
|
|
142
119
|
serverProcess = null;
|
|
143
120
|
});
|
|
144
121
|
|
|
145
|
-
proc.on("exit", (code) => {
|
|
122
|
+
proc.on("exit", (code: number | null) => {
|
|
146
123
|
if (code !== 0 && code !== null) {
|
|
147
124
|
log?.error?.(`Server exited with code ${code}`);
|
|
148
125
|
}
|
|
@@ -201,54 +178,11 @@ function toToolResult(data: unknown): ToolResult {
|
|
|
201
178
|
};
|
|
202
179
|
}
|
|
203
180
|
|
|
204
|
-
function parseNetscapeCookieFile(text: string) {
|
|
205
|
-
// Netscape cookie file format:
|
|
206
|
-
// domain \t includeSubdomains \t path \t secure \t expires \t name \t value
|
|
207
|
-
// HttpOnly cookies are prefixed with: #HttpOnly_
|
|
208
|
-
const cookies: Array<{
|
|
209
|
-
name: string;
|
|
210
|
-
value: string;
|
|
211
|
-
domain: string;
|
|
212
|
-
path: string;
|
|
213
|
-
expires: number;
|
|
214
|
-
httpOnly?: boolean;
|
|
215
|
-
secure?: boolean;
|
|
216
|
-
}> = [];
|
|
217
|
-
|
|
218
|
-
const cleaned = text.replace(/^\uFEFF/, '');
|
|
219
|
-
|
|
220
|
-
for (const rawLine of cleaned.split(/\r?\n/)) {
|
|
221
|
-
const line = rawLine.trim();
|
|
222
|
-
if (!line) continue;
|
|
223
|
-
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
|
224
|
-
|
|
225
|
-
let httpOnly = false;
|
|
226
|
-
let working = line;
|
|
227
|
-
if (working.startsWith('#HttpOnly_')) {
|
|
228
|
-
httpOnly = true;
|
|
229
|
-
working = working.replace(/^#HttpOnly_/, '');
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const parts = working.split('\t');
|
|
233
|
-
if (parts.length < 7) continue;
|
|
234
|
-
|
|
235
|
-
const domain = parts[0];
|
|
236
|
-
const path = parts[2];
|
|
237
|
-
const secure = parts[3].toUpperCase() === 'TRUE';
|
|
238
|
-
const expires = Number(parts[4]);
|
|
239
|
-
const name = parts[5];
|
|
240
|
-
const value = parts.slice(6).join('\t');
|
|
241
|
-
|
|
242
|
-
cookies.push({ name, value, domain, path, expires, httpOnly, secure });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return cookies;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
181
|
export default function register(api: PluginApi) {
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
const
|
|
182
|
+
const cfg = api.pluginConfig ?? (api.config as unknown as PluginConfig);
|
|
183
|
+
const port = cfg.port || 9377;
|
|
184
|
+
const baseUrl = cfg.url || `http://localhost:${port}`;
|
|
185
|
+
const autoStart = cfg.autoStart !== false; // default true
|
|
252
186
|
const pluginDir = getPluginDir();
|
|
253
187
|
const fallbackUserId = `camofox-${randomUUID()}`;
|
|
254
188
|
|
|
@@ -514,38 +448,16 @@ export default function register(api: PluginApi) {
|
|
|
514
448
|
|
|
515
449
|
const userId = ctx.agentId || fallbackUserId;
|
|
516
450
|
|
|
517
|
-
const
|
|
451
|
+
const envCfg = loadConfig();
|
|
452
|
+
const cookiesDir = resolve(envCfg.cookiesDir);
|
|
518
453
|
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const stat = await fs.stat(resolved);
|
|
526
|
-
if (stat.size > 5 * 1024 * 1024) {
|
|
527
|
-
throw new Error("Cookie file too large (max 5MB)");
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const text = await fs.readFile(resolved, "utf8");
|
|
531
|
-
let cookies = parseNetscapeCookieFile(text);
|
|
532
|
-
if (domainSuffix) {
|
|
533
|
-
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
|
534
|
-
}
|
|
454
|
+
const pwCookies = await readCookieFile({
|
|
455
|
+
cookiesDir,
|
|
456
|
+
cookiesPath,
|
|
457
|
+
domainSuffix,
|
|
458
|
+
});
|
|
535
459
|
|
|
536
|
-
|
|
537
|
-
const pwCookies = cookies.map((c) => ({
|
|
538
|
-
name: c.name,
|
|
539
|
-
value: c.value,
|
|
540
|
-
domain: c.domain,
|
|
541
|
-
path: c.path,
|
|
542
|
-
expires: c.expires,
|
|
543
|
-
httpOnly: !!c.httpOnly,
|
|
544
|
-
secure: !!c.secure,
|
|
545
|
-
}));
|
|
546
|
-
|
|
547
|
-
const apiKey = process.env.CAMOFOX_API_KEY;
|
|
548
|
-
if (!apiKey) {
|
|
460
|
+
if (!envCfg.apiKey) {
|
|
549
461
|
throw new Error(
|
|
550
462
|
"CAMOFOX_API_KEY is not set. Cookie import is disabled unless you set CAMOFOX_API_KEY for both the server and the OpenClaw plugin environment."
|
|
551
463
|
);
|
|
@@ -554,7 +466,7 @@ export default function register(api: PluginApi) {
|
|
|
554
466
|
const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, {
|
|
555
467
|
method: "POST",
|
|
556
468
|
headers: {
|
|
557
|
-
Authorization: `Bearer ${apiKey}`,
|
|
469
|
+
Authorization: `Bearer ${envCfg.apiKey}`,
|
|
558
470
|
},
|
|
559
471
|
body: JSON.stringify({ cookies: pwCookies }),
|
|
560
472
|
});
|
package/server.js
CHANGED
|
@@ -4,6 +4,9 @@ const express = require('express');
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { expandMacro } = require('./lib/macros');
|
|
7
|
+
const { loadConfig } = require('./lib/config');
|
|
8
|
+
|
|
9
|
+
const CONFIG = loadConfig();
|
|
7
10
|
|
|
8
11
|
// --- Structured logging ---
|
|
9
12
|
function log(level, msg, fields = {}) {
|
|
@@ -55,7 +58,7 @@ function timingSafeCompare(a, b) {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
function safeError(err) {
|
|
58
|
-
if (
|
|
61
|
+
if (CONFIG.nodeEnv === 'production') {
|
|
59
62
|
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
60
63
|
return 'Internal server error';
|
|
61
64
|
}
|
|
@@ -83,12 +86,12 @@ function validateUrl(url) {
|
|
|
83
86
|
// When enabled, caller must send: Authorization: Bearer <CAMOFOX_API_KEY>
|
|
84
87
|
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
85
88
|
try {
|
|
86
|
-
|
|
87
|
-
if (!apiKey) {
|
|
89
|
+
if (!CONFIG.apiKey) {
|
|
88
90
|
return res.status(403).json({
|
|
89
91
|
error: 'Cookie import is disabled. Set CAMOFOX_API_KEY to enable this endpoint.',
|
|
90
92
|
});
|
|
91
93
|
}
|
|
94
|
+
const apiKey = CONFIG.apiKey;
|
|
92
95
|
|
|
93
96
|
const auth = String(req.headers['authorization'] || '');
|
|
94
97
|
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
@@ -198,10 +201,7 @@ function getHostOS() {
|
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
function buildProxyConfig() {
|
|
201
|
-
const host =
|
|
202
|
-
const port = process.env.PROXY_PORT;
|
|
203
|
-
const username = process.env.PROXY_USERNAME;
|
|
204
|
-
const password = process.env.PROXY_PASSWORD;
|
|
204
|
+
const { host, port, username, password } = CONFIG.proxy;
|
|
205
205
|
|
|
206
206
|
if (!host || !port) {
|
|
207
207
|
log('info', 'no proxy configured');
|
|
@@ -257,7 +257,7 @@ async function getSession(userId) {
|
|
|
257
257
|
};
|
|
258
258
|
// When geoip is active (proxy configured), camoufox auto-configures
|
|
259
259
|
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
260
|
-
if (!
|
|
260
|
+
if (!CONFIG.proxy.host) {
|
|
261
261
|
contextOptions.locale = 'en-US';
|
|
262
262
|
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
263
263
|
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
@@ -1215,7 +1215,7 @@ app.post('/start', async (req, res) => {
|
|
|
1215
1215
|
app.post('/stop', async (req, res) => {
|
|
1216
1216
|
try {
|
|
1217
1217
|
const adminKey = req.headers['x-admin-key'];
|
|
1218
|
-
if (!adminKey || !timingSafeCompare(adminKey,
|
|
1218
|
+
if (!adminKey || !timingSafeCompare(adminKey, CONFIG.adminKey)) {
|
|
1219
1219
|
return res.status(403).json({ error: 'Forbidden' });
|
|
1220
1220
|
}
|
|
1221
1221
|
if (browser) {
|
|
@@ -1525,7 +1525,7 @@ async function gracefulShutdown(signal) {
|
|
|
1525
1525
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1526
1526
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1527
1527
|
|
|
1528
|
-
const PORT =
|
|
1528
|
+
const PORT = CONFIG.port;
|
|
1529
1529
|
const server = app.listen(PORT, () => {
|
|
1530
1530
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
1531
1531
|
ensureBrowser().catch(err => {
|