@askjo/camofox-browser 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +17 -2
- package/README.md +20 -0
- package/camofox.config.json +10 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +1 -0
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +174 -0
- package/lib/tmp-cleanup.js +40 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -1
- package/plugin.ts +8 -1
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +120 -0
- package/plugins/persistence/persistence.test.js +117 -0
- package/plugins/persistence/plugin.test.js +98 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/vnc/vnc.test.js +204 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.test.js +41 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/plugin.test.js +117 -0
- package/server.js +286 -328
- /package/{lib → plugins/youtube}/youtube.js +0 -0
package/server.js
CHANGED
|
@@ -8,6 +8,8 @@ import { expandMacro } from './lib/macros.js';
|
|
|
8
8
|
import { loadConfig } from './lib/config.js';
|
|
9
9
|
import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
|
|
10
10
|
import { createFlyHelpers } from './lib/fly.js';
|
|
11
|
+
import { createPluginEvents, loadPlugins } from './lib/plugins.js';
|
|
12
|
+
import { requireAuth, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
|
|
11
13
|
import { windowSnapshot } from './lib/snapshot.js';
|
|
12
14
|
import {
|
|
13
15
|
MAX_DOWNLOAD_INLINE_BYTES,
|
|
@@ -17,17 +19,25 @@ import {
|
|
|
17
19
|
getDownloadsList,
|
|
18
20
|
} from './lib/downloads.js';
|
|
19
21
|
import { extractPageImages } from './lib/images.js';
|
|
20
|
-
|
|
22
|
+
|
|
21
23
|
import {
|
|
22
|
-
initMetrics, getRegister, isMetricsEnabled,
|
|
24
|
+
initMetrics, getRegister, isMetricsEnabled, createMetric,
|
|
23
25
|
startMemoryReporter, stopMemoryReporter,
|
|
24
26
|
} from './lib/metrics.js';
|
|
25
27
|
import { actionFromReq, classifyError } from './lib/request-utils.js';
|
|
28
|
+
import { cleanupOrphanedTempFiles } from './lib/tmp-cleanup.js';
|
|
29
|
+
import { coalesceInflight } from './lib/inflight.js';
|
|
26
30
|
|
|
27
31
|
const CONFIG = loadConfig();
|
|
28
32
|
|
|
33
|
+
// --- Plugin event bus ---
|
|
34
|
+
const pluginEvents = createPluginEvents();
|
|
35
|
+
|
|
36
|
+
// --- Shared auth middleware ---
|
|
37
|
+
const authMiddleware = () => requireAuth(CONFIG);
|
|
38
|
+
|
|
29
39
|
const {
|
|
30
|
-
requestsTotal, requestDuration, pageLoadDuration,
|
|
40
|
+
requestsTotal, requestDuration, pageLoadDuration, snapshotBytes,
|
|
31
41
|
activeTabsGauge, tabLockQueueDepth,
|
|
32
42
|
tabLockTimeoutsTotal,
|
|
33
43
|
failuresTotal, browserRestartsTotal, tabsDestroyedTotal,
|
|
@@ -106,16 +116,9 @@ const SKIP_PATTERNS = [
|
|
|
106
116
|
/date/i, /calendar/i, /picker/i, /datepicker/i
|
|
107
117
|
];
|
|
108
118
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const bufB = Buffer.from(b);
|
|
113
|
-
if (bufA.length !== bufB.length) {
|
|
114
|
-
crypto.timingSafeEqual(bufA, bufA);
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
118
|
-
}
|
|
119
|
+
// timingSafeCompare and isLoopbackAddress imported from lib/auth.js
|
|
120
|
+
const timingSafeCompare = _timingSafeCompare;
|
|
121
|
+
const isLoopbackAddress = _isLoopbackAddress;
|
|
119
122
|
|
|
120
123
|
// Custom error for stale/unknown element refs — returned as 422 instead of 500
|
|
121
124
|
class StaleRefsError extends Error {
|
|
@@ -158,10 +161,7 @@ function validateUrl(url) {
|
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
163
|
|
|
161
|
-
|
|
162
|
-
if (!address) return false;
|
|
163
|
-
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
164
|
-
}
|
|
164
|
+
// isLoopbackAddress — now imported from lib/auth.js (see top of file)
|
|
165
165
|
|
|
166
166
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
167
167
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
@@ -238,6 +238,7 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
|
|
|
238
238
|
await session.context.addCookies(sanitized);
|
|
239
239
|
const result = { ok: true, userId: String(userId), count: sanitized.length };
|
|
240
240
|
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
|
|
241
|
+
pluginEvents.emit('session:cookies:import', { userId: String(userId), count: sanitized.length });
|
|
241
242
|
res.json(result);
|
|
242
243
|
} catch (err) {
|
|
243
244
|
failuresTotal.labels(classifyError(err), 'set_cookies').inc();
|
|
@@ -486,15 +487,14 @@ async function restartBrowser(reason) {
|
|
|
486
487
|
healthState.isRecovering = true;
|
|
487
488
|
browserRestartsTotal.labels(reason).inc();
|
|
488
489
|
log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
|
|
490
|
+
pluginEvents.emit('browser:restart', { reason });
|
|
489
491
|
try {
|
|
490
|
-
|
|
491
|
-
await session.context.close().catch(() => {});
|
|
492
|
-
}
|
|
493
|
-
sessions.clear();
|
|
492
|
+
await closeAllSessions(`browser_restart:${reason}`, { clearDownloads: true, clearLocks: true });
|
|
494
493
|
if (browser) {
|
|
495
494
|
await browser.close().catch(() => {});
|
|
496
495
|
browser = null;
|
|
497
496
|
}
|
|
497
|
+
pluginEvents.emit('browser:closed', { reason });
|
|
498
498
|
browserLaunchPromise = null;
|
|
499
499
|
await ensureBrowser();
|
|
500
500
|
healthState.consecutiveNavFailures = 0;
|
|
@@ -575,7 +575,7 @@ async function launchBrowserInstance() {
|
|
|
575
575
|
|
|
576
576
|
try {
|
|
577
577
|
if (os.platform() === 'linux') {
|
|
578
|
-
localVirtualDisplay =
|
|
578
|
+
localVirtualDisplay = pluginCtx.createVirtualDisplay();
|
|
579
579
|
vdDisplay = localVirtualDisplay.get();
|
|
580
580
|
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
|
|
581
581
|
}
|
|
@@ -608,6 +608,7 @@ async function launchBrowserInstance() {
|
|
|
608
608
|
virtual_display: vdDisplay,
|
|
609
609
|
});
|
|
610
610
|
options.proxy = normalizePlaywrightProxy(options.proxy);
|
|
611
|
+
await pluginEvents.emitAsync('browser:launching', { options });
|
|
611
612
|
|
|
612
613
|
candidateBrowser = await firefox.launch(options);
|
|
613
614
|
|
|
@@ -638,6 +639,7 @@ async function launchBrowserInstance() {
|
|
|
638
639
|
browserLaunchProxy = launchProxy;
|
|
639
640
|
browser = candidateBrowser;
|
|
640
641
|
attachBrowserCleanup(browser, localVirtualDisplay);
|
|
642
|
+
pluginEvents.emit('browser:launched', { browser, display: vdDisplay });
|
|
641
643
|
|
|
642
644
|
log('info', 'camoufox launched', {
|
|
643
645
|
attempt,
|
|
@@ -671,10 +673,7 @@ async function ensureBrowser() {
|
|
|
671
673
|
log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
|
|
672
674
|
deadSessions: sessions.size,
|
|
673
675
|
});
|
|
674
|
-
|
|
675
|
-
await session.context.close().catch(() => {});
|
|
676
|
-
}
|
|
677
|
-
sessions.clear();
|
|
676
|
+
await closeAllSessions('browser_disconnected', { clearDownloads: true, clearLocks: true });
|
|
678
677
|
// Clean up virtual display from dead browser before relaunching
|
|
679
678
|
if (virtualDisplay) {
|
|
680
679
|
virtualDisplay.kill();
|
|
@@ -698,58 +697,114 @@ function normalizeUserId(userId) {
|
|
|
698
697
|
return String(userId);
|
|
699
698
|
}
|
|
700
699
|
|
|
700
|
+
const sessionCreations = new Map();
|
|
701
|
+
|
|
702
|
+
function clearSessionLocks(session) {
|
|
703
|
+
if (!session?.tabGroups) return;
|
|
704
|
+
for (const [, group] of session.tabGroups) {
|
|
705
|
+
for (const tabId of group.keys()) {
|
|
706
|
+
const lock = tabLocks.get(tabId);
|
|
707
|
+
if (lock) {
|
|
708
|
+
lock.drain();
|
|
709
|
+
tabLocks.delete(tabId);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
refreshTabLockQueueDepth();
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function closeSession(userId, session, {
|
|
717
|
+
reason = 'session_closed',
|
|
718
|
+
clearDownloads = true,
|
|
719
|
+
clearLocks = true,
|
|
720
|
+
} = {}) {
|
|
721
|
+
if (!session) return;
|
|
722
|
+
|
|
723
|
+
const key = normalizeUserId(userId);
|
|
724
|
+
|
|
725
|
+
if (clearDownloads) {
|
|
726
|
+
await clearSessionDownloads(session).catch(() => {});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
await session.context.close().catch(() => {});
|
|
730
|
+
sessions.delete(key);
|
|
731
|
+
await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
|
|
732
|
+
|
|
733
|
+
if (clearLocks) {
|
|
734
|
+
clearSessionLocks(session);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
refreshActiveTabsGauge();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) {
|
|
741
|
+
const openSessions = Array.from(sessions.entries());
|
|
742
|
+
for (const [userId, session] of openSessions) {
|
|
743
|
+
await closeSession(userId, session, { reason, clearDownloads, clearLocks });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
701
747
|
async function getSession(userId) {
|
|
702
748
|
const key = normalizeUserId(userId);
|
|
703
749
|
let session = sessions.get(key);
|
|
704
750
|
|
|
705
751
|
// Check if existing session's context is still alive
|
|
706
752
|
if (session) {
|
|
707
|
-
|
|
708
|
-
//
|
|
709
|
-
session.context.pages();
|
|
710
|
-
} catch (err) {
|
|
711
|
-
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
712
|
-
session.context.close().catch(() => {});
|
|
713
|
-
sessions.delete(key);
|
|
753
|
+
if (session._closing) {
|
|
754
|
+
// Session is being torn down by reaper/expiry — treat as dead
|
|
714
755
|
session = null;
|
|
756
|
+
} else {
|
|
757
|
+
try {
|
|
758
|
+
// Lightweight probe: pages() is synchronous-ish and throws if context is dead
|
|
759
|
+
session.context.pages();
|
|
760
|
+
} catch (err) {
|
|
761
|
+
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
762
|
+
await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true });
|
|
763
|
+
session = null;
|
|
764
|
+
}
|
|
715
765
|
}
|
|
716
766
|
}
|
|
717
767
|
|
|
718
768
|
if (!session) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
769
|
+
session = await coalesceInflight(sessionCreations, key, async () => {
|
|
770
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
771
|
+
throw new Error('Maximum concurrent sessions reached');
|
|
772
|
+
}
|
|
773
|
+
const b = await ensureBrowser();
|
|
774
|
+
const contextOptions = {
|
|
775
|
+
viewport: { width: 1280, height: 720 },
|
|
776
|
+
permissions: ['geolocation'],
|
|
777
|
+
};
|
|
778
|
+
// When geoip is active (proxy configured), camoufox auto-configures
|
|
779
|
+
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
780
|
+
if (!CONFIG.proxy.host) {
|
|
781
|
+
contextOptions.locale = 'en-US';
|
|
782
|
+
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
783
|
+
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
784
|
+
}
|
|
785
|
+
let sessionProxy = null;
|
|
786
|
+
if (proxyPool?.canRotateSessions) {
|
|
787
|
+
sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
|
|
788
|
+
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
789
|
+
log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
|
|
790
|
+
} else if (proxyPool) {
|
|
791
|
+
sessionProxy = proxyPool.getNext();
|
|
792
|
+
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
793
|
+
log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
|
|
794
|
+
}
|
|
795
|
+
await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
|
|
796
|
+
const context = await b.newContext(contextOptions);
|
|
797
|
+
|
|
798
|
+
const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
|
|
799
|
+
sessions.set(key, created);
|
|
800
|
+
await pluginEvents.emitAsync('session:created', { userId: key, context });
|
|
801
|
+
log('info', 'session created', {
|
|
802
|
+
userId: key,
|
|
803
|
+
proxyMode: proxyPool?.mode || null,
|
|
804
|
+
proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
|
|
805
|
+
proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
|
|
806
|
+
});
|
|
807
|
+
return created;
|
|
753
808
|
});
|
|
754
809
|
}
|
|
755
810
|
session.lastAccess = Date.now();
|
|
@@ -801,6 +856,10 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
801
856
|
failuresTotal.labels(failureType, action).inc();
|
|
802
857
|
|
|
803
858
|
const userId = req.body?.userId || req.query?.userId;
|
|
859
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
860
|
+
if (tabId) {
|
|
861
|
+
pluginEvents.emit('tab:error', { userId, tabId, error: err });
|
|
862
|
+
}
|
|
804
863
|
if (userId && isDeadContextError(err)) {
|
|
805
864
|
destroySession(userId);
|
|
806
865
|
}
|
|
@@ -823,7 +882,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
823
882
|
found.tabState.consecutiveTimeouts++;
|
|
824
883
|
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
|
|
825
884
|
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
|
|
826
|
-
destroyTab(session, tabId, 'consecutive_timeouts');
|
|
885
|
+
destroyTab(session, tabId, 'consecutive_timeouts', userId);
|
|
827
886
|
}
|
|
828
887
|
}
|
|
829
888
|
}
|
|
@@ -833,7 +892,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
833
892
|
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
834
893
|
const session = sessions.get(normalizeUserId(userId));
|
|
835
894
|
if (session && tabId) {
|
|
836
|
-
destroyTab(session, tabId, 'lock_queue');
|
|
895
|
+
destroyTab(session, tabId, 'lock_queue', userId);
|
|
837
896
|
}
|
|
838
897
|
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
|
|
839
898
|
}
|
|
@@ -844,7 +903,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
844
903
|
sendError(res, err, extraFields);
|
|
845
904
|
}
|
|
846
905
|
|
|
847
|
-
function destroyTab(session, tabId, reason) {
|
|
906
|
+
function destroyTab(session, tabId, reason, userId) {
|
|
848
907
|
const lock = tabLocks.get(tabId);
|
|
849
908
|
if (lock) {
|
|
850
909
|
lock.drain();
|
|
@@ -860,6 +919,7 @@ function destroyTab(session, tabId, reason) {
|
|
|
860
919
|
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
861
920
|
refreshActiveTabsGauge();
|
|
862
921
|
if (reason) tabsDestroyedTotal.labels(reason).inc();
|
|
922
|
+
pluginEvents.emit('tab:destroyed', { userId: userId || null, tabId, reason: reason || 'unknown' });
|
|
863
923
|
return true;
|
|
864
924
|
}
|
|
865
925
|
}
|
|
@@ -871,7 +931,7 @@ function destroyTab(session, tabId, reason) {
|
|
|
871
931
|
* Closes the old tab's page and removes it from its group.
|
|
872
932
|
* Returns { recycledTabId, recycledFromGroup } or null if no tab to recycle.
|
|
873
933
|
*/
|
|
874
|
-
async function recycleOldestTab(session, reqId) {
|
|
934
|
+
async function recycleOldestTab(session, reqId, userId) {
|
|
875
935
|
let oldestTab = null;
|
|
876
936
|
let oldestGroup = null;
|
|
877
937
|
let oldestGroupKey = null;
|
|
@@ -895,6 +955,7 @@ async function recycleOldestTab(session, reqId) {
|
|
|
895
955
|
if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
|
|
896
956
|
refreshTabLockQueueDepth();
|
|
897
957
|
tabsRecycledTotal.inc();
|
|
958
|
+
pluginEvents.emit('tab:recycled', { userId: userId || null, tabId: oldestTabId });
|
|
898
959
|
log('info', 'tab recycled (limit reached)', { reqId, recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey });
|
|
899
960
|
return { recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey };
|
|
900
961
|
}
|
|
@@ -904,8 +965,8 @@ function destroySession(userId) {
|
|
|
904
965
|
const session = sessions.get(key);
|
|
905
966
|
if (!session) return;
|
|
906
967
|
log('warn', 'destroying dead session', { userId: key });
|
|
907
|
-
session.context.close().catch(() => {});
|
|
908
968
|
sessions.delete(key);
|
|
969
|
+
closeSession(key, session, { reason: 'destroy_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
909
970
|
}
|
|
910
971
|
|
|
911
972
|
function findTab(session, tabId) {
|
|
@@ -929,6 +990,7 @@ function createTabState(page) {
|
|
|
929
990
|
lastSnapshot: null,
|
|
930
991
|
lastRequestedUrl: null,
|
|
931
992
|
googleRetryCount: 0,
|
|
993
|
+
navigateAbort: null,
|
|
932
994
|
};
|
|
933
995
|
}
|
|
934
996
|
|
|
@@ -949,8 +1011,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
|
|
|
949
1011
|
const key = normalizeUserId(userId);
|
|
950
1012
|
const oldSession = sessions.get(key);
|
|
951
1013
|
if (oldSession) {
|
|
952
|
-
await
|
|
953
|
-
sessions.delete(key);
|
|
1014
|
+
await closeSession(key, oldSession, { reason: 'google_rotate_context', clearDownloads: true, clearLocks: true });
|
|
954
1015
|
}
|
|
955
1016
|
const session = await getSession(userId);
|
|
956
1017
|
const group = getTabGroup(session, sessionKey);
|
|
@@ -958,7 +1019,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
|
|
|
958
1019
|
const tabState = createTabState(page);
|
|
959
1020
|
tabState.googleRetryCount = (previousTabState.googleRetryCount || 0) + 1;
|
|
960
1021
|
tabState.lastRequestedUrl = previousTabState.lastRequestedUrl;
|
|
961
|
-
attachDownloadListener(tabState, tabId, log);
|
|
1022
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
962
1023
|
group.set(tabId, tabState);
|
|
963
1024
|
refreshActiveTabsGauge();
|
|
964
1025
|
|
|
@@ -1447,188 +1508,6 @@ async function refreshTabRefs(tabState, options = {}) {
|
|
|
1447
1508
|
return refreshedRefs;
|
|
1448
1509
|
}
|
|
1449
1510
|
|
|
1450
|
-
// --- YouTube transcript ---
|
|
1451
|
-
// Implementation extracted to lib/youtube.js to avoid scanner false positives
|
|
1452
|
-
// (child_process + app.post in same file triggers OpenClaw skill-scanner)
|
|
1453
|
-
|
|
1454
|
-
await detectYtDlp(log);
|
|
1455
|
-
|
|
1456
|
-
app.post('/youtube/transcript', async (req, res) => {
|
|
1457
|
-
const reqId = req.reqId;
|
|
1458
|
-
try {
|
|
1459
|
-
const { url, languages = ['en'] } = req.body;
|
|
1460
|
-
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
1461
|
-
|
|
1462
|
-
const urlErr = validateUrl(url);
|
|
1463
|
-
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
1464
|
-
|
|
1465
|
-
const videoIdMatch = url.match(
|
|
1466
|
-
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
|
|
1467
|
-
);
|
|
1468
|
-
if (!videoIdMatch) {
|
|
1469
|
-
return res.status(400).json({ error: 'Could not extract YouTube video ID from URL' });
|
|
1470
|
-
}
|
|
1471
|
-
const videoId = videoIdMatch[1];
|
|
1472
|
-
const lang = languages[0] || 'en';
|
|
1473
|
-
|
|
1474
|
-
// Re-detect yt-dlp if startup detection failed (transient issue)
|
|
1475
|
-
await ensureYtDlp(log);
|
|
1476
|
-
|
|
1477
|
-
const ytDlpProxyUrl = buildProxyUrl(proxyPool, CONFIG.proxy);
|
|
1478
|
-
log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser', hasProxy: !!ytDlpProxyUrl });
|
|
1479
|
-
|
|
1480
|
-
let result;
|
|
1481
|
-
if (hasYtDlp()) {
|
|
1482
|
-
try {
|
|
1483
|
-
result = await ytDlpTranscript(reqId, url, videoId, lang, ytDlpProxyUrl);
|
|
1484
|
-
} catch (ytErr) {
|
|
1485
|
-
log('warn', 'yt-dlp threw, falling back to browser', { reqId, error: ytErr.message });
|
|
1486
|
-
result = null;
|
|
1487
|
-
}
|
|
1488
|
-
// If yt-dlp returned an error result (e.g. no captions) or threw, try browser
|
|
1489
|
-
if (!result || result.status !== 'ok') {
|
|
1490
|
-
if (result) log('warn', 'yt-dlp returned error, falling back to browser', { reqId, status: result.status, code: result.code });
|
|
1491
|
-
result = await browserTranscript(reqId, url, videoId, lang);
|
|
1492
|
-
}
|
|
1493
|
-
} else {
|
|
1494
|
-
result = await browserTranscript(reqId, url, videoId, lang);
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
log('info', 'youtube transcript: done', { reqId, videoId, status: result.status, words: result.total_words });
|
|
1498
|
-
res.json(result);
|
|
1499
|
-
} catch (err) {
|
|
1500
|
-
failuresTotal.labels(classifyError(err), 'youtube_transcript').inc();
|
|
1501
|
-
log('error', 'youtube transcript failed', { reqId, error: err.message, stack: err.stack });
|
|
1502
|
-
res.status(500).json({ error: safeError(err) });
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
// Browser fallback — play video, intercept timedtext network response
|
|
1507
|
-
async function browserTranscript(reqId, url, videoId, lang) {
|
|
1508
|
-
return await withUserLimit('__yt_transcript__', async () => {
|
|
1509
|
-
await ensureBrowser();
|
|
1510
|
-
const session = await getSession('__yt_transcript__');
|
|
1511
|
-
const page = await session.context.newPage();
|
|
1512
|
-
|
|
1513
|
-
try {
|
|
1514
|
-
await page.addInitScript(() => {
|
|
1515
|
-
const origPlay = HTMLMediaElement.prototype.play;
|
|
1516
|
-
HTMLMediaElement.prototype.play = function() { this.volume = 0; this.muted = true; return origPlay.call(this); };
|
|
1517
|
-
});
|
|
1518
|
-
|
|
1519
|
-
let interceptedCaptions = null;
|
|
1520
|
-
page.on('response', async (response) => {
|
|
1521
|
-
const respUrl = response.url();
|
|
1522
|
-
if (respUrl.includes('/api/timedtext') && respUrl.includes(`v=${videoId}`) && !interceptedCaptions) {
|
|
1523
|
-
try {
|
|
1524
|
-
const body = await response.text();
|
|
1525
|
-
if (body && body.length > 0) interceptedCaptions = body;
|
|
1526
|
-
} catch {}
|
|
1527
|
-
}
|
|
1528
|
-
});
|
|
1529
|
-
|
|
1530
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
|
|
1531
|
-
await page.waitForTimeout(2000);
|
|
1532
|
-
|
|
1533
|
-
// Extract caption track URLs and metadata from ytInitialPlayerResponse
|
|
1534
|
-
const meta = await page.evaluate(() => {
|
|
1535
|
-
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
|
|
1536
|
-
if (!r) return { title: '', tracks: [] };
|
|
1537
|
-
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
|
|
1538
|
-
return {
|
|
1539
|
-
title: r?.videoDetails?.title || '',
|
|
1540
|
-
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
|
|
1541
|
-
};
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
|
-
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
|
|
1545
|
-
|
|
1546
|
-
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
|
|
1547
|
-
// These URLs are freshly signed by YouTube and work immediately
|
|
1548
|
-
if (meta.tracks && meta.tracks.length > 0) {
|
|
1549
|
-
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
|
|
1550
|
-
if (track && track.url) {
|
|
1551
|
-
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
|
|
1552
|
-
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
|
|
1553
|
-
try {
|
|
1554
|
-
const captionResp = await page.evaluate(async (fetchUrl) => {
|
|
1555
|
-
const resp = await fetch(fetchUrl);
|
|
1556
|
-
return resp.ok ? await resp.text() : null;
|
|
1557
|
-
}, captionUrl);
|
|
1558
|
-
if (captionResp && captionResp.length > 0) {
|
|
1559
|
-
let transcriptText = null;
|
|
1560
|
-
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
|
|
1561
|
-
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
|
|
1562
|
-
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
|
|
1563
|
-
if (transcriptText && transcriptText.trim()) {
|
|
1564
|
-
return {
|
|
1565
|
-
status: 'ok', transcript: transcriptText,
|
|
1566
|
-
video_url: url, video_id: videoId, video_title: meta.title,
|
|
1567
|
-
language: track.code, total_words: transcriptText.split(/\s+/).length,
|
|
1568
|
-
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
|
|
1569
|
-
};
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
} catch (fetchErr) {
|
|
1573
|
-
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
// Strategy B: Play video and intercept timedtext network response
|
|
1579
|
-
await page.evaluate(() => {
|
|
1580
|
-
const v = document.querySelector('video');
|
|
1581
|
-
if (v) { v.muted = true; v.play().catch(() => {}); }
|
|
1582
|
-
}).catch(() => {});
|
|
1583
|
-
|
|
1584
|
-
for (let i = 0; i < 40 && !interceptedCaptions; i++) {
|
|
1585
|
-
await page.waitForTimeout(500);
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
if (!interceptedCaptions) {
|
|
1589
|
-
return {
|
|
1590
|
-
status: 'error', code: 404,
|
|
1591
|
-
message: 'No captions available for this video',
|
|
1592
|
-
video_url: url, video_id: videoId, title: meta.title,
|
|
1593
|
-
};
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
log('info', 'youtube transcript: intercepted captions', { reqId, len: interceptedCaptions.length });
|
|
1597
|
-
|
|
1598
|
-
let transcriptText = null;
|
|
1599
|
-
if (interceptedCaptions.trimStart().startsWith('{')) transcriptText = parseJson3(interceptedCaptions);
|
|
1600
|
-
else if (interceptedCaptions.includes('WEBVTT')) transcriptText = parseVtt(interceptedCaptions);
|
|
1601
|
-
else if (interceptedCaptions.includes('<text')) transcriptText = parseXml(interceptedCaptions);
|
|
1602
|
-
|
|
1603
|
-
if (!transcriptText || !transcriptText.trim()) {
|
|
1604
|
-
return {
|
|
1605
|
-
status: 'error', code: 404,
|
|
1606
|
-
message: 'Caption data intercepted but could not be parsed',
|
|
1607
|
-
video_url: url, video_id: videoId, title: meta.title,
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
return {
|
|
1612
|
-
status: 'ok', transcript: transcriptText,
|
|
1613
|
-
video_url: url, video_id: videoId, video_title: meta.title,
|
|
1614
|
-
language: lang, total_words: transcriptText.split(/\s+/).length,
|
|
1615
|
-
available_languages: meta.languages,
|
|
1616
|
-
};
|
|
1617
|
-
} finally {
|
|
1618
|
-
await safePageClose(page);
|
|
1619
|
-
// Clean up phantom transcript session if no tabs remain
|
|
1620
|
-
const ytSession = sessions.get(normalizeUserId('__yt_transcript__'));
|
|
1621
|
-
if (ytSession) {
|
|
1622
|
-
let totalTabs = 0;
|
|
1623
|
-
for (const g of ytSession.tabGroups.values()) totalTabs += g.size;
|
|
1624
|
-
if (totalTabs === 0) {
|
|
1625
|
-
ytSession.context.close().catch(() => {});
|
|
1626
|
-
sessions.delete(normalizeUserId('__yt_transcript__'));
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
});
|
|
1631
|
-
}
|
|
1632
1511
|
|
|
1633
1512
|
app.get('/health', (req, res) => {
|
|
1634
1513
|
if (healthState.isRecovering) {
|
|
@@ -1686,7 +1565,7 @@ app.post('/tabs', async (req, res) => {
|
|
|
1686
1565
|
|
|
1687
1566
|
// Recycle oldest tab when limits are reached instead of rejecting
|
|
1688
1567
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
1689
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
1568
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
1690
1569
|
if (!recycled) {
|
|
1691
1570
|
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
|
|
1692
1571
|
}
|
|
@@ -1697,7 +1576,7 @@ app.post('/tabs', async (req, res) => {
|
|
|
1697
1576
|
const page = await session.context.newPage();
|
|
1698
1577
|
const tabId = fly.makeTabId();
|
|
1699
1578
|
const tabState = createTabState(page);
|
|
1700
|
-
attachDownloadListener(tabState, tabId);
|
|
1579
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1701
1580
|
group.set(tabId, tabState);
|
|
1702
1581
|
refreshActiveTabsGauge();
|
|
1703
1582
|
|
|
@@ -1709,6 +1588,7 @@ app.post('/tabs', async (req, res) => {
|
|
|
1709
1588
|
tabState.visitedUrls.add(url);
|
|
1710
1589
|
}
|
|
1711
1590
|
|
|
1591
|
+
pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
|
|
1712
1592
|
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
1713
1593
|
return { tabId, url: page.url() };
|
|
1714
1594
|
})(), requestTimeoutMs(), 'tab create');
|
|
@@ -1741,7 +1621,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1741
1621
|
for (const g of session.tabGroups.values()) sessionTabs += g.size;
|
|
1742
1622
|
if (getTotalTabCount() >= MAX_TABS_GLOBAL || sessionTabs >= MAX_TABS_PER_SESSION) {
|
|
1743
1623
|
// Recycle oldest tab to free a slot, then create new page
|
|
1744
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
1624
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
1745
1625
|
if (!recycled) {
|
|
1746
1626
|
throw new Error('Maximum tabs per session reached');
|
|
1747
1627
|
}
|
|
@@ -1749,7 +1629,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1749
1629
|
{
|
|
1750
1630
|
const page = await session.context.newPage();
|
|
1751
1631
|
tabState = createTabState(page);
|
|
1752
|
-
attachDownloadListener(tabState, tabId, log);
|
|
1632
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1753
1633
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
1754
1634
|
group.set(tabId, tabState);
|
|
1755
1635
|
refreshActiveTabsGauge();
|
|
@@ -1776,9 +1656,21 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1776
1656
|
|
|
1777
1657
|
const navigateCurrentPage = async () => {
|
|
1778
1658
|
tabState.lastRequestedUrl = targetUrl;
|
|
1779
|
-
|
|
1780
|
-
tabState.
|
|
1781
|
-
|
|
1659
|
+
const ac = tabState.navigateAbort = new AbortController();
|
|
1660
|
+
const gotoP = withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1661
|
+
try {
|
|
1662
|
+
await Promise.race([
|
|
1663
|
+
gotoP,
|
|
1664
|
+
new Promise((_, reject) => ac.signal.addEventListener('abort', () => reject(new Error('Navigation aborted: tab deleted')), { once: true })),
|
|
1665
|
+
]);
|
|
1666
|
+
tabState.visitedUrls.add(targetUrl);
|
|
1667
|
+
tabState.lastSnapshot = null;
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
gotoP.catch(() => {}); // suppress unhandled rejection from still-pending goto
|
|
1670
|
+
throw err;
|
|
1671
|
+
} finally {
|
|
1672
|
+
tabState.navigateAbort = null;
|
|
1673
|
+
}
|
|
1782
1674
|
};
|
|
1783
1675
|
|
|
1784
1676
|
const prewarmGoogleHome = async () => {
|
|
@@ -1796,15 +1688,14 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1796
1688
|
const key = normalizeUserId(userId);
|
|
1797
1689
|
const oldSession = sessions.get(key);
|
|
1798
1690
|
if (oldSession) {
|
|
1799
|
-
await
|
|
1800
|
-
sessions.delete(key);
|
|
1691
|
+
await closeSession(key, oldSession, { reason: 'google_blocked_context_rotate', clearDownloads: true, clearLocks: true });
|
|
1801
1692
|
}
|
|
1802
1693
|
session = await getSession(userId);
|
|
1803
1694
|
const group = getTabGroup(session, currentSessionKey);
|
|
1804
1695
|
const page = await session.context.newPage();
|
|
1805
1696
|
tabState = createTabState(page);
|
|
1806
1697
|
tabState.googleRetryCount = previousRetryCount + 1;
|
|
1807
|
-
attachDownloadListener(tabState, tabId, log);
|
|
1698
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1808
1699
|
group.set(tabId, tabState);
|
|
1809
1700
|
refreshActiveTabsGauge();
|
|
1810
1701
|
};
|
|
@@ -1845,6 +1736,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1845
1736
|
})(), requestTimeoutMs(), 'navigate'));
|
|
1846
1737
|
|
|
1847
1738
|
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
|
|
1739
|
+
pluginEvents.emit('tab:navigated', { userId: req.body.userId, tabId, url: result.url, prevUrl: null });
|
|
1848
1740
|
res.json(result);
|
|
1849
1741
|
} catch (err) {
|
|
1850
1742
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
@@ -1909,6 +1801,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1909
1801
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
1910
1802
|
tabState.refs = googleRefs;
|
|
1911
1803
|
tabState.lastSnapshot = googleSnapshot;
|
|
1804
|
+
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
1912
1805
|
const annotatedYaml = googleSnapshot;
|
|
1913
1806
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
1914
1807
|
const response = {
|
|
@@ -1965,6 +1858,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1965
1858
|
}
|
|
1966
1859
|
|
|
1967
1860
|
tabState.lastSnapshot = annotatedYaml;
|
|
1861
|
+
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
1968
1862
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
1969
1863
|
|
|
1970
1864
|
const response = {
|
|
@@ -1985,6 +1879,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1985
1879
|
return response;
|
|
1986
1880
|
})(), requestTimeoutMs(), 'snapshot'));
|
|
1987
1881
|
|
|
1882
|
+
pluginEvents.emit('tab:snapshot', { userId: req.query.userId, tabId: req.params.tabId, snapshot: result.snapshot });
|
|
1988
1883
|
log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount, hasScreenshot: !!result.screenshot, truncated: result.truncated });
|
|
1989
1884
|
res.json(result);
|
|
1990
1885
|
} catch (err) {
|
|
@@ -2157,6 +2052,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2157
2052
|
}));
|
|
2158
2053
|
|
|
2159
2054
|
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
2055
|
+
pluginEvents.emit('tab:click', { userId: req.body.userId, tabId, ref: req.body.ref, selector: req.body.selector });
|
|
2160
2056
|
res.json(result);
|
|
2161
2057
|
} catch (err) {
|
|
2162
2058
|
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
@@ -2187,7 +2083,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
2187
2083
|
const tabId = req.params.tabId;
|
|
2188
2084
|
|
|
2189
2085
|
try {
|
|
2190
|
-
const { userId, ref, selector, text } = req.body;
|
|
2086
|
+
const { userId, ref, selector, text, mode = 'fill', delay = 30, submit = false, pressEnter = false } = req.body;
|
|
2191
2087
|
const session = sessions.get(normalizeUserId(userId));
|
|
2192
2088
|
const found = session && findTab(session, tabId);
|
|
2193
2089
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
@@ -2195,25 +2091,50 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
2195
2091
|
const { tabState } = found;
|
|
2196
2092
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2197
2093
|
|
|
2198
|
-
if (
|
|
2199
|
-
return res.status(400).json({ error: '
|
|
2094
|
+
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
2095
|
+
return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
|
|
2200
2096
|
}
|
|
2097
|
+
if (typeof text !== 'string') {
|
|
2098
|
+
return res.status(400).json({ error: 'text is required' });
|
|
2099
|
+
}
|
|
2100
|
+
// keyboard mode: ref/selector are optional (types into current focus)
|
|
2101
|
+
if (mode === 'fill' && !ref && !selector) {
|
|
2102
|
+
return res.status(400).json({ error: 'ref or selector required for mode=fill' });
|
|
2103
|
+
}
|
|
2104
|
+
const shouldSubmit = submit || pressEnter;
|
|
2201
2105
|
|
|
2202
2106
|
await withTabLock(tabId, async () => {
|
|
2107
|
+
// Resolve and focus the target if ref/selector provided
|
|
2108
|
+
let locator = null;
|
|
2203
2109
|
if (ref) {
|
|
2204
|
-
|
|
2110
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2205
2111
|
if (!locator) {
|
|
2206
|
-
log('info', 'auto-refreshing refs before
|
|
2112
|
+
log('info', 'auto-refreshing refs before type', { ref, hadRefs: tabState.refs.size, mode });
|
|
2207
2113
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'type' });
|
|
2208
2114
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2209
2115
|
}
|
|
2210
2116
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
2211
|
-
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (mode === 'fill') {
|
|
2120
|
+
if (locator) {
|
|
2121
|
+
await locator.fill(text, { timeout: 10000 });
|
|
2122
|
+
} else {
|
|
2123
|
+
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
2124
|
+
}
|
|
2212
2125
|
} else {
|
|
2213
|
-
|
|
2126
|
+
// keyboard mode — char-by-char real key events (required for Ember/contenteditable)
|
|
2127
|
+
if (locator) {
|
|
2128
|
+
await locator.focus({ timeout: 10000 });
|
|
2129
|
+
} else if (selector) {
|
|
2130
|
+
await tabState.page.focus(selector, { timeout: 10000 });
|
|
2131
|
+
}
|
|
2132
|
+
await tabState.page.keyboard.type(text, { delay });
|
|
2214
2133
|
}
|
|
2134
|
+
if (shouldSubmit) await tabState.page.keyboard.press('Enter');
|
|
2215
2135
|
});
|
|
2216
2136
|
|
|
2137
|
+
pluginEvents.emit('tab:type', { userId: req.body.userId, tabId, text: req.body.text, ref: req.body.ref, mode: req.body.mode || 'fill' });
|
|
2217
2138
|
res.json({ ok: true });
|
|
2218
2139
|
} catch (err) {
|
|
2219
2140
|
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2256,6 +2177,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
2256
2177
|
await tabState.page.keyboard.press(key);
|
|
2257
2178
|
});
|
|
2258
2179
|
|
|
2180
|
+
pluginEvents.emit('tab:press', { userId, tabId, key });
|
|
2259
2181
|
res.json({ ok: true });
|
|
2260
2182
|
} catch (err) {
|
|
2261
2183
|
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2279,6 +2201,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
2279
2201
|
await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
|
|
2280
2202
|
await tabState.page.waitForTimeout(300);
|
|
2281
2203
|
|
|
2204
|
+
pluginEvents.emit('tab:scroll', { userId, tabId: req.params.tabId, direction, amount });
|
|
2282
2205
|
res.json({ ok: true });
|
|
2283
2206
|
} catch (err) {
|
|
2284
2207
|
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2481,6 +2404,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
2481
2404
|
|
|
2482
2405
|
const { tabState } = found;
|
|
2483
2406
|
const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
|
|
2407
|
+
pluginEvents.emit('tab:screenshot', { userId, tabId: req.params.tabId, buffer });
|
|
2484
2408
|
res.set('Content-Type', 'image/png');
|
|
2485
2409
|
res.send(buffer);
|
|
2486
2410
|
} catch (err) {
|
|
@@ -2529,7 +2453,9 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
|
|
|
2529
2453
|
const { tabState } = found;
|
|
2530
2454
|
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2531
2455
|
|
|
2456
|
+
pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
|
|
2532
2457
|
const result = await tabState.page.evaluate(expression);
|
|
2458
|
+
pluginEvents.emit('tab:evaluated', { userId, tabId: req.params.tabId, result });
|
|
2533
2459
|
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
|
|
2534
2460
|
res.json({ ok: true, result });
|
|
2535
2461
|
} catch (err) {
|
|
@@ -2547,6 +2473,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
2547
2473
|
const session = sessions.get(normalizeUserId(userId));
|
|
2548
2474
|
const found = session && findTab(session, req.params.tabId);
|
|
2549
2475
|
if (found) {
|
|
2476
|
+
if (found.tabState.navigateAbort) found.tabState.navigateAbort.abort();
|
|
2550
2477
|
await clearTabDownloads(found.tabState);
|
|
2551
2478
|
await safePageClose(found.tabState.page);
|
|
2552
2479
|
found.group.delete(req.params.tabId);
|
|
@@ -2599,21 +2526,7 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
2599
2526
|
const userId = normalizeUserId(req.params.userId);
|
|
2600
2527
|
const session = sessions.get(userId);
|
|
2601
2528
|
if (session) {
|
|
2602
|
-
await
|
|
2603
|
-
await session.context.close();
|
|
2604
|
-
sessions.delete(userId);
|
|
2605
|
-
// Remove any lingering tab locks for the session
|
|
2606
|
-
for (const [listItemId, group] of session.tabGroups) {
|
|
2607
|
-
for (const tabId of group.keys()) {
|
|
2608
|
-
const lock = tabLocks.get(tabId);
|
|
2609
|
-
if (lock) {
|
|
2610
|
-
lock.drain();
|
|
2611
|
-
tabLocks.delete(tabId);
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
refreshTabLockQueueDepth();
|
|
2616
|
-
refreshActiveTabsGauge();
|
|
2529
|
+
await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true });
|
|
2617
2530
|
log('info', 'session closed', { userId });
|
|
2618
2531
|
}
|
|
2619
2532
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
@@ -2627,13 +2540,13 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
2627
2540
|
// Cleanup stale sessions
|
|
2628
2541
|
setInterval(() => {
|
|
2629
2542
|
const now = Date.now();
|
|
2630
|
-
for (const [userId, session] of sessions) {
|
|
2543
|
+
for (const [userId, session] of Array.from(sessions.entries())) {
|
|
2631
2544
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
2545
|
+
session._closing = true;
|
|
2546
|
+
const idleMs = now - session.lastAccess;
|
|
2632
2547
|
sessionsExpiredTotal.inc();
|
|
2633
|
-
|
|
2634
|
-
session
|
|
2635
|
-
sessions.delete(userId);
|
|
2636
|
-
refreshActiveTabsGauge();
|
|
2548
|
+
pluginEvents.emit('session:expired', { userId, idleMs });
|
|
2549
|
+
closeSession(userId, session, { reason: 'session_timeout', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
2637
2550
|
log('info', 'session expired', { userId });
|
|
2638
2551
|
}
|
|
2639
2552
|
}
|
|
@@ -2675,7 +2588,15 @@ setInterval(() => {
|
|
|
2675
2588
|
session.tabGroups.delete(listItemId);
|
|
2676
2589
|
}
|
|
2677
2590
|
}
|
|
2591
|
+
// Clean up sessions with zero tabs remaining — free browser context memory
|
|
2592
|
+
if (session.tabGroups.size === 0) {
|
|
2593
|
+
session._closing = true;
|
|
2594
|
+
log('info', 'session empty after tab reaper, closing', { userId });
|
|
2595
|
+
closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
2596
|
+
sessionsExpiredTotal.inc();
|
|
2597
|
+
}
|
|
2678
2598
|
}
|
|
2599
|
+
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
2679
2600
|
}, 60_000);
|
|
2680
2601
|
|
|
2681
2602
|
// =============================================================================
|
|
@@ -2746,7 +2667,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2746
2667
|
let totalTabs = 0;
|
|
2747
2668
|
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
2748
2669
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
2749
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
2670
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
2750
2671
|
if (!recycled) {
|
|
2751
2672
|
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
2752
2673
|
}
|
|
@@ -2757,7 +2678,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2757
2678
|
const page = await session.context.newPage();
|
|
2758
2679
|
const tabId = fly.makeTabId();
|
|
2759
2680
|
const tabState = createTabState(page);
|
|
2760
|
-
attachDownloadListener(tabState, tabId, log);
|
|
2681
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
2761
2682
|
group.set(tabId, tabState);
|
|
2762
2683
|
refreshActiveTabsGauge();
|
|
2763
2684
|
|
|
@@ -2800,26 +2721,7 @@ app.post('/stop', async (req, res) => {
|
|
|
2800
2721
|
await browser.close().catch(() => {});
|
|
2801
2722
|
browser = null;
|
|
2802
2723
|
}
|
|
2803
|
-
|
|
2804
|
-
for (const session of sessions.values()) {
|
|
2805
|
-
cleanupTasks.push(clearSessionDownloads(session));
|
|
2806
|
-
}
|
|
2807
|
-
await Promise.all(cleanupTasks);
|
|
2808
|
-
for (const session of sessions.values()) {
|
|
2809
|
-
for (const [, group] of session.tabGroups) {
|
|
2810
|
-
for (const tabId of group.keys()) {
|
|
2811
|
-
const lock = tabLocks.get(tabId);
|
|
2812
|
-
if (lock) {
|
|
2813
|
-
lock.drain();
|
|
2814
|
-
tabLocks.delete(tabId);
|
|
2815
|
-
}
|
|
2816
|
-
}
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
tabLocks.clear();
|
|
2820
|
-
sessions.clear();
|
|
2821
|
-
refreshActiveTabsGauge();
|
|
2822
|
-
refreshTabLockQueueDepth();
|
|
2724
|
+
await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
|
|
2823
2725
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
2824
2726
|
} catch (err) {
|
|
2825
2727
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
@@ -2907,6 +2809,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2907
2809
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
2908
2810
|
tabState.refs = googleRefs;
|
|
2909
2811
|
tabState.lastSnapshot = googleSnapshot;
|
|
2812
|
+
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
2910
2813
|
const annotatedYaml = googleSnapshot;
|
|
2911
2814
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
2912
2815
|
const response = {
|
|
@@ -2951,6 +2854,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2951
2854
|
}
|
|
2952
2855
|
|
|
2953
2856
|
tabState.lastSnapshot = annotatedYaml;
|
|
2857
|
+
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
2954
2858
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
2955
2859
|
|
|
2956
2860
|
const response = {
|
|
@@ -3043,28 +2947,43 @@ app.post('/act', async (req, res) => {
|
|
|
3043
2947
|
}
|
|
3044
2948
|
|
|
3045
2949
|
case 'type': {
|
|
3046
|
-
const { ref, selector, text, submit } = params;
|
|
3047
|
-
if (!ref && !selector) {
|
|
3048
|
-
throw new Error('ref or selector required');
|
|
2950
|
+
const { ref, selector, text, submit, mode = 'fill', delay = 30 } = params;
|
|
2951
|
+
if (mode === 'fill' && !ref && !selector) {
|
|
2952
|
+
throw new Error('ref or selector required for mode=fill');
|
|
3049
2953
|
}
|
|
3050
2954
|
if (typeof text !== 'string') {
|
|
3051
2955
|
throw new Error('text is required');
|
|
3052
2956
|
}
|
|
2957
|
+
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
2958
|
+
throw new Error("mode must be 'fill' or 'keyboard'");
|
|
2959
|
+
}
|
|
3053
2960
|
|
|
2961
|
+
let locator = null;
|
|
3054
2962
|
if (ref) {
|
|
3055
|
-
|
|
2963
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
3056
2964
|
if (!locator) {
|
|
3057
|
-
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
2965
|
+
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size, mode });
|
|
3058
2966
|
tabState.refs = await buildRefs(tabState.page);
|
|
3059
2967
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
3060
2968
|
}
|
|
3061
2969
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
3062
|
-
|
|
3063
|
-
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
if (mode === 'fill') {
|
|
2973
|
+
if (locator) {
|
|
2974
|
+
await locator.fill(text, { timeout: 10000 });
|
|
2975
|
+
} else {
|
|
2976
|
+
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
2977
|
+
}
|
|
3064
2978
|
} else {
|
|
3065
|
-
|
|
3066
|
-
|
|
2979
|
+
if (locator) {
|
|
2980
|
+
await locator.focus({ timeout: 10000 });
|
|
2981
|
+
} else if (selector) {
|
|
2982
|
+
await tabState.page.focus(selector, { timeout: 10000 });
|
|
2983
|
+
}
|
|
2984
|
+
await tabState.page.keyboard.type(text, { delay });
|
|
3067
2985
|
}
|
|
2986
|
+
if (submit) await tabState.page.keyboard.press('Enter');
|
|
3068
2987
|
return { ok: true, targetId };
|
|
3069
2988
|
}
|
|
3070
2989
|
|
|
@@ -3198,6 +3117,7 @@ setInterval(async () => {
|
|
|
3198
3117
|
|
|
3199
3118
|
// Crash logging
|
|
3200
3119
|
process.on('uncaughtException', (err) => {
|
|
3120
|
+
pluginEvents.emit('browser:error', { error: err });
|
|
3201
3121
|
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
|
|
3202
3122
|
process.exit(1);
|
|
3203
3123
|
});
|
|
@@ -3212,6 +3132,7 @@ async function gracefulShutdown(signal) {
|
|
|
3212
3132
|
if (shuttingDown) return;
|
|
3213
3133
|
shuttingDown = true;
|
|
3214
3134
|
log('info', 'shutting down', { signal });
|
|
3135
|
+
pluginEvents.emit('server:shutdown', { signal });
|
|
3215
3136
|
|
|
3216
3137
|
const forceTimeout = setTimeout(() => {
|
|
3217
3138
|
log('error', 'shutdown timed out, forcing exit');
|
|
@@ -3222,9 +3143,11 @@ async function gracefulShutdown(signal) {
|
|
|
3222
3143
|
server.close();
|
|
3223
3144
|
stopMemoryReporter();
|
|
3224
3145
|
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3146
|
+
await closeAllSessions(`shutdown:${signal}`, {
|
|
3147
|
+
clearDownloads: false,
|
|
3148
|
+
clearLocks: false,
|
|
3149
|
+
});
|
|
3150
|
+
|
|
3228
3151
|
if (browser) await browser.close().catch(() => {});
|
|
3229
3152
|
process.exit(0);
|
|
3230
3153
|
}
|
|
@@ -3238,15 +3161,50 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
3238
3161
|
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
|
|
3239
3162
|
|
|
3240
3163
|
const PORT = CONFIG.port;
|
|
3164
|
+
pluginEvents.emit('server:starting', { port: PORT });
|
|
3165
|
+
|
|
3166
|
+
// Load plugins before starting the server
|
|
3167
|
+
const pluginCtx = {
|
|
3168
|
+
sessions,
|
|
3169
|
+
config: CONFIG,
|
|
3170
|
+
log,
|
|
3171
|
+
events: pluginEvents,
|
|
3172
|
+
auth: authMiddleware,
|
|
3173
|
+
ensureBrowser,
|
|
3174
|
+
getSession,
|
|
3175
|
+
destroySession,
|
|
3176
|
+
closeSession,
|
|
3177
|
+
withUserLimit,
|
|
3178
|
+
safePageClose,
|
|
3179
|
+
normalizeUserId,
|
|
3180
|
+
validateUrl,
|
|
3181
|
+
safeError,
|
|
3182
|
+
buildProxyUrl,
|
|
3183
|
+
proxyPool,
|
|
3184
|
+
failuresTotal,
|
|
3185
|
+
metricsRegistry: getRegister,
|
|
3186
|
+
createMetric,
|
|
3187
|
+
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
|
|
3188
|
+
createVirtualDisplay: () => new VirtualDisplay(),
|
|
3189
|
+
/** The upstream VirtualDisplay class — plugins can subclass it. */
|
|
3190
|
+
VirtualDisplay,
|
|
3191
|
+
};
|
|
3192
|
+
const loadedPlugins = await loadPlugins(app, pluginCtx);
|
|
3193
|
+
|
|
3241
3194
|
const server = app.listen(PORT, async () => {
|
|
3242
3195
|
startMemoryReporter();
|
|
3243
3196
|
refreshActiveTabsGauge();
|
|
3244
3197
|
refreshTabLockQueueDepth();
|
|
3198
|
+
pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
|
|
3245
3199
|
if (FLY_MACHINE_ID) {
|
|
3246
3200
|
log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
|
|
3247
3201
|
} else {
|
|
3248
3202
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
3249
3203
|
}
|
|
3204
|
+
const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
|
|
3205
|
+
if (tmpCleanup.removed > 0) {
|
|
3206
|
+
log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
|
|
3207
|
+
}
|
|
3250
3208
|
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
3251
3209
|
try {
|
|
3252
3210
|
const start = Date.now();
|