@askjo/camofox-browser 1.8.8 → 1.8.11
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 +580 -0
- package/Dockerfile +2 -2
- package/README.md +69 -29
- package/lib/auth.js +6 -6
- package/lib/metrics.js +3 -3
- package/lib/openapi.js +1 -1
- package/lib/plugins.js +12 -12
- package/lib/proxy.js +9 -9
- package/lib/reporter.js +24 -24
- package/lib/request-utils.js +1 -1
- package/lib/resources.js +4 -8
- package/lib/snapshot.js +1 -1
- package/lib/tmp-cleanup.js +1 -1
- package/openclaw.plugin.json +97 -1
- package/package.json +46 -3
- package/plugins/vnc/index.js +2 -2
- package/plugins/vnc/vnc-launcher.js +2 -2
- package/plugins/vnc/vnc-watcher.sh +3 -3
- package/plugins/vnc/vnc.test.js +2 -2
- package/plugins/youtube/index.js +2 -2
- package/plugins/youtube/youtube.js +1 -1
- package/scripts/install-plugin-deps.sh +1 -1
- package/scripts/plugin.js +19 -19
- package/scripts/plugin.test.js +2 -2
- package/server.js +31 -31
package/server.js
CHANGED
|
@@ -144,7 +144,7 @@ app.use('/tabs/:tabId', fly.replayMiddleware(log));
|
|
|
144
144
|
|
|
145
145
|
// Access-key middleware: gates every route when CAMOFOX_ACCESS_KEY is set.
|
|
146
146
|
// Exempts /health (Docker healthcheck) and routes that have their own
|
|
147
|
-
// dedicated keys (cookie import
|
|
147
|
+
// dedicated keys (cookie import -> CAMOFOX_API_KEY, /stop -> CAMOFOX_ADMIN_KEY)
|
|
148
148
|
// so each key gates a distinct surface. When unset, behavior is unchanged.
|
|
149
149
|
app.use(accessKeyMiddleware(CONFIG));
|
|
150
150
|
|
|
@@ -167,7 +167,7 @@ const SKIP_PATTERNS = [
|
|
|
167
167
|
const timingSafeCompare = _timingSafeCompare;
|
|
168
168
|
const isLoopbackAddress = _isLoopbackAddress;
|
|
169
169
|
|
|
170
|
-
// Custom error for stale/unknown element refs
|
|
170
|
+
// Custom error for stale/unknown element refs -- returned as 422 instead of 500
|
|
171
171
|
class StaleRefsError extends Error {
|
|
172
172
|
constructor(ref, maxRef, totalRefs) {
|
|
173
173
|
super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
|
|
@@ -208,7 +208,7 @@ function validateUrl(url) {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// isLoopbackAddress
|
|
211
|
+
// isLoopbackAddress -- now imported from lib/auth.js (see top of file)
|
|
212
212
|
|
|
213
213
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
214
214
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
@@ -634,7 +634,7 @@ function getTotalTabCount() {
|
|
|
634
634
|
|
|
635
635
|
// Virtual display for WebGL support and anti-detection.
|
|
636
636
|
// Xvfb gives Firefox a real X display with GLX, enabling software-rendered WebGL
|
|
637
|
-
// via Mesa llvmpipe. Without this, WebGL returns "no context"
|
|
637
|
+
// via Mesa llvmpipe. Without this, WebGL returns "no context" -- a massive bot signal.
|
|
638
638
|
let virtualDisplay = null;
|
|
639
639
|
let browserLaunchProxy = null;
|
|
640
640
|
|
|
@@ -680,8 +680,8 @@ function attachBrowserCleanup(candidateBrowser, localVirtualDisplay) {
|
|
|
680
680
|
*
|
|
681
681
|
* Serialized: concurrent callers await the same promise (no double-close).
|
|
682
682
|
*
|
|
683
|
-
* Order: capture PID
|
|
684
|
-
* clean temp profiles
|
|
683
|
+
* Order: capture PID -> close browser -> force-kill survivors ->
|
|
684
|
+
* clean temp profiles -> verify FD/handle drop.
|
|
685
685
|
*/
|
|
686
686
|
async function closeBrowserFully(reason) {
|
|
687
687
|
if (_browserClosePromise) return _browserClosePromise;
|
|
@@ -697,7 +697,7 @@ async function _closeBrowserFullyImpl(reason) {
|
|
|
697
697
|
const b = browser;
|
|
698
698
|
if (!b) return;
|
|
699
699
|
|
|
700
|
-
// Capture PID before nulling browser ref
|
|
700
|
+
// Capture PID before nulling browser ref -- we need it for force-kill
|
|
701
701
|
const pid = _lastBrowserPid;
|
|
702
702
|
const preCloseFds = _countOpenFds();
|
|
703
703
|
const preCloseHandles = _countActiveHandles();
|
|
@@ -1032,7 +1032,7 @@ async function getSession(userId, { trace = false } = {}) {
|
|
|
1032
1032
|
// Check if existing session's context is still alive
|
|
1033
1033
|
if (session) {
|
|
1034
1034
|
if (session._closing) {
|
|
1035
|
-
// Session is being torn down by reaper/expiry
|
|
1035
|
+
// Session is being torn down by reaper/expiry -- treat as dead
|
|
1036
1036
|
session = null;
|
|
1037
1037
|
} else {
|
|
1038
1038
|
try {
|
|
@@ -1157,7 +1157,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
1157
1157
|
if (userId && isDeadContextError(err)) {
|
|
1158
1158
|
destroySession(userId);
|
|
1159
1159
|
}
|
|
1160
|
-
// Proxy errors mean the session is dead
|
|
1160
|
+
// Proxy errors mean the session is dead -- rotate at context level.
|
|
1161
1161
|
// Destroy the user's session so the next request gets a fresh context with a new proxy.
|
|
1162
1162
|
if (isProxyError(err) && proxyPool?.canRotateSessions && userId) {
|
|
1163
1163
|
log('warn', 'proxy error detected, destroying user session for fresh proxy on next request', {
|
|
@@ -1331,7 +1331,7 @@ function createTabState(page) {
|
|
|
1331
1331
|
async function isGoogleUnavailable(page) {
|
|
1332
1332
|
if (!page || page.isClosed()) return false;
|
|
1333
1333
|
const bodyText = await page.evaluate(() => document.body?.innerText?.slice(0, 600) || '').catch(() => '');
|
|
1334
|
-
return /Unable to connect|502 Bad Gateway or Proxy Error|Camoufox can
|
|
1334
|
+
return /Unable to connect|502 Bad Gateway or Proxy Error|Camoufox can't establish a connection/.test(bodyText);
|
|
1335
1335
|
}
|
|
1336
1336
|
|
|
1337
1337
|
async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reason, reqId) {
|
|
@@ -1340,7 +1340,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
|
|
|
1340
1340
|
|
|
1341
1341
|
browserRestartsTotal.labels(reason).inc(); // track rotation events (not a full restart)
|
|
1342
1342
|
|
|
1343
|
-
// Rotate at context level
|
|
1343
|
+
// Rotate at context level -- create a fresh context with a new proxy session
|
|
1344
1344
|
// instead of restarting the entire browser (which kills ALL sessions/tabs).
|
|
1345
1345
|
const key = normalizeUserId(userId);
|
|
1346
1346
|
const oldSession = sessions.get(key);
|
|
@@ -1672,7 +1672,7 @@ async function buildRefs(page) {
|
|
|
1672
1672
|
return refs;
|
|
1673
1673
|
}
|
|
1674
1674
|
|
|
1675
|
-
// Google SERP fast path
|
|
1675
|
+
// Google SERP fast path -- skip ariaSnapshot entirely
|
|
1676
1676
|
const url = page.url();
|
|
1677
1677
|
if (isGoogleSerp(url)) {
|
|
1678
1678
|
const { refs: googleRefs } = await extractGoogleSerp(page);
|
|
@@ -2207,7 +2207,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
2207
2207
|
const recreateTabOnFreshContext = async () => {
|
|
2208
2208
|
const previousRetryCount = tabState.googleRetryCount || 0;
|
|
2209
2209
|
browserRestartsTotal.labels('google_search_block').inc();
|
|
2210
|
-
// Rotate at context level
|
|
2210
|
+
// Rotate at context level -- destroy this user's session and create
|
|
2211
2211
|
// a fresh one with a new proxy session. Does NOT restart the browser.
|
|
2212
2212
|
const key = normalizeUserId(userId);
|
|
2213
2213
|
const oldSession = sessions.get(key);
|
|
@@ -2243,7 +2243,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
2243
2243
|
}
|
|
2244
2244
|
|
|
2245
2245
|
// For Google SERP: skip eager ref building during navigate.
|
|
2246
|
-
// Results render asynchronously after DOMContentLoaded
|
|
2246
|
+
// Results render asynchronously after DOMContentLoaded -- the snapshot
|
|
2247
2247
|
// call will wait for and extract them.
|
|
2248
2248
|
if (isGoogleSerp(tabState.page.url())) {
|
|
2249
2249
|
tabState.refs = new Map();
|
|
@@ -2383,7 +2383,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
2383
2383
|
|
|
2384
2384
|
const pageUrl = tabState.page.url();
|
|
2385
2385
|
|
|
2386
|
-
// Google SERP fast path
|
|
2386
|
+
// Google SERP fast path -- DOM extraction instead of ariaSnapshot
|
|
2387
2387
|
if (isGoogleSerp(pageUrl)) {
|
|
2388
2388
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
2389
2389
|
tabState.refs = googleRefs;
|
|
@@ -2617,7 +2617,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2617
2617
|
const clickStart = Date.now();
|
|
2618
2618
|
const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
|
|
2619
2619
|
// Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
|
|
2620
|
-
// Dispatches: mouseover
|
|
2620
|
+
// Dispatches: mouseover -> mouseenter -> mousedown -> mouseup -> click
|
|
2621
2621
|
const dispatchMouseSequence = async (locator) => {
|
|
2622
2622
|
const box = await locator.boundingBox();
|
|
2623
2623
|
if (!box) throw new Error('Element not visible (no bounding box)');
|
|
@@ -2638,7 +2638,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2638
2638
|
};
|
|
2639
2639
|
|
|
2640
2640
|
// On Google SERPs, skip the normal click attempt (always intercepted by overlays)
|
|
2641
|
-
// and go directly to force click
|
|
2641
|
+
// and go directly to force click -- saves 5s timeout per click
|
|
2642
2642
|
const onGoogleSerp = isGoogleSerp(tabState.page.url());
|
|
2643
2643
|
|
|
2644
2644
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
@@ -2710,7 +2710,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2710
2710
|
await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
|
|
2711
2711
|
} catch {}
|
|
2712
2712
|
await tabState.page.waitForTimeout(200);
|
|
2713
|
-
// Skip buildRefs here
|
|
2713
|
+
// Skip buildRefs here -- SERP clicks typically navigate to a new page,
|
|
2714
2714
|
// and the caller always requests /snapshot next which rebuilds refs.
|
|
2715
2715
|
tabState.lastSnapshot = null;
|
|
2716
2716
|
tabState.refs = new Map();
|
|
@@ -2721,7 +2721,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2721
2721
|
await tabState.page.waitForTimeout(500);
|
|
2722
2722
|
}
|
|
2723
2723
|
tabState.lastSnapshot = null;
|
|
2724
|
-
// buildRefs after click
|
|
2724
|
+
// buildRefs after click -- use remaining budget (min 2s) so we don't blow the handler timeout.
|
|
2725
2725
|
// If it times out, return without refs (caller's next /snapshot will rebuild them).
|
|
2726
2726
|
const postClickBudget = Math.max(2000, remainingBudget());
|
|
2727
2727
|
try {
|
|
@@ -2867,7 +2867,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
2867
2867
|
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
2868
2868
|
}
|
|
2869
2869
|
} else {
|
|
2870
|
-
// keyboard mode
|
|
2870
|
+
// keyboard mode -- char-by-char real key events (required for Ember/contenteditable)
|
|
2871
2871
|
if (locator) {
|
|
2872
2872
|
await locator.focus({ timeout: 10000 });
|
|
2873
2873
|
} else if (selector) {
|
|
@@ -3099,7 +3099,7 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
3099
3099
|
await tabState.page.goBack({ timeout: 10000 });
|
|
3100
3100
|
} catch (navErr) {
|
|
3101
3101
|
// NS_BINDING_CANCELLED_OLD_LOAD: Firefox cancels the old load when going back.
|
|
3102
|
-
// The navigation itself succeeded
|
|
3102
|
+
// The navigation itself succeeded -- just the prior page's load was interrupted.
|
|
3103
3103
|
if (navErr.message && navErr.message.includes('NS_BINDING_CANCELLED')) {
|
|
3104
3104
|
log('info', 'goBack cancelled old load (expected)', { reqId: req.reqId, tabId });
|
|
3105
3105
|
} else {
|
|
@@ -3767,7 +3767,7 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
|
|
|
3767
3767
|
* schema:
|
|
3768
3768
|
* $ref: '#/components/schemas/Error'
|
|
3769
3769
|
* 409:
|
|
3770
|
-
* description: No refs available
|
|
3770
|
+
* description: No refs available -- call snapshot first.
|
|
3771
3771
|
* content:
|
|
3772
3772
|
* application/json:
|
|
3773
3773
|
* schema:
|
|
@@ -3818,7 +3818,7 @@ app.post('/tabs/:tabId/extract', express.json({ limit: '256kb' }), async (req, r
|
|
|
3818
3818
|
|
|
3819
3819
|
if (!tabState.refs || tabState.refs.size === 0) {
|
|
3820
3820
|
return res.status(409).json({
|
|
3821
|
-
error: 'no refs available
|
|
3821
|
+
error: 'no refs available -- call GET /tabs/:tabId/snapshot first to build the ref table',
|
|
3822
3822
|
snapshot: tabState.lastSnapshot || null,
|
|
3823
3823
|
});
|
|
3824
3824
|
}
|
|
@@ -4247,7 +4247,7 @@ setInterval(() => {
|
|
|
4247
4247
|
refreshTabLockQueueDepth();
|
|
4248
4248
|
}, 60_000);
|
|
4249
4249
|
|
|
4250
|
-
// Per-tab inactivity reaper
|
|
4250
|
+
// Per-tab inactivity reaper -- close tabs idle for TAB_INACTIVITY_MS
|
|
4251
4251
|
setInterval(() => {
|
|
4252
4252
|
const now = Date.now();
|
|
4253
4253
|
for (const [userId, session] of sessions) {
|
|
@@ -4278,7 +4278,7 @@ setInterval(() => {
|
|
|
4278
4278
|
session.tabGroups.delete(listItemId);
|
|
4279
4279
|
}
|
|
4280
4280
|
}
|
|
4281
|
-
// Clean up sessions with zero tabs remaining
|
|
4281
|
+
// Clean up sessions with zero tabs remaining -- free browser context memory
|
|
4282
4282
|
if (session.tabGroups.size === 0) {
|
|
4283
4283
|
session._closing = true;
|
|
4284
4284
|
log('info', 'session empty after tab reaper, closing', { userId });
|
|
@@ -4294,7 +4294,7 @@ setInterval(() => {
|
|
|
4294
4294
|
// These allow camoufox to be used as a profile backend for OpenClaw's browser tool
|
|
4295
4295
|
// =============================================================================
|
|
4296
4296
|
|
|
4297
|
-
// GET / - Status (passive
|
|
4297
|
+
// GET / - Status (passive -- does not launch browser)
|
|
4298
4298
|
/**
|
|
4299
4299
|
* @openapi
|
|
4300
4300
|
* /:
|
|
@@ -5076,12 +5076,12 @@ setInterval(() => {
|
|
|
5076
5076
|
});
|
|
5077
5077
|
}, 5 * 60_000);
|
|
5078
5078
|
|
|
5079
|
-
// Active health probe
|
|
5079
|
+
// Active health probe -- detect hung browser even when isConnected() lies
|
|
5080
5080
|
setInterval(async () => {
|
|
5081
5081
|
if (!browser || healthState.isRecovering) return;
|
|
5082
5082
|
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
5083
5083
|
// Skip probe if operations are in flight AND last success was recent.
|
|
5084
|
-
// If it's been >120s since any successful operation, probe anyway
|
|
5084
|
+
// If it's been >120s since any successful operation, probe anyway --
|
|
5085
5085
|
// active ops are likely stuck on a frozen browser and will time out eventually.
|
|
5086
5086
|
if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
|
|
5087
5087
|
log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
|
|
@@ -5150,7 +5150,7 @@ async function gracefulShutdown(signal) {
|
|
|
5150
5150
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
5151
5151
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
5152
5152
|
|
|
5153
|
-
// Idle self-shutdown REMOVED
|
|
5153
|
+
// Idle self-shutdown REMOVED -- it was racing with min_machines_running=2
|
|
5154
5154
|
// and stopping machines that Fly couldn't auto-restart fast enough, leaving
|
|
5155
5155
|
// only 1 machine to handle all browser traffic (causing timeouts for users).
|
|
5156
5156
|
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
|
|
@@ -5181,7 +5181,7 @@ const pluginCtx = {
|
|
|
5181
5181
|
createMetric,
|
|
5182
5182
|
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
|
|
5183
5183
|
createVirtualDisplay: () => new VirtualDisplay(),
|
|
5184
|
-
/** The upstream VirtualDisplay class
|
|
5184
|
+
/** The upstream VirtualDisplay class -- plugins can subclass it. */
|
|
5185
5185
|
VirtualDisplay,
|
|
5186
5186
|
};
|
|
5187
5187
|
const loadedPlugins = await loadPlugins(app, pluginCtx);
|
|
@@ -5235,7 +5235,7 @@ const server = app.listen(PORT, async () => {
|
|
|
5235
5235
|
log('error', 'browser pre-warm failed (will retry in background)', { error: err.message });
|
|
5236
5236
|
scheduleBrowserWarmRetry();
|
|
5237
5237
|
}
|
|
5238
|
-
// Idle self-shutdown removed
|
|
5238
|
+
// Idle self-shutdown removed -- Fly manages machine lifecycle via fly.toml.
|
|
5239
5239
|
});
|
|
5240
5240
|
|
|
5241
5241
|
server.on('error', (err) => {
|