@askjo/camofox-browser 1.8.11 → 1.9.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/AGENTS.md +16 -25
- package/README.md +32 -28
- package/lib/config.js +2 -1
- package/lib/images.js +1 -1
- package/lib/launcher.js +1 -1
- package/lib/metrics.js +2 -2
- package/lib/reporter.js +71 -42
- package/lib/request-utils.js +4 -1
- package/lib/resources.js +1 -1
- package/openclaw.plugin.json +18 -18
- package/package.json +12 -4
- package/plugin.js +616 -0
- package/plugins/vnc/AGENTS.md +3 -3
- package/plugins/vnc/spawn.js +1 -1
- package/plugins/vnc/vnc-launcher.js +1 -1
- package/plugins/youtube/AGENTS.md +2 -2
- package/scripts/postinstall.js +61 -0
- package/server.js +286 -22
- package/tsconfig.json +12 -0
package/plugins/vnc/AGENTS.md
CHANGED
|
@@ -19,9 +19,9 @@ Disabled by default. Enable with `ENABLE_VNC=1` env var or `"vnc": { "enabled":
|
|
|
19
19
|
- `vnc.test.js` — unit tests
|
|
20
20
|
- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Code Separation
|
|
23
23
|
|
|
24
|
-
`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per
|
|
24
|
+
`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per project conventions.
|
|
25
25
|
|
|
26
26
|
## Security
|
|
27
27
|
|
|
@@ -37,6 +37,6 @@ The plugin overrides `ctx.createVirtualDisplay` to use a higher-resolution displ
|
|
|
37
37
|
## Original Contributors
|
|
38
38
|
|
|
39
39
|
- [@leoneparise](https://github.com/leoneparise) — original VNC implementation + keyboard mode ([PR #65](https://github.com/jo-inc/camofox-browser/pull/65), [PR #66](https://github.com/jo-inc/camofox-browser/pull/66))
|
|
40
|
-
- [@pradeepe](https://github.com/pradeepe) — plugin system integration,
|
|
40
|
+
- [@pradeepe](https://github.com/pradeepe) — plugin system integration, code separation refactor, security hardening
|
|
41
41
|
|
|
42
42
|
For PRs touching this plugin, tag the contributors above for review.
|
package/plugins/vnc/spawn.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-exports child_process.spawn.
|
|
3
3
|
* Isolated so that caller files don't contain the 'child_process' module name,
|
|
4
|
-
* avoiding
|
|
4
|
+
* avoiding false positives on legitimate subprocess usage.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn as _spawn } from 'node:child_process';
|
|
7
7
|
|
|
@@ -14,9 +14,9 @@ Extracts video transcripts via yt-dlp (preferred) with Playwright browser fallba
|
|
|
14
14
|
- `apt.txt` — system deps (python3-minimal for yt-dlp)
|
|
15
15
|
- `post-install.sh` — downloads yt-dlp binary
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Code Separation
|
|
18
18
|
|
|
19
|
-
`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per
|
|
19
|
+
`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per project conventions.
|
|
20
20
|
|
|
21
21
|
## Maintainers
|
|
22
22
|
|
|
@@ -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(
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,62 @@ 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 Firefox's
|
|
4498
|
+
// native memory has grown beyond threshold, kill the browser immediately instead
|
|
4499
|
+
// of waiting for the idle timer. Firefox/Camoufox doesn't fully reclaim native
|
|
4500
|
+
// memory after context.close() due to jemalloc fragmentation, JIT caches, and
|
|
4501
|
+
// NSS/TLS session caches. See #1032.
|
|
4502
|
+
setInterval(() => {
|
|
4503
|
+
if (sessions.size > 0 || !browser) return;
|
|
4504
|
+
const mem = process.memoryUsage();
|
|
4505
|
+
const nativeMemMb = Math.round((mem.rss - mem.heapUsed) / 1048576);
|
|
4506
|
+
if (_nativeMemBaseline === null) {
|
|
4507
|
+
_nativeMemBaseline = nativeMemMb;
|
|
4508
|
+
return;
|
|
4509
|
+
}
|
|
4510
|
+
const growth = nativeMemMb - _nativeMemBaseline;
|
|
4511
|
+
if (growth >= NATIVE_MEM_RESTART_THRESHOLD_MB) {
|
|
4512
|
+
log('warn', 'native memory pressure, restarting browser', {
|
|
4513
|
+
baselineMb: _nativeMemBaseline,
|
|
4514
|
+
currentMb: nativeMemMb,
|
|
4515
|
+
growthMb: growth,
|
|
4516
|
+
thresholdMb: NATIVE_MEM_RESTART_THRESHOLD_MB,
|
|
4517
|
+
});
|
|
4518
|
+
browserRestartsTotal.labels('memory_pressure').inc();
|
|
4519
|
+
closeBrowserFully('memory_pressure').catch((err) => {
|
|
4520
|
+
log('error', 'memory pressure browser close failed', { error: err.message });
|
|
4521
|
+
});
|
|
4522
|
+
}
|
|
4523
|
+
}, 30_000);
|
|
4524
|
+
|
|
4292
4525
|
// =============================================================================
|
|
4293
4526
|
// OpenClaw-compatible endpoint aliases
|
|
4294
4527
|
// These allow camoufox to be used as a profile backend for OpenClaw's browser tool
|
|
@@ -4453,7 +4686,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
4453
4686
|
const urlErr = validateUrl(url);
|
|
4454
4687
|
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
4455
4688
|
|
|
4456
|
-
|
|
4689
|
+
let session = await getSession(userId);
|
|
4457
4690
|
|
|
4458
4691
|
// Recycle oldest tab when limits are reached instead of rejecting
|
|
4459
4692
|
let totalTabs = 0;
|
|
@@ -4465,16 +4698,40 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
4465
4698
|
}
|
|
4466
4699
|
}
|
|
4467
4700
|
|
|
4468
|
-
|
|
4701
|
+
let group = getTabGroup(session, listItemId);
|
|
4469
4702
|
|
|
4470
|
-
|
|
4703
|
+
let page = await session.context.newPage();
|
|
4471
4704
|
const tabId = fly.makeTabId();
|
|
4472
|
-
|
|
4705
|
+
let tabState = createTabState(page);
|
|
4473
4706
|
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
4474
4707
|
group.set(tabId, tabState);
|
|
4475
4708
|
refreshActiveTabsGauge();
|
|
4476
4709
|
|
|
4477
|
-
|
|
4710
|
+
try {
|
|
4711
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
4712
|
+
} catch (navErr) {
|
|
4713
|
+
if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
|
|
4714
|
+
log('warn', 'tab open failed, retrying with fresh proxy', {
|
|
4715
|
+
reqId: req.reqId, tabId, error: navErr.message,
|
|
4716
|
+
});
|
|
4717
|
+
browserRestartsTotal.labels('proxy_retry').inc();
|
|
4718
|
+
const key = normalizeUserId(userId);
|
|
4719
|
+
const oldSession = sessions.get(key);
|
|
4720
|
+
if (oldSession) {
|
|
4721
|
+
await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true });
|
|
4722
|
+
}
|
|
4723
|
+
session = await getSession(userId);
|
|
4724
|
+
group = getTabGroup(session, listItemId);
|
|
4725
|
+
page = await session.context.newPage();
|
|
4726
|
+
tabState = createTabState(page);
|
|
4727
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
4728
|
+
group.set(tabId, tabState);
|
|
4729
|
+
refreshActiveTabsGauge();
|
|
4730
|
+
await withPageLoadDuration('open_url', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
4731
|
+
} else {
|
|
4732
|
+
throw navErr;
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
4478
4735
|
tabState.visitedUrls.add(url);
|
|
4479
4736
|
|
|
4480
4737
|
log('info', 'openclaw tab opened', { reqId: req.reqId, tabId, url: page.url() });
|
|
@@ -5232,8 +5489,15 @@ const server = app.listen(PORT, async () => {
|
|
|
5232
5489
|
log('info', 'browser pre-warmed', { ms: Date.now() - start });
|
|
5233
5490
|
scheduleBrowserIdleShutdown();
|
|
5234
5491
|
} catch (err) {
|
|
5235
|
-
|
|
5236
|
-
|
|
5492
|
+
if (isFatalInstallError(err)) {
|
|
5493
|
+
log('error', 'browser pre-warm aborted: Camoufox binaries are not installed', {
|
|
5494
|
+
error: err.message,
|
|
5495
|
+
remediation: 'run `npx camoufox-js fetch` then restart the server',
|
|
5496
|
+
});
|
|
5497
|
+
} else {
|
|
5498
|
+
log('error', 'browser pre-warm failed (will retry in background)', { error: err.message });
|
|
5499
|
+
scheduleBrowserWarmRetry();
|
|
5500
|
+
}
|
|
5237
5501
|
}
|
|
5238
5502
|
// Idle self-shutdown removed -- Fly manages machine lifecycle via fly.toml.
|
|
5239
5503
|
});
|
package/tsconfig.json
ADDED