@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/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
- import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
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
- function timingSafeCompare(a, b) {
110
- if (typeof a !== 'string' || typeof b !== 'string') return false;
111
- const bufA = Buffer.from(a);
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
- function isLoopbackAddress(address) {
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
- for (const [, session] of sessions) {
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 = new VirtualDisplay();
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
- for (const [userId, session] of sessions) {
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
- try {
708
- // Lightweight probe: pages() is synchronous-ish and throws if context is dead
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
- if (sessions.size >= MAX_SESSIONS) {
720
- throw new Error('Maximum concurrent sessions reached');
721
- }
722
- const b = await ensureBrowser();
723
- const contextOptions = {
724
- viewport: { width: 1280, height: 720 },
725
- permissions: ['geolocation'],
726
- };
727
- // When geoip is active (proxy configured), camoufox auto-configures
728
- // locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
729
- if (!CONFIG.proxy.host) {
730
- contextOptions.locale = 'en-US';
731
- contextOptions.timezoneId = 'America/Los_Angeles';
732
- contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
733
- }
734
- let sessionProxy = null;
735
- if (proxyPool?.canRotateSessions) {
736
- sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
737
- contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
738
- log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
739
- } else if (proxyPool) {
740
- sessionProxy = proxyPool.getNext();
741
- contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
742
- log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
743
- }
744
- const context = await b.newContext(contextOptions);
745
-
746
- session = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
747
- sessions.set(key, session);
748
- log('info', 'session created', {
749
- userId: key,
750
- proxyMode: proxyPool?.mode || null,
751
- proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
752
- proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
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 oldSession.context.close().catch(() => {});
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
- await withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
1780
- tabState.visitedUrls.add(targetUrl);
1781
- tabState.lastSnapshot = null;
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 oldSession.context.close().catch(() => {});
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 (!ref && !selector) {
2199
- return res.status(400).json({ error: 'ref or selector required' });
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
- let locator = refToLocator(tabState.page, ref, tabState.refs);
2110
+ locator = refToLocator(tabState.page, ref, tabState.refs);
2205
2111
  if (!locator) {
2206
- log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
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
- await locator.fill(text, { timeout: 10000 });
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
- await tabState.page.fill(selector, text, { timeout: 10000 });
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 clearSessionDownloads(session);
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
- clearSessionDownloads(session).catch(() => {});
2634
- session.context.close().catch(() => {});
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
- const cleanupTasks = [];
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
- let locator = refToLocator(tabState.page, ref, tabState.refs);
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
- await locator.fill(text, { timeout: 10000 });
3063
- if (submit) await tabState.page.keyboard.press('Enter');
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
- await tabState.page.fill(selector, text, { timeout: 10000 });
3066
- if (submit) await tabState.page.keyboard.press('Enter');
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
- for (const [userId, session] of sessions) {
3226
- await session.context.close().catch(() => {});
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();