@askjo/camofox-browser 1.5.2 → 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
  }
@@ -2677,12 +2590,10 @@ setInterval(() => {
2677
2590
  }
2678
2591
  // Clean up sessions with zero tabs remaining — free browser context memory
2679
2592
  if (session.tabGroups.size === 0) {
2593
+ session._closing = true;
2680
2594
  log('info', 'session empty after tab reaper, closing', { userId });
2681
- clearSessionDownloads(session).catch(() => {});
2682
- session.context.close().catch(() => {});
2683
- sessions.delete(userId);
2595
+ closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
2684
2596
  sessionsExpiredTotal.inc();
2685
- refreshActiveTabsGauge();
2686
2597
  }
2687
2598
  }
2688
2599
  if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2756,7 +2667,7 @@ app.post('/tabs/open', async (req, res) => {
2756
2667
  let totalTabs = 0;
2757
2668
  for (const g of session.tabGroups.values()) totalTabs += g.size;
2758
2669
  if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
2759
- const recycled = await recycleOldestTab(session, req.reqId);
2670
+ const recycled = await recycleOldestTab(session, req.reqId, userId);
2760
2671
  if (!recycled) {
2761
2672
  return res.status(429).json({ error: 'Maximum tabs per session reached' });
2762
2673
  }
@@ -2767,7 +2678,7 @@ app.post('/tabs/open', async (req, res) => {
2767
2678
  const page = await session.context.newPage();
2768
2679
  const tabId = fly.makeTabId();
2769
2680
  const tabState = createTabState(page);
2770
- attachDownloadListener(tabState, tabId, log);
2681
+ attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
2771
2682
  group.set(tabId, tabState);
2772
2683
  refreshActiveTabsGauge();
2773
2684
 
@@ -2810,26 +2721,7 @@ app.post('/stop', async (req, res) => {
2810
2721
  await browser.close().catch(() => {});
2811
2722
  browser = null;
2812
2723
  }
2813
- const cleanupTasks = [];
2814
- for (const session of sessions.values()) {
2815
- cleanupTasks.push(clearSessionDownloads(session));
2816
- }
2817
- await Promise.all(cleanupTasks);
2818
- for (const session of sessions.values()) {
2819
- for (const [, group] of session.tabGroups) {
2820
- for (const tabId of group.keys()) {
2821
- const lock = tabLocks.get(tabId);
2822
- if (lock) {
2823
- lock.drain();
2824
- tabLocks.delete(tabId);
2825
- }
2826
- }
2827
- }
2828
- }
2829
- tabLocks.clear();
2830
- sessions.clear();
2831
- refreshActiveTabsGauge();
2832
- refreshTabLockQueueDepth();
2724
+ await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
2833
2725
  res.json({ ok: true, stopped: true, profile: 'camoufox' });
2834
2726
  } catch (err) {
2835
2727
  res.status(500).json({ ok: false, error: safeError(err) });
@@ -2917,6 +2809,7 @@ app.get('/snapshot', async (req, res) => {
2917
2809
  const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
2918
2810
  tabState.refs = googleRefs;
2919
2811
  tabState.lastSnapshot = googleSnapshot;
2812
+ snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
2920
2813
  const annotatedYaml = googleSnapshot;
2921
2814
  const win = windowSnapshot(annotatedYaml, 0);
2922
2815
  const response = {
@@ -2961,6 +2854,7 @@ app.get('/snapshot', async (req, res) => {
2961
2854
  }
2962
2855
 
2963
2856
  tabState.lastSnapshot = annotatedYaml;
2857
+ if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
2964
2858
  const win = windowSnapshot(annotatedYaml, 0);
2965
2859
 
2966
2860
  const response = {
@@ -3053,28 +2947,43 @@ app.post('/act', async (req, res) => {
3053
2947
  }
3054
2948
 
3055
2949
  case 'type': {
3056
- const { ref, selector, text, submit } = params;
3057
- if (!ref && !selector) {
3058
- 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');
3059
2953
  }
3060
2954
  if (typeof text !== 'string') {
3061
2955
  throw new Error('text is required');
3062
2956
  }
2957
+ if (mode !== 'fill' && mode !== 'keyboard') {
2958
+ throw new Error("mode must be 'fill' or 'keyboard'");
2959
+ }
3063
2960
 
2961
+ let locator = null;
3064
2962
  if (ref) {
3065
- let locator = refToLocator(tabState.page, ref, tabState.refs);
2963
+ locator = refToLocator(tabState.page, ref, tabState.refs);
3066
2964
  if (!locator) {
3067
- 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 });
3068
2966
  tabState.refs = await buildRefs(tabState.page);
3069
2967
  locator = refToLocator(tabState.page, ref, tabState.refs);
3070
2968
  }
3071
2969
  if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
3072
- await locator.fill(text, { timeout: 10000 });
3073
- 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
+ }
3074
2978
  } else {
3075
- await tabState.page.fill(selector, text, { timeout: 10000 });
3076
- 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 });
3077
2985
  }
2986
+ if (submit) await tabState.page.keyboard.press('Enter');
3078
2987
  return { ok: true, targetId };
3079
2988
  }
3080
2989
 
@@ -3208,6 +3117,7 @@ setInterval(async () => {
3208
3117
 
3209
3118
  // Crash logging
3210
3119
  process.on('uncaughtException', (err) => {
3120
+ pluginEvents.emit('browser:error', { error: err });
3211
3121
  log('error', 'uncaughtException', { error: err.message, stack: err.stack });
3212
3122
  process.exit(1);
3213
3123
  });
@@ -3222,6 +3132,7 @@ async function gracefulShutdown(signal) {
3222
3132
  if (shuttingDown) return;
3223
3133
  shuttingDown = true;
3224
3134
  log('info', 'shutting down', { signal });
3135
+ pluginEvents.emit('server:shutdown', { signal });
3225
3136
 
3226
3137
  const forceTimeout = setTimeout(() => {
3227
3138
  log('error', 'shutdown timed out, forcing exit');
@@ -3232,9 +3143,11 @@ async function gracefulShutdown(signal) {
3232
3143
  server.close();
3233
3144
  stopMemoryReporter();
3234
3145
 
3235
- for (const [userId, session] of sessions) {
3236
- await session.context.close().catch(() => {});
3237
- }
3146
+ await closeAllSessions(`shutdown:${signal}`, {
3147
+ clearDownloads: false,
3148
+ clearLocks: false,
3149
+ });
3150
+
3238
3151
  if (browser) await browser.close().catch(() => {});
3239
3152
  process.exit(0);
3240
3153
  }
@@ -3248,15 +3161,50 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
3248
3161
  // Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
3249
3162
 
3250
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
+
3251
3194
  const server = app.listen(PORT, async () => {
3252
3195
  startMemoryReporter();
3253
3196
  refreshActiveTabsGauge();
3254
3197
  refreshTabLockQueueDepth();
3198
+ pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
3255
3199
  if (FLY_MACHINE_ID) {
3256
3200
  log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
3257
3201
  } else {
3258
3202
  log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
3259
3203
  }
3204
+ const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
3205
+ if (tmpCleanup.removed > 0) {
3206
+ log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
3207
+ }
3260
3208
  // Pre-warm browser so first request doesn't eat a 6-7s cold start
3261
3209
  try {
3262
3210
  const start = Date.now();