@askjo/camofox-browser 1.0.14 → 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
@@ -33,7 +33,10 @@ This project wraps that engine in a REST API built for agents: accessibility sna
33
33
  - **Element Refs** — stable `e1`, `e2`, `e3` identifiers for reliable interaction
34
34
  - **Token-Efficient** — accessibility snapshots are ~90% smaller than raw HTML
35
35
  - **Session Isolation** — separate cookies/storage per user
36
- - **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
37
40
  - **Deploy Anywhere** — Docker, Fly.io, Railway
38
41
 
39
42
  ## Quick Start
@@ -44,7 +47,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
44
47
  openclaw plugins install @askjo/camofox-browser
45
48
  ```
46
49
 
47
- **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`
48
51
 
49
52
  ### Standalone
50
53
 
@@ -55,7 +58,7 @@ npm install
55
58
  npm start # downloads Camoufox on first run (~300MB)
56
59
  ```
57
60
 
58
- Default port is `9377`. Set `CAMOFOX_PORT` to override.
61
+ Default port is `9377`. See [Environment Variables](#environment-variables) for all options.
59
62
 
60
63
  ### Docker
61
64
 
@@ -70,6 +73,70 @@ docker run -p 9377:9377 camofox-browser
70
73
 
71
74
  ## Usage
72
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
+
73
140
  ```bash
74
141
  # Create a tab
75
142
  curl -X POST http://localhost:9377/tabs \
@@ -134,9 +201,32 @@ curl -X POST http://localhost:9377/tabs/TAB_ID/navigate \
134
201
  | `POST` | `/start` | Start browser engine |
135
202
  | `POST` | `/stop` | Stop browser engine |
136
203
 
204
+ ### Sessions
205
+
206
+ | Method | Endpoint | Description |
207
+ |--------|----------|-------------|
208
+ | `POST` | `/sessions/:userId/cookies` | Add cookies to a user session (Playwright cookie objects) |
209
+
137
210
  ## Search Macros
138
211
 
139
- `@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 | — |
140
230
 
141
231
  ## Architecture
142
232
 
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.14",
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",
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 => {
@@ -113,6 +115,13 @@ async function startServer(
113
115
  HOME: process.env.HOME,
114
116
  NODE_ENV: process.env.NODE_ENV,
115
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,
116
125
  },
117
126
  stdio: ["ignore", "pipe", "pipe"],
118
127
  detached: false,
@@ -192,11 +201,56 @@ function toToolResult(data: unknown): ToolResult {
192
201
  };
193
202
  }
194
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
+
195
248
  export default function register(api: PluginApi) {
196
249
  const port = api.config.port || 9377;
197
250
  const baseUrl = api.config.url || `http://localhost:${port}`;
198
251
  const autoStart = api.config.autoStart !== false; // default true
199
252
  const pluginDir = getPluginDir();
253
+ const fallbackUserId = `camofox-${randomUUID()}`;
200
254
 
201
255
  // Auto-start server if configured (default: true)
202
256
  if (autoStart) {
@@ -227,7 +281,7 @@ export default function register(api: PluginApi) {
227
281
  },
228
282
  async execute(_id, params) {
229
283
  const sessionKey = ctx.sessionKey || "default";
230
- const userId = ctx.agentId || "openclaw";
284
+ const userId = ctx.agentId || fallbackUserId;
231
285
  const result = await fetchApi(baseUrl, "/tabs", {
232
286
  method: "POST",
233
287
  body: JSON.stringify({ ...params, userId, sessionKey }),
@@ -249,7 +303,7 @@ export default function register(api: PluginApi) {
249
303
  },
250
304
  async execute(_id, params) {
251
305
  const { tabId } = params as { tabId: string };
252
- const userId = ctx.agentId || "openclaw";
306
+ const userId = ctx.agentId || fallbackUserId;
253
307
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
254
308
  return toToolResult(result);
255
309
  },
@@ -269,7 +323,7 @@ export default function register(api: PluginApi) {
269
323
  },
270
324
  async execute(_id, params) {
271
325
  const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
272
- const userId = ctx.agentId || "openclaw";
326
+ const userId = ctx.agentId || fallbackUserId;
273
327
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
274
328
  method: "POST",
275
329
  body: JSON.stringify({ ...rest, userId }),
@@ -294,7 +348,7 @@ export default function register(api: PluginApi) {
294
348
  },
295
349
  async execute(_id, params) {
296
350
  const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
297
- const userId = ctx.agentId || "openclaw";
351
+ const userId = ctx.agentId || fallbackUserId;
298
352
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
299
353
  method: "POST",
300
354
  body: JSON.stringify({ ...rest, userId }),
@@ -337,7 +391,7 @@ export default function register(api: PluginApi) {
337
391
  },
338
392
  async execute(_id, params) {
339
393
  const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
340
- const userId = ctx.agentId || "openclaw";
394
+ const userId = ctx.agentId || fallbackUserId;
341
395
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
342
396
  method: "POST",
343
397
  body: JSON.stringify({ ...rest, userId }),
@@ -360,7 +414,7 @@ export default function register(api: PluginApi) {
360
414
  },
361
415
  async execute(_id, params) {
362
416
  const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
363
- const userId = ctx.agentId || "openclaw";
417
+ const userId = ctx.agentId || fallbackUserId;
364
418
  const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
365
419
  method: "POST",
366
420
  body: JSON.stringify({ ...rest, userId }),
@@ -381,7 +435,7 @@ export default function register(api: PluginApi) {
381
435
  },
382
436
  async execute(_id, params) {
383
437
  const { tabId } = params as { tabId: string };
384
- const userId = ctx.agentId || "openclaw";
438
+ const userId = ctx.agentId || fallbackUserId;
385
439
  const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`;
386
440
  const res = await fetch(url);
387
441
  if (!res.ok) {
@@ -414,7 +468,7 @@ export default function register(api: PluginApi) {
414
468
  },
415
469
  async execute(_id, params) {
416
470
  const { tabId } = params as { tabId: string };
417
- const userId = ctx.agentId || "openclaw";
471
+ const userId = ctx.agentId || fallbackUserId;
418
472
  const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
419
473
  method: "DELETE",
420
474
  });
@@ -431,12 +485,84 @@ export default function register(api: PluginApi) {
431
485
  required: [],
432
486
  },
433
487
  async execute(_id, _params) {
434
- const userId = ctx.agentId || "openclaw";
488
+ const userId = ctx.agentId || fallbackUserId;
435
489
  const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
436
490
  return toToolResult(result);
437
491
  },
438
492
  }));
439
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
+
440
566
  api.registerCommand({
441
567
  name: "camofox",
442
568
  description: "Camoufox browser server control (status, start, stop)",
package/server.js CHANGED
@@ -5,8 +5,149 @@ const crypto = require('crypto');
5
5
  const os = require('os');
6
6
  const { expandMacro } = require('./lib/macros');
7
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
+
8
24
  const app = express();
9
- 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
+ });
10
151
 
11
152
  let browser = null;
12
153
  // userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
@@ -16,18 +157,8 @@ const sessions = new Map();
16
157
 
17
158
  const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
18
159
  const MAX_SNAPSHOT_NODES = 500;
19
- const DEBUG_RESPONSES = true; // Log response payloads
20
-
21
- function logResponse(endpoint, data) {
22
- if (!DEBUG_RESPONSES) return;
23
- let logData = data;
24
- // Truncate snapshot for readability
25
- if (data && data.snapshot) {
26
- const snap = data.snapshot;
27
- logData = { ...data, snapshot: `[${snap.length} chars] ${snap.slice(0, 300)}...` };
28
- }
29
- console.log(`📤 ${endpoint} ->`, JSON.stringify(logData, null, 2));
30
- }
160
+ const MAX_SESSIONS = 50;
161
+ const MAX_TABS_PER_SESSION = 10;
31
162
 
32
163
  // Per-tab locks to serialize operations on the same tab
33
164
  // tabId -> Promise (the currently executing operation)
@@ -66,20 +197,43 @@ function getHostOS() {
66
197
  return 'linux';
67
198
  }
68
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
+
69
219
  async function ensureBrowser() {
70
220
  if (!browser) {
71
221
  const hostOS = getHostOS();
72
- console.log(`Launching Camoufox browser (host OS: ${hostOS})...`);
222
+ const proxy = buildProxyConfig();
223
+
224
+ log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
73
225
 
74
226
  const options = await launchOptions({
75
227
  headless: true,
76
228
  os: hostOS,
77
229
  humanize: true,
78
230
  enable_cache: true,
231
+ proxy: proxy,
232
+ geoip: !!proxy,
79
233
  });
80
234
 
81
235
  browser = await firefox.launch(options);
82
- console.log('Camoufox browser launched');
236
+ log('info', 'camoufox launched');
83
237
  }
84
238
  return browser;
85
239
  }
@@ -93,18 +247,26 @@ async function getSession(userId) {
93
247
  const key = normalizeUserId(userId);
94
248
  let session = sessions.get(key);
95
249
  if (!session) {
250
+ if (sessions.size >= MAX_SESSIONS) {
251
+ throw new Error('Maximum concurrent sessions reached');
252
+ }
96
253
  const b = await ensureBrowser();
97
- const context = await b.newContext({
254
+ const contextOptions = {
98
255
  viewport: { width: 1280, height: 720 },
99
- locale: 'en-US',
100
- timezoneId: 'America/Los_Angeles',
101
- geolocation: { latitude: 37.7749, longitude: -122.4194 },
102
256
  permissions: ['geolocation'],
103
- });
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);
104
266
 
105
267
  session = { context, tabGroups: new Map(), lastAccess: Date.now() };
106
268
  sessions.set(key, session);
107
- console.log(`Session created for user ${key}`);
269
+ log('info', 'session created', { userId: key });
108
270
  }
109
271
  session.lastAccess = Date.now();
110
272
  return session;
@@ -146,7 +308,7 @@ async function waitForPageReady(page, options = {}) {
146
308
 
147
309
  if (waitForNetwork) {
148
310
  await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
149
- console.log('waitForPageReady: networkidle timeout (continuing anyway)');
311
+ log('warn', 'networkidle timeout, continuing');
150
312
  });
151
313
  }
152
314
 
@@ -167,7 +329,7 @@ async function waitForPageReady(page, options = {}) {
167
329
  await new Promise(r => setTimeout(r, 250));
168
330
  }
169
331
  }).catch(() => {
170
- console.log('waitForPageReady: framework hydration wait failed (continuing anyway)');
332
+ log('warn', 'hydration wait failed, continuing');
171
333
  });
172
334
 
173
335
  await page.waitForTimeout(200);
@@ -177,7 +339,7 @@ async function waitForPageReady(page, options = {}) {
177
339
 
178
340
  return true;
179
341
  } catch (err) {
180
- console.log(`waitForPageReady: ${err.message}`);
342
+ log('warn', 'page ready failed', { error: err.message });
181
343
  return false;
182
344
  }
183
345
  }
@@ -217,7 +379,7 @@ async function dismissConsentDialogs(page) {
217
379
  const button = page.locator(selector).first();
218
380
  if (await button.isVisible({ timeout: 100 })) {
219
381
  await button.click({ timeout: 1000 }).catch(() => {});
220
- console.log(`🍪 Auto-dismissed consent dialog via: ${selector}`);
382
+ log('info', 'dismissed consent dialog', { selector });
221
383
  await page.waitForTimeout(300); // Brief pause after dismiss
222
384
  break; // Only dismiss one dialog per page load
223
385
  }
@@ -231,7 +393,7 @@ async function buildRefs(page) {
231
393
  const refs = new Map();
232
394
 
233
395
  if (!page || page.isClosed()) {
234
- console.log('buildRefs: Page is closed or invalid');
396
+ log('warn', 'buildRefs: page closed or invalid');
235
397
  return refs;
236
398
  }
237
399
 
@@ -244,7 +406,7 @@ async function buildRefs(page) {
244
406
  try {
245
407
  ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
246
408
  } catch (err) {
247
- console.log('buildRefs: ariaSnapshot failed, retrying after navigation settles');
409
+ log('warn', 'ariaSnapshot failed, retrying');
248
410
  await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
249
411
  ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
250
412
  }
@@ -270,7 +432,7 @@ async function buildRefs(page) {
270
432
  }).catch(() => []);
271
433
 
272
434
  if (!ariaYaml) {
273
- console.log('buildRefs: No aria snapshot available');
435
+ log('warn', 'buildRefs: no aria snapshot');
274
436
  return refs;
275
437
  }
276
438
 
@@ -353,11 +515,10 @@ app.get('/health', async (req, res) => {
353
515
  res.json({
354
516
  ok: true,
355
517
  engine: 'camoufox',
356
- sessions: sessions.size,
357
518
  browserConnected: b.isConnected()
358
519
  });
359
520
  } catch (err) {
360
- res.status(500).json({ ok: false, error: err.message });
521
+ res.status(500).json({ ok: false, error: safeError(err) });
361
522
  }
362
523
  });
363
524
 
@@ -372,6 +533,13 @@ app.post('/tabs', async (req, res) => {
372
533
  }
373
534
 
374
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
+
375
543
  const group = getTabGroup(session, resolvedSessionKey);
376
544
 
377
545
  const page = await session.context.newPage();
@@ -380,15 +548,17 @@ app.post('/tabs', async (req, res) => {
380
548
  group.set(tabId, tabState);
381
549
 
382
550
  if (url) {
551
+ const urlErr = validateUrl(url);
552
+ if (urlErr) return res.status(400).json({ error: urlErr });
383
553
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
384
554
  tabState.visitedUrls.add(url);
385
555
  }
386
556
 
387
- 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() });
388
558
  res.json({ tabId, url: page.url() });
389
559
  } catch (err) {
390
- console.error('Create tab error:', err);
391
- 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) });
392
562
  }
393
563
  });
394
564
 
@@ -414,6 +584,9 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
414
584
  return res.status(400).json({ error: 'url or macro required' });
415
585
  }
416
586
 
587
+ const urlErr = validateUrl(targetUrl);
588
+ if (urlErr) return res.status(400).json({ error: urlErr });
589
+
417
590
  // Serialize navigation operations on the same tab
418
591
  const result = await withTabLock(tabId, async () => {
419
592
  await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
@@ -422,11 +595,11 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
422
595
  return { ok: true, url: tabState.page.url() };
423
596
  });
424
597
 
425
- logResponse(`POST /tabs/${tabId}/navigate`, result);
598
+ log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
426
599
  res.json(result);
427
600
  } catch (err) {
428
- console.error('Navigate error:', err);
429
- 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) });
430
603
  }
431
604
  });
432
605
 
@@ -498,11 +671,11 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
498
671
  snapshot: annotatedYaml,
499
672
  refsCount: tabState.refs.size
500
673
  };
501
- 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 });
502
675
  res.json(result);
503
676
  } catch (err) {
504
- console.error('Snapshot error:', err);
505
- 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) });
506
679
  }
507
680
  });
508
681
 
@@ -519,8 +692,8 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
519
692
 
520
693
  res.json({ ok: true, ready });
521
694
  } catch (err) {
522
- console.error('Wait error:', err);
523
- 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) });
524
697
  }
525
698
  });
526
699
 
@@ -560,7 +733,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
560
733
  await tabState.page.waitForTimeout(50);
561
734
  await tabState.page.mouse.up();
562
735
 
563
- 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) });
564
737
  };
565
738
 
566
739
  const doClick = async (locatorOrSelector, isLocator) => {
@@ -572,17 +745,17 @@ app.post('/tabs/:tabId/click', async (req, res) => {
572
745
  } catch (err) {
573
746
  // Fallback 1: If intercepted by overlay, retry with force
574
747
  if (err.message.includes('intercepts pointer events')) {
575
- console.log('Click intercepted, retrying with force:true');
748
+ log('warn', 'click intercepted, retrying with force');
576
749
  try {
577
750
  await locator.click({ timeout: 5000, force: true });
578
751
  } catch (forceErr) {
579
752
  // Fallback 2: Full mouse event sequence for stubborn JS handlers
580
- console.log('Force click failed, trying full mouse sequence');
753
+ log('warn', 'force click failed, trying mouse sequence');
581
754
  await dispatchMouseSequence(locator);
582
755
  }
583
756
  } else if (err.message.includes('not visible') || err.message.includes('timeout')) {
584
757
  // Fallback 2: Element not responding to click, try mouse sequence
585
- console.log('Click timeout/not visible, trying full mouse sequence');
758
+ log('warn', 'click timeout, trying mouse sequence');
586
759
  await dispatchMouseSequence(locator);
587
760
  } else {
588
761
  throw err;
@@ -609,11 +782,11 @@ app.post('/tabs/:tabId/click', async (req, res) => {
609
782
  return { ok: true, url: newUrl };
610
783
  });
611
784
 
612
- logResponse(`POST /tabs/${tabId}/click`, result);
785
+ log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
613
786
  res.json(result);
614
787
  } catch (err) {
615
- console.error('Click error:', err);
616
- 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) });
617
790
  }
618
791
  });
619
792
 
@@ -646,8 +819,8 @@ app.post('/tabs/:tabId/type', async (req, res) => {
646
819
 
647
820
  res.json({ ok: true });
648
821
  } catch (err) {
649
- console.error('Type error:', err);
650
- 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) });
651
824
  }
652
825
  });
653
826
 
@@ -670,8 +843,8 @@ app.post('/tabs/:tabId/press', async (req, res) => {
670
843
 
671
844
  res.json({ ok: true });
672
845
  } catch (err) {
673
- console.error('Press error:', err);
674
- 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) });
675
848
  }
676
849
  });
677
850
 
@@ -692,8 +865,8 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
692
865
 
693
866
  res.json({ ok: true });
694
867
  } catch (err) {
695
- console.error('Scroll error:', err);
696
- 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) });
697
870
  }
698
871
  });
699
872
 
@@ -718,8 +891,8 @@ app.post('/tabs/:tabId/back', async (req, res) => {
718
891
 
719
892
  res.json(result);
720
893
  } catch (err) {
721
- console.error('Back error:', err);
722
- 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) });
723
896
  }
724
897
  });
725
898
 
@@ -744,8 +917,8 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
744
917
 
745
918
  res.json(result);
746
919
  } catch (err) {
747
- console.error('Forward error:', err);
748
- 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) });
749
922
  }
750
923
  });
751
924
 
@@ -770,8 +943,8 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
770
943
 
771
944
  res.json(result);
772
945
  } catch (err) {
773
- console.error('Refresh error:', err);
774
- 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) });
775
948
  }
776
949
  });
777
950
 
@@ -784,7 +957,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
784
957
  const session = sessions.get(normalizeUserId(userId));
785
958
  const found = session && findTab(session, req.params.tabId);
786
959
  if (!found) {
787
- 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 });
788
961
  return res.status(404).json({ error: 'Tab not found' });
789
962
  }
790
963
 
@@ -811,8 +984,8 @@ app.get('/tabs/:tabId/links', async (req, res) => {
811
984
  pagination: { total, offset, limit, hasMore: offset + limit < total }
812
985
  });
813
986
  } catch (err) {
814
- console.error('Links error:', err);
815
- 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) });
816
989
  }
817
990
  });
818
991
 
@@ -830,8 +1003,8 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
830
1003
  res.set('Content-Type', 'image/png');
831
1004
  res.send(buffer);
832
1005
  } catch (err) {
833
- console.error('Screenshot error:', err);
834
- 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) });
835
1008
  }
836
1009
  });
837
1010
 
@@ -854,8 +1027,8 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
854
1027
  refsCount: tabState.refs.size
855
1028
  });
856
1029
  } catch (err) {
857
- console.error('Stats error:', err);
858
- 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) });
859
1032
  }
860
1033
  });
861
1034
 
@@ -868,15 +1041,16 @@ app.delete('/tabs/:tabId', async (req, res) => {
868
1041
  if (found) {
869
1042
  await found.tabState.page.close();
870
1043
  found.group.delete(req.params.tabId);
1044
+ tabLocks.delete(req.params.tabId);
871
1045
  if (found.group.size === 0) {
872
1046
  session.tabGroups.delete(found.listItemId);
873
1047
  }
874
- console.log(`Tab ${req.params.tabId} closed for user ${userId}`);
1048
+ log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
875
1049
  }
876
1050
  res.json({ ok: true });
877
1051
  } catch (err) {
878
- console.error('Close tab error:', err);
879
- 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) });
880
1054
  }
881
1055
  });
882
1056
 
@@ -889,31 +1063,32 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
889
1063
  if (group) {
890
1064
  for (const [tabId, tabState] of group) {
891
1065
  await tabState.page.close().catch(() => {});
1066
+ tabLocks.delete(tabId);
892
1067
  }
893
1068
  session.tabGroups.delete(req.params.listItemId);
894
- 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 });
895
1070
  }
896
1071
  res.json({ ok: true });
897
1072
  } catch (err) {
898
- console.error('Close tab group error:', err);
899
- 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) });
900
1075
  }
901
1076
  });
902
1077
 
903
1078
  // Close session
904
1079
  app.delete('/sessions/:userId', async (req, res) => {
905
1080
  try {
906
- const userId = req.params.userId;
907
- const session = sessions.get(normalizeUserId(userId));
1081
+ const userId = normalizeUserId(req.params.userId);
1082
+ const session = sessions.get(userId);
908
1083
  if (session) {
909
1084
  await session.context.close();
910
1085
  sessions.delete(userId);
911
- console.log(`Session closed for user ${userId}`);
1086
+ log('info', 'session closed', { userId });
912
1087
  }
913
1088
  res.json({ ok: true });
914
1089
  } catch (err) {
915
- console.error('Close session error:', err);
916
- res.status(500).json({ error: err.message });
1090
+ log('error', 'session close failed', { error: err.message });
1091
+ res.status(500).json({ error: safeError(err) });
917
1092
  }
918
1093
  });
919
1094
 
@@ -924,7 +1099,7 @@ setInterval(() => {
924
1099
  if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
925
1100
  session.context.close().catch(() => {});
926
1101
  sessions.delete(userId);
927
- console.log(`Session expired for user ${userId}`);
1102
+ log('info', 'session expired', { userId });
928
1103
  }
929
1104
  }
930
1105
  }, 60_000);
@@ -943,11 +1118,10 @@ app.get('/', async (req, res) => {
943
1118
  enabled: true,
944
1119
  running: b.isConnected(),
945
1120
  engine: 'camoufox',
946
- sessions: sessions.size,
947
1121
  browserConnected: b.isConnected()
948
1122
  });
949
1123
  } catch (err) {
950
- res.status(500).json({ ok: false, error: err.message });
1124
+ res.status(500).json({ ok: false, error: safeError(err) });
951
1125
  }
952
1126
  });
953
1127
 
@@ -976,20 +1150,33 @@ app.get('/tabs', async (req, res) => {
976
1150
 
977
1151
  res.json({ running: true, tabs });
978
1152
  } catch (err) {
979
- console.error('List tabs error:', err);
980
- 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) });
981
1155
  }
982
1156
  });
983
1157
 
984
1158
  // POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
985
1159
  app.post('/tabs/open', async (req, res) => {
986
1160
  try {
987
- 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
+ }
988
1165
  if (!url) {
989
1166
  return res.status(400).json({ error: 'url is required' });
990
1167
  }
991
1168
 
1169
+ const urlErr = validateUrl(url);
1170
+ if (urlErr) return res.status(400).json({ error: urlErr });
1171
+
992
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
+
993
1180
  const group = getTabGroup(session, listItemId);
994
1181
 
995
1182
  const page = await session.context.newPage();
@@ -1000,7 +1187,7 @@ app.post('/tabs/open', async (req, res) => {
1000
1187
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1001
1188
  tabState.visitedUrls.add(url);
1002
1189
 
1003
- console.log(`[OpenClaw] Tab ${tabId} opened: ${url}`);
1190
+ log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
1004
1191
  res.json({
1005
1192
  ok: true,
1006
1193
  targetId: tabId,
@@ -1009,8 +1196,8 @@ app.post('/tabs/open', async (req, res) => {
1009
1196
  title: await page.title().catch(() => '')
1010
1197
  });
1011
1198
  } catch (err) {
1012
- console.error('Open tab error:', err);
1013
- 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) });
1014
1201
  }
1015
1202
  });
1016
1203
 
@@ -1020,13 +1207,17 @@ app.post('/start', async (req, res) => {
1020
1207
  await ensureBrowser();
1021
1208
  res.json({ ok: true, profile: 'camoufox' });
1022
1209
  } catch (err) {
1023
- res.status(500).json({ ok: false, error: err.message });
1210
+ res.status(500).json({ ok: false, error: safeError(err) });
1024
1211
  }
1025
1212
  });
1026
1213
 
1027
1214
  // POST /stop - Stop browser (OpenClaw expects this)
1028
1215
  app.post('/stop', async (req, res) => {
1029
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
+ }
1030
1221
  if (browser) {
1031
1222
  await browser.close().catch(() => {});
1032
1223
  browser = null;
@@ -1034,18 +1225,24 @@ app.post('/stop', async (req, res) => {
1034
1225
  sessions.clear();
1035
1226
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
1036
1227
  } catch (err) {
1037
- res.status(500).json({ ok: false, error: err.message });
1228
+ res.status(500).json({ ok: false, error: safeError(err) });
1038
1229
  }
1039
1230
  });
1040
1231
 
1041
1232
  // POST /navigate - Navigate (OpenClaw format with targetId in body)
1042
1233
  app.post('/navigate', async (req, res) => {
1043
1234
  try {
1044
- 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
+ }
1045
1239
  if (!url) {
1046
1240
  return res.status(400).json({ error: 'url is required' });
1047
1241
  }
1048
1242
 
1243
+ const urlErr = validateUrl(url);
1244
+ if (urlErr) return res.status(400).json({ error: urlErr });
1245
+
1049
1246
  const session = sessions.get(normalizeUserId(userId));
1050
1247
  const found = session && findTab(session, targetId);
1051
1248
  if (!found) {
@@ -1064,15 +1261,18 @@ app.post('/navigate', async (req, res) => {
1064
1261
 
1065
1262
  res.json(result);
1066
1263
  } catch (err) {
1067
- console.error('Navigate error:', err);
1068
- 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) });
1069
1266
  }
1070
1267
  });
1071
1268
 
1072
1269
  // GET /snapshot - Snapshot (OpenClaw format with query params)
1073
1270
  app.get('/snapshot', async (req, res) => {
1074
1271
  try {
1075
- 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
+ }
1076
1276
 
1077
1277
  const session = sessions.get(normalizeUserId(userId));
1078
1278
  const found = session && findTab(session, targetId);
@@ -1119,8 +1319,8 @@ app.get('/snapshot', async (req, res) => {
1119
1319
  refsCount: tabState.refs.size
1120
1320
  });
1121
1321
  } catch (err) {
1122
- console.error('Snapshot error:', err);
1123
- 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) });
1124
1324
  }
1125
1325
  });
1126
1326
 
@@ -1128,7 +1328,10 @@ app.get('/snapshot', async (req, res) => {
1128
1328
  // Routes to click/type/scroll/press/etc based on 'kind' parameter
1129
1329
  app.post('/act', async (req, res) => {
1130
1330
  try {
1131
- 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
+ }
1132
1335
 
1133
1336
  if (!kind) {
1134
1337
  return res.status(400).json({ error: 'kind is required' });
@@ -1252,6 +1455,7 @@ app.post('/act', async (req, res) => {
1252
1455
  case 'close': {
1253
1456
  await tabState.page.close();
1254
1457
  found.group.delete(targetId);
1458
+ tabLocks.delete(targetId);
1255
1459
  return { ok: true, targetId };
1256
1460
  }
1257
1461
 
@@ -1262,9 +1466,37 @@ app.post('/act', async (req, res) => {
1262
1466
 
1263
1467
  res.json(result);
1264
1468
  } catch (err) {
1265
- console.error('Act error:', err);
1266
- 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
+ }
1267
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) });
1268
1500
  });
1269
1501
 
1270
1502
  // Graceful shutdown
@@ -1273,10 +1505,10 @@ let shuttingDown = false;
1273
1505
  async function gracefulShutdown(signal) {
1274
1506
  if (shuttingDown) return;
1275
1507
  shuttingDown = true;
1276
- console.log(`${signal} received, shutting down...`);
1508
+ log('info', 'shutting down', { signal });
1277
1509
 
1278
1510
  const forceTimeout = setTimeout(() => {
1279
- console.error('Shutdown timed out after 10s, forcing exit');
1511
+ log('error', 'shutdown timed out, forcing exit');
1280
1512
  process.exit(1);
1281
1513
  }, 10000);
1282
1514
  forceTimeout.unref();
@@ -1293,19 +1525,19 @@ async function gracefulShutdown(signal) {
1293
1525
  process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
1294
1526
  process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1295
1527
 
1296
- const PORT = process.env.CAMOFOX_PORT || 9377;
1528
+ const PORT = process.env.CAMOFOX_PORT || process.env.PORT || 9377;
1297
1529
  const server = app.listen(PORT, () => {
1298
- console.log(`camofox-browser listening on port ${PORT}`);
1530
+ log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
1299
1531
  ensureBrowser().catch(err => {
1300
- console.error('Failed to pre-launch browser:', err.message);
1532
+ log('error', 'browser pre-launch failed', { error: err.message });
1301
1533
  });
1302
1534
  });
1303
1535
 
1304
1536
  server.on('error', (err) => {
1305
1537
  if (err.code === 'EADDRINUSE') {
1306
- 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 });
1307
1539
  process.exit(1);
1308
1540
  }
1309
- console.error('Server error:', err);
1541
+ log('error', 'server error', { error: err.message });
1310
1542
  process.exit(1);
1311
1543
  });