@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 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
- Built on <a href="https://camoufox.com">Camoufox</a> — a Firefox fork with fingerprint spoofing at the C++ level.<br/>
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
- - **Search Macros** — `@google_search`, `@youtube_search`, `@amazon_search`, and 10 more
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`. Set `CAMOFOX_PORT` to override.
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/?q=${encodeURIComponent(query || '')}`,
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.13",
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: { ...process.env, CAMOFOX_PORT: String(port) },
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 || "openclaw";
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 || "openclaw";
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 || "openclaw";
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 || "openclaw";
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 || "openclaw";
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 || "openclaw";
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 || "openclaw";
380
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/screenshot?userId=${userId}`);
381
- return toToolResult(result);
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 || "openclaw";
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 || "openclaw";
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: '5mb' }));
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 DEBUG_RESPONSES = true; // Log response payloads
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
- console.log(`Launching Camoufox browser (host OS: ${hostOS})...`);
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
- console.log('Camoufox browser launched');
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 context = await b.newContext({
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
- console.log(`Session created for user ${key}`);
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
- console.log('waitForPageReady: networkidle timeout (continuing anyway)');
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
- console.log('waitForPageReady: framework hydration wait failed (continuing anyway)');
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
- console.log(`waitForPageReady: ${err.message}`);
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
- console.log(`🍪 Auto-dismissed consent dialog via: ${selector}`);
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
- console.log('buildRefs: Page is closed or invalid');
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
- console.log('buildRefs: ariaSnapshot failed, retrying after navigation settles');
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
- console.log('buildRefs: No aria snapshot available');
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.message });
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
- console.log(`Tab ${tabId} created for user ${userId}, session ${resolvedSessionKey}`);
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
- console.error('Create tab error:', err);
392
- res.status(500).json({ error: err.message });
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
- logResponse(`POST /tabs/${tabId}/navigate`, result);
598
+ log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
427
599
  res.json(result);
428
600
  } catch (err) {
429
- console.error('Navigate error:', err);
430
- res.status(500).json({ error: err.message });
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
- logResponse(`GET /tabs/${req.params.tabId}/snapshot`, result);
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
- console.error('Snapshot error:', err);
506
- res.status(500).json({ error: err.message });
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
- console.error('Wait error:', err);
524
- res.status(500).json({ error: err.message });
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
- console.log(`🖱️ Dispatched full mouse sequence at (${x.toFixed(0)}, ${y.toFixed(0)})`);
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
- console.log('Click intercepted, retrying with force:true');
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
- console.log('Force click failed, trying full mouse sequence');
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
- console.log('Click timeout/not visible, trying full mouse sequence');
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
- logResponse(`POST /tabs/${tabId}/click`, result);
785
+ log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
614
786
  res.json(result);
615
787
  } catch (err) {
616
- console.error('Click error:', err);
617
- res.status(500).json({ error: err.message });
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
- console.error('Type error:', err);
651
- res.status(500).json({ error: err.message });
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
- console.error('Press error:', err);
675
- res.status(500).json({ error: err.message });
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
- console.error('Scroll error:', err);
697
- res.status(500).json({ error: err.message });
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
- console.error('Back error:', err);
723
- res.status(500).json({ error: err.message });
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
- console.error('Forward error:', err);
749
- res.status(500).json({ error: err.message });
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
- console.error('Refresh error:', err);
775
- res.status(500).json({ error: err.message });
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
- console.log(`GET /tabs/${req.params.tabId}/links -> 404 (userId=${userId}, hasSession=${!!session}, sessionUsers=${[...sessions.keys()].join(',')})`);
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
- console.error('Links error:', err);
816
- res.status(500).json({ error: err.message });
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
- console.error('Screenshot error:', err);
835
- res.status(500).json({ error: err.message });
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
- console.error('Stats error:', err);
859
- res.status(500).json({ error: err.message });
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
- console.log(`Tab ${req.params.tabId} closed for user ${userId}`);
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
- console.error('Close tab error:', err);
880
- res.status(500).json({ error: err.message });
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
- console.log(`Tab group ${req.params.listItemId} closed for user ${userId}`);
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
- console.error('Close tab group error:', err);
900
- res.status(500).json({ error: err.message });
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(normalizeUserId(userId));
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
- console.log(`Session closed for user ${userId}`);
1086
+ log('info', 'session closed', { userId });
913
1087
  }
914
1088
  res.json({ ok: true });
915
1089
  } catch (err) {
916
- console.error('Close session error:', err);
917
- res.status(500).json({ error: err.message });
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
- console.log(`Session expired for user ${userId}`);
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.message });
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
- console.error('List tabs error:', err);
981
- res.status(500).json({ error: err.message });
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 = 'openclaw', listItemId = 'default' } = req.body;
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
- console.log(`[OpenClaw] Tab ${tabId} opened: ${url}`);
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
- console.error('Open tab error:', err);
1014
- res.status(500).json({ error: err.message });
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.message });
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.message });
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 = 'openclaw' } = req.body;
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
- console.error('Navigate error:', err);
1069
- res.status(500).json({ error: err.message });
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 = 'openclaw', format = 'text' } = req.query;
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
- console.error('Snapshot error:', err);
1124
- res.status(500).json({ error: err.message });
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 = 'openclaw', ...params } = req.body;
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
- console.error('Act error:', err);
1267
- res.status(500).json({ error: err.message });
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
- console.log(`${signal} received, shutting down...`);
1508
+ log('info', 'shutting down', { signal });
1278
1509
 
1279
1510
  const forceTimeout = setTimeout(() => {
1280
- console.error('Shutdown timed out after 10s, forcing exit');
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
- console.log(`camofox-browser listening on port ${PORT}`);
1530
+ log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
1300
1531
  ensureBrowser().catch(err => {
1301
- console.error('Failed to pre-launch browser:', err.message);
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
- console.error(`FATAL: Port ${PORT} is already in use. Set CAMOFOX_PORT env var to use a different port.`);
1538
+ log('error', 'port in use', { port: PORT });
1308
1539
  process.exit(1);
1309
1540
  }
1310
- console.error('Server error:', err);
1541
+ log('error', 'server error', { error: err.message });
1311
1542
  process.exit(1);
1312
1543
  });