@askjo/camofox-browser 1.1.2 → 1.2.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/Dockerfile CHANGED
@@ -57,4 +57,4 @@ ENV CAMOFOX_PORT=3000
57
57
 
58
58
  EXPOSE 3000
59
59
 
60
- CMD ["node", "server.js"]
60
+ CMD ["sh", "-c", "node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js"]
package/README.md CHANGED
@@ -32,6 +32,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
32
32
  - **C++ Anti-Detection** - bypasses Google, Cloudflare, and most bot detection
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
+ - **Runs on Anything** - lazy browser launch + idle shutdown keeps memory at ~40MB when idle. Designed to share a box with the rest of your stack — Raspberry Pi, $5 VPS, shared Railway infra.
35
36
  - **Session Isolation** - separate cookies/storage per user
36
37
  - **Cookie Import** - inject Netscape-format cookie files for authenticated browsing
37
38
  - **Proxy + GeoIP** - route traffic through residential proxies with automatic locale/timezone
@@ -294,6 +295,13 @@ Reddit macros return JSON directly (no HTML parsing needed):
294
295
  | `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | - |
295
296
  | `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | - |
296
297
  | `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
298
+ | `MAX_SESSIONS` | Max concurrent browser sessions | `50` |
299
+ | `MAX_TABS_PER_SESSION` | Max tabs per session | `10` |
300
+ | `SESSION_TIMEOUT_MS` | Session inactivity timeout | `1800000` (30min) |
301
+ | `BROWSER_IDLE_TIMEOUT_MS` | Kill browser when idle (0 = never) | `300000` (5min) |
302
+ | `HANDLER_TIMEOUT_MS` | Max time for any handler | `30000` (30s) |
303
+ | `MAX_CONCURRENT_PER_USER` | Concurrent request cap per user | `3` |
304
+ | `MAX_OLD_SPACE_SIZE` | Node.js V8 heap limit (MB) | `128` |
297
305
  | `PROXY_HOST` | Proxy hostname or IP | - |
298
306
  | `PROXY_PORT` | Proxy port | - |
299
307
  | `PROXY_USERNAME` | Proxy auth username | - |
@@ -311,7 +319,7 @@ Browser Instance (Camoufox)
311
319
  └── Tab (amazon.com)
312
320
  ```
313
321
 
314
- Sessions auto-expire after 30 minutes of inactivity.
322
+ Sessions auto-expire after 30 minutes of inactivity. The browser itself shuts down after 5 minutes with no active sessions, and relaunches on the next request.
315
323
 
316
324
  ## Testing
317
325
 
package/lib/config.js CHANGED
@@ -15,6 +15,8 @@ function loadConfig() {
15
15
  adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
16
16
  apiKey: process.env.CAMOFOX_API_KEY || '',
17
17
  cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
18
+ handlerTimeoutMs: parseInt(process.env.HANDLER_TIMEOUT_MS) || 30000,
19
+ maxConcurrentPerUser: parseInt(process.env.MAX_CONCURRENT_PER_USER) || 3,
18
20
  proxy: {
19
21
  host: process.env.PROXY_HOST || '',
20
22
  port: process.env.PROXY_PORT || '',
package/lib/launcher.js CHANGED
@@ -14,12 +14,14 @@ const startProcess = cp.spawn;
14
14
  * @param {string} opts.pluginDir - Directory containing server.js
15
15
  * @param {number} opts.port - Port number for the server
16
16
  * @param {object} opts.env - Environment variables to pass to the subprocess
17
+ * @param {string[]} [opts.nodeArgs] - Extra Node.js CLI flags (e.g. --max-old-space-size=128)
17
18
  * @param {{ info: (msg: string) => void, error: (msg: string) => void }} opts.log - Logger
18
19
  * @returns {import('child_process').ChildProcess}
19
20
  */
20
- function launchServer({ pluginDir, port, env, log }) {
21
+ function launchServer({ pluginDir, port, env, nodeArgs, log }) {
21
22
  const serverPath = join(pluginDir, 'server.js');
22
- const proc = startProcess('node', [serverPath], {
23
+ const args = [...(nodeArgs || []), serverPath];
24
+ const proc = startProcess('node', args, {
23
25
  cwd: pluginDir,
24
26
  env: {
25
27
  ...env,
@@ -2,7 +2,7 @@
2
2
  "id": "camofox-browser",
3
3
  "name": "Camofox Browser",
4
4
  "description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
5
- "version": "1.0.11",
5
+ "version": "1.0.12",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "properties": {
@@ -19,6 +19,31 @@
19
19
  "type": "boolean",
20
20
  "description": "Auto-start the camofox-browser server with the Gateway",
21
21
  "default": true
22
+ },
23
+ "maxSessions": {
24
+ "type": "number",
25
+ "description": "Maximum concurrent browser sessions (server default: 50)",
26
+ "default": 5
27
+ },
28
+ "maxTabsPerSession": {
29
+ "type": "number",
30
+ "description": "Maximum tabs per session (server default: 10)",
31
+ "default": 3
32
+ },
33
+ "sessionTimeoutMs": {
34
+ "type": "number",
35
+ "description": "Session inactivity timeout in milliseconds (server default: 1800000)",
36
+ "default": 600000
37
+ },
38
+ "browserIdleTimeoutMs": {
39
+ "type": "number",
40
+ "description": "Kill browser after this many ms with no sessions (0 = never)",
41
+ "default": 300000
42
+ },
43
+ "maxOldSpaceSize": {
44
+ "type": "number",
45
+ "description": "Node.js V8 heap limit in MB",
46
+ "default": 128
22
47
  }
23
48
  },
24
49
  "additionalProperties": false
@@ -34,6 +59,26 @@
34
59
  },
35
60
  "autoStart": {
36
61
  "label": "Auto-start server with Gateway"
62
+ },
63
+ "maxSessions": {
64
+ "label": "Max Sessions",
65
+ "placeholder": "5"
66
+ },
67
+ "maxTabsPerSession": {
68
+ "label": "Max Tabs per Session",
69
+ "placeholder": "3"
70
+ },
71
+ "sessionTimeoutMs": {
72
+ "label": "Session Timeout (ms)",
73
+ "placeholder": "600000"
74
+ },
75
+ "browserIdleTimeoutMs": {
76
+ "label": "Browser Idle Timeout (ms)",
77
+ "placeholder": "300000"
78
+ },
79
+ "maxOldSpaceSize": {
80
+ "label": "Node Heap Limit (MB)",
81
+ "placeholder": "128"
37
82
  }
38
83
  }
39
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askjo/camofox-browser",
3
- "version": "1.1.2",
3
+ "version": "1.2.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
@@ -29,6 +29,11 @@ interface PluginConfig {
29
29
  url?: string;
30
30
  autoStart?: boolean;
31
31
  port?: number;
32
+ maxSessions?: number;
33
+ maxTabsPerSession?: number;
34
+ sessionTimeoutMs?: number;
35
+ browserIdleTimeoutMs?: number;
36
+ maxOldSpaceSize?: number;
32
37
  }
33
38
 
34
39
  interface ToolResult {
@@ -109,10 +114,16 @@ let serverProcess: ChildProcess | null = null;
109
114
  async function startServer(
110
115
  pluginDir: string,
111
116
  port: number,
112
- log: PluginApi["log"]
117
+ log: PluginApi["log"],
118
+ pluginCfg?: PluginConfig
113
119
  ): Promise<ChildProcess> {
114
120
  const cfg = loadConfig();
115
- const proc = launchServer({ pluginDir, port, env: cfg.serverEnv, log });
121
+ const env: Record<string, string> = { ...cfg.serverEnv };
122
+ if (pluginCfg?.maxSessions != null) env.MAX_SESSIONS = String(pluginCfg.maxSessions);
123
+ if (pluginCfg?.maxTabsPerSession != null) env.MAX_TABS_PER_SESSION = String(pluginCfg.maxTabsPerSession);
124
+ if (pluginCfg?.sessionTimeoutMs != null) env.SESSION_TIMEOUT_MS = String(pluginCfg.sessionTimeoutMs);
125
+ if (pluginCfg?.browserIdleTimeoutMs != null) env.BROWSER_IDLE_TIMEOUT_MS = String(pluginCfg.browserIdleTimeoutMs);
126
+ const proc = launchServer({ pluginDir, port, env, log, nodeArgs: pluginCfg?.maxOldSpaceSize != null ? [`--max-old-space-size=${pluginCfg.maxOldSpaceSize}`] : undefined });
116
127
 
117
128
  proc.on("error", (err: Error) => {
118
129
  log?.error?.(`Server process error: ${err.message}`);
@@ -194,7 +205,7 @@ export default function register(api: PluginApi) {
194
205
  api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
195
206
  } else {
196
207
  try {
197
- serverProcess = await startServer(pluginDir, port, api.log);
208
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
198
209
  } catch (err) {
199
210
  api.log?.error?.(`Failed to auto-start server: ${(err as Error).message}`);
200
211
  }
@@ -499,7 +510,7 @@ export default function register(api: PluginApi) {
499
510
  return;
500
511
  }
501
512
  try {
502
- serverProcess = await startServer(pluginDir, port, api.log);
513
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
503
514
  } catch (err) {
504
515
  api.log?.error?.(`Failed to start server: ${(err as Error).message}`);
505
516
  }
@@ -622,7 +633,7 @@ export default function register(api: PluginApi) {
622
633
  }
623
634
  try {
624
635
  console.log(`Starting camofox server on port ${port}...`);
625
- serverProcess = await startServer(pluginDir, port, api.log);
636
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
626
637
  console.log(`Camoufox server started at ${baseUrl}`);
627
638
  } catch (err) {
628
639
  console.error(`Failed to start server: ${(err as Error).message}`);
package/run.sh CHANGED
@@ -32,6 +32,6 @@ fi
32
32
 
33
33
  echo "Starting camofox-browser on http://localhost:$CAMOFOX_PORT (with auto-reload)"
34
34
  echo "Logs: /tmp/camofox-browser.log"
35
- nodemon --watch server.js --exec "node server.js" 2>&1 | while IFS= read -r line; do
35
+ nodemon --watch server.js --exec "node --max-old-space-size=128 server.js" 2>&1 | while IFS= read -r line; do
36
36
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"
37
37
  done | tee -a /tmp/camofox-browser.log
package/server.js CHANGED
@@ -46,6 +46,19 @@ app.use((req, res, next) => {
46
46
 
47
47
  const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
48
48
 
49
+ // Interactive roles to include - exclude combobox to avoid opening complex widgets
50
+ // (date pickers, dropdowns) that can interfere with navigation
51
+ const INTERACTIVE_ROLES = [
52
+ 'button', 'link', 'textbox', 'checkbox', 'radio',
53
+ 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
54
+ // 'combobox' excluded - can trigger date pickers and complex dropdowns
55
+ ];
56
+
57
+ // Patterns to skip (date pickers, calendar widgets)
58
+ const SKIP_PATTERNS = [
59
+ /date/i, /calendar/i, /picker/i, /datepicker/i
60
+ ];
61
+
49
62
  function timingSafeCompare(a, b) {
50
63
  if (typeof a !== 'string' || typeof b !== 'string') return false;
51
64
  const bufA = Buffer.from(a);
@@ -158,10 +171,13 @@ let browser = null;
158
171
  // Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
159
172
  const sessions = new Map();
160
173
 
161
- const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
174
+ const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS) || 1800000; // 30 min
162
175
  const MAX_SNAPSHOT_NODES = 500;
163
- const MAX_SESSIONS = 50;
164
- const MAX_TABS_PER_SESSION = 10;
176
+ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 50;
177
+ const MAX_TABS_PER_SESSION = parseInt(process.env.MAX_TABS_PER_SESSION) || 10;
178
+ const HANDLER_TIMEOUT_MS = parseInt(process.env.HANDLER_TIMEOUT_MS) || 30000;
179
+ const MAX_CONCURRENT_PER_USER = parseInt(process.env.MAX_CONCURRENT_PER_USER) || 3;
180
+ const PAGE_CLOSE_TIMEOUT_MS = 5000;
165
181
 
166
182
  // Per-tab locks to serialize operations on the same tab
167
183
  // tabId -> Promise (the currently executing operation)
@@ -192,6 +208,56 @@ async function withTabLock(tabId, operation) {
192
208
  }
193
209
  }
194
210
 
211
+ function withTimeout(promise, ms, label) {
212
+ return Promise.race([
213
+ promise,
214
+ new Promise((_, reject) =>
215
+ setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
216
+ )
217
+ ]);
218
+ }
219
+
220
+ const userConcurrency = new Map();
221
+
222
+ async function withUserLimit(userId, operation) {
223
+ const key = normalizeUserId(userId);
224
+ let state = userConcurrency.get(key);
225
+ if (!state) {
226
+ state = { active: 0, queue: [] };
227
+ userConcurrency.set(key, state);
228
+ }
229
+ if (state.active >= MAX_CONCURRENT_PER_USER) {
230
+ await new Promise((resolve, reject) => {
231
+ const timer = setTimeout(() => reject(new Error('User concurrency limit reached, try again')), 30000);
232
+ state.queue.push(() => { clearTimeout(timer); resolve(); });
233
+ });
234
+ }
235
+ state.active++;
236
+ try {
237
+ return await operation();
238
+ } finally {
239
+ state.active--;
240
+ if (state.queue.length > 0) {
241
+ const next = state.queue.shift();
242
+ next();
243
+ }
244
+ if (state.active === 0 && state.queue.length === 0) {
245
+ userConcurrency.delete(key);
246
+ }
247
+ }
248
+ }
249
+
250
+ async function safePageClose(page) {
251
+ try {
252
+ await Promise.race([
253
+ page.close(),
254
+ new Promise(resolve => setTimeout(resolve, PAGE_CLOSE_TIMEOUT_MS))
255
+ ]);
256
+ } catch (e) {
257
+ log('warn', 'page close failed', { error: e.message });
258
+ }
259
+ }
260
+
195
261
  // Detect host OS for fingerprint generation
196
262
  function getHostOS() {
197
263
  const platform = os.platform();
@@ -216,26 +282,70 @@ function buildProxyConfig() {
216
282
  };
217
283
  }
218
284
 
285
+ const BROWSER_IDLE_TIMEOUT_MS = parseInt(process.env.BROWSER_IDLE_TIMEOUT_MS) || 300000; // 5 min
286
+ let browserIdleTimer = null;
287
+ let browserLaunchPromise = null;
288
+
289
+ function scheduleBrowserIdleShutdown() {
290
+ clearBrowserIdleTimer();
291
+ if (sessions.size === 0 && browser) {
292
+ browserIdleTimer = setTimeout(async () => {
293
+ if (sessions.size === 0 && browser) {
294
+ log('info', 'browser idle shutdown (no sessions)');
295
+ const b = browser;
296
+ browser = null;
297
+ await b.close().catch(() => {});
298
+ }
299
+ }, BROWSER_IDLE_TIMEOUT_MS);
300
+ }
301
+ }
302
+
303
+ function clearBrowserIdleTimer() {
304
+ if (browserIdleTimer) {
305
+ clearTimeout(browserIdleTimer);
306
+ browserIdleTimer = null;
307
+ }
308
+ }
309
+
310
+ async function launchBrowserInstance() {
311
+ const hostOS = getHostOS();
312
+ const proxy = buildProxyConfig();
313
+
314
+ log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
315
+
316
+ const options = await launchOptions({
317
+ headless: true,
318
+ os: hostOS,
319
+ humanize: true,
320
+ enable_cache: true,
321
+ proxy: proxy,
322
+ geoip: !!proxy,
323
+ });
324
+
325
+ browser = await firefox.launch(options);
326
+ log('info', 'camoufox launched');
327
+ return browser;
328
+ }
329
+
219
330
  async function ensureBrowser() {
220
- if (!browser) {
221
- const hostOS = getHostOS();
222
- const proxy = buildProxyConfig();
223
-
224
- log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
225
-
226
- const options = await launchOptions({
227
- headless: true,
228
- os: hostOS,
229
- humanize: true,
230
- enable_cache: true,
231
- proxy: proxy,
232
- geoip: !!proxy,
331
+ clearBrowserIdleTimer();
332
+ if (browser && !browser.isConnected()) {
333
+ log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
334
+ deadSessions: sessions.size,
233
335
  });
234
-
235
- browser = await firefox.launch(options);
236
- log('info', 'camoufox launched');
336
+ for (const [userId, session] of sessions) {
337
+ await session.context.close().catch(() => {});
338
+ }
339
+ sessions.clear();
340
+ browser = null;
237
341
  }
238
- return browser;
342
+ if (browser) return browser;
343
+ if (browserLaunchPromise) return browserLaunchPromise;
344
+ browserLaunchPromise = Promise.race([
345
+ launchBrowserInstance(),
346
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Browser launch timeout (30s)')), 30000)),
347
+ ]).finally(() => { browserLaunchPromise = null; });
348
+ return browserLaunchPromise;
239
349
  }
240
350
 
241
351
  // Helper to normalize userId to string (JSON body may parse as number)
@@ -404,33 +514,18 @@ async function buildRefs(page) {
404
514
  // inject a script to collect shadow DOM elements for additional coverage
405
515
  let ariaYaml;
406
516
  try {
407
- ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
517
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 5000 });
408
518
  } catch (err) {
409
519
  log('warn', 'ariaSnapshot failed, retrying');
410
- await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
411
- ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 10000 });
520
+ try {
521
+ await page.waitForLoadState('load', { timeout: 3000 }).catch(() => {});
522
+ ariaYaml = await page.locator('body').ariaSnapshot({ timeout: 5000 });
523
+ } catch (retryErr) {
524
+ log('warn', 'ariaSnapshot retry failed, returning empty refs', { error: retryErr.message });
525
+ return refs;
526
+ }
412
527
  }
413
528
 
414
- // Collect additional interactive elements from shadow DOM
415
- const shadowElements = await page.evaluate(() => {
416
- const elements = [];
417
- const collectFromShadow = (root, depth = 0) => {
418
- if (depth > 5) return; // Limit recursion
419
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
420
- while (walker.nextNode()) {
421
- const el = walker.currentNode;
422
- if (el.shadowRoot) {
423
- collectFromShadow(el.shadowRoot, depth + 1);
424
- }
425
- }
426
- };
427
- // Start collection from all shadow roots
428
- document.querySelectorAll('*').forEach(el => {
429
- if (el.shadowRoot) collectFromShadow(el.shadowRoot);
430
- });
431
- return elements;
432
- }).catch(() => []);
433
-
434
529
  if (!ariaYaml) {
435
530
  log('warn', 'buildRefs: no aria snapshot');
436
531
  return refs;
@@ -439,19 +534,6 @@ async function buildRefs(page) {
439
534
  const lines = ariaYaml.split('\n');
440
535
  let refCounter = 1;
441
536
 
442
- // Interactive roles to include - exclude combobox to avoid opening complex widgets
443
- // (date pickers, dropdowns) that can interfere with navigation
444
- const interactiveRoles = [
445
- 'button', 'link', 'textbox', 'checkbox', 'radio',
446
- 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
447
- // 'combobox' excluded - can trigger date pickers and complex dropdowns
448
- ];
449
-
450
- // Patterns to skip (date pickers, calendar widgets)
451
- const skipPatterns = [
452
- /date/i, /calendar/i, /picker/i, /datepicker/i
453
- ];
454
-
455
537
  // Track occurrences of each role+name combo for nth disambiguation
456
538
  const seenCounts = new Map(); // "role:name" -> count
457
539
 
@@ -463,13 +545,11 @@ async function buildRefs(page) {
463
545
  const [, role, name] = match;
464
546
  const normalizedRole = role.toLowerCase();
465
547
 
466
- // Skip combobox role entirely (date pickers, complex dropdowns)
467
548
  if (normalizedRole === 'combobox') continue;
468
549
 
469
- // Skip elements with date/calendar-related names
470
- if (name && skipPatterns.some(p => p.test(name))) continue;
550
+ if (name && SKIP_PATTERNS.some(p => p.test(name))) continue;
471
551
 
472
- if (interactiveRoles.includes(normalizedRole)) {
552
+ if (INTERACTIVE_ROLES.includes(normalizedRole)) {
473
553
  const normalizedName = name || '';
474
554
  const key = `${normalizedRole}:${normalizedName}`;
475
555
 
@@ -491,7 +571,12 @@ async function getAriaSnapshot(page) {
491
571
  return null;
492
572
  }
493
573
  await waitForPageReady(page, { waitForNetwork: false });
494
- return await page.locator('body').ariaSnapshot({ timeout: 10000 });
574
+ try {
575
+ return await page.locator('body').ariaSnapshot({ timeout: 5000 });
576
+ } catch (err) {
577
+ log('warn', 'getAriaSnapshot failed', { error: err.message });
578
+ return null;
579
+ }
495
580
  }
496
581
 
497
582
  function refToLocator(page, ref, refs) {
@@ -508,18 +593,16 @@ function refToLocator(page, ref, refs) {
508
593
  return locator;
509
594
  }
510
595
 
511
- // Health check
512
- app.get('/health', async (req, res) => {
513
- try {
514
- const b = await ensureBrowser();
515
- res.json({
516
- ok: true,
517
- engine: 'camoufox',
518
- browserConnected: b.isConnected()
519
- });
520
- } catch (err) {
521
- res.status(500).json({ ok: false, error: safeError(err) });
522
- }
596
+ // Health check (passive — does not launch browser)
597
+ app.get('/health', (req, res) => {
598
+ const running = browser !== null && (browser.isConnected?.() ?? false);
599
+ res.json({
600
+ ok: true,
601
+ engine: 'camoufox',
602
+ browserConnected: running,
603
+ browserRunning: running,
604
+ sessions: sessions.size,
605
+ });
523
606
  });
524
607
 
525
608
  // Create new tab
@@ -567,33 +650,50 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
567
650
  const tabId = req.params.tabId;
568
651
 
569
652
  try {
570
- const { userId, url, macro, query } = req.body;
571
- const session = sessions.get(normalizeUserId(userId));
572
- const found = session && findTab(session, tabId);
573
- if (!found) return res.status(404).json({ error: 'Tab not found' });
574
-
575
- const { tabState } = found;
576
- tabState.toolCalls++;
577
-
578
- let targetUrl = url;
579
- if (macro) {
580
- targetUrl = expandMacro(macro, query) || url;
581
- }
582
-
583
- if (!targetUrl) {
584
- return res.status(400).json({ error: 'url or macro required' });
585
- }
586
-
587
- const urlErr = validateUrl(targetUrl);
588
- if (urlErr) return res.status(400).json({ error: urlErr });
589
-
590
- // Serialize navigation operations on the same tab
591
- const result = await withTabLock(tabId, async () => {
592
- await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
593
- tabState.visitedUrls.add(targetUrl);
594
- tabState.refs = await buildRefs(tabState.page);
595
- return { ok: true, url: tabState.page.url() };
596
- });
653
+ const { userId, url, macro, query, sessionKey, listItemId } = req.body;
654
+ if (!userId) return res.status(400).json({ error: 'userId required' });
655
+
656
+ const result = await withUserLimit(userId, () => withTimeout((async () => {
657
+ await ensureBrowser();
658
+ let session = sessions.get(normalizeUserId(userId));
659
+ let found = session && findTab(session, tabId);
660
+
661
+ if (!found) {
662
+ const resolvedSessionKey = sessionKey || listItemId || 'default';
663
+ session = await getSession(userId);
664
+ let totalTabs = 0;
665
+ for (const g of session.tabGroups.values()) totalTabs += g.size;
666
+ if (totalTabs >= MAX_TABS_PER_SESSION) {
667
+ throw new Error('Maximum tabs per session reached');
668
+ }
669
+ const page = await session.context.newPage();
670
+ const newTabState = createTabState(page);
671
+ const group = getTabGroup(session, resolvedSessionKey);
672
+ group.set(tabId, newTabState);
673
+ found = { tabState: newTabState, listItemId: resolvedSessionKey, group };
674
+ log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
675
+ }
676
+
677
+ const { tabState } = found;
678
+ tabState.toolCalls++;
679
+
680
+ let targetUrl = url;
681
+ if (macro) {
682
+ targetUrl = expandMacro(macro, query) || url;
683
+ }
684
+
685
+ if (!targetUrl) throw new Error('url or macro required');
686
+
687
+ const urlErr = validateUrl(targetUrl);
688
+ if (urlErr) throw new Error(urlErr);
689
+
690
+ return await withTabLock(tabId, async () => {
691
+ await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
692
+ tabState.visitedUrls.add(targetUrl);
693
+ tabState.refs = await buildRefs(tabState.page);
694
+ return { ok: true, tabId, url: tabState.page.url() };
695
+ });
696
+ })(), HANDLER_TIMEOUT_MS, 'navigate'));
597
697
 
598
698
  log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
599
699
  res.json(result);
@@ -607,6 +707,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
607
707
  app.get('/tabs/:tabId/snapshot', async (req, res) => {
608
708
  try {
609
709
  const userId = req.query.userId;
710
+ if (!userId) return res.status(400).json({ error: 'userId required' });
610
711
  const format = req.query.format || 'text';
611
712
  const session = sessions.get(normalizeUserId(userId));
612
713
  const found = session && findTab(session, req.params.tabId);
@@ -614,63 +715,52 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
614
715
 
615
716
  const { tabState } = found;
616
717
  tabState.toolCalls++;
617
- tabState.refs = await buildRefs(tabState.page);
618
-
619
- const ariaYaml = await getAriaSnapshot(tabState.page);
620
-
621
- // Annotate YAML with ref IDs for interactive elements
622
- let annotatedYaml = ariaYaml || '';
623
- if (annotatedYaml && tabState.refs.size > 0) {
624
- // Build a map of role+name -> refId for annotation
625
- const refsByKey = new Map();
626
- const seenCounts = new Map();
627
- for (const [refId, info] of tabState.refs) {
628
- const key = `${info.role}:${info.name}:${info.nth}`;
629
- refsByKey.set(key, refId);
630
- }
631
-
632
- // Track occurrences while annotating
633
- const annotationCounts = new Map();
634
- const lines = annotatedYaml.split('\n');
635
- // Must match buildRefs - excludes combobox to avoid date pickers/complex dropdowns
636
- const interactiveRoles = [
637
- 'button', 'link', 'textbox', 'checkbox', 'radio',
638
- 'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
639
- ];
640
- const skipPatterns = [/date/i, /calendar/i, /picker/i, /datepicker/i];
718
+
719
+ const result = await withUserLimit(userId, () => withTimeout((async () => {
720
+ tabState.refs = await buildRefs(tabState.page);
721
+ const ariaYaml = await getAriaSnapshot(tabState.page);
641
722
 
642
- annotatedYaml = lines.map(line => {
643
- const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
644
- if (match) {
645
- const [, prefix, role, nameMatch, name, suffix] = match;
646
- const normalizedRole = role.toLowerCase();
647
-
648
- // Skip combobox and date-related elements (same as buildRefs)
649
- if (normalizedRole === 'combobox') return line;
650
- if (name && skipPatterns.some(p => p.test(name))) return line;
651
-
652
- if (interactiveRoles.includes(normalizedRole)) {
653
- const normalizedName = name || '';
654
- const countKey = `${normalizedRole}:${normalizedName}`;
655
- const nth = annotationCounts.get(countKey) || 0;
656
- annotationCounts.set(countKey, nth + 1);
657
-
658
- const key = `${normalizedRole}:${normalizedName}:${nth}`;
659
- const refId = refsByKey.get(key);
660
- if (refId) {
661
- return `${prefix}${role}${nameMatch || ''} [${refId}]${suffix}`;
723
+ let annotatedYaml = ariaYaml || '';
724
+ if (annotatedYaml && tabState.refs.size > 0) {
725
+ const refsByKey = new Map();
726
+ for (const [refId, info] of tabState.refs) {
727
+ const key = `${info.role}:${info.name}:${info.nth}`;
728
+ refsByKey.set(key, refId);
729
+ }
730
+
731
+ const annotationCounts = new Map();
732
+ const lines = annotatedYaml.split('\n');
733
+
734
+ annotatedYaml = lines.map(line => {
735
+ const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
736
+ if (match) {
737
+ const [, prefix, role, nameMatch, name, suffix] = match;
738
+ const normalizedRole = role.toLowerCase();
739
+ if (normalizedRole === 'combobox') return line;
740
+ if (name && SKIP_PATTERNS.some(p => p.test(name))) return line;
741
+ if (INTERACTIVE_ROLES.includes(normalizedRole)) {
742
+ const normalizedName = name || '';
743
+ const countKey = `${normalizedRole}:${normalizedName}`;
744
+ const nth = annotationCounts.get(countKey) || 0;
745
+ annotationCounts.set(countKey, nth + 1);
746
+ const key = `${normalizedRole}:${normalizedName}:${nth}`;
747
+ const refId = refsByKey.get(key);
748
+ if (refId) {
749
+ return `${prefix}${role}${nameMatch || ''} [${refId}]${suffix}`;
750
+ }
662
751
  }
663
752
  }
664
- }
665
- return line;
666
- }).join('\n');
667
- }
668
-
669
- const result = {
670
- url: tabState.page.url(),
671
- snapshot: annotatedYaml,
672
- refsCount: tabState.refs.size
673
- };
753
+ return line;
754
+ }).join('\n');
755
+ }
756
+
757
+ return {
758
+ url: tabState.page.url(),
759
+ snapshot: annotatedYaml,
760
+ refsCount: tabState.refs.size
761
+ };
762
+ })(), HANDLER_TIMEOUT_MS, 'snapshot'));
763
+
674
764
  log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount });
675
765
  res.json(result);
676
766
  } catch (err) {
@@ -703,6 +793,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
703
793
 
704
794
  try {
705
795
  const { userId, ref, selector } = req.body;
796
+ if (!userId) return res.status(400).json({ error: 'userId required' });
706
797
  const session = sessions.get(normalizeUserId(userId));
707
798
  const found = session && findTab(session, tabId);
708
799
  if (!found) return res.status(404).json({ error: 'Tab not found' });
@@ -714,7 +805,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
714
805
  return res.status(400).json({ error: 'ref or selector required' });
715
806
  }
716
807
 
717
- const result = await withTabLock(tabId, async () => {
808
+ const result = await withUserLimit(userId, () => withTimeout(withTabLock(tabId, async () => {
718
809
  // Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
719
810
  // Dispatches: mouseover → mouseenter → mousedown → mouseup → click
720
811
  const dispatchMouseSequence = async (locator) => {
@@ -780,7 +871,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
780
871
  const newUrl = tabState.page.url();
781
872
  tabState.visitedUrls.add(newUrl);
782
873
  return { ok: true, url: newUrl };
783
- });
874
+ }), HANDLER_TIMEOUT_MS, 'click'));
784
875
 
785
876
  log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
786
877
  res.json(result);
@@ -883,11 +974,11 @@ app.post('/tabs/:tabId/back', async (req, res) => {
883
974
  const { tabState } = found;
884
975
  tabState.toolCalls++;
885
976
 
886
- const result = await withTabLock(tabId, async () => {
977
+ const result = await withTimeout(withTabLock(tabId, async () => {
887
978
  await tabState.page.goBack({ timeout: 10000 });
888
979
  tabState.refs = await buildRefs(tabState.page);
889
980
  return { ok: true, url: tabState.page.url() };
890
- });
981
+ }), HANDLER_TIMEOUT_MS, 'back');
891
982
 
892
983
  res.json(result);
893
984
  } catch (err) {
@@ -909,11 +1000,11 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
909
1000
  const { tabState } = found;
910
1001
  tabState.toolCalls++;
911
1002
 
912
- const result = await withTabLock(tabId, async () => {
1003
+ const result = await withTimeout(withTabLock(tabId, async () => {
913
1004
  await tabState.page.goForward({ timeout: 10000 });
914
1005
  tabState.refs = await buildRefs(tabState.page);
915
1006
  return { ok: true, url: tabState.page.url() };
916
- });
1007
+ }), HANDLER_TIMEOUT_MS, 'forward');
917
1008
 
918
1009
  res.json(result);
919
1010
  } catch (err) {
@@ -935,11 +1026,11 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
935
1026
  const { tabState } = found;
936
1027
  tabState.toolCalls++;
937
1028
 
938
- const result = await withTabLock(tabId, async () => {
1029
+ const result = await withTimeout(withTabLock(tabId, async () => {
939
1030
  await tabState.page.reload({ timeout: 30000 });
940
1031
  tabState.refs = await buildRefs(tabState.page);
941
1032
  return { ok: true, url: tabState.page.url() };
942
- });
1033
+ }), HANDLER_TIMEOUT_MS, 'refresh');
943
1034
 
944
1035
  res.json(result);
945
1036
  } catch (err) {
@@ -1039,7 +1130,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
1039
1130
  const session = sessions.get(normalizeUserId(userId));
1040
1131
  const found = session && findTab(session, req.params.tabId);
1041
1132
  if (found) {
1042
- await found.tabState.page.close();
1133
+ await safePageClose(found.tabState.page);
1043
1134
  found.group.delete(req.params.tabId);
1044
1135
  tabLocks.delete(req.params.tabId);
1045
1136
  if (found.group.size === 0) {
@@ -1062,7 +1153,7 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
1062
1153
  const group = session?.tabGroups.get(req.params.listItemId);
1063
1154
  if (group) {
1064
1155
  for (const [tabId, tabState] of group) {
1065
- await tabState.page.close().catch(() => {});
1156
+ await safePageClose(tabState.page);
1066
1157
  tabLocks.delete(tabId);
1067
1158
  }
1068
1159
  session.tabGroups.delete(req.params.listItemId);
@@ -1085,6 +1176,7 @@ app.delete('/sessions/:userId', async (req, res) => {
1085
1176
  sessions.delete(userId);
1086
1177
  log('info', 'session closed', { userId });
1087
1178
  }
1179
+ if (sessions.size === 0) scheduleBrowserIdleShutdown();
1088
1180
  res.json({ ok: true });
1089
1181
  } catch (err) {
1090
1182
  log('error', 'session close failed', { error: err.message });
@@ -1102,6 +1194,10 @@ setInterval(() => {
1102
1194
  log('info', 'session expired', { userId });
1103
1195
  }
1104
1196
  }
1197
+ // When all sessions gone, start idle timer to kill browser
1198
+ if (sessions.size === 0) {
1199
+ scheduleBrowserIdleShutdown();
1200
+ }
1105
1201
  }, 60_000);
1106
1202
 
1107
1203
  // =============================================================================
@@ -1109,20 +1205,18 @@ setInterval(() => {
1109
1205
  // These allow camoufox to be used as a profile backend for OpenClaw's browser tool
1110
1206
  // =============================================================================
1111
1207
 
1112
- // GET / - Status (alias for GET /health)
1113
- app.get('/', async (req, res) => {
1114
- try {
1115
- const b = await ensureBrowser();
1116
- res.json({
1117
- ok: true,
1118
- enabled: true,
1119
- running: b.isConnected(),
1120
- engine: 'camoufox',
1121
- browserConnected: b.isConnected()
1122
- });
1123
- } catch (err) {
1124
- res.status(500).json({ ok: false, error: safeError(err) });
1125
- }
1208
+ // GET / - Status (passive does not launch browser)
1209
+ app.get('/', (req, res) => {
1210
+ const running = browser !== null && (browser.isConnected?.() ?? false);
1211
+ res.json({
1212
+ ok: true,
1213
+ enabled: true,
1214
+ running,
1215
+ engine: 'camoufox',
1216
+ browserConnected: running,
1217
+ browserRunning: running,
1218
+ sessions: sessions.size,
1219
+ });
1126
1220
  });
1127
1221
 
1128
1222
  // GET /tabs - List all tabs (OpenClaw expects this)
@@ -1252,12 +1346,12 @@ app.post('/navigate', async (req, res) => {
1252
1346
  const { tabState } = found;
1253
1347
  tabState.toolCalls++;
1254
1348
 
1255
- const result = await withTabLock(targetId, async () => {
1349
+ const result = await withTimeout(withTabLock(targetId, async () => {
1256
1350
  await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1257
1351
  tabState.visitedUrls.add(url);
1258
1352
  tabState.refs = await buildRefs(tabState.page);
1259
1353
  return { ok: true, targetId, url: tabState.page.url() };
1260
- });
1354
+ }), HANDLER_TIMEOUT_MS, 'openclaw-navigate');
1261
1355
 
1262
1356
  res.json(result);
1263
1357
  } catch (err) {
@@ -1346,7 +1440,7 @@ app.post('/act', async (req, res) => {
1346
1440
  const { tabState } = found;
1347
1441
  tabState.toolCalls++;
1348
1442
 
1349
- const result = await withTabLock(targetId, async () => {
1443
+ const result = await withTimeout(withTabLock(targetId, async () => {
1350
1444
  switch (kind) {
1351
1445
  case 'click': {
1352
1446
  const { ref, selector, doubleClick } = params;
@@ -1453,7 +1547,7 @@ app.post('/act', async (req, res) => {
1453
1547
  }
1454
1548
 
1455
1549
  case 'close': {
1456
- await tabState.page.close();
1550
+ await safePageClose(tabState.page);
1457
1551
  found.group.delete(targetId);
1458
1552
  tabLocks.delete(targetId);
1459
1553
  return { ok: true, targetId };
@@ -1462,7 +1556,7 @@ app.post('/act', async (req, res) => {
1462
1556
  default:
1463
1557
  throw new Error(`Unsupported action kind: ${kind}`);
1464
1558
  }
1465
- });
1559
+ }), HANDLER_TIMEOUT_MS, 'act');
1466
1560
 
1467
1561
  res.json(result);
1468
1562
  } catch (err) {
@@ -1528,9 +1622,7 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
1528
1622
  const PORT = CONFIG.port;
1529
1623
  const server = app.listen(PORT, () => {
1530
1624
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
1531
- ensureBrowser().catch(err => {
1532
- log('error', 'browser pre-launch failed', { error: err.message });
1533
- });
1625
+ // Browser launches lazily on first request (saves ~550MB when idle)
1534
1626
  });
1535
1627
 
1536
1628
  server.on('error', (err) => {