@askjo/camofox-browser 1.8.12 → 1.9.1

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.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // Postinstall: download Camoufox binaries and verify the cache is populated.
3
+ //
4
+ // Why a script instead of an inline `npx camoufox-js fetch`:
5
+ // 1. Cross-platform: avoids POSIX-only `VAR= cmd` shell syntax (Windows
6
+ // cmd.exe does not honor it).
7
+ // 2. Defends against PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 inherited from
8
+ // the user's shell or a CI/Docker base image. `camoufox-js` honors
9
+ // that flag by convention (same env name as `playwright`'s skip flag),
10
+ // which leaves the binary cache empty and makes the server crash at
11
+ // runtime with "Version information not found".
12
+ // 3. Verifies the cache after fetch and exits non-zero with actionable
13
+ // remediation if the binary is still missing — failing the install
14
+ // is strictly better than a silent runtime crash.
15
+
16
+ import { spawnSync } from 'node:child_process';
17
+ import { existsSync } from 'node:fs';
18
+ import { homedir, platform } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ function camoufoxCacheDir() {
22
+ const home = homedir();
23
+ const plat = platform();
24
+ if (plat === 'darwin') return join(home, 'Library', 'Caches', 'camoufox');
25
+ if (plat === 'win32') {
26
+ // Matches camoufox-js/dist/pkgman.js:246 which nests the app name twice:
27
+ // %LOCALAPPDATA%\camoufox\camoufox\Cache
28
+ const base = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
29
+ return join(base, 'camoufox', 'camoufox', 'Cache');
30
+ }
31
+ return join(process.env.XDG_CACHE_HOME || join(home, '.cache'), 'camoufox');
32
+ }
33
+
34
+ function fail(message) {
35
+ process.stderr.write(`[camofox-browser] postinstall: ${message}\n`);
36
+ process.exit(1);
37
+ }
38
+
39
+ const childEnv = { ...process.env };
40
+ delete childEnv.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD;
41
+
42
+ const isWindows = platform() === 'win32';
43
+ const result = spawnSync(isWindows ? 'npx.cmd' : 'npx', ['camoufox-js', 'fetch'], {
44
+ stdio: 'inherit',
45
+ env: childEnv,
46
+ shell: isWindows,
47
+ });
48
+
49
+ if (result.error) fail(`failed to spawn npx: ${result.error.message}`);
50
+ if (result.status !== 0) fail(`\`npx camoufox-js fetch\` exited with code ${result.status}`);
51
+
52
+ const versionFile = join(camoufoxCacheDir(), 'version.json');
53
+ if (!existsSync(versionFile)) {
54
+ process.stderr.write('[camofox-browser] postinstall: Camoufox cache not populated.\n');
55
+ process.stderr.write(` Expected file: ${versionFile}\n`);
56
+ process.stderr.write(' Possible causes:\n');
57
+ process.stderr.write(' - Network failure during binary download (check your connection)\n');
58
+ process.stderr.write(' - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD re-exported by a wrapping process\n');
59
+ process.stderr.write(' Manual fix: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD= npx camoufox-js fetch\n');
60
+ process.exit(1);
61
+ }
package/server.js CHANGED
@@ -384,6 +384,8 @@ const MAX_CONCURRENT_PER_USER = CONFIG.maxConcurrentPerUser;
384
384
  const PAGE_CLOSE_TIMEOUT_MS = 5000;
385
385
  const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
386
386
  const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
387
+ const NATIVE_MEM_RESTART_THRESHOLD_MB = CONFIG.nativeMemRestartThresholdMb;
388
+ let _nativeMemBaseline = null; // RSS - heapUsed at first idle measurement
387
389
  const FAILURE_THRESHOLD = 3;
388
390
  const MAX_CONSECUTIVE_TIMEOUTS = 3;
389
391
  const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
@@ -508,13 +510,16 @@ async function withUserLimit(userId, operation) {
508
510
  }
509
511
 
510
512
  async function safePageClose(page) {
513
+ if (!page || page.isClosed()) return;
511
514
  try {
512
515
  await Promise.race([
513
- page.close(),
514
- new Promise(resolve => setTimeout(resolve, PAGE_CLOSE_TIMEOUT_MS))
516
+ page.close({ runBeforeUnload: false }),
517
+ new Promise((_, reject) => setTimeout(() => reject(new Error('page close timed out')), PAGE_CLOSE_TIMEOUT_MS)),
515
518
  ]);
516
519
  } catch (e) {
517
- log('warn', 'page close failed', { error: e.message });
520
+ log('warn', 'page close timed out or failed, force-closing', { error: e.message });
521
+ try { await page.close({ runBeforeUnload: false }); } catch (_) {}
522
+ page.removeAllListeners();
518
523
  }
519
524
  }
520
525
 
@@ -567,6 +572,20 @@ function clearBrowserIdleTimer() {
567
572
  }
568
573
  }
569
574
 
575
+ // Detects errors that retrying cannot recover from (e.g., Camoufox binary
576
+ // missing because postinstall was skipped). The user must run
577
+ // `npx camoufox-js fetch` and restart; looping on this wastes resources
578
+ // and buries the actionable error under noise.
579
+ //
580
+ // Sentinel: matches the human-readable message thrown by camoufox-js's
581
+ // FileNotFoundError in dist/pkgman.js (Version.fromPath). FileNotFoundError
582
+ // is not exported from the public API, so substring matching is the only
583
+ // available hook. If the upstream message changes, this regex needs an
584
+ // update; the dependency range in package.json controls exposure.
585
+ function isFatalInstallError(err) {
586
+ return /Version information not found/i.test(err?.message || '');
587
+ }
588
+
570
589
  function scheduleBrowserWarmRetry(delayMs = 5000) {
571
590
  if (browserWarmRetryTimer || browser || browserLaunchPromise) return;
572
591
  browserWarmRetryTimer = setTimeout(async () => {
@@ -576,6 +595,13 @@ function scheduleBrowserWarmRetry(delayMs = 5000) {
576
595
  await ensureBrowser();
577
596
  log('info', 'background browser warm retry succeeded', { ms: Date.now() - start });
578
597
  } catch (err) {
598
+ if (isFatalInstallError(err)) {
599
+ log('error', 'browser unavailable: Camoufox binaries are not installed; aborting retry loop', {
600
+ error: err.message,
601
+ remediation: 'run `npx camoufox-js fetch` then restart the server',
602
+ });
603
+ return;
604
+ }
579
605
  log('warn', 'background browser warm retry failed', { error: err.message, nextDelayMs: delayMs });
580
606
  scheduleBrowserWarmRetry(Math.min(delayMs * 2, 30000));
581
607
  }
@@ -625,8 +651,13 @@ async function restartBrowser(reason) {
625
651
  function getTotalTabCount() {
626
652
  let total = 0;
627
653
  for (const session of sessions.values()) {
628
- for (const group of session.tabGroups.values()) {
629
- total += group.size;
654
+ try {
655
+ // Use real Playwright page count so leaked pages exert backpressure
656
+ // on MAX_TABS_GLOBAL, surfacing leaks before Firefox starves.
657
+ total += session.context.pages().length;
658
+ } catch (_) {
659
+ // Context is dead — fall back to bookkeeping count for this session.
660
+ for (const group of session.tabGroups.values()) total += group.size;
630
661
  }
631
662
  }
632
663
  return total;
@@ -734,6 +765,7 @@ async function _closeBrowserFullyImpl(reason) {
734
765
 
735
766
  // Reset native memory baseline so next browser measures from fresh
736
767
  reporter.resetNativeMemBaseline();
768
+ _nativeMemBaseline = null;
737
769
 
738
770
  // Verify cleanup: check FD/handle counts dropped (after force-kill completes)
739
771
  const postCloseFds = _countOpenFds();
@@ -993,6 +1025,12 @@ async function closeSession(userId, session, {
993
1025
 
994
1026
  const key = normalizeUserId(userId);
995
1027
 
1028
+ // Drain locks BEFORE closing context — queued operations get clean "Tab destroyed"
1029
+ // (410) instead of messy "Target page closed" (500) errors.
1030
+ if (clearLocks) {
1031
+ clearSessionLocks(session);
1032
+ }
1033
+
996
1034
  if (clearDownloads) {
997
1035
  await clearSessionDownloads(session).catch(() => {});
998
1036
  }
@@ -1011,10 +1049,6 @@ async function closeSession(userId, session, {
1011
1049
  sessions.delete(key);
1012
1050
  await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
1013
1051
 
1014
- if (clearLocks) {
1015
- clearSessionLocks(session);
1016
- }
1017
-
1018
1052
  refreshActiveTabsGauge();
1019
1053
  }
1020
1054
 
@@ -1166,8 +1200,21 @@ function handleRouteError(err, req, res, extraFields = {}) {
1166
1200
  browserRestartsTotal.labels('proxy_error').inc();
1167
1201
  destroySession(userId);
1168
1202
  }
1203
+ // Navigation-related timeouts can poison the proxy session (e.g., Cloudflare holding
1204
+ // the connection open for 30s). The browser context shares a single proxy session, so
1205
+ // one poisoned page kills all subsequent navigations in that context. Destroy the
1206
+ // entire session so the next request gets a fresh BrowserContext + proxy.
1207
+ const NAVIGATION_TIMEOUT_ACTIONS = new Set(['click', 'navigate', 'open_url']);
1208
+ if (isTimeoutError(err) && userId && NAVIGATION_TIMEOUT_ACTIONS.has(action)) {
1209
+ log('warn', 'navigation timeout — destroying session for fresh proxy', {
1210
+ action, userId, error: err.message,
1211
+ });
1212
+ browserRestartsTotal.labels('navigation_timeout').inc();
1213
+ destroySession(userId);
1214
+ }
1169
1215
  // Track consecutive timeouts per tab and auto-destroy stuck tabs
1170
- if (userId && isTimeoutError(err)) {
1216
+ // (for non-navigation timeouts like type, scroll that don't poison the proxy)
1217
+ if (userId && isTimeoutError(err) && !NAVIGATION_TIMEOUT_ACTIONS.has(action)) {
1171
1218
  const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
1172
1219
  const session = sessions.get(normalizeUserId(userId));
1173
1220
  if (session && tabId) {
@@ -1193,6 +1240,12 @@ function handleRouteError(err, req, res, extraFields = {}) {
1193
1240
  if (isTabDestroyedError(err)) {
1194
1241
  return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
1195
1242
  }
1243
+ // Dead context = session torn down (by proxy error, timeout, or reaper) while this op
1244
+ // was in flight. The ROOT CAUSE was already reported — this is a cascade error.
1245
+ // Return 503 (retriable) so the client retries with a fresh session.
1246
+ if (isDeadContextError(err)) {
1247
+ return res.status(503).json({ error: 'Browser session expired. Retry to get a fresh session.', code: 'session_expired', ...extraFields });
1248
+ }
1196
1249
  // --- Frustration detection: report when a tab hits a streak of failures ---
1197
1250
  // Individual failures are noise. 3+ consecutive = the site is persistently broken.
1198
1251
  const FRUSTRATION_TYPES = new Set(['timeout', 'dead_context', 'nav_aborted']);
@@ -2027,7 +2080,7 @@ app.post('/tabs', async (req, res) => {
2027
2080
  { statusCode: 409 },
2028
2081
  );
2029
2082
  }
2030
- const session = await getSession(userId, { trace: !!trace });
2083
+ let session = await getSession(userId, { trace: !!trace });
2031
2084
 
2032
2085
  let totalTabs = 0;
2033
2086
  for (const group of session.tabGroups.values()) totalTabs += group.size;
@@ -2044,7 +2097,7 @@ app.post('/tabs', async (req, res) => {
2044
2097
 
2045
2098
  const page = await session.context.newPage();
2046
2099
  const tabId = fly.makeTabId();
2047
- const tabState = createTabState(page);
2100
+ let tabState = createTabState(page);
2048
2101
  attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
2049
2102
  group.set(tabId, tabState);
2050
2103
  refreshActiveTabsGauge();
@@ -2053,7 +2106,32 @@ app.post('/tabs', async (req, res) => {
2053
2106
  const urlErr = validateUrl(url);
2054
2107
  if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
2055
2108
  tabState.lastRequestedUrl = url;
2056
- await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
2109
+ try {
2110
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
2111
+ } catch (navErr) {
2112
+ if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
2113
+ log('warn', 'tab create navigate failed, retrying with fresh proxy', {
2114
+ reqId: req.reqId, tabId, error: navErr.message,
2115
+ });
2116
+ browserRestartsTotal.labels('proxy_retry').inc();
2117
+ const key = normalizeUserId(userId);
2118
+ const oldSession = sessions.get(key);
2119
+ if (oldSession) {
2120
+ await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true });
2121
+ }
2122
+ session = await getSession(userId, { trace: !!trace });
2123
+ const retryGroup = getTabGroup(session, resolvedSessionKey);
2124
+ const retryPage = await session.context.newPage();
2125
+ tabState = createTabState(retryPage);
2126
+ tabState.lastRequestedUrl = url;
2127
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
2128
+ retryGroup.set(tabId, tabState);
2129
+ refreshActiveTabsGauge();
2130
+ await withPageLoadDuration('open_url', () => retryPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
2131
+ } else {
2132
+ throw navErr;
2133
+ }
2134
+ }
2057
2135
  tabState.visitedUrls.add(url);
2058
2136
  }
2059
2137
 
@@ -2228,7 +2306,24 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
2228
2306
  await prewarmGoogleHome();
2229
2307
  }
2230
2308
 
2231
- await navigateCurrentPage();
2309
+ // Navigate with transparent retry on proxy/timeout errors.
2310
+ // If the proxy is blocked or the page times out, destroy the session,
2311
+ // get a fresh proxy, and retry once before failing to the caller.
2312
+ try {
2313
+ await navigateCurrentPage();
2314
+ } catch (navErr) {
2315
+ if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
2316
+ log('warn', 'navigate failed, retrying with fresh proxy session', {
2317
+ reqId: req.reqId, tabId, error: navErr.message,
2318
+ });
2319
+ browserRestartsTotal.labels('proxy_retry').inc();
2320
+ await recreateTabOnFreshContext();
2321
+ if (isGoogleSearch) await prewarmGoogleHome();
2322
+ await navigateCurrentPage();
2323
+ } else {
2324
+ throw navErr;
2325
+ }
2326
+ }
2232
2327
 
2233
2328
  if (isGoogleSearch && proxyPool?.canRotateSessions && await isGoogleSearchBlocked(tabState.page)) {
2234
2329
  log('warn', 'google search blocked, rotating browser proxy session', {
@@ -3040,6 +3135,88 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
3040
3135
  }
3041
3136
  });
3042
3137
 
3138
+ // Viewport
3139
+ /**
3140
+ * @openapi
3141
+ * /tabs/{tabId}/viewport:
3142
+ * post:
3143
+ * tags: [Interaction]
3144
+ * summary: Set the page viewport size
3145
+ * description: >
3146
+ * Physically resizes the page via Playwright's `page.setViewportSize`,
3147
+ * triggering a real layout reflow. Use for responsive testing —
3148
+ * `window.resizeTo()` is a no-op on non-popup windows.
3149
+ * parameters:
3150
+ * - name: tabId
3151
+ * in: path
3152
+ * required: true
3153
+ * schema:
3154
+ * type: string
3155
+ * requestBody:
3156
+ * required: true
3157
+ * content:
3158
+ * application/json:
3159
+ * schema:
3160
+ * type: object
3161
+ * required: [userId, width, height]
3162
+ * properties:
3163
+ * userId:
3164
+ * type: string
3165
+ * width:
3166
+ * type: integer
3167
+ * minimum: 100
3168
+ * maximum: 4000
3169
+ * height:
3170
+ * type: integer
3171
+ * minimum: 100
3172
+ * maximum: 4000
3173
+ * responses:
3174
+ * 200:
3175
+ * description: Viewport set.
3176
+ * content:
3177
+ * application/json:
3178
+ * schema:
3179
+ * type: object
3180
+ * properties:
3181
+ * ok:
3182
+ * type: boolean
3183
+ * width:
3184
+ * type: integer
3185
+ * height:
3186
+ * type: integer
3187
+ * 400:
3188
+ * description: Width or height missing or out of range.
3189
+ * 404:
3190
+ * description: Tab not found.
3191
+ * content:
3192
+ * application/json:
3193
+ * schema:
3194
+ * $ref: '#/components/schemas/Error'
3195
+ */
3196
+ app.post('/tabs/:tabId/viewport', async (req, res) => {
3197
+ try {
3198
+ const { userId, width, height } = req.body;
3199
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 100 || height < 100 || width > 4000 || height > 4000) {
3200
+ return res.status(400).json({ error: 'width and height required (100..4000 px)' });
3201
+ }
3202
+ const session = sessions.get(normalizeUserId(userId));
3203
+ const found = session && findTab(session, req.params.tabId);
3204
+ if (!found) return res.status(404).json({ error: 'Tab not found' });
3205
+
3206
+ const { tabState } = found;
3207
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
3208
+
3209
+ await tabState.page.setViewportSize({ width: Math.round(width), height: Math.round(height) });
3210
+ await tabState.page.waitForTimeout(150);
3211
+
3212
+ pluginEvents.emit('tab:viewport', { userId, tabId: req.params.tabId, width, height });
3213
+ res.json({ ok: true, width: Math.round(width), height: Math.round(height) });
3214
+ } catch (err) {
3215
+ log('error', 'viewport failed', { reqId: req.reqId, error: err.message });
3216
+ handleRouteError(err, req, res);
3217
+ }
3218
+ });
3219
+
3043
3220
  // Back
3044
3221
  /**
3045
3222
  * @openapi
@@ -3096,7 +3273,7 @@ app.post('/tabs/:tabId/back', async (req, res) => {
3096
3273
 
3097
3274
  const result = await withTabLock(tabId, async () => {
3098
3275
  try {
3099
- await tabState.page.goBack({ timeout: 10000 });
3276
+ await tabState.page.goBack({ timeout: 20000 });
3100
3277
  } catch (navErr) {
3101
3278
  // NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
3102
3279
  // The navigation itself succeeded -- just the prior page's load was interrupted.
@@ -4289,6 +4466,64 @@ setInterval(() => {
4289
4466
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
4290
4467
  }, 60_000);
4291
4468
 
4469
+ // Orphan page reaper -- force-closes Playwright pages that survived a safePageClose
4470
+ // timeout or were otherwise dropped from tabGroups tracking. Without this, leaked
4471
+ // pages starve Firefox of DOM threads and eventually block new tab creation.
4472
+ setInterval(() => {
4473
+ let reaped = 0;
4474
+ for (const session of sessions.values()) {
4475
+ if (session._closing) continue;
4476
+ let contextPages;
4477
+ try {
4478
+ contextPages = session.context.pages();
4479
+ } catch (_) {
4480
+ continue; // context already dead
4481
+ }
4482
+ const registered = new Set();
4483
+ for (const group of session.tabGroups.values()) {
4484
+ for (const tabState of group.values()) registered.add(tabState.page);
4485
+ }
4486
+ for (const page of contextPages) {
4487
+ if (!registered.has(page)) {
4488
+ reaped++;
4489
+ page.removeAllListeners();
4490
+ page.close({ runBeforeUnload: false }).catch(() => {});
4491
+ }
4492
+ }
4493
+ }
4494
+ if (reaped > 0) log('warn', 'orphan page reaper closed leaked pages', { reaped });
4495
+ }, 60_000);
4496
+
4497
+ // Native memory pressure restart -- when all sessions are gone and the Node
4498
+ // process's native memory (RSS minus V8 heap) has grown beyond threshold, kill
4499
+ // the browser process immediately instead of waiting for the idle timer.
4500
+ // Note: This measures Node/Playwright internal state (CDP buffers, glibc arenas),
4501
+ // NOT Firefox's own memory (which is a separate child process). Firefox jemalloc
4502
+ // fragmentation is tracked separately via browser RSS in /proc/<pid>/status.
4503
+ // The restart reclaims Playwright state; Firefox's process dies with it.
4504
+ setInterval(() => {
4505
+ if (sessions.size > 0 || !browser) return;
4506
+ const mem = process.memoryUsage();
4507
+ const nativeMemMb = Math.round((mem.rss - mem.heapUsed) / 1048576);
4508
+ if (_nativeMemBaseline === null) {
4509
+ _nativeMemBaseline = nativeMemMb;
4510
+ return;
4511
+ }
4512
+ const growth = nativeMemMb - _nativeMemBaseline;
4513
+ if (growth >= NATIVE_MEM_RESTART_THRESHOLD_MB) {
4514
+ log('warn', 'native memory pressure, restarting browser', {
4515
+ baselineMb: _nativeMemBaseline,
4516
+ currentMb: nativeMemMb,
4517
+ growthMb: growth,
4518
+ thresholdMb: NATIVE_MEM_RESTART_THRESHOLD_MB,
4519
+ });
4520
+ browserRestartsTotal.labels('memory_pressure').inc();
4521
+ closeBrowserFully('memory_pressure').catch((err) => {
4522
+ log('error', 'memory pressure browser close failed', { error: err.message });
4523
+ });
4524
+ }
4525
+ }, 30_000);
4526
+
4292
4527
  // =============================================================================
4293
4528
  // OpenClaw-compatible endpoint aliases
4294
4529
  // These allow camoufox to be used as a profile backend for OpenClaw's browser tool
@@ -4453,7 +4688,7 @@ app.post('/tabs/open', async (req, res) => {
4453
4688
  const urlErr = validateUrl(url);
4454
4689
  if (urlErr) return res.status(400).json({ error: urlErr });
4455
4690
 
4456
- const session = await getSession(userId);
4691
+ let session = await getSession(userId);
4457
4692
 
4458
4693
  // Recycle oldest tab when limits are reached instead of rejecting
4459
4694
  let totalTabs = 0;
@@ -4465,16 +4700,40 @@ app.post('/tabs/open', async (req, res) => {
4465
4700
  }
4466
4701
  }
4467
4702
 
4468
- const group = getTabGroup(session, listItemId);
4703
+ let group = getTabGroup(session, listItemId);
4469
4704
 
4470
- const page = await session.context.newPage();
4705
+ let page = await session.context.newPage();
4471
4706
  const tabId = fly.makeTabId();
4472
- const tabState = createTabState(page);
4707
+ let tabState = createTabState(page);
4473
4708
  attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
4474
4709
  group.set(tabId, tabState);
4475
4710
  refreshActiveTabsGauge();
4476
4711
 
4477
- await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
4712
+ try {
4713
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
4714
+ } catch (navErr) {
4715
+ if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
4716
+ log('warn', 'tab open failed, retrying with fresh proxy', {
4717
+ reqId: req.reqId, tabId, error: navErr.message,
4718
+ });
4719
+ browserRestartsTotal.labels('proxy_retry').inc();
4720
+ const key = normalizeUserId(userId);
4721
+ const oldSession = sessions.get(key);
4722
+ if (oldSession) {
4723
+ await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true });
4724
+ }
4725
+ session = await getSession(userId);
4726
+ group = getTabGroup(session, listItemId);
4727
+ page = await session.context.newPage();
4728
+ tabState = createTabState(page);
4729
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
4730
+ group.set(tabId, tabState);
4731
+ refreshActiveTabsGauge();
4732
+ await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
4733
+ } else {
4734
+ throw navErr;
4735
+ }
4736
+ }
4478
4737
  tabState.visitedUrls.add(url);
4479
4738
 
4480
4739
  log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
@@ -5232,8 +5491,15 @@ const server = app.listen(PORT, async () => {
5232
5491
  log('info', 'browser pre-warmed', { ms: Date.now() - start });
5233
5492
  scheduleBrowserIdleShutdown();
5234
5493
  } catch (err) {
5235
- log('error', 'browser pre-warm failed (will retry in background)', { error: err.message });
5236
- scheduleBrowserWarmRetry();
5494
+ if (isFatalInstallError(err)) {
5495
+ log('error', 'browser pre-warm aborted: Camoufox binaries are not installed', {
5496
+ error: err.message,
5497
+ remediation: 'run `npx camoufox-js fetch` then restart the server',
5498
+ });
5499
+ } else {
5500
+ log('error', 'browser pre-warm failed (will retry in background)', { error: err.message });
5501
+ scheduleBrowserWarmRetry();
5502
+ }
5237
5503
  }
5238
5504
  // Idle self-shutdown removed -- Fly manages machine lifecycle via fly.toml.
5239
5505
  });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "esModuleInterop": true,
7
+ "skipLibCheck": true,
8
+ "noImplicitAny": false,
9
+ "types": ["node"]
10
+ },
11
+ "files": ["plugin.ts"]
12
+ }