Merge pull request #11933 from jo-inc/release/1.18

Camofox Browser 1.18: reliability and platform fixes
This commit is contained in:
Pradeep Elankumaran
2026-09-30 09:52:51 -07:00
committed by GitHub
16 changed files with 407 additions and 191 deletions
+11 -5
View File
@@ -8,7 +8,7 @@ FROM node:22-trixie-slim AS camofox-browser
# Update these when upgrading Camoufox
ARG CAMOUFOX_VERSION=152.0.4
ARG CAMOUFOX_RELEASE=beta.28
ARG ARCH=x86_64
ARG TARGETARCH
ARG YT_DLP_VERSION=2026.08.19
ARG YT_DLP_SHA256=1fa6733c37ea6fb51c99ad8fe785e7b7e5f3246c9b980230329d4fb72ed8d4d6
@@ -54,9 +54,14 @@ RUN apt-get update && apt-get install -y \
# -f so a 404 fails here instead of writing "Not Found" into the .zip: without it the
# build dies three commands later on "unzip: cannot find zipfile directory", which
# points at the archive rather than at the URL that was actually wrong. Note the Linux
# arm asset is named lin.arm64.zip -- pass --build-arg ARCH=arm64, not aarch64.
RUN mkdir -p /root/.cache/camoufox \
&& curl -fL -o /tmp/camoufox.zip "https://github.com/daijro/camoufox/releases/download/v${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}/camoufox-${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}-lin.${ARCH}.zip" \
# BuildKit supplies TARGETARCH; map its names to Camoufox release asset names.
RUN case "${TARGETARCH}" in \
amd64) CAMOUFOX_ARCH="x86_64" ;; \
arm64) CAMOUFOX_ARCH="arm64" ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& mkdir -p /root/.cache/camoufox \
&& curl -fL -o /tmp/camoufox.zip "https://github.com/daijro/camoufox/releases/download/v${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}/camoufox-${CAMOUFOX_VERSION}-${CAMOUFOX_RELEASE}-lin.${CAMOUFOX_ARCH}.zip" \
&& (unzip -q /tmp/camoufox.zip -d /root/.cache/camoufox || true) \
&& rm /tmp/camoufox.zip \
&& chmod -R 755 /root/.cache/camoufox \
@@ -67,7 +72,8 @@ RUN mkdir -p /root/.cache/camoufox \
WORKDIR /app
COPY package.json package-lock.json ./
COPY package.json package-lock.json postinstall.js ./
COPY lib/camoufox-download.js ./lib/
COPY scripts/ ./scripts/
# better-sqlite3 has no prebuild matching this node/arch, so npm ci falls back to
# `node-gyp rebuild`, which fails on node:*-slim with "Error: not found: make".
+3
View File
@@ -66,6 +66,9 @@ RUN set -eux; \
WORKDIR /app
COPY package.json package-lock.json ./
# npm ci runs the postinstall hook, which needs postinstall.js and its one lib/ import.
COPY postinstall.js ./
COPY lib/camoufox-download.js ./lib/
COPY scripts/ ./scripts/
# Build tools are needed when better-sqlite3 has no native prebuild for the target.
# Trixie's glibc also supports the upstream arm64 prebuild at runtime.
+11
View File
@@ -9,6 +9,17 @@ const MODIFIER_ALIASES = new Map([
['return', 'Enter'],
['enter', 'Enter'],
['del', 'Delete'],
['backspace', 'Backspace'],
['tab', 'Tab'],
['space', 'Space'],
['home', 'Home'],
['end', 'End'],
['pageup', 'PageUp'],
['pagedown', 'PageDown'],
['arrowup', 'ArrowUp'],
['arrowdown', 'ArrowDown'],
['arrowleft', 'ArrowLeft'],
['arrowright', 'ArrowRight'],
]);
/** Normalize common agent key spellings to Playwright keyboard.press syntax. */
+37 -1
View File
@@ -61,6 +61,12 @@ function findResourceDir(executablePath) {
dirname(executablePath),
dirname(resolvedPath),
];
const macAppResources = platform() === 'darwin'
? [
join(dirname(dirname(executablePath)), 'Resources'),
join(dirname(dirname(resolvedPath)), 'Resources'),
]
: [];
const storeRoot = nixStoreRoot(resolvedPath) || nixStoreRoot(executablePath);
const likelyDirs = storeRoot
@@ -73,7 +79,7 @@ function findResourceDir(executablePath) {
]
: [];
const allCandidates = [...directDirs, ...likelyDirs];
const allCandidates = [...directDirs, ...macAppResources, ...likelyDirs];
for (const dir of allCandidates) {
if (existsSync(join(dir, 'properties.json'))) return dir;
}
@@ -101,7 +107,16 @@ function shimRootFor(executablePath, resourceDir) {
return join(tmpdir(), 'camofox-browser-external-camoufox', key);
}
function isMacAppExecutable(executablePath) {
return platform() === 'darwin' && /\.app\/Contents\/MacOS\/[^/]+$/.test(executablePath);
}
function ensureLaunchShim(executablePath, resourceDir) {
// A macOS app executable resolves XPCOM and dylibs relative to its .app
// bundle. Launching it through a flattened compatibility shim breaks that
// lookup, so retain the original immutable bundle path.
if (isMacAppExecutable(executablePath)) return executablePath;
const shimRoot = shimRootFor(executablePath, resourceDir);
mkdirSync(shimRoot, { recursive: true });
@@ -165,6 +180,22 @@ function ensureCamoufoxJsCache(resourceDir, cacheDir, executablePath) {
}
}
function ensureMacCamoufoxJsCompatibility(executablePath, resourceDir, cacheDir) {
const bundleCacheDir = dirname(dirname(dirname(dirname(executablePath))));
const bundledVersion = join(bundleCacheDir, 'version.json');
const cacheVersion = join(cacheDir, 'version.json');
const executableProperties = join(dirname(executablePath), 'properties.json');
const resourceProperties = join(resourceDir, 'properties.json');
if (!existsSync(cacheVersion)) {
if (!existsSync(bundledVersion)) {
throw new Error(`macOS external Camoufox bundle requires version.json beside its Camoufox.app: ${bundledVersion}`);
}
mkdirSync(cacheDir, { recursive: true });
writeFileSync(cacheVersion, readFileSync(bundledVersion));
}
if (!existsSync(executableProperties)) ensureSymlink(realpathSync(resourceProperties), executableProperties);
}
export function prepareExternalCamoufoxExecutable(executablePath, { cacheDir } = {}) {
if (!executablePath) return null;
if (!cacheDir) throw new Error('cacheDir is required for external Camoufox executable preparation');
@@ -180,6 +211,11 @@ export function prepareExternalCamoufoxExecutable(executablePath, { cacheDir } =
);
}
if (isMacAppExecutable(resolvedExecutable)) {
ensureMacCamoufoxJsCompatibility(resolvedExecutable, resourceDir, cacheDir);
return { executablePath: resolvedExecutable, resourceDir };
}
ensureCamoufoxJsCache(resourceDir, cacheDir, resolvedExecutable);
return {
+8 -1
View File
@@ -907,10 +907,17 @@ export function createReporter(config) {
}
// Grace period after reset -- let memory settle before re-baselining
const mem = process.memoryUsage();
if (nativeMemGraceRemaining > 0) {
nativeMemGraceRemaining--;
} else if (mem.rss < mem.heapUsed) {
// RSS below the JS heap can occur when heap pages are not resident
// (swapped out or reclaimed by the OS), so the difference cannot
// estimate native memory. Seeding the baseline from it reports the
// later return of those pages as a leak (the negative baselines in
// auto-filed reports). Skip the sample.
nativeMemConsecutiveAbove = 0;
} else {
const mem = process.memoryUsage();
const nativeMemMb = Math.round((mem.rss - mem.heapUsed) / 1048576);
if (nativeMemBaseline === null) {
nativeMemBaseline = nativeMemMb;
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "@askjo/camofox-browser-mcp",
"version": "1.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@askjo/camofox-browser-mcp",
"version": "1.17.0",
"version": "1.18.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0"
@@ -452,9 +452,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -640,9 +640,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"license": "MIT",
"engines": {
"node": ">= 12"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@askjo/camofox-browser-mcp",
"version": "1.17.0",
"version": "1.18.0",
"description": "Standalone stdio MCP server exposing the camofox-browser REST API as MCP tools (Claude Code, Codex, agy, Cursor, opencode). Proxies to an already-running camofox-browser REST server — does not itself require the browser binary, Playwright, or the core server's dependencies. Initial MCP implementation contributed by @epicsagas.",
"type": "module",
"main": "server.mjs",
@@ -41,7 +41,7 @@
"@modelcontextprotocol/sdk": "^1.30.0"
},
"overrides": {
"fast-uri": "3.1.7",
"fast-uri": "3.1.8",
"hono": "4.13.5",
"qs": "6.16.0"
}
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "camofox-browser",
"version": "1.17.0",
"version": "1.18.0",
"description": "Anti-detection browser automation server for AI agents. Accessibility snapshots, element refs, session isolation, cookie import, proxy rotation, and structured logs.",
"license": {
"name": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "camofox-browser",
"name": "Camofox Browser",
"description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
"version": "1.17.0",
"version": "1.18.0",
"configSchema": {
"type": "object",
"properties": {
+14 -14
View File
@@ -1,12 +1,12 @@
{
"name": "@askjo/camofox-browser",
"version": "1.17.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@askjo/camofox-browser",
"version": "1.17.0",
"version": "1.18.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -2867,9 +2867,9 @@
}
},
"node_modules/adm-zip": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz",
"integrity": "sha512-Xwrja8nx9e5o2N1my4DsKCeKpdrnACyr1wtbPxBDgGzKzKyE9kRtBFA8mWldI+RVlD7CBZNWY/wQ2+ydwOR6kQ==",
"license": "MIT",
"engines": {
"node": ">=14.0"
@@ -3260,9 +3260,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -4347,9 +4347,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -5303,9 +5303,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"devOptional": true,
"license": "MIT",
"engines": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@askjo/camofox-browser",
"version": "1.17.0",
"version": "1.18.0",
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
"type": "module",
"main": "server.js",
@@ -169,7 +169,7 @@
"glob": "13.0.6"
},
"qs": "6.16.0",
"fast-uri": "3.1.7",
"fast-uri": "3.1.8",
"hono": "4.13.5"
},
"bin": {
+81 -17
View File
@@ -1484,6 +1484,15 @@ function getTabGroup(session, listItemId) {
return group;
}
// Drop an emptied group only while its key still maps to it. A cleanup that
// awaited page close can resume after another cleanup dropped the group and a
// new tab registered a replacement group under the same key.
function dropEmptyTabGroup(session, listItemId, group) {
if (group.size === 0 && session.tabGroups.get(listItemId) === group) {
session.tabGroups.delete(listItemId);
}
}
// Centralized error handler for route catch blocks.
// Auto-destroys dead browser sessions and returns appropriate status codes.
function isProxyError(err) {
@@ -1687,7 +1696,7 @@ async function destroyTimedOutTab(session, tabId, reason, userId) {
log('warn', 'timed-out tab cleanup failed', { tabId, error: err.message });
} finally {
group.delete(tabId);
if (group.size === 0) session.tabGroups.delete(listItemId);
dropEmptyTabGroup(session, listItemId, group);
const lock = tabLocks.get(tabId);
if (lock) {
lock.drain();
@@ -1724,7 +1733,7 @@ async function recycleOldestTab(session, reqId, userId) {
await safePageClose(oldestTab.page);
oldestGroup.delete(oldestTabId);
if (oldestGroup.size === 0) session.tabGroups.delete(oldestGroupKey);
dropEmptyTabGroup(session, oldestGroupKey, oldestGroup);
const lock = tabLocks.get(oldestTabId);
if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
refreshTabLockQueueDepth();
@@ -2901,6 +2910,18 @@ app.post('/pressure/cleanup', async (req, res) => {
* $ref: '#/components/schemas/Error'
*/
app.post('/tabs', async (req, res) => {
// The tab is registered before it navigates, and the route deadline can pass
// while navigation is still in flight. An error response carries no tabId, so
// the catch below attempts to discard any registered tab, and a registration
// completed after abandonment attempts its own cleanup.
let createdTabId = null;
let abandoned = false;
const discardCreatedTab = async () => {
if (!createdTabId) return;
const session = sessions.get(normalizeUserId(req.body.userId));
if (session) await destroyTimedOutTab(session, createdTabId, 'tab_create_failed', req.body.userId);
createdTabId = null;
};
try {
const { userId, sessionKey, listItemId, url, trace } = req.body;
// Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
@@ -2948,7 +2969,7 @@ app.post('/tabs', async (req, res) => {
const createdPage = await createPageWithRecoveryForUser(userId, session, { trace: !!trace });
session = createdPage.session;
const page = createdPage.page;
let page = createdPage.page;
const lease = createdPage.lease;
const group = getTabGroup(session, resolvedSessionKey);
@@ -2956,10 +2977,16 @@ app.post('/tabs', async (req, res) => {
let tabState = createTabState(page);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
group.set(tabId, tabState);
createdTabId = tabId;
releasePageLease(session, lease);
attachPopupHandler(page, userId, resolvedSessionKey);
refreshActiveTabsGauge();
if (abandoned) {
// The route deadline passed during session or page creation.
await discardCreatedTab();
throw new Error('tab create abandoned after the route deadline');
}
if (url) {
const urlErr = validateUrl(url);
if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
@@ -2969,6 +2996,8 @@ app.post('/tabs', async (req, res) => {
tabState.lastNavigationHttpStatus = typeof navigationResponse?.status === 'function' ? navigationResponse.status() : null;
recordNavSuccess(userId);
} catch (navErr) {
// A page closed by discardCreatedTab is not a navigation verdict.
if (abandoned) throw navErr;
if ((isProxyError(navErr) || isTimeoutError(navErr)) && proxyPool?.canRotateSessions) {
log('warn', 'tab create navigate failed, retrying with fresh proxy', {
reqId: req.reqId, tabId, error: navErr.message,
@@ -2983,12 +3012,18 @@ app.post('/tabs', async (req, res) => {
const retryGroup = getTabGroup(session, resolvedSessionKey);
const { page: retryPage, lease: retryLease } = await createLeasedPage(session);
tabState = createTabState(retryPage);
page = retryPage;
tabState.lastRequestedUrl = url;
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
retryGroup.set(tabId, tabState);
createdTabId = tabId;
releasePageLease(session, retryLease);
attachPopupHandler(retryPage, userId, resolvedSessionKey);
refreshActiveTabsGauge();
if (abandoned) {
await discardCreatedTab();
throw new Error('tab create abandoned after the route deadline');
}
const navigationResponse = await withPageLoadDuration('open_url', () => navigatePage(retryPage, url));
tabState.lastNavigationHttpStatus = typeof navigationResponse?.status === 'function' ? navigationResponse.status() : null;
recordNavSuccess(userId);
@@ -3001,7 +3036,10 @@ app.post('/tabs', async (req, res) => {
}
tabState.visitedUrls.add(url);
}
// The route deadline passed during navigation and the catch below has
// begun discarding the tab, so do not announce it.
if (abandoned) throw new Error('tab create abandoned after the route deadline');
pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
return {
@@ -3015,6 +3053,12 @@ app.post('/tabs', async (req, res) => {
res.json(result);
} catch (err) {
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
abandoned = true;
try {
await discardCreatedTab();
} catch (cleanupErr) {
log('warn', 'tab create cleanup failed', { reqId: req.reqId, tabId: createdTabId, error: cleanupErr?.message ?? String(cleanupErr) });
}
// SSL certificate errors on initial navigation — non-retriable
const isSslError = err.message && (
err.message.includes('SEC_ERROR') ||
@@ -3841,6 +3885,14 @@ app.post('/tabs/:tabId/click', async (req, res) => {
const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
// Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
// Dispatches: mouseover -> mouseenter -> mousedown -> mouseup -> click
const dispatchDomClick = async (locator) => {
await locator.evaluate((element) => {
if (!(element instanceof HTMLElement)) throw new Error('Element is not an HTMLElement');
element.click();
});
log('info', 'DOM click dispatched after Playwright click failure');
};
const dispatchMouseSequence = async (locator) => {
// boundingBox() with no timeout inherits Playwright's 30s default, which
// silently eats the entire handler budget when the element detached after
@@ -3876,6 +3928,15 @@ app.post('/tabs/:tabId/click', async (req, res) => {
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
};
const recoverForceClickFailure = async (locator) => {
try {
await dispatchDomClick(locator);
} catch (domErr) {
log('warn', 'DOM click fallback failed, trying mouse sequence', { error: domErr.message });
await dispatchMouseSequence(locator);
}
};
// On Google SERPs, skip the normal click attempt (always intercepted by overlays)
// and go directly to force click -- saves 5s timeout per click
const onGoogleSerp = isGoogleSerp(tabState.page.url());
@@ -3900,8 +3961,8 @@ app.post('/tabs/:tabId/click', async (req, res) => {
try {
await click({ timeout: 3000, force: true });
} catch (forceErr) {
log('warn', 'google force click failed, trying mouse sequence');
await dispatchMouseSequence(locator);
log('warn', 'google force click failed, trying DOM click fallback');
await recoverForceClickFailure(locator);
}
return;
}
@@ -3916,14 +3977,17 @@ app.post('/tabs/:tabId/click', async (req, res) => {
try {
await click({ timeout: 3000, force: true });
} catch (forceErr) {
// Fallback 2: Full mouse event sequence for stubborn JS handlers
log('warn', 'force click failed, trying mouse sequence');
await dispatchMouseSequence(locator);
log('warn', 'force click failed, trying DOM click fallback');
await recoverForceClickFailure(locator);
}
} else if (err.message.includes('not visible') || err.message.toLowerCase().includes('timeout')) {
// Fallback 2: Element not responding to click, try mouse sequence
log('warn', 'click timeout, trying mouse sequence');
await dispatchMouseSequence(locator);
log('warn', 'click timeout, retrying with force');
try {
await click({ timeout: Math.max(1, Math.min(3000, remainingBudget())), force: true });
} catch (forceErr) {
log('warn', 'force click failed, trying DOM click fallback');
await recoverForceClickFailure(locator);
}
} else {
throw err;
}
@@ -5655,9 +5719,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
await safePageClose(found.tabState.page);
found.group.delete(req.params.tabId);
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); refreshTabLockQueueDepth(); }
if (found.group.size === 0) {
session.tabGroups.delete(found.listItemId);
}
dropEmptyTabGroup(session, found.listItemId, found.group);
refreshActiveTabsGauge();
log('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
}
@@ -5720,8 +5782,10 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
lock.drain();
tabLocks.delete(tabId);
}
group.delete(tabId);
}
session.tabGroups.delete(req.params.listItemId);
// Do not remove a replacement group registered under the same key.
dropEmptyTabGroup(session, req.params.listItemId, group);
refreshTabLockQueueDepth();
refreshActiveTabsGauge();
log('info', 'tab group closed', { reqId: req.reqId, listItemId: req.params.listItemId, userId });
+59
View File
@@ -0,0 +1,59 @@
import { createClient } from '../helpers/client.js';
import { getSharedEnv } from './sharedEnv.js';
// POST /tabs registers the tab before it validates or loads the URL. A failed
// creation must not leave that tab open under an id the client never received.
describe('Tab create cleanup', () => {
let serverUrl;
let testSiteUrl;
beforeAll(() => {
const env = getSharedEnv();
serverUrl = env.serverUrl;
testSiteUrl = env.testSiteUrl;
});
async function listTabIds(client) {
const { tabs } = await client.request('GET', `/tabs?userId=${encodeURIComponent(client.userId)}`);
return tabs.map(tab => tab.tabId);
}
function createTabRequest(client, url, options = {}) {
return client.request('POST', '/tabs', {
userId: client.userId, sessionKey: client.sessionKey, url,
}, options);
}
test('a blocked URL scheme leaves no tab behind', async () => {
const client = createClient(serverUrl);
try {
const { tabId: sentinel } = await client.createTab(`${testSiteUrl}/pageA`);
await expect(createTabRequest(client, 'ftp://example.com/')).rejects.toMatchObject({ status: 400 });
expect(await listTabIds(client)).toEqual([sentinel]);
} finally {
await client.cleanup();
}
});
test('a navigation that outlives the route deadline leaves no tab behind', async () => {
const client = createClient(serverUrl);
try {
const { tabId: sentinel } = await client.createTab(`${testSiteUrl}/pageA`);
// The route deadline (30 s by default) fires before the navigation's own
// 30 s timeout, which starts later; the message names the deadline.
await expect(createTabRequest(client, `${testSiteUrl}/slow-navigation`, { timeout: 45000 }))
.rejects.toMatchObject({ status: 500, message: expect.stringContaining('tab create timed out') });
expect(await listTabIds(client)).toEqual([sentinel]);
// Recheck after a short delay for a leftover registration.
await new Promise(resolve => setTimeout(resolve, 2000));
expect(await listTabIds(client)).toEqual([sentinel]);
} finally {
await client.cleanup();
}
}, 60000);
});
+4 -1
View File
@@ -11,6 +11,9 @@ describe('normalizeBrowserKey', () => {
test('normalizes named keys case-insensitively', () => {
expect(normalizeBrowserKey('Enter')).toBe('Enter');
expect(normalizeBrowserKey('ENTER')).toBe('Enter');
expect(normalizeBrowserKey('TAB')).toBe('TAB');
expect(normalizeBrowserKey('TAB')).toBe('Tab');
expect(normalizeBrowserKey('END')).toBe('End');
expect(normalizeBrowserKey('ARROWDOWN')).toBe('ArrowDown');
expect(normalizeBrowserKey('ctrl+ARROWLEFT')).toBe('Control+ArrowLeft');
});
});
+22 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from '@jest/globals';
import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { dirname, join } from 'path';
import { platform, tmpdir } from 'os';
import { prepareExternalCamoufoxExecutable } from '../../lib/camoufox-executable.js';
@@ -45,6 +45,27 @@ describe('prepareExternalCamoufoxExecutable', () => {
expect(existsSync(cacheExecutable)).toBe(true);
});
test('preserves a macOS app bundle executable instead of flattening it', () => {
if (platform() !== 'darwin') return;
const root = makeTempDir();
const cacheDir = makeTempDir();
const contents = join(root, 'Camoufox.app', 'Contents');
const executable = join(contents, 'MacOS', 'camoufox');
mkdirSync(dirname(executable), { recursive: true });
mkdirSync(join(contents, 'Resources'), { recursive: true });
writeFileSync(executable, '#!/bin/sh\nexit 0\n');
chmodSync(executable, 0o755);
writeFileSync(join(root, 'version.json'), '{"version":"test"}\n');
writeFileSync(join(contents, 'Resources', 'properties.json'), '[]\n');
const prepared = prepareExternalCamoufoxExecutable(executable, { cacheDir });
expect(prepared.resourceDir).toBe(join(contents, 'Resources'));
expect(prepared.executablePath).toBe(executable);
expect(existsSync(join(cacheDir, 'version.json'))).toBe(true);
expect(existsSync(join(dirname(executable), 'properties.json'))).toBe(true);
});
test('fails clearly when bundle resources are missing', () => {
const bundleDir = makeTempDir();
const executable = join(bundleDir, 'camoufox-bin');
+143 -137
View File
@@ -1,3 +1,4 @@
import { jest } from '@jest/globals';
import {
anonymize, stackSignature, createReporter, sendToRelay, createUrlAnonymizer,
createTabHealthTracker, collectResourceSnapshot, detectBotProtection,
@@ -948,12 +949,6 @@ describe('collectResourceSnapshot native memory', () => {
expect(snap.nodeHeapUsedMb > 0).toBeTruthy();
});
test('native memory (RSS - heapUsed) is non-negative', () => {
const snap = collectResourceSnapshot();
const nativeMb = snap.nodeRssMb - snap.nodeHeapUsedMb;
expect(nativeMb >= 0).toBeTruthy();
});
test('includes session/tab counts when provided', () => {
const snap = collectResourceSnapshot({ sessionCount: 3, tabCount: 7 });
expect(snap.browserContexts).toBe(3);
@@ -980,178 +975,189 @@ describe('collectResourceSnapshot native memory', () => {
// ============================================================================
describe('native memory leak detection', () => {
// These tests verify the three false-positive prevention mechanisms:
// 1. Minimum uptime (2 min) -- no alerts during browser initialization
// 2. Sustained growth (3 consecutive checks) -- one-time spikes don't trigger
// 3. Grace period after baseline reset -- memory settles before re-baselining
//
// We test by creating a reporter, starting its watchdog, then simulating
// time passing via mocked process.uptime() and process.memoryUsage().
// The watchdog's native memory check runs every 30s, so we advance the
// setInterval manually.
// The watchdog ticks every second and samples native memory (RSS minus
// heapUsed) every 30 s. These tests drive it with fake timers, so each 30 s
// advance yields exactly one sample of the values set before it, and they
// capture the reports through a mocked fetch.
const MB = 1048576;
const originalFetch = globalThis.fetch;
const originalUptime = process.uptime;
const originalMemoryUsage = process.memoryUsage;
const started = [];
let fetchCalls;
let mockUptimeSeconds;
let mockRss;
let mockHeapUsed;
let uptimeSeconds;
let rss;
let heapUsed;
beforeEach(() => {
jest.useFakeTimers();
fetchCalls = [];
globalThis.fetch = async (url, opts) => {
fetchCalls.push({ url, body: JSON.parse(opts?.body || '{}') });
return { ok: true, status: 200 };
};
mockUptimeSeconds = 200; // default: past min uptime
mockRss = 150 * 1048576; // 150MB baseline
mockHeapUsed = 50 * 1048576; // 50MB heap -> 100MB native
uptimeSeconds = 200; // past the two-minute warm-up unless a test says otherwise
process.uptime = () => uptimeSeconds;
const origMem = originalMemoryUsage.call(process);
process.memoryUsage = () => ({ ...origMem, rss, heapUsed, heapTotal: heapUsed + 10 * MB });
process.memoryUsage.rss = () => rss;
});
afterEach(() => {
afterEach(async () => {
for (const reporter of started.splice(0)) await reporter.stop();
globalThis.fetch = originalFetch;
process.uptime = originalUptime;
process.memoryUsage = originalMemoryUsage;
jest.useRealTimers();
});
function mockProcessForWatchdog() {
process.uptime = () => mockUptimeSeconds;
const origMem = originalMemoryUsage.call(process);
process.memoryUsage = () => ({
...origMem,
rss: mockRss,
heapUsed: mockHeapUsed,
heapTotal: mockHeapUsed + 10 * 1048576,
external: 5 * 1048576,
arrayBuffers: 1 * 1048576,
});
// Also need cpuUsage to not throw
if (!process.memoryUsage.rss) {
process.memoryUsage.rss = () => mockRss;
function createTestReporter() {
const reporter = createReporter({ crashReportEnabled: true, crashReportRateLimit: 50 });
reporter.startWatchdog(5000);
started.push(reporter);
return reporter;
}
async function sampleBytes(rssBytes, heapUsedBytes, checks = 1) {
rss = rssBytes;
heapUsed = heapUsedBytes;
for (let i = 0; i < checks; i++) {
await jest.advanceTimersByTimeAsync(30_000);
}
}
const sample = (rssMb, heapUsedMb, checks) => sampleBytes(rssMb * MB, heapUsedMb * MB, checks);
/**
* Simulate N native memory checks by calling the watchdog interval callback.
* The watchdog checks native memory every 30s (NATIVE_MEM_CHECK_INTERVAL_MS).
* We use Jest's fake timers to advance time.
*/
function createTestReporter() {
return createReporter({
crashReportEnabled: true,
crashReportRateLimit: 50,
});
function leakReports() {
return fetchCalls.filter(c => c.body?.type === 'leak:native-memory').map(c => c.body);
}
const leakTitles = () => leakReports().map(report => report.title);
test('does not fire alert when process uptime < 2 minutes', async () => {
mockProcessForWatchdog();
mockUptimeSeconds = 30; // 30 seconds -- below 120s threshold
mockRss = 700 * 1048576; // 700MB -- way above any threshold
mockHeapUsed = 50 * 1048576; // native = 650MB
// Growth above 600 MB reports even with no sessions and no browser RSS, so the
// idle self-healing rule does not hide these results.
test('reports sustained growth above a valid baseline after three consecutive checks', async () => {
const reporter = createTestReporter();
reporter.startWatchdog(5000);
// Wait for multiple watchdog ticks + native memory check interval
await new Promise(r => setTimeout(r, 150));
await sample(150, 50); // baseline: 100 MB native
await sample(800, 50, 2); // 750 MB native: two checks above the threshold
expect(leakTitles()).toEqual([]);
await sample(800, 50); // the third consecutive check reports
await reporter.stop();
// No leak reports should have been sent (uptime too low)
const leakReports = fetchCalls.filter(c => c.body?.type === 'leak:native-memory');
expect(leakReports.length).toBe(0);
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('grew by 650MB (baseline: 100MB, current: 750MB, high-water: 750MB)');
});
test('does not fire alert on first threshold breach (requires sustained growth)', async () => {
mockProcessForWatchdog();
mockUptimeSeconds = 200; // well past min uptime
test('the report carries the native memory details', async () => {
const reporter = createTestReporter();
// Override the check interval to be very short for testing
reporter.startWatchdog(5000);
// Wait for first check to establish baseline at 100MB native
await new Promise(r => setTimeout(r, 100));
// Spike native memory way above threshold (single spike)
mockRss = 700 * 1048576; // native = 650MB, growth = 550MB > 400MB threshold
// Wait for one more check -- should NOT fire yet (only 1 consecutive)
await new Promise(r => setTimeout(r, 100));
await sample(150, 50);
await sample(800, 50, 3);
await reporter.stop();
// The first breach should NOT trigger an alert (needs 3 consecutive)
const leakReports = fetchCalls.filter(c => c.body?.type === 'leak:native-memory');
expect(leakReports.length).toBe(0);
const [report] = leakReports();
expect(report.labels).toEqual(['auto-report', 'memory-leak']);
expect(report.body).toContain('## Native Memory Details');
expect(report.body).toContain('- **baseline:** 100 MB');
expect(report.body).toContain('- **growth:** 650 MB');
expect(report.body).toContain('- **browser RSS (last seen):** not captured (browser already dead)');
});
test('resetNativeMemBaseline clears consecutive counter and adds grace period', () => {
test('reports once until the baseline is reset', async () => {
const reporter = createTestReporter();
// Just verify resetNativeMemBaseline is callable and doesn't throw
expect(typeof reporter.resetNativeMemBaseline).toBe('function');
await sample(150, 50);
await sample(800, 50, 6);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
});
test('does not measure until the process has been up for two minutes', async () => {
const reporter = createTestReporter();
uptimeSeconds = 119;
await sample(150, 50);
await sample(800, 50, 3); // not measured, so no baseline and no report
expect(leakTitles()).toEqual([]);
uptimeSeconds = 121;
await sample(150, 50); // first measured sample: baseline 100 MB
await sample(800, 50, 3);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 100MB');
});
test('a drop below the threshold restarts the streak', async () => {
const reporter = createTestReporter();
await sample(150, 50);
await sample(800, 50, 2);
await sample(150, 50); // back at the baseline: the streak restarts
await sample(800, 50, 2);
expect(leakTitles()).toEqual([]);
await sample(800, 50);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
});
test('resetNativeMemBaseline re-baselines after two grace checks', async () => {
const reporter = createTestReporter();
await sample(150, 50);
await sample(800, 50, 2); // two checks above the old baseline
reporter.resetNativeMemBaseline();
reporter.stop();
});
test('native memory alert includes sustained growth metadata', async () => {
// This is a structural test -- verifies the report payload shape
// when a real alert would fire (after 3 consecutive checks).
// We test the report formatting by checking formatIssueBody output.
const reporter = createTestReporter();
expect(typeof reporter.reportCrash).toBe('function');
expect(typeof reporter.resetNativeMemBaseline).toBe('function');
await sample(800, 50, 2); // grace: not measured
await sample(800, 50, 4); // new baseline 750 MB, then no growth
await reporter.stop();
expect(leakTitles()).toEqual([]);
});
test('consecutive counter resets when memory drops back below threshold', async () => {
mockProcessForWatchdog();
mockUptimeSeconds = 200;
// A process whose heap pages are not resident (swapped out or reclaimed)
// reports RSS below heapUsed. Such a sample cannot estimate native memory:
// seeding the baseline from it made the later return of those pages look like
// a leak (the negative baselines in auto-filed reports).
test('a first sample with RSS below the heap does not seed the baseline', async () => {
const reporter = createTestReporter();
reporter.startWatchdog(5000);
// Wait for baseline to be established
await new Promise(r => setTimeout(r, 100));
// Spike above threshold
mockRss = 700 * 1048576;
await new Promise(r => setTimeout(r, 100));
// Drop back below threshold -- should reset consecutive counter
mockRss = 150 * 1048576;
await new Promise(r => setTimeout(r, 100));
// Spike again -- counter should be back to 0
mockRss = 700 * 1048576;
await new Promise(r => setTimeout(r, 100));
await sample(20, 300); // skipped instead of a -280 MB baseline
await sample(104, 50, 3); // the first valid sample (54 MB) becomes the baseline
expect(leakTitles()).toEqual([]);
await sample(800, 50, 3);
await reporter.stop();
// No alerts should have fired (never hit 3 consecutive)
const leakReports = fetchCalls.filter(c => c.body?.type === 'leak:native-memory');
expect(leakReports.length).toBe(0);
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 54MB');
});
test('minimum uptime constant is 120 seconds', () => {
// Verify the constant hasn't been accidentally changed.
// This is a public contract -- community users depend on the 2-min warmup.
// We can't import the constant directly (it's a closure var), but we can
// verify the behavior: uptime=119 should not alert, uptime=121 should allow checks.
mockProcessForWatchdog();
mockUptimeSeconds = 119;
mockRss = 800 * 1048576; // huge spike
test('a swapped-out sample breaks the streak but keeps the baseline', async () => {
const reporter = createTestReporter();
reporter.startWatchdog(5000);
await sample(150, 50);
await sample(800, 50, 2);
await sample(20, 300); // skipped: the streak restarts
await sample(800, 50, 2);
expect(leakTitles()).toEqual([]);
await sample(800, 50);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 100MB');
});
// Give it a tick
return new Promise(resolve => {
setTimeout(async () => {
await reporter.stop();
const leakReports = fetchCalls.filter(c => c.body?.type === 'leak:native-memory');
expect(leakReports.length).toBe(0);
resolve();
}, 100);
});
test('a swapped-out sample after a baseline reset does not seed the baseline', async () => {
const reporter = createTestReporter();
await sample(150, 50);
reporter.resetNativeMemBaseline();
await sample(20, 300, 3); // two grace checks, then the swapped-out sample
await sample(104, 50);
await sample(800, 50, 3);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 54MB');
});
test('compares raw bytes: one byte below the heap is skipped, equal is a zero estimate', async () => {
const reporter = createTestReporter();
await sampleBytes(300 * MB - 1, 300 * MB); // rounds to 0 MB but is skipped
await sample(104, 50);
await sample(800, 50, 3);
await reporter.stop();
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 54MB');
fetchCalls.length = 0;
const second = createTestReporter();
await sample(300, 300); // a zero estimate is a valid baseline
await sample(800, 50, 3);
await second.stop();
expect(leakTitles()).toHaveLength(1);
expect(leakTitles()[0]).toContain('baseline: 0MB');
});
});