@askjo/camofox-browser 1.0.13 → 1.1.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.
- package/README.md +103 -5
- package/lib/macros.js +2 -1
- package/package.json +1 -2
- package/plugin.ts +159 -13
- package/server.js +337 -106
package/README.md
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
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
|
-
|
|
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/>
|
|
13
14
|
The same engine behind <a href="https://askjo.ai">askjo.ai</a>'s web browsing.
|
|
14
15
|
</p>
|
|
15
16
|
</div>
|
|
16
17
|
|
|
18
|
+
<br/>
|
|
19
|
+
|
|
17
20
|
---
|
|
18
21
|
|
|
19
22
|
## Why
|
|
@@ -30,7 +33,10 @@ This project wraps that engine in a REST API built for agents: accessibility sna
|
|
|
30
33
|
- **Element Refs** — stable `e1`, `e2`, `e3` identifiers for reliable interaction
|
|
31
34
|
- **Token-Efficient** — accessibility snapshots are ~90% smaller than raw HTML
|
|
32
35
|
- **Session Isolation** — separate cookies/storage per user
|
|
33
|
-
- **
|
|
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
|
|
34
40
|
- **Deploy Anywhere** — Docker, Fly.io, Railway
|
|
35
41
|
|
|
36
42
|
## Quick Start
|
|
@@ -41,7 +47,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
|
|
|
41
47
|
openclaw plugins install @askjo/camofox-browser
|
|
42
48
|
```
|
|
43
49
|
|
|
44
|
-
**Tools:** `camofox_create_tab` · `camofox_snapshot` · `camofox_click` · `camofox_type` · `camofox_navigate` · `camofox_scroll` · `camofox_screenshot` · `camofox_close_tab` · `camofox_list_tabs`
|
|
50
|
+
**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`
|
|
45
51
|
|
|
46
52
|
### Standalone
|
|
47
53
|
|
|
@@ -52,7 +58,7 @@ npm install
|
|
|
52
58
|
npm start # downloads Camoufox on first run (~300MB)
|
|
53
59
|
```
|
|
54
60
|
|
|
55
|
-
Default port is `9377`.
|
|
61
|
+
Default port is `9377`. See [Environment Variables](#environment-variables) for all options.
|
|
56
62
|
|
|
57
63
|
### Docker
|
|
58
64
|
|
|
@@ -67,6 +73,70 @@ docker run -p 9377:9377 camofox-browser
|
|
|
67
73
|
|
|
68
74
|
## Usage
|
|
69
75
|
|
|
76
|
+
### Cookie Injection (Netscape cookie file → Playwright cookies)
|
|
77
|
+
|
|
78
|
+
If you’re using the OpenClaw plugin, you can import a Netscape-format cookie file (e.g., exported from a browser) to authenticate sessions without interactive login.
|
|
79
|
+
|
|
80
|
+
- Tool: `camofox_import_cookies`
|
|
81
|
+
- Server endpoint: `POST /sessions/:userId/cookies`
|
|
82
|
+
|
|
83
|
+
**Security:** this endpoint is disabled unless `CAMOFOX_API_KEY` is set on the server. When enabled, callers must include `Authorization: Bearer <CAMOFOX_API_KEY>`.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# OpenClaw tool usage (conceptual)
|
|
87
|
+
# camofox_import_cookies({ cookiesPath: "linkedin.txt", domainSuffix: "linkedin.com" })
|
|
88
|
+
|
|
89
|
+
# Direct server usage (Playwright cookie objects)
|
|
90
|
+
curl -X POST http://localhost:9377/sessions/agent1/cookies \
|
|
91
|
+
-H 'Content-Type: application/json' \
|
|
92
|
+
-H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \
|
|
93
|
+
-d '{"cookies":[{"name":"foo","value":"bar","domain":"example.com","path":"/","expires":-1,"httpOnly":false,"secure":false}]}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Proxy + GeoIP
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
Set these environment variables before starting the server:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
export PROXY_HOST=166.88.179.132
|
|
104
|
+
export PROXY_PORT=46040
|
|
105
|
+
export PROXY_USERNAME=myuser
|
|
106
|
+
export PROXY_PASSWORD=mypass
|
|
107
|
+
npm start
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or in Docker:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
docker run -p 9377:9377 \
|
|
114
|
+
-e PROXY_HOST=166.88.179.132 \
|
|
115
|
+
-e PROXY_PORT=46040 \
|
|
116
|
+
-e PROXY_USERNAME=myuser \
|
|
117
|
+
-e PROXY_PASSWORD=mypass \
|
|
118
|
+
camofox-browser
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When a proxy is configured:
|
|
122
|
+
- All traffic routes through the proxy
|
|
123
|
+
- Camoufox's GeoIP automatically sets `locale`, `timezone`, and `geolocation` to match the proxy's exit IP
|
|
124
|
+
- Browser fingerprint (language, timezone, coordinates) is consistent with the proxy location
|
|
125
|
+
- Without a proxy, defaults to `en-US`, `America/Los_Angeles`, San Francisco coordinates
|
|
126
|
+
|
|
127
|
+
### Structured Logging
|
|
128
|
+
|
|
129
|
+
All log output is JSON (one object per line) for easy parsing by log aggregators:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{"ts":"2026-02-11T23:45:01.234Z","level":"info","msg":"req","reqId":"a1b2c3d4","method":"POST","path":"/tabs","userId":"agent1"}
|
|
133
|
+
{"ts":"2026-02-11T23:45:01.567Z","level":"info","msg":"res","reqId":"a1b2c3d4","status":200,"ms":333}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Health check requests (`/health`) are excluded from request logging to reduce noise.
|
|
137
|
+
|
|
138
|
+
### Basic Browsing
|
|
139
|
+
|
|
70
140
|
```bash
|
|
71
141
|
# Create a tab
|
|
72
142
|
curl -X POST http://localhost:9377/tabs \
|
|
@@ -131,9 +201,32 @@ curl -X POST http://localhost:9377/tabs/TAB_ID/navigate \
|
|
|
131
201
|
| `POST` | `/start` | Start browser engine |
|
|
132
202
|
| `POST` | `/stop` | Stop browser engine |
|
|
133
203
|
|
|
204
|
+
### Sessions
|
|
205
|
+
|
|
206
|
+
| Method | Endpoint | Description |
|
|
207
|
+
|--------|----------|-------------|
|
|
208
|
+
| `POST` | `/sessions/:userId/cookies` | Add cookies to a user session (Playwright cookie objects) |
|
|
209
|
+
|
|
134
210
|
## Search Macros
|
|
135
211
|
|
|
136
|
-
`@google_search` · `@youtube_search` · `@amazon_search` · `@reddit_search` · `@wikipedia_search` · `@twitter_search` · `@yelp_search` · `@spotify_search` · `@netflix_search` · `@linkedin_search` · `@instagram_search` · `@tiktok_search` · `@twitch_search`
|
|
212
|
+
`@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
|
+
|
|
214
|
+
Reddit macros return JSON directly (no HTML parsing needed):
|
|
215
|
+
- `@reddit_search` — search all of Reddit, returns JSON with 25 results
|
|
216
|
+
- `@reddit_subreddit` — browse a subreddit (e.g., query `"programming"` → `/r/programming.json`)
|
|
217
|
+
|
|
218
|
+
## Environment Variables
|
|
219
|
+
|
|
220
|
+
| Variable | Description | Default |
|
|
221
|
+
|----------|-------------|---------|
|
|
222
|
+
| `CAMOFOX_PORT` | Server port | `9377` |
|
|
223
|
+
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | — |
|
|
224
|
+
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | — |
|
|
225
|
+
| `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 | — |
|
|
137
230
|
|
|
138
231
|
## Architecture
|
|
139
232
|
|
|
@@ -167,8 +260,13 @@ npm install @askjo/camofox-browser
|
|
|
167
260
|
## Credits
|
|
168
261
|
|
|
169
262
|
- [Camoufox](https://camoufox.com) — Firefox-based browser with C++ anti-detection
|
|
263
|
+
- [Donate to Camoufox's original creator daijro](https://camoufox.com/about/)
|
|
170
264
|
- [OpenClaw](https://openclaw.ai) — Open-source AI agent framework
|
|
171
265
|
|
|
266
|
+
## Crypto Scam Warning
|
|
267
|
+
|
|
268
|
+
Sketchy people are doing sketchy things with crypto tokens named "Camofox" now that this project is getting attention. **Camofox is not a crypto project and will never be one.** Any token, coin, or NFT using the Camofox name has nothing to do with us.
|
|
269
|
+
|
|
172
270
|
## License
|
|
173
271
|
|
|
174
272
|
MIT
|
package/lib/macros.js
CHANGED
|
@@ -2,7 +2,8 @@ const MACROS = {
|
|
|
2
2
|
'@google_search': (query) => `https://www.google.com/search?q=${encodeURIComponent(query || '')}`,
|
|
3
3
|
'@youtube_search': (query) => `https://www.youtube.com/results?search_query=${encodeURIComponent(query || '')}`,
|
|
4
4
|
'@amazon_search': (query) => `https://www.amazon.com/s?k=${encodeURIComponent(query || '')}`,
|
|
5
|
-
'@reddit_search': (query) => `https://www.reddit.com/search
|
|
5
|
+
'@reddit_search': (query) => `https://www.reddit.com/search.json?q=${encodeURIComponent(query || '')}&limit=25`,
|
|
6
|
+
'@reddit_subreddit': (query) => `https://www.reddit.com/r/${encodeURIComponent(query || 'all')}.json?limit=25`,
|
|
6
7
|
'@wikipedia_search': (query) => `https://en.wikipedia.org/wiki/Special:Search?search=${encodeURIComponent(query || '')}`,
|
|
7
8
|
'@twitter_search': (query) => `https://twitter.com/search?q=${encodeURIComponent(query || '')}`,
|
|
8
9
|
'@yelp_search': (query) => `https://www.yelp.com/search?find_desc=${encodeURIComponent(query || '')}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askjo/camofox-browser",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,7 +56,6 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"camoufox-js": "^0.8.5",
|
|
59
|
-
"dotenv": "^17.2.3",
|
|
60
59
|
"express": "^4.18.2",
|
|
61
60
|
"playwright": "^1.50.0",
|
|
62
61
|
"playwright-core": "^1.58.0",
|
package/plugin.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn, ChildProcess } from "child_process";
|
|
9
|
-
import { join, dirname } from "path";
|
|
9
|
+
import { join, dirname, resolve, sep } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { homedir } from "os";
|
|
11
13
|
|
|
12
14
|
// Get plugin directory - works in both ESM and CJS contexts
|
|
13
15
|
const getPluginDir = (): string => {
|
|
@@ -108,7 +110,19 @@ async function startServer(
|
|
|
108
110
|
const serverPath = join(pluginDir, "server.js");
|
|
109
111
|
const proc = spawn("node", [serverPath], {
|
|
110
112
|
cwd: pluginDir,
|
|
111
|
-
env: {
|
|
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
|
+
},
|
|
112
126
|
stdio: ["ignore", "pipe", "pipe"],
|
|
113
127
|
detached: false,
|
|
114
128
|
});
|
|
@@ -187,11 +201,56 @@ function toToolResult(data: unknown): ToolResult {
|
|
|
187
201
|
};
|
|
188
202
|
}
|
|
189
203
|
|
|
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
|
+
|
|
190
248
|
export default function register(api: PluginApi) {
|
|
191
249
|
const port = api.config.port || 9377;
|
|
192
250
|
const baseUrl = api.config.url || `http://localhost:${port}`;
|
|
193
251
|
const autoStart = api.config.autoStart !== false; // default true
|
|
194
252
|
const pluginDir = getPluginDir();
|
|
253
|
+
const fallbackUserId = `camofox-${randomUUID()}`;
|
|
195
254
|
|
|
196
255
|
// Auto-start server if configured (default: true)
|
|
197
256
|
if (autoStart) {
|
|
@@ -222,7 +281,7 @@ export default function register(api: PluginApi) {
|
|
|
222
281
|
},
|
|
223
282
|
async execute(_id, params) {
|
|
224
283
|
const sessionKey = ctx.sessionKey || "default";
|
|
225
|
-
const userId = ctx.agentId ||
|
|
284
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
226
285
|
const result = await fetchApi(baseUrl, "/tabs", {
|
|
227
286
|
method: "POST",
|
|
228
287
|
body: JSON.stringify({ ...params, userId, sessionKey }),
|
|
@@ -244,7 +303,7 @@ export default function register(api: PluginApi) {
|
|
|
244
303
|
},
|
|
245
304
|
async execute(_id, params) {
|
|
246
305
|
const { tabId } = params as { tabId: string };
|
|
247
|
-
const userId = ctx.agentId ||
|
|
306
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
248
307
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
|
|
249
308
|
return toToolResult(result);
|
|
250
309
|
},
|
|
@@ -264,7 +323,7 @@ export default function register(api: PluginApi) {
|
|
|
264
323
|
},
|
|
265
324
|
async execute(_id, params) {
|
|
266
325
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
267
|
-
const userId = ctx.agentId ||
|
|
326
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
268
327
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
|
|
269
328
|
method: "POST",
|
|
270
329
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -289,7 +348,7 @@ export default function register(api: PluginApi) {
|
|
|
289
348
|
},
|
|
290
349
|
async execute(_id, params) {
|
|
291
350
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
292
|
-
const userId = ctx.agentId ||
|
|
351
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
293
352
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
|
|
294
353
|
method: "POST",
|
|
295
354
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -332,7 +391,7 @@ export default function register(api: PluginApi) {
|
|
|
332
391
|
},
|
|
333
392
|
async execute(_id, params) {
|
|
334
393
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
335
|
-
const userId = ctx.agentId ||
|
|
394
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
336
395
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
|
|
337
396
|
method: "POST",
|
|
338
397
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -355,7 +414,7 @@ export default function register(api: PluginApi) {
|
|
|
355
414
|
},
|
|
356
415
|
async execute(_id, params) {
|
|
357
416
|
const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
|
|
358
|
-
const userId = ctx.agentId ||
|
|
417
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
359
418
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
|
|
360
419
|
method: "POST",
|
|
361
420
|
body: JSON.stringify({ ...rest, userId }),
|
|
@@ -376,9 +435,24 @@ export default function register(api: PluginApi) {
|
|
|
376
435
|
},
|
|
377
436
|
async execute(_id, params) {
|
|
378
437
|
const { tabId } = params as { tabId: string };
|
|
379
|
-
const userId = ctx.agentId ||
|
|
380
|
-
const
|
|
381
|
-
|
|
438
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
439
|
+
const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`;
|
|
440
|
+
const res = await fetch(url);
|
|
441
|
+
if (!res.ok) {
|
|
442
|
+
const text = await res.text();
|
|
443
|
+
throw new Error(`${res.status}: ${text}`);
|
|
444
|
+
}
|
|
445
|
+
const arrayBuffer = await res.arrayBuffer();
|
|
446
|
+
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
447
|
+
return {
|
|
448
|
+
content: [
|
|
449
|
+
{
|
|
450
|
+
type: "image",
|
|
451
|
+
data: base64,
|
|
452
|
+
mimeType: "image/png",
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
};
|
|
382
456
|
},
|
|
383
457
|
}));
|
|
384
458
|
|
|
@@ -394,7 +468,7 @@ export default function register(api: PluginApi) {
|
|
|
394
468
|
},
|
|
395
469
|
async execute(_id, params) {
|
|
396
470
|
const { tabId } = params as { tabId: string };
|
|
397
|
-
const userId = ctx.agentId ||
|
|
471
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
398
472
|
const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
|
|
399
473
|
method: "DELETE",
|
|
400
474
|
});
|
|
@@ -411,12 +485,84 @@ export default function register(api: PluginApi) {
|
|
|
411
485
|
required: [],
|
|
412
486
|
},
|
|
413
487
|
async execute(_id, _params) {
|
|
414
|
-
const userId = ctx.agentId ||
|
|
488
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
415
489
|
const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
|
|
416
490
|
return toToolResult(result);
|
|
417
491
|
},
|
|
418
492
|
}));
|
|
419
493
|
|
|
494
|
+
api.registerTool((ctx: ToolContext) => ({
|
|
495
|
+
name: "camofox_import_cookies",
|
|
496
|
+
description:
|
|
497
|
+
"Import cookies into the current Camoufox user session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login.",
|
|
498
|
+
parameters: {
|
|
499
|
+
type: "object",
|
|
500
|
+
properties: {
|
|
501
|
+
cookiesPath: { type: "string", description: "Path to Netscape-format cookies.txt file" },
|
|
502
|
+
domainSuffix: {
|
|
503
|
+
type: "string",
|
|
504
|
+
description: "Only import cookies whose domain ends with this suffix",
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
required: ["cookiesPath"],
|
|
508
|
+
},
|
|
509
|
+
async execute(_id, params) {
|
|
510
|
+
const { cookiesPath, domainSuffix } = params as {
|
|
511
|
+
cookiesPath: string;
|
|
512
|
+
domainSuffix?: string;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const userId = ctx.agentId || fallbackUserId;
|
|
516
|
+
|
|
517
|
+
const fs = await import("fs/promises");
|
|
518
|
+
|
|
519
|
+
const cookiesDir = resolve(process.env.CAMOFOX_COOKIES_DIR || join(homedir(), ".camofox", "cookies"));
|
|
520
|
+
const resolved = resolve(cookiesDir, cookiesPath);
|
|
521
|
+
if (!resolved.startsWith(cookiesDir + sep)) {
|
|
522
|
+
throw new Error("cookiesPath must be a relative path within the cookies directory");
|
|
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
|
+
}
|
|
535
|
+
|
|
536
|
+
// Translate into Playwright cookie objects
|
|
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) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
"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
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: {
|
|
557
|
+
Authorization: `Bearer ${apiKey}`,
|
|
558
|
+
},
|
|
559
|
+
body: JSON.stringify({ cookies: pwCookies }),
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
return toToolResult({ imported: pwCookies.length, userId, result });
|
|
563
|
+
},
|
|
564
|
+
}));
|
|
565
|
+
|
|
420
566
|
api.registerCommand({
|
|
421
567
|
name: "camofox",
|
|
422
568
|
description: "Camoufox browser server control (status, start, stop)",
|
package/server.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require('dotenv').config();
|
|
2
1
|
const { Camoufox, launchOptions } = require('camoufox-js');
|
|
3
2
|
const { firefox } = require('playwright-core');
|
|
4
3
|
const express = require('express');
|
|
@@ -6,8 +5,149 @@ const crypto = require('crypto');
|
|
|
6
5
|
const os = require('os');
|
|
7
6
|
const { expandMacro } = require('./lib/macros');
|
|
8
7
|
|
|
8
|
+
// --- Structured logging ---
|
|
9
|
+
function log(level, msg, fields = {}) {
|
|
10
|
+
const entry = {
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
level,
|
|
13
|
+
msg,
|
|
14
|
+
...fields,
|
|
15
|
+
};
|
|
16
|
+
const line = JSON.stringify(entry);
|
|
17
|
+
if (level === 'error') {
|
|
18
|
+
process.stderr.write(line + '\n');
|
|
19
|
+
} else {
|
|
20
|
+
process.stdout.write(line + '\n');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
const app = express();
|
|
10
|
-
app.use(express.json({ limit: '
|
|
25
|
+
app.use(express.json({ limit: '100kb' }));
|
|
26
|
+
|
|
27
|
+
// Request logging middleware
|
|
28
|
+
app.use((req, res, next) => {
|
|
29
|
+
if (req.path === '/health') return next();
|
|
30
|
+
const reqId = crypto.randomUUID().slice(0, 8);
|
|
31
|
+
req.reqId = reqId;
|
|
32
|
+
req.startTime = Date.now();
|
|
33
|
+
const userId = req.body?.userId || req.query?.userId || '-';
|
|
34
|
+
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
|
|
35
|
+
const origEnd = res.end.bind(res);
|
|
36
|
+
res.end = function (...args) {
|
|
37
|
+
const ms = Date.now() - req.startTime;
|
|
38
|
+
log('info', 'res', { reqId, status: res.statusCode, ms });
|
|
39
|
+
return origEnd(...args);
|
|
40
|
+
};
|
|
41
|
+
next();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
|
|
45
|
+
|
|
46
|
+
function timingSafeCompare(a, b) {
|
|
47
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
48
|
+
const bufA = Buffer.from(a);
|
|
49
|
+
const bufB = Buffer.from(b);
|
|
50
|
+
if (bufA.length !== bufB.length) {
|
|
51
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeError(err) {
|
|
58
|
+
if (process.env.NODE_ENV === 'production') {
|
|
59
|
+
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
60
|
+
return 'Internal server error';
|
|
61
|
+
}
|
|
62
|
+
return err.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateUrl(url) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = new URL(url);
|
|
68
|
+
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
|
|
69
|
+
return `Blocked URL scheme: ${parsed.protocol} (only http/https allowed)`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
} catch {
|
|
73
|
+
return `Invalid URL: ${url}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Import cookies into a user's browser context (Playwright cookies format)
|
|
78
|
+
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
79
|
+
//
|
|
80
|
+
// SECURITY:
|
|
81
|
+
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
82
|
+
// This endpoint is DISABLED unless CAMOFOX_API_KEY is set.
|
|
83
|
+
// When enabled, caller must send: Authorization: Bearer <CAMOFOX_API_KEY>
|
|
84
|
+
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
85
|
+
try {
|
|
86
|
+
const apiKey = process.env.CAMOFOX_API_KEY;
|
|
87
|
+
if (!apiKey) {
|
|
88
|
+
return res.status(403).json({
|
|
89
|
+
error: 'Cookie import is disabled. Set CAMOFOX_API_KEY to enable this endpoint.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth = String(req.headers['authorization'] || '');
|
|
94
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
95
|
+
if (!match || !timingSafeCompare(match[1], apiKey)) {
|
|
96
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const userId = req.params.userId;
|
|
100
|
+
if (!req.body || !('cookies' in req.body)) {
|
|
101
|
+
return res.status(400).json({ error: 'Missing "cookies" field in request body' });
|
|
102
|
+
}
|
|
103
|
+
const cookies = req.body.cookies;
|
|
104
|
+
if (!Array.isArray(cookies)) {
|
|
105
|
+
return res.status(400).json({ error: 'cookies must be an array' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (cookies.length > 500) {
|
|
109
|
+
return res.status(400).json({ error: 'Too many cookies. Maximum 500 per request.' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const invalid = [];
|
|
113
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
114
|
+
const c = cookies[i];
|
|
115
|
+
const missing = [];
|
|
116
|
+
if (!c || typeof c !== 'object') {
|
|
117
|
+
invalid.push({ index: i, error: 'cookie must be an object' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (typeof c.name !== 'string' || !c.name) missing.push('name');
|
|
121
|
+
if (typeof c.value !== 'string') missing.push('value');
|
|
122
|
+
if (typeof c.domain !== 'string' || !c.domain) missing.push('domain');
|
|
123
|
+
if (missing.length) invalid.push({ index: i, missing });
|
|
124
|
+
}
|
|
125
|
+
if (invalid.length) {
|
|
126
|
+
return res.status(400).json({
|
|
127
|
+
error: 'Invalid cookie objects: each cookie must include name, value, and domain',
|
|
128
|
+
invalid,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const allowedFields = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'];
|
|
133
|
+
const sanitized = cookies.map(c => {
|
|
134
|
+
const clean = {};
|
|
135
|
+
for (const k of allowedFields) {
|
|
136
|
+
if (c[k] !== undefined) clean[k] = c[k];
|
|
137
|
+
}
|
|
138
|
+
return clean;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const session = await getSession(userId);
|
|
142
|
+
await session.context.addCookies(sanitized);
|
|
143
|
+
const result = { ok: true, userId: String(userId), count: sanitized.length };
|
|
144
|
+
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
|
|
145
|
+
res.json(result);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log('error', 'cookie import failed', { reqId: req.reqId, error: err.message });
|
|
148
|
+
res.status(500).json({ error: safeError(err) });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
11
151
|
|
|
12
152
|
let browser = null;
|
|
13
153
|
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
|
|
@@ -17,18 +157,8 @@ const sessions = new Map();
|
|
|
17
157
|
|
|
18
158
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
|
19
159
|
const MAX_SNAPSHOT_NODES = 500;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
function logResponse(endpoint, data) {
|
|
23
|
-
if (!DEBUG_RESPONSES) return;
|
|
24
|
-
let logData = data;
|
|
25
|
-
// Truncate snapshot for readability
|
|
26
|
-
if (data && data.snapshot) {
|
|
27
|
-
const snap = data.snapshot;
|
|
28
|
-
logData = { ...data, snapshot: `[${snap.length} chars] ${snap.slice(0, 300)}...` };
|
|
29
|
-
}
|
|
30
|
-
console.log(`📤 ${endpoint} ->`, JSON.stringify(logData, null, 2));
|
|
31
|
-
}
|
|
160
|
+
const MAX_SESSIONS = 50;
|
|
161
|
+
const MAX_TABS_PER_SESSION = 10;
|
|
32
162
|
|
|
33
163
|
// Per-tab locks to serialize operations on the same tab
|
|
34
164
|
// tabId -> Promise (the currently executing operation)
|
|
@@ -67,20 +197,43 @@ function getHostOS() {
|
|
|
67
197
|
return 'linux';
|
|
68
198
|
}
|
|
69
199
|
|
|
200
|
+
function buildProxyConfig() {
|
|
201
|
+
const host = process.env.PROXY_HOST;
|
|
202
|
+
const port = process.env.PROXY_PORT;
|
|
203
|
+
const username = process.env.PROXY_USERNAME;
|
|
204
|
+
const password = process.env.PROXY_PASSWORD;
|
|
205
|
+
|
|
206
|
+
if (!host || !port) {
|
|
207
|
+
log('info', 'no proxy configured');
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log('info', 'proxy configured', { host, port });
|
|
212
|
+
return {
|
|
213
|
+
server: `http://${host}:${port}`,
|
|
214
|
+
username,
|
|
215
|
+
password,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
70
219
|
async function ensureBrowser() {
|
|
71
220
|
if (!browser) {
|
|
72
221
|
const hostOS = getHostOS();
|
|
73
|
-
|
|
222
|
+
const proxy = buildProxyConfig();
|
|
223
|
+
|
|
224
|
+
log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
|
|
74
225
|
|
|
75
226
|
const options = await launchOptions({
|
|
76
227
|
headless: true,
|
|
77
228
|
os: hostOS,
|
|
78
229
|
humanize: true,
|
|
79
230
|
enable_cache: true,
|
|
231
|
+
proxy: proxy,
|
|
232
|
+
geoip: !!proxy,
|
|
80
233
|
});
|
|
81
234
|
|
|
82
235
|
browser = await firefox.launch(options);
|
|
83
|
-
|
|
236
|
+
log('info', 'camoufox launched');
|
|
84
237
|
}
|
|
85
238
|
return browser;
|
|
86
239
|
}
|
|
@@ -94,18 +247,26 @@ async function getSession(userId) {
|
|
|
94
247
|
const key = normalizeUserId(userId);
|
|
95
248
|
let session = sessions.get(key);
|
|
96
249
|
if (!session) {
|
|
250
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
251
|
+
throw new Error('Maximum concurrent sessions reached');
|
|
252
|
+
}
|
|
97
253
|
const b = await ensureBrowser();
|
|
98
|
-
const
|
|
254
|
+
const contextOptions = {
|
|
99
255
|
viewport: { width: 1280, height: 720 },
|
|
100
|
-
locale: 'en-US',
|
|
101
|
-
timezoneId: 'America/Los_Angeles',
|
|
102
|
-
geolocation: { latitude: 37.7749, longitude: -122.4194 },
|
|
103
256
|
permissions: ['geolocation'],
|
|
104
|
-
}
|
|
257
|
+
};
|
|
258
|
+
// When geoip is active (proxy configured), camoufox auto-configures
|
|
259
|
+
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
260
|
+
if (!process.env.PROXY_HOST) {
|
|
261
|
+
contextOptions.locale = 'en-US';
|
|
262
|
+
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
263
|
+
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
264
|
+
}
|
|
265
|
+
const context = await b.newContext(contextOptions);
|
|
105
266
|
|
|
106
267
|
session = { context, tabGroups: new Map(), lastAccess: Date.now() };
|
|
107
268
|
sessions.set(key, session);
|
|
108
|
-
|
|
269
|
+
log('info', 'session created', { userId: key });
|
|
109
270
|
}
|
|
110
271
|
session.lastAccess = Date.now();
|
|
111
272
|
return session;
|
|
@@ -147,7 +308,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
147
308
|
|
|
148
309
|
if (waitForNetwork) {
|
|
149
310
|
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
|
150
|
-
|
|
311
|
+
log('warn', 'networkidle timeout, continuing');
|
|
151
312
|
});
|
|
152
313
|
}
|
|
153
314
|
|
|
@@ -168,7 +329,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
168
329
|
await new Promise(r => setTimeout(r, 250));
|
|
169
330
|
}
|
|
170
331
|
}).catch(() => {
|
|
171
|
-
|
|
332
|
+
log('warn', 'hydration wait failed, continuing');
|
|
172
333
|
});
|
|
173
334
|
|
|
174
335
|
await page.waitForTimeout(200);
|
|
@@ -178,7 +339,7 @@ async function waitForPageReady(page, options = {}) {
|
|
|
178
339
|
|
|
179
340
|
return true;
|
|
180
341
|
} catch (err) {
|
|
181
|
-
|
|
342
|
+
log('warn', 'page ready failed', { error: err.message });
|
|
182
343
|
return false;
|
|
183
344
|
}
|
|
184
345
|
}
|
|
@@ -218,7 +379,7 @@ async function dismissConsentDialogs(page) {
|
|
|
218
379
|
const button = page.locator(selector).first();
|
|
219
380
|
if (await button.isVisible({ timeout: 100 })) {
|
|
220
381
|
await button.click({ timeout: 1000 }).catch(() => {});
|
|
221
|
-
|
|
382
|
+
log('info', 'dismissed consent dialog', { selector });
|
|
222
383
|
await page.waitForTimeout(300); // Brief pause after dismiss
|
|
223
384
|
break; // Only dismiss one dialog per page load
|
|
224
385
|
}
|
|
@@ -232,7 +393,7 @@ async function buildRefs(page) {
|
|
|
232
393
|
const refs = new Map();
|
|
233
394
|
|
|
234
395
|
if (!page || page.isClosed()) {
|
|
235
|
-
|
|
396
|
+
log('warn', 'buildRefs: page closed or invalid');
|
|
236
397
|
return refs;
|
|
237
398
|
}
|
|
238
399
|
|
|
@@ -245,7 +406,7 @@ async function buildRefs(page) {
|
|
|
245
406
|
try {
|
|
246
407
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
|
|
247
408
|
} catch (err) {
|
|
248
|
-
|
|
409
|
+
log('warn', 'ariaSnapshot failed, retrying');
|
|
249
410
|
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
250
411
|
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
|
|
251
412
|
}
|
|
@@ -271,7 +432,7 @@ async function buildRefs(page) {
|
|
|
271
432
|
}).catch(() => []);
|
|
272
433
|
|
|
273
434
|
if (!ariaYaml) {
|
|
274
|
-
|
|
435
|
+
log('warn', 'buildRefs: no aria snapshot');
|
|
275
436
|
return refs;
|
|
276
437
|
}
|
|
277
438
|
|
|
@@ -354,11 +515,10 @@ app.get('/health', async (req, res) => {
|
|
|
354
515
|
res.json({
|
|
355
516
|
ok: true,
|
|
356
517
|
engine: 'camoufox',
|
|
357
|
-
sessions: sessions.size,
|
|
358
518
|
browserConnected: b.isConnected()
|
|
359
519
|
});
|
|
360
520
|
} catch (err) {
|
|
361
|
-
res.status(500).json({ ok: false, error: err
|
|
521
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
362
522
|
}
|
|
363
523
|
});
|
|
364
524
|
|
|
@@ -373,6 +533,13 @@ app.post('/tabs', async (req, res) => {
|
|
|
373
533
|
}
|
|
374
534
|
|
|
375
535
|
const session = await getSession(userId);
|
|
536
|
+
|
|
537
|
+
let totalTabs = 0;
|
|
538
|
+
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
539
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
540
|
+
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
541
|
+
}
|
|
542
|
+
|
|
376
543
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
377
544
|
|
|
378
545
|
const page = await session.context.newPage();
|
|
@@ -381,15 +548,17 @@ app.post('/tabs', async (req, res) => {
|
|
|
381
548
|
group.set(tabId, tabState);
|
|
382
549
|
|
|
383
550
|
if (url) {
|
|
551
|
+
const urlErr = validateUrl(url);
|
|
552
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
384
553
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
385
554
|
tabState.visitedUrls.add(url);
|
|
386
555
|
}
|
|
387
556
|
|
|
388
|
-
|
|
557
|
+
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
389
558
|
res.json({ tabId, url: page.url() });
|
|
390
559
|
} catch (err) {
|
|
391
|
-
|
|
392
|
-
res.status(500).json({ error: err
|
|
560
|
+
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
|
|
561
|
+
res.status(500).json({ error: safeError(err) });
|
|
393
562
|
}
|
|
394
563
|
});
|
|
395
564
|
|
|
@@ -415,6 +584,9 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
415
584
|
return res.status(400).json({ error: 'url or macro required' });
|
|
416
585
|
}
|
|
417
586
|
|
|
587
|
+
const urlErr = validateUrl(targetUrl);
|
|
588
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
589
|
+
|
|
418
590
|
// Serialize navigation operations on the same tab
|
|
419
591
|
const result = await withTabLock(tabId, async () => {
|
|
420
592
|
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
@@ -423,11 +595,11 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
423
595
|
return { ok: true, url: tabState.page.url() };
|
|
424
596
|
});
|
|
425
597
|
|
|
426
|
-
|
|
598
|
+
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
|
|
427
599
|
res.json(result);
|
|
428
600
|
} catch (err) {
|
|
429
|
-
|
|
430
|
-
res.status(500).json({ error: err
|
|
601
|
+
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
602
|
+
res.status(500).json({ error: safeError(err) });
|
|
431
603
|
}
|
|
432
604
|
});
|
|
433
605
|
|
|
@@ -499,11 +671,11 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
499
671
|
snapshot: annotatedYaml,
|
|
500
672
|
refsCount: tabState.refs.size
|
|
501
673
|
};
|
|
502
|
-
|
|
674
|
+
log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount });
|
|
503
675
|
res.json(result);
|
|
504
676
|
} catch (err) {
|
|
505
|
-
|
|
506
|
-
res.status(500).json({ error: err
|
|
677
|
+
log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
|
|
678
|
+
res.status(500).json({ error: safeError(err) });
|
|
507
679
|
}
|
|
508
680
|
});
|
|
509
681
|
|
|
@@ -520,8 +692,8 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
|
520
692
|
|
|
521
693
|
res.json({ ok: true, ready });
|
|
522
694
|
} catch (err) {
|
|
523
|
-
|
|
524
|
-
res.status(500).json({ error: err
|
|
695
|
+
log('error', 'wait failed', { reqId: req.reqId, error: err.message });
|
|
696
|
+
res.status(500).json({ error: safeError(err) });
|
|
525
697
|
}
|
|
526
698
|
});
|
|
527
699
|
|
|
@@ -561,7 +733,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
561
733
|
await tabState.page.waitForTimeout(50);
|
|
562
734
|
await tabState.page.mouse.up();
|
|
563
735
|
|
|
564
|
-
|
|
736
|
+
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
|
|
565
737
|
};
|
|
566
738
|
|
|
567
739
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
@@ -573,17 +745,17 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
573
745
|
} catch (err) {
|
|
574
746
|
// Fallback 1: If intercepted by overlay, retry with force
|
|
575
747
|
if (err.message.includes('intercepts pointer events')) {
|
|
576
|
-
|
|
748
|
+
log('warn', 'click intercepted, retrying with force');
|
|
577
749
|
try {
|
|
578
750
|
await locator.click({ timeout: 5000, force: true });
|
|
579
751
|
} catch (forceErr) {
|
|
580
752
|
// Fallback 2: Full mouse event sequence for stubborn JS handlers
|
|
581
|
-
|
|
753
|
+
log('warn', 'force click failed, trying mouse sequence');
|
|
582
754
|
await dispatchMouseSequence(locator);
|
|
583
755
|
}
|
|
584
756
|
} else if (err.message.includes('not visible') || err.message.includes('timeout')) {
|
|
585
757
|
// Fallback 2: Element not responding to click, try mouse sequence
|
|
586
|
-
|
|
758
|
+
log('warn', 'click timeout, trying mouse sequence');
|
|
587
759
|
await dispatchMouseSequence(locator);
|
|
588
760
|
} else {
|
|
589
761
|
throw err;
|
|
@@ -610,11 +782,11 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
610
782
|
return { ok: true, url: newUrl };
|
|
611
783
|
});
|
|
612
784
|
|
|
613
|
-
|
|
785
|
+
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
614
786
|
res.json(result);
|
|
615
787
|
} catch (err) {
|
|
616
|
-
|
|
617
|
-
res.status(500).json({ error: err
|
|
788
|
+
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
789
|
+
res.status(500).json({ error: safeError(err) });
|
|
618
790
|
}
|
|
619
791
|
});
|
|
620
792
|
|
|
@@ -647,8 +819,8 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
647
819
|
|
|
648
820
|
res.json({ ok: true });
|
|
649
821
|
} catch (err) {
|
|
650
|
-
|
|
651
|
-
res.status(500).json({ error: err
|
|
822
|
+
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
823
|
+
res.status(500).json({ error: safeError(err) });
|
|
652
824
|
}
|
|
653
825
|
});
|
|
654
826
|
|
|
@@ -671,8 +843,8 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
671
843
|
|
|
672
844
|
res.json({ ok: true });
|
|
673
845
|
} catch (err) {
|
|
674
|
-
|
|
675
|
-
res.status(500).json({ error: err
|
|
846
|
+
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
847
|
+
res.status(500).json({ error: safeError(err) });
|
|
676
848
|
}
|
|
677
849
|
});
|
|
678
850
|
|
|
@@ -693,8 +865,8 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
693
865
|
|
|
694
866
|
res.json({ ok: true });
|
|
695
867
|
} catch (err) {
|
|
696
|
-
|
|
697
|
-
res.status(500).json({ error: err
|
|
868
|
+
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
869
|
+
res.status(500).json({ error: safeError(err) });
|
|
698
870
|
}
|
|
699
871
|
});
|
|
700
872
|
|
|
@@ -719,8 +891,8 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
719
891
|
|
|
720
892
|
res.json(result);
|
|
721
893
|
} catch (err) {
|
|
722
|
-
|
|
723
|
-
res.status(500).json({ error: err
|
|
894
|
+
log('error', 'back failed', { reqId: req.reqId, error: err.message });
|
|
895
|
+
res.status(500).json({ error: safeError(err) });
|
|
724
896
|
}
|
|
725
897
|
});
|
|
726
898
|
|
|
@@ -745,8 +917,8 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
745
917
|
|
|
746
918
|
res.json(result);
|
|
747
919
|
} catch (err) {
|
|
748
|
-
|
|
749
|
-
res.status(500).json({ error: err
|
|
920
|
+
log('error', 'forward failed', { reqId: req.reqId, error: err.message });
|
|
921
|
+
res.status(500).json({ error: safeError(err) });
|
|
750
922
|
}
|
|
751
923
|
});
|
|
752
924
|
|
|
@@ -771,8 +943,8 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
771
943
|
|
|
772
944
|
res.json(result);
|
|
773
945
|
} catch (err) {
|
|
774
|
-
|
|
775
|
-
res.status(500).json({ error: err
|
|
946
|
+
log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
|
|
947
|
+
res.status(500).json({ error: safeError(err) });
|
|
776
948
|
}
|
|
777
949
|
});
|
|
778
950
|
|
|
@@ -785,7 +957,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
785
957
|
const session = sessions.get(normalizeUserId(userId));
|
|
786
958
|
const found = session && findTab(session, req.params.tabId);
|
|
787
959
|
if (!found) {
|
|
788
|
-
|
|
960
|
+
log('warn', 'links: tab not found', { reqId: req.reqId, tabId: req.params.tabId, userId, hasSession: !!session });
|
|
789
961
|
return res.status(404).json({ error: 'Tab not found' });
|
|
790
962
|
}
|
|
791
963
|
|
|
@@ -812,8 +984,8 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
812
984
|
pagination: { total, offset, limit, hasMore: offset + limit < total }
|
|
813
985
|
});
|
|
814
986
|
} catch (err) {
|
|
815
|
-
|
|
816
|
-
res.status(500).json({ error: err
|
|
987
|
+
log('error', 'links failed', { reqId: req.reqId, error: err.message });
|
|
988
|
+
res.status(500).json({ error: safeError(err) });
|
|
817
989
|
}
|
|
818
990
|
});
|
|
819
991
|
|
|
@@ -831,8 +1003,8 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
831
1003
|
res.set('Content-Type', 'image/png');
|
|
832
1004
|
res.send(buffer);
|
|
833
1005
|
} catch (err) {
|
|
834
|
-
|
|
835
|
-
res.status(500).json({ error: err
|
|
1006
|
+
log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
|
|
1007
|
+
res.status(500).json({ error: safeError(err) });
|
|
836
1008
|
}
|
|
837
1009
|
});
|
|
838
1010
|
|
|
@@ -855,8 +1027,8 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
855
1027
|
refsCount: tabState.refs.size
|
|
856
1028
|
});
|
|
857
1029
|
} catch (err) {
|
|
858
|
-
|
|
859
|
-
res.status(500).json({ error: err
|
|
1030
|
+
log('error', 'stats failed', { reqId: req.reqId, error: err.message });
|
|
1031
|
+
res.status(500).json({ error: safeError(err) });
|
|
860
1032
|
}
|
|
861
1033
|
});
|
|
862
1034
|
|
|
@@ -869,15 +1041,16 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
869
1041
|
if (found) {
|
|
870
1042
|
await found.tabState.page.close();
|
|
871
1043
|
found.group.delete(req.params.tabId);
|
|
1044
|
+
tabLocks.delete(req.params.tabId);
|
|
872
1045
|
if (found.group.size === 0) {
|
|
873
1046
|
session.tabGroups.delete(found.listItemId);
|
|
874
1047
|
}
|
|
875
|
-
|
|
1048
|
+
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
|
|
876
1049
|
}
|
|
877
1050
|
res.json({ ok: true });
|
|
878
1051
|
} catch (err) {
|
|
879
|
-
|
|
880
|
-
res.status(500).json({ error: err
|
|
1052
|
+
log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
|
|
1053
|
+
res.status(500).json({ error: safeError(err) });
|
|
881
1054
|
}
|
|
882
1055
|
});
|
|
883
1056
|
|
|
@@ -890,31 +1063,32 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
890
1063
|
if (group) {
|
|
891
1064
|
for (const [tabId, tabState] of group) {
|
|
892
1065
|
await tabState.page.close().catch(() => {});
|
|
1066
|
+
tabLocks.delete(tabId);
|
|
893
1067
|
}
|
|
894
1068
|
session.tabGroups.delete(req.params.listItemId);
|
|
895
|
-
|
|
1069
|
+
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
|
|
896
1070
|
}
|
|
897
1071
|
res.json({ ok: true });
|
|
898
1072
|
} catch (err) {
|
|
899
|
-
|
|
900
|
-
res.status(500).json({ error: err
|
|
1073
|
+
log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
|
|
1074
|
+
res.status(500).json({ error: safeError(err) });
|
|
901
1075
|
}
|
|
902
1076
|
});
|
|
903
1077
|
|
|
904
1078
|
// Close session
|
|
905
1079
|
app.delete('/sessions/:userId', async (req, res) => {
|
|
906
1080
|
try {
|
|
907
|
-
const userId = req.params.userId;
|
|
908
|
-
const session = sessions.get(
|
|
1081
|
+
const userId = normalizeUserId(req.params.userId);
|
|
1082
|
+
const session = sessions.get(userId);
|
|
909
1083
|
if (session) {
|
|
910
1084
|
await session.context.close();
|
|
911
1085
|
sessions.delete(userId);
|
|
912
|
-
|
|
1086
|
+
log('info', 'session closed', { userId });
|
|
913
1087
|
}
|
|
914
1088
|
res.json({ ok: true });
|
|
915
1089
|
} catch (err) {
|
|
916
|
-
|
|
917
|
-
res.status(500).json({ error: err
|
|
1090
|
+
log('error', 'session close failed', { error: err.message });
|
|
1091
|
+
res.status(500).json({ error: safeError(err) });
|
|
918
1092
|
}
|
|
919
1093
|
});
|
|
920
1094
|
|
|
@@ -925,7 +1099,7 @@ setInterval(() => {
|
|
|
925
1099
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
926
1100
|
session.context.close().catch(() => {});
|
|
927
1101
|
sessions.delete(userId);
|
|
928
|
-
|
|
1102
|
+
log('info', 'session expired', { userId });
|
|
929
1103
|
}
|
|
930
1104
|
}
|
|
931
1105
|
}, 60_000);
|
|
@@ -944,11 +1118,10 @@ app.get('/', async (req, res) => {
|
|
|
944
1118
|
enabled: true,
|
|
945
1119
|
running: b.isConnected(),
|
|
946
1120
|
engine: 'camoufox',
|
|
947
|
-
sessions: sessions.size,
|
|
948
1121
|
browserConnected: b.isConnected()
|
|
949
1122
|
});
|
|
950
1123
|
} catch (err) {
|
|
951
|
-
res.status(500).json({ ok: false, error: err
|
|
1124
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
952
1125
|
}
|
|
953
1126
|
});
|
|
954
1127
|
|
|
@@ -977,20 +1150,33 @@ app.get('/tabs', async (req, res) => {
|
|
|
977
1150
|
|
|
978
1151
|
res.json({ running: true, tabs });
|
|
979
1152
|
} catch (err) {
|
|
980
|
-
|
|
981
|
-
res.status(500).json({ error: err
|
|
1153
|
+
log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
|
|
1154
|
+
res.status(500).json({ error: safeError(err) });
|
|
982
1155
|
}
|
|
983
1156
|
});
|
|
984
1157
|
|
|
985
1158
|
// POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
|
|
986
1159
|
app.post('/tabs/open', async (req, res) => {
|
|
987
1160
|
try {
|
|
988
|
-
const { url, userId
|
|
1161
|
+
const { url, userId, listItemId = 'default' } = req.body;
|
|
1162
|
+
if (!userId) {
|
|
1163
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1164
|
+
}
|
|
989
1165
|
if (!url) {
|
|
990
1166
|
return res.status(400).json({ error: 'url is required' });
|
|
991
1167
|
}
|
|
992
1168
|
|
|
1169
|
+
const urlErr = validateUrl(url);
|
|
1170
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
1171
|
+
|
|
993
1172
|
const session = await getSession(userId);
|
|
1173
|
+
|
|
1174
|
+
let totalTabs = 0;
|
|
1175
|
+
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
1176
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
1177
|
+
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
1178
|
+
}
|
|
1179
|
+
|
|
994
1180
|
const group = getTabGroup(session, listItemId);
|
|
995
1181
|
|
|
996
1182
|
const page = await session.context.newPage();
|
|
@@ -1001,7 +1187,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1001
1187
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1002
1188
|
tabState.visitedUrls.add(url);
|
|
1003
1189
|
|
|
1004
|
-
|
|
1190
|
+
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
1005
1191
|
res.json({
|
|
1006
1192
|
ok: true,
|
|
1007
1193
|
targetId: tabId,
|
|
@@ -1010,8 +1196,8 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1010
1196
|
title: await page.title().catch(() => '')
|
|
1011
1197
|
});
|
|
1012
1198
|
} catch (err) {
|
|
1013
|
-
|
|
1014
|
-
res.status(500).json({ error: err
|
|
1199
|
+
log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
|
|
1200
|
+
res.status(500).json({ error: safeError(err) });
|
|
1015
1201
|
}
|
|
1016
1202
|
});
|
|
1017
1203
|
|
|
@@ -1021,13 +1207,17 @@ app.post('/start', async (req, res) => {
|
|
|
1021
1207
|
await ensureBrowser();
|
|
1022
1208
|
res.json({ ok: true, profile: 'camoufox' });
|
|
1023
1209
|
} catch (err) {
|
|
1024
|
-
res.status(500).json({ ok: false, error: err
|
|
1210
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
1025
1211
|
}
|
|
1026
1212
|
});
|
|
1027
1213
|
|
|
1028
1214
|
// POST /stop - Stop browser (OpenClaw expects this)
|
|
1029
1215
|
app.post('/stop', async (req, res) => {
|
|
1030
1216
|
try {
|
|
1217
|
+
const adminKey = req.headers['x-admin-key'];
|
|
1218
|
+
if (!adminKey || !timingSafeCompare(adminKey, process.env.CAMOFOX_ADMIN_KEY || '')) {
|
|
1219
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
1220
|
+
}
|
|
1031
1221
|
if (browser) {
|
|
1032
1222
|
await browser.close().catch(() => {});
|
|
1033
1223
|
browser = null;
|
|
@@ -1035,18 +1225,24 @@ app.post('/stop', async (req, res) => {
|
|
|
1035
1225
|
sessions.clear();
|
|
1036
1226
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
1037
1227
|
} catch (err) {
|
|
1038
|
-
res.status(500).json({ ok: false, error: err
|
|
1228
|
+
res.status(500).json({ ok: false, error: safeError(err) });
|
|
1039
1229
|
}
|
|
1040
1230
|
});
|
|
1041
1231
|
|
|
1042
1232
|
// POST /navigate - Navigate (OpenClaw format with targetId in body)
|
|
1043
1233
|
app.post('/navigate', async (req, res) => {
|
|
1044
1234
|
try {
|
|
1045
|
-
const { targetId, url, userId
|
|
1235
|
+
const { targetId, url, userId } = req.body;
|
|
1236
|
+
if (!userId) {
|
|
1237
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1238
|
+
}
|
|
1046
1239
|
if (!url) {
|
|
1047
1240
|
return res.status(400).json({ error: 'url is required' });
|
|
1048
1241
|
}
|
|
1049
1242
|
|
|
1243
|
+
const urlErr = validateUrl(url);
|
|
1244
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
1245
|
+
|
|
1050
1246
|
const session = sessions.get(normalizeUserId(userId));
|
|
1051
1247
|
const found = session && findTab(session, targetId);
|
|
1052
1248
|
if (!found) {
|
|
@@ -1065,15 +1261,18 @@ app.post('/navigate', async (req, res) => {
|
|
|
1065
1261
|
|
|
1066
1262
|
res.json(result);
|
|
1067
1263
|
} catch (err) {
|
|
1068
|
-
|
|
1069
|
-
res.status(500).json({ error: err
|
|
1264
|
+
log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
|
|
1265
|
+
res.status(500).json({ error: safeError(err) });
|
|
1070
1266
|
}
|
|
1071
1267
|
});
|
|
1072
1268
|
|
|
1073
1269
|
// GET /snapshot - Snapshot (OpenClaw format with query params)
|
|
1074
1270
|
app.get('/snapshot', async (req, res) => {
|
|
1075
1271
|
try {
|
|
1076
|
-
const { targetId, userId
|
|
1272
|
+
const { targetId, userId, format = 'text' } = req.query;
|
|
1273
|
+
if (!userId) {
|
|
1274
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1275
|
+
}
|
|
1077
1276
|
|
|
1078
1277
|
const session = sessions.get(normalizeUserId(userId));
|
|
1079
1278
|
const found = session && findTab(session, targetId);
|
|
@@ -1120,8 +1319,8 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1120
1319
|
refsCount: tabState.refs.size
|
|
1121
1320
|
});
|
|
1122
1321
|
} catch (err) {
|
|
1123
|
-
|
|
1124
|
-
res.status(500).json({ error: err
|
|
1322
|
+
log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
|
|
1323
|
+
res.status(500).json({ error: safeError(err) });
|
|
1125
1324
|
}
|
|
1126
1325
|
});
|
|
1127
1326
|
|
|
@@ -1129,7 +1328,10 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1129
1328
|
// Routes to click/type/scroll/press/etc based on 'kind' parameter
|
|
1130
1329
|
app.post('/act', async (req, res) => {
|
|
1131
1330
|
try {
|
|
1132
|
-
const { kind, targetId, userId
|
|
1331
|
+
const { kind, targetId, userId, ...params } = req.body;
|
|
1332
|
+
if (!userId) {
|
|
1333
|
+
return res.status(400).json({ error: 'userId is required' });
|
|
1334
|
+
}
|
|
1133
1335
|
|
|
1134
1336
|
if (!kind) {
|
|
1135
1337
|
return res.status(400).json({ error: 'kind is required' });
|
|
@@ -1253,6 +1455,7 @@ app.post('/act', async (req, res) => {
|
|
|
1253
1455
|
case 'close': {
|
|
1254
1456
|
await tabState.page.close();
|
|
1255
1457
|
found.group.delete(targetId);
|
|
1458
|
+
tabLocks.delete(targetId);
|
|
1256
1459
|
return { ok: true, targetId };
|
|
1257
1460
|
}
|
|
1258
1461
|
|
|
@@ -1263,9 +1466,37 @@ app.post('/act', async (req, res) => {
|
|
|
1263
1466
|
|
|
1264
1467
|
res.json(result);
|
|
1265
1468
|
} catch (err) {
|
|
1266
|
-
|
|
1267
|
-
res.status(500).json({ error: err
|
|
1469
|
+
log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
|
|
1470
|
+
res.status(500).json({ error: safeError(err) });
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
// Periodic stats beacon (every 5 min)
|
|
1475
|
+
setInterval(() => {
|
|
1476
|
+
const mem = process.memoryUsage();
|
|
1477
|
+
let totalTabs = 0;
|
|
1478
|
+
for (const [, session] of sessions) {
|
|
1479
|
+
for (const [, group] of session.tabGroups) {
|
|
1480
|
+
totalTabs += group.size;
|
|
1481
|
+
}
|
|
1268
1482
|
}
|
|
1483
|
+
log('info', 'stats', {
|
|
1484
|
+
sessions: sessions.size,
|
|
1485
|
+
tabs: totalTabs,
|
|
1486
|
+
rssBytes: mem.rss,
|
|
1487
|
+
heapUsedBytes: mem.heapUsed,
|
|
1488
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
1489
|
+
browserConnected: browser?.isConnected() ?? false,
|
|
1490
|
+
});
|
|
1491
|
+
}, 5 * 60_000);
|
|
1492
|
+
|
|
1493
|
+
// Crash logging
|
|
1494
|
+
process.on('uncaughtException', (err) => {
|
|
1495
|
+
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
|
|
1496
|
+
process.exit(1);
|
|
1497
|
+
});
|
|
1498
|
+
process.on('unhandledRejection', (reason) => {
|
|
1499
|
+
log('error', 'unhandledRejection', { reason: String(reason) });
|
|
1269
1500
|
});
|
|
1270
1501
|
|
|
1271
1502
|
// Graceful shutdown
|
|
@@ -1274,10 +1505,10 @@ let shuttingDown = false;
|
|
|
1274
1505
|
async function gracefulShutdown(signal) {
|
|
1275
1506
|
if (shuttingDown) return;
|
|
1276
1507
|
shuttingDown = true;
|
|
1277
|
-
|
|
1508
|
+
log('info', 'shutting down', { signal });
|
|
1278
1509
|
|
|
1279
1510
|
const forceTimeout = setTimeout(() => {
|
|
1280
|
-
|
|
1511
|
+
log('error', 'shutdown timed out, forcing exit');
|
|
1281
1512
|
process.exit(1);
|
|
1282
1513
|
}, 10000);
|
|
1283
1514
|
forceTimeout.unref();
|
|
@@ -1294,19 +1525,19 @@ async function gracefulShutdown(signal) {
|
|
|
1294
1525
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
1295
1526
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1296
1527
|
|
|
1297
|
-
const PORT = process.env.CAMOFOX_PORT || 9377;
|
|
1528
|
+
const PORT = process.env.CAMOFOX_PORT || process.env.PORT || 9377;
|
|
1298
1529
|
const server = app.listen(PORT, () => {
|
|
1299
|
-
|
|
1530
|
+
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
1300
1531
|
ensureBrowser().catch(err => {
|
|
1301
|
-
|
|
1532
|
+
log('error', 'browser pre-launch failed', { error: err.message });
|
|
1302
1533
|
});
|
|
1303
1534
|
});
|
|
1304
1535
|
|
|
1305
1536
|
server.on('error', (err) => {
|
|
1306
1537
|
if (err.code === 'EADDRINUSE') {
|
|
1307
|
-
|
|
1538
|
+
log('error', 'port in use', { port: PORT });
|
|
1308
1539
|
process.exit(1);
|
|
1309
1540
|
}
|
|
1310
|
-
|
|
1541
|
+
log('error', 'server error', { error: err.message });
|
|
1311
1542
|
process.exit(1);
|
|
1312
1543
|
});
|