@askjo/camofox-browser 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +17 -2
- package/README.md +138 -8
- package/camofox.config.json +18 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +27 -1
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/extract.js +74 -0
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/openapi.js +100 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +175 -0
- package/lib/reporter.js +751 -0
- package/lib/tmp-cleanup.js +40 -0
- package/lib/tracing.js +137 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -2
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +124 -0
- package/plugins/persistence/persistence.test.js +117 -0
- package/plugins/persistence/plugin.test.js +98 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/spawn.js +8 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/vnc/vnc.test.js +204 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.test.js +41 -0
- package/scripts/exec.js +8 -0
- package/scripts/generate-openapi.js +24 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/plugin.test.js +117 -0
- package/server.js +2124 -355
- /package/{lib → plugins/youtube}/youtube.js +0 -0
package/server.js
CHANGED
|
@@ -3,11 +3,14 @@ import { VirtualDisplay } from 'camoufox-js/dist/virtdisplay.js';
|
|
|
3
3
|
import { firefox } from 'playwright-core';
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import crypto from 'crypto';
|
|
6
|
+
import fs from 'fs';
|
|
6
7
|
import os from 'os';
|
|
7
8
|
import { expandMacro } from './lib/macros.js';
|
|
8
9
|
import { loadConfig } from './lib/config.js';
|
|
9
10
|
import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
|
|
10
11
|
import { createFlyHelpers } from './lib/fly.js';
|
|
12
|
+
import { createPluginEvents, loadPlugins } from './lib/plugins.js';
|
|
13
|
+
import { requireAuth, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
|
|
11
14
|
import { windowSnapshot } from './lib/snapshot.js';
|
|
12
15
|
import {
|
|
13
16
|
MAX_DOWNLOAD_INLINE_BYTES,
|
|
@@ -17,17 +20,50 @@ import {
|
|
|
17
20
|
getDownloadsList,
|
|
18
21
|
} from './lib/downloads.js';
|
|
19
22
|
import { extractPageImages } from './lib/images.js';
|
|
20
|
-
import {
|
|
23
|
+
import { extractDeterministic, validateSchema as validateExtractSchema } from './lib/extract.js';
|
|
21
24
|
import {
|
|
22
|
-
|
|
25
|
+
ensureTracesDir, resolveTracePath, tracePathFor, makeTraceFilename,
|
|
26
|
+
listUserTraces, statTrace, deleteTrace, sweepOldTraces,
|
|
27
|
+
} from './lib/tracing.js';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
initMetrics, getRegister, isMetricsEnabled, createMetric,
|
|
23
31
|
startMemoryReporter, stopMemoryReporter,
|
|
24
32
|
} from './lib/metrics.js';
|
|
25
33
|
import { actionFromReq, classifyError } from './lib/request-utils.js';
|
|
34
|
+
import { cleanupOrphanedTempFiles } from './lib/tmp-cleanup.js';
|
|
35
|
+
import { coalesceInflight } from './lib/inflight.js';
|
|
36
|
+
import { createReporter, createTabHealthTracker } from './lib/reporter.js';
|
|
37
|
+
import { mountDocs } from './lib/openapi.js';
|
|
26
38
|
|
|
27
39
|
const CONFIG = loadConfig();
|
|
28
40
|
|
|
41
|
+
// --- Crash reporter (opt-in, anonymized GitHub issues) ---
|
|
42
|
+
import { readFileSync } from 'fs';
|
|
43
|
+
const _pkgVersion = (() => { try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; } catch { return 'unknown'; } })();
|
|
44
|
+
const reporter = createReporter({ ...CONFIG, version: _pkgVersion });
|
|
45
|
+
reporter.startWatchdog(5000, () => {
|
|
46
|
+
const summary = [];
|
|
47
|
+
for (const [userId, session] of sessions) {
|
|
48
|
+
const urls = [];
|
|
49
|
+
for (const group of session.tabGroups.values()) {
|
|
50
|
+
for (const tab of group.values()) {
|
|
51
|
+
try { if (tab.page) urls.push(tab.page.url()); } catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
summary.push({ userId, urls });
|
|
55
|
+
}
|
|
56
|
+
return { sessions: sessions.size, summary };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- Plugin event bus ---
|
|
60
|
+
const pluginEvents = createPluginEvents();
|
|
61
|
+
|
|
62
|
+
// --- Shared auth middleware ---
|
|
63
|
+
const authMiddleware = () => requireAuth(CONFIG);
|
|
64
|
+
|
|
29
65
|
const {
|
|
30
|
-
requestsTotal, requestDuration, pageLoadDuration,
|
|
66
|
+
requestsTotal, requestDuration, pageLoadDuration, snapshotBytes,
|
|
31
67
|
activeTabsGauge, tabLockQueueDepth,
|
|
32
68
|
tabLockTimeoutsTotal,
|
|
33
69
|
failuresTotal, browserRestartsTotal, tabsDestroyedTotal,
|
|
@@ -106,16 +142,9 @@ const SKIP_PATTERNS = [
|
|
|
106
142
|
/date/i, /calendar/i, /picker/i, /datepicker/i
|
|
107
143
|
];
|
|
108
144
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const bufB = Buffer.from(b);
|
|
113
|
-
if (bufA.length !== bufB.length) {
|
|
114
|
-
crypto.timingSafeEqual(bufA, bufA);
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
118
|
-
}
|
|
145
|
+
// timingSafeCompare and isLoopbackAddress imported from lib/auth.js
|
|
146
|
+
const timingSafeCompare = _timingSafeCompare;
|
|
147
|
+
const isLoopbackAddress = _isLoopbackAddress;
|
|
119
148
|
|
|
120
149
|
// Custom error for stale/unknown element refs — returned as 422 instead of 500
|
|
121
150
|
class StaleRefsError extends Error {
|
|
@@ -158,20 +187,88 @@ function validateUrl(url) {
|
|
|
158
187
|
}
|
|
159
188
|
}
|
|
160
189
|
|
|
161
|
-
|
|
162
|
-
if (!address) return false;
|
|
163
|
-
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
164
|
-
}
|
|
190
|
+
// isLoopbackAddress — now imported from lib/auth.js (see top of file)
|
|
165
191
|
|
|
166
192
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
167
193
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
168
194
|
//
|
|
169
195
|
// SECURITY:
|
|
170
196
|
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
197
|
+
/**
|
|
198
|
+
* @openapi
|
|
199
|
+
* /sessions/{userId}/cookies:
|
|
200
|
+
* post:
|
|
201
|
+
* tags: [Sessions]
|
|
202
|
+
* summary: Import cookies into a user session
|
|
203
|
+
* description: Import cookies for authenticated browsing. Requires BearerAuth in production.
|
|
204
|
+
* security:
|
|
205
|
+
* - BearerAuth: []
|
|
206
|
+
* parameters:
|
|
207
|
+
* - name: userId
|
|
208
|
+
* in: path
|
|
209
|
+
* required: true
|
|
210
|
+
* schema:
|
|
211
|
+
* type: string
|
|
212
|
+
* description: Session owner identifier.
|
|
213
|
+
* requestBody:
|
|
214
|
+
* required: true
|
|
215
|
+
* content:
|
|
216
|
+
* application/json:
|
|
217
|
+
* schema:
|
|
218
|
+
* type: object
|
|
219
|
+
* required: [cookies]
|
|
220
|
+
* properties:
|
|
221
|
+
* cookies:
|
|
222
|
+
* type: array
|
|
223
|
+
* maxItems: 500
|
|
224
|
+
* items:
|
|
225
|
+
* type: object
|
|
226
|
+
* required: [name, value, domain]
|
|
227
|
+
* properties:
|
|
228
|
+
* name:
|
|
229
|
+
* type: string
|
|
230
|
+
* value:
|
|
231
|
+
* type: string
|
|
232
|
+
* domain:
|
|
233
|
+
* type: string
|
|
234
|
+
* path:
|
|
235
|
+
* type: string
|
|
236
|
+
* expires:
|
|
237
|
+
* type: number
|
|
238
|
+
* httpOnly:
|
|
239
|
+
* type: boolean
|
|
240
|
+
* secure:
|
|
241
|
+
* type: boolean
|
|
242
|
+
* sameSite:
|
|
243
|
+
* type: string
|
|
244
|
+
* enum: [Strict, Lax, None]
|
|
245
|
+
* responses:
|
|
246
|
+
* 200:
|
|
247
|
+
* description: Cookies imported.
|
|
248
|
+
* content:
|
|
249
|
+
* application/json:
|
|
250
|
+
* schema:
|
|
251
|
+
* type: object
|
|
252
|
+
* properties:
|
|
253
|
+
* ok:
|
|
254
|
+
* type: boolean
|
|
255
|
+
* userId:
|
|
256
|
+
* type: string
|
|
257
|
+
* count:
|
|
258
|
+
* type: integer
|
|
259
|
+
* 400:
|
|
260
|
+
* description: Invalid cookie data.
|
|
261
|
+
* content:
|
|
262
|
+
* application/json:
|
|
263
|
+
* schema:
|
|
264
|
+
* $ref: '#/components/schemas/Error'
|
|
265
|
+
* 403:
|
|
266
|
+
* description: Forbidden.
|
|
267
|
+
* content:
|
|
268
|
+
* application/json:
|
|
269
|
+
* schema:
|
|
270
|
+
* $ref: '#/components/schemas/Error'
|
|
271
|
+
*/
|
|
175
272
|
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
176
273
|
try {
|
|
177
274
|
if (CONFIG.apiKey) {
|
|
@@ -238,6 +335,7 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
|
|
|
238
335
|
await session.context.addCookies(sanitized);
|
|
239
336
|
const result = { ok: true, userId: String(userId), count: sanitized.length };
|
|
240
337
|
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
|
|
338
|
+
pluginEvents.emit('session:cookies:import', { userId: String(userId), count: sanitized.length });
|
|
241
339
|
res.json(result);
|
|
242
340
|
} catch (err) {
|
|
243
341
|
failuresTotal.labels(classifyError(err), 'set_cookies').inc();
|
|
@@ -486,15 +584,14 @@ async function restartBrowser(reason) {
|
|
|
486
584
|
healthState.isRecovering = true;
|
|
487
585
|
browserRestartsTotal.labels(reason).inc();
|
|
488
586
|
log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
|
|
587
|
+
pluginEvents.emit('browser:restart', { reason });
|
|
489
588
|
try {
|
|
490
|
-
|
|
491
|
-
await session.context.close().catch(() => {});
|
|
492
|
-
}
|
|
493
|
-
sessions.clear();
|
|
589
|
+
await closeAllSessions(`browser_restart:${reason}`, { clearDownloads: true, clearLocks: true });
|
|
494
590
|
if (browser) {
|
|
495
591
|
await browser.close().catch(() => {});
|
|
496
592
|
browser = null;
|
|
497
593
|
}
|
|
594
|
+
pluginEvents.emit('browser:closed', { reason });
|
|
498
595
|
browserLaunchPromise = null;
|
|
499
596
|
await ensureBrowser();
|
|
500
597
|
healthState.consecutiveNavFailures = 0;
|
|
@@ -575,7 +672,7 @@ async function launchBrowserInstance() {
|
|
|
575
672
|
|
|
576
673
|
try {
|
|
577
674
|
if (os.platform() === 'linux') {
|
|
578
|
-
localVirtualDisplay =
|
|
675
|
+
localVirtualDisplay = pluginCtx.createVirtualDisplay();
|
|
579
676
|
vdDisplay = localVirtualDisplay.get();
|
|
580
677
|
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
|
|
581
678
|
}
|
|
@@ -608,6 +705,7 @@ async function launchBrowserInstance() {
|
|
|
608
705
|
virtual_display: vdDisplay,
|
|
609
706
|
});
|
|
610
707
|
options.proxy = normalizePlaywrightProxy(options.proxy);
|
|
708
|
+
await pluginEvents.emitAsync('browser:launching', { options });
|
|
611
709
|
|
|
612
710
|
candidateBrowser = await firefox.launch(options);
|
|
613
711
|
|
|
@@ -638,6 +736,7 @@ async function launchBrowserInstance() {
|
|
|
638
736
|
browserLaunchProxy = launchProxy;
|
|
639
737
|
browser = candidateBrowser;
|
|
640
738
|
attachBrowserCleanup(browser, localVirtualDisplay);
|
|
739
|
+
pluginEvents.emit('browser:launched', { browser, display: vdDisplay });
|
|
641
740
|
|
|
642
741
|
log('info', 'camoufox launched', {
|
|
643
742
|
attempt,
|
|
@@ -671,10 +770,7 @@ async function ensureBrowser() {
|
|
|
671
770
|
log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
|
|
672
771
|
deadSessions: sessions.size,
|
|
673
772
|
});
|
|
674
|
-
|
|
675
|
-
await session.context.close().catch(() => {});
|
|
676
|
-
}
|
|
677
|
-
sessions.clear();
|
|
773
|
+
await closeAllSessions('browser_disconnected', { clearDownloads: true, clearLocks: true });
|
|
678
774
|
// Clean up virtual display from dead browser before relaunching
|
|
679
775
|
if (virtualDisplay) {
|
|
680
776
|
virtualDisplay.kill();
|
|
@@ -698,58 +794,137 @@ function normalizeUserId(userId) {
|
|
|
698
794
|
return String(userId);
|
|
699
795
|
}
|
|
700
796
|
|
|
701
|
-
|
|
797
|
+
const sessionCreations = new Map();
|
|
798
|
+
|
|
799
|
+
function clearSessionLocks(session) {
|
|
800
|
+
if (!session?.tabGroups) return;
|
|
801
|
+
for (const [, group] of session.tabGroups) {
|
|
802
|
+
for (const tabId of group.keys()) {
|
|
803
|
+
const lock = tabLocks.get(tabId);
|
|
804
|
+
if (lock) {
|
|
805
|
+
lock.drain();
|
|
806
|
+
tabLocks.delete(tabId);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
refreshTabLockQueueDepth();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function closeSession(userId, session, {
|
|
814
|
+
reason = 'session_closed',
|
|
815
|
+
clearDownloads = true,
|
|
816
|
+
clearLocks = true,
|
|
817
|
+
} = {}) {
|
|
818
|
+
if (!session) return;
|
|
819
|
+
|
|
820
|
+
const key = normalizeUserId(userId);
|
|
821
|
+
|
|
822
|
+
if (clearDownloads) {
|
|
823
|
+
await clearSessionDownloads(session).catch(() => {});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
await pluginEvents.emitAsync('session:destroying', { userId: key, reason });
|
|
827
|
+
if (session.tracePath) {
|
|
828
|
+
try {
|
|
829
|
+
await session.context.tracing.stop({ path: session.tracePath });
|
|
830
|
+
log('info', 'tracing saved', { userId: key, path: session.tracePath });
|
|
831
|
+
} catch (err) {
|
|
832
|
+
log('warn', 'tracing.stop failed', { userId: key, error: err.message });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
await session.context.close().catch(() => {});
|
|
837
|
+
sessions.delete(key);
|
|
838
|
+
await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
|
|
839
|
+
|
|
840
|
+
if (clearLocks) {
|
|
841
|
+
clearSessionLocks(session);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
refreshActiveTabsGauge();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) {
|
|
848
|
+
const openSessions = Array.from(sessions.entries());
|
|
849
|
+
for (const [userId, session] of openSessions) {
|
|
850
|
+
await closeSession(userId, session, { reason, clearDownloads, clearLocks });
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function getSession(userId, { trace = false } = {}) {
|
|
702
855
|
const key = normalizeUserId(userId);
|
|
703
856
|
let session = sessions.get(key);
|
|
704
857
|
|
|
705
858
|
// Check if existing session's context is still alive
|
|
706
859
|
if (session) {
|
|
707
|
-
|
|
708
|
-
//
|
|
709
|
-
session.context.pages();
|
|
710
|
-
} catch (err) {
|
|
711
|
-
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
712
|
-
session.context.close().catch(() => {});
|
|
713
|
-
sessions.delete(key);
|
|
860
|
+
if (session._closing) {
|
|
861
|
+
// Session is being torn down by reaper/expiry — treat as dead
|
|
714
862
|
session = null;
|
|
863
|
+
} else {
|
|
864
|
+
try {
|
|
865
|
+
// Lightweight probe: pages() is synchronous-ish and throws if context is dead
|
|
866
|
+
session.context.pages();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
869
|
+
await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true });
|
|
870
|
+
session = null;
|
|
871
|
+
}
|
|
715
872
|
}
|
|
716
873
|
}
|
|
717
874
|
|
|
718
875
|
if (!session) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
876
|
+
session = await coalesceInflight(sessionCreations, key, async () => {
|
|
877
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
878
|
+
throw new Error('Maximum concurrent sessions reached');
|
|
879
|
+
}
|
|
880
|
+
const b = await ensureBrowser();
|
|
881
|
+
const contextOptions = {
|
|
882
|
+
viewport: { width: 1280, height: 720 },
|
|
883
|
+
permissions: ['geolocation'],
|
|
884
|
+
};
|
|
885
|
+
// When geoip is active (proxy configured), camoufox auto-configures
|
|
886
|
+
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
|
|
887
|
+
if (!CONFIG.proxy.host) {
|
|
888
|
+
contextOptions.locale = 'en-US';
|
|
889
|
+
contextOptions.timezoneId = 'America/Los_Angeles';
|
|
890
|
+
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
|
|
891
|
+
}
|
|
892
|
+
let sessionProxy = null;
|
|
893
|
+
if (proxyPool?.canRotateSessions) {
|
|
894
|
+
sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
|
|
895
|
+
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
896
|
+
log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
|
|
897
|
+
} else if (proxyPool) {
|
|
898
|
+
sessionProxy = proxyPool.getNext();
|
|
899
|
+
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
|
|
900
|
+
log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
|
|
901
|
+
}
|
|
902
|
+
await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
|
|
903
|
+
const context = await b.newContext(contextOptions);
|
|
904
|
+
|
|
905
|
+
let tracePath = null;
|
|
906
|
+
if (trace) {
|
|
907
|
+
const traceDir = ensureTracesDir(CONFIG.tracesDir, key);
|
|
908
|
+
tracePath = tracePathFor(CONFIG.tracesDir, key, makeTraceFilename());
|
|
909
|
+
try {
|
|
910
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
|
|
911
|
+
log('info', 'tracing enabled for session', { userId: key, traceDir, tracePath });
|
|
912
|
+
} catch (err) {
|
|
913
|
+
log('warn', 'tracing.start failed; session will not be traced', { userId: key, error: err.message });
|
|
914
|
+
tracePath = null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null, tracePath };
|
|
919
|
+
sessions.set(key, created);
|
|
920
|
+
await pluginEvents.emitAsync('session:created', { userId: key, context });
|
|
921
|
+
log('info', 'session created', {
|
|
922
|
+
userId: key,
|
|
923
|
+
proxyMode: proxyPool?.mode || null,
|
|
924
|
+
proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
|
|
925
|
+
proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
|
|
926
|
+
});
|
|
927
|
+
return created;
|
|
753
928
|
});
|
|
754
929
|
}
|
|
755
930
|
session.lastAccess = Date.now();
|
|
@@ -801,6 +976,10 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
801
976
|
failuresTotal.labels(failureType, action).inc();
|
|
802
977
|
|
|
803
978
|
const userId = req.body?.userId || req.query?.userId;
|
|
979
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
980
|
+
if (tabId) {
|
|
981
|
+
pluginEvents.emit('tab:error', { userId, tabId, error: err });
|
|
982
|
+
}
|
|
804
983
|
if (userId && isDeadContextError(err)) {
|
|
805
984
|
destroySession(userId);
|
|
806
985
|
}
|
|
@@ -823,17 +1002,16 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
823
1002
|
found.tabState.consecutiveTimeouts++;
|
|
824
1003
|
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
|
|
825
1004
|
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
|
|
826
|
-
destroyTab(session, tabId, 'consecutive_timeouts');
|
|
1005
|
+
destroyTab(session, tabId, 'consecutive_timeouts', userId);
|
|
827
1006
|
}
|
|
828
1007
|
}
|
|
829
1008
|
}
|
|
830
1009
|
}
|
|
831
1010
|
// Lock queue timeout = tab is stuck. Destroy immediately.
|
|
832
1011
|
if (userId && isTabLockQueueTimeout(err)) {
|
|
833
|
-
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
834
1012
|
const session = sessions.get(normalizeUserId(userId));
|
|
835
1013
|
if (session && tabId) {
|
|
836
|
-
destroyTab(session, tabId, 'lock_queue');
|
|
1014
|
+
destroyTab(session, tabId, 'lock_queue', userId);
|
|
837
1015
|
}
|
|
838
1016
|
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
|
|
839
1017
|
}
|
|
@@ -841,10 +1019,38 @@ function handleRouteError(err, req, res, extraFields = {}) {
|
|
|
841
1019
|
if (isTabDestroyedError(err)) {
|
|
842
1020
|
return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
|
|
843
1021
|
}
|
|
1022
|
+
// --- Frustration detection: report when a tab hits a streak of failures ---
|
|
1023
|
+
// Individual failures are noise. 3+ consecutive = the site is persistently broken.
|
|
1024
|
+
const FRUSTRATION_TYPES = new Set(['timeout', 'dead_context', 'nav_aborted']);
|
|
1025
|
+
if (FRUSTRATION_TYPES.has(failureType) && userId && tabId) {
|
|
1026
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1027
|
+
const found = session && findTab(session, tabId);
|
|
1028
|
+
if (found) {
|
|
1029
|
+
const ts = found.tabState;
|
|
1030
|
+
ts.consecutiveFailures = (ts.consecutiveFailures || 0) + 1;
|
|
1031
|
+
if (!ts.failureJournal) ts.failureJournal = [];
|
|
1032
|
+
ts.failureJournal.push({ type: failureType, action, at: Date.now() });
|
|
1033
|
+
if (ts.failureJournal.length > 20) ts.failureJournal = ts.failureJournal.slice(-20);
|
|
1034
|
+
|
|
1035
|
+
if (ts.consecutiveFailures === 3) {
|
|
1036
|
+
reporter.reportHang(action, req.startTime ? Date.now() - req.startTime : 0, {
|
|
1037
|
+
error: err,
|
|
1038
|
+
url: ts.lastRequestedUrl || undefined,
|
|
1039
|
+
healthSnapshot: ts.healthTracker ? ts.healthTracker.snapshot() : undefined,
|
|
1040
|
+
context: {
|
|
1041
|
+
failureType,
|
|
1042
|
+
consecutiveFailures: ts.consecutiveFailures,
|
|
1043
|
+
toolCalls: ts.toolCalls,
|
|
1044
|
+
journal: ts.failureJournal.map(j => `${j.type}:${j.action}`),
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
844
1050
|
sendError(res, err, extraFields);
|
|
845
1051
|
}
|
|
846
1052
|
|
|
847
|
-
function destroyTab(session, tabId, reason) {
|
|
1053
|
+
function destroyTab(session, tabId, reason, userId) {
|
|
848
1054
|
const lock = tabLocks.get(tabId);
|
|
849
1055
|
if (lock) {
|
|
850
1056
|
lock.drain();
|
|
@@ -860,6 +1066,7 @@ function destroyTab(session, tabId, reason) {
|
|
|
860
1066
|
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
861
1067
|
refreshActiveTabsGauge();
|
|
862
1068
|
if (reason) tabsDestroyedTotal.labels(reason).inc();
|
|
1069
|
+
pluginEvents.emit('tab:destroyed', { userId: userId || null, tabId, reason: reason || 'unknown' });
|
|
863
1070
|
return true;
|
|
864
1071
|
}
|
|
865
1072
|
}
|
|
@@ -871,7 +1078,7 @@ function destroyTab(session, tabId, reason) {
|
|
|
871
1078
|
* Closes the old tab's page and removes it from its group.
|
|
872
1079
|
* Returns { recycledTabId, recycledFromGroup } or null if no tab to recycle.
|
|
873
1080
|
*/
|
|
874
|
-
async function recycleOldestTab(session, reqId) {
|
|
1081
|
+
async function recycleOldestTab(session, reqId, userId) {
|
|
875
1082
|
let oldestTab = null;
|
|
876
1083
|
let oldestGroup = null;
|
|
877
1084
|
let oldestGroupKey = null;
|
|
@@ -895,6 +1102,7 @@ async function recycleOldestTab(session, reqId) {
|
|
|
895
1102
|
if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
|
|
896
1103
|
refreshTabLockQueueDepth();
|
|
897
1104
|
tabsRecycledTotal.inc();
|
|
1105
|
+
pluginEvents.emit('tab:recycled', { userId: userId || null, tabId: oldestTabId });
|
|
898
1106
|
log('info', 'tab recycled (limit reached)', { reqId, recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey });
|
|
899
1107
|
return { recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey };
|
|
900
1108
|
}
|
|
@@ -904,8 +1112,8 @@ function destroySession(userId) {
|
|
|
904
1112
|
const session = sessions.get(key);
|
|
905
1113
|
if (!session) return;
|
|
906
1114
|
log('warn', 'destroying dead session', { userId: key });
|
|
907
|
-
session.context.close().catch(() => {});
|
|
908
1115
|
sessions.delete(key);
|
|
1116
|
+
closeSession(key, session, { reason: 'destroy_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
909
1117
|
}
|
|
910
1118
|
|
|
911
1119
|
function findTab(session, tabId) {
|
|
@@ -919,6 +1127,7 @@ function findTab(session, tabId) {
|
|
|
919
1127
|
}
|
|
920
1128
|
|
|
921
1129
|
function createTabState(page) {
|
|
1130
|
+
const healthTracker = createTabHealthTracker(page);
|
|
922
1131
|
return {
|
|
923
1132
|
page,
|
|
924
1133
|
refs: new Map(),
|
|
@@ -926,9 +1135,13 @@ function createTabState(page) {
|
|
|
926
1135
|
downloads: [],
|
|
927
1136
|
toolCalls: 0,
|
|
928
1137
|
consecutiveTimeouts: 0,
|
|
1138
|
+
consecutiveFailures: 0,
|
|
1139
|
+
failureJournal: [],
|
|
1140
|
+
healthTracker,
|
|
929
1141
|
lastSnapshot: null,
|
|
930
1142
|
lastRequestedUrl: null,
|
|
931
1143
|
googleRetryCount: 0,
|
|
1144
|
+
navigateAbort: null,
|
|
932
1145
|
};
|
|
933
1146
|
}
|
|
934
1147
|
|
|
@@ -949,8 +1162,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
|
|
|
949
1162
|
const key = normalizeUserId(userId);
|
|
950
1163
|
const oldSession = sessions.get(key);
|
|
951
1164
|
if (oldSession) {
|
|
952
|
-
await
|
|
953
|
-
sessions.delete(key);
|
|
1165
|
+
await closeSession(key, oldSession, { reason: 'google_rotate_context', clearDownloads: true, clearLocks: true });
|
|
954
1166
|
}
|
|
955
1167
|
const session = await getSession(userId);
|
|
956
1168
|
const group = getTabGroup(session, sessionKey);
|
|
@@ -958,7 +1170,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
|
|
|
958
1170
|
const tabState = createTabState(page);
|
|
959
1171
|
tabState.googleRetryCount = (previousTabState.googleRetryCount || 0) + 1;
|
|
960
1172
|
tabState.lastRequestedUrl = previousTabState.lastRequestedUrl;
|
|
961
|
-
attachDownloadListener(tabState, tabId, log);
|
|
1173
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
962
1174
|
group.set(tabId, tabState);
|
|
963
1175
|
refreshActiveTabsGauge();
|
|
964
1176
|
|
|
@@ -1447,189 +1659,48 @@ async function refreshTabRefs(tabState, options = {}) {
|
|
|
1447
1659
|
return refreshedRefs;
|
|
1448
1660
|
}
|
|
1449
1661
|
|
|
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
1662
|
|
|
1663
|
+
/**
|
|
1664
|
+
* @openapi
|
|
1665
|
+
* /health:
|
|
1666
|
+
* get:
|
|
1667
|
+
* tags: [System]
|
|
1668
|
+
* summary: Health check
|
|
1669
|
+
* description: Detailed health with tab/session counts and failure tracking.
|
|
1670
|
+
* responses:
|
|
1671
|
+
* 200:
|
|
1672
|
+
* description: Healthy.
|
|
1673
|
+
* content:
|
|
1674
|
+
* application/json:
|
|
1675
|
+
* schema:
|
|
1676
|
+
* type: object
|
|
1677
|
+
* properties:
|
|
1678
|
+
* ok:
|
|
1679
|
+
* type: boolean
|
|
1680
|
+
* engine:
|
|
1681
|
+
* type: string
|
|
1682
|
+
* browserConnected:
|
|
1683
|
+
* type: boolean
|
|
1684
|
+
* browserRunning:
|
|
1685
|
+
* type: boolean
|
|
1686
|
+
* activeTabs:
|
|
1687
|
+
* type: integer
|
|
1688
|
+
* activeSessions:
|
|
1689
|
+
* type: integer
|
|
1690
|
+
* consecutiveFailures:
|
|
1691
|
+
* type: integer
|
|
1692
|
+
* 503:
|
|
1693
|
+
* description: Unhealthy or recovering.
|
|
1694
|
+
* content:
|
|
1695
|
+
* application/json:
|
|
1696
|
+
* schema:
|
|
1697
|
+
* type: object
|
|
1698
|
+
* properties:
|
|
1699
|
+
* ok:
|
|
1700
|
+
* type: boolean
|
|
1701
|
+
* recovering:
|
|
1702
|
+
* type: boolean
|
|
1703
|
+
*/
|
|
1633
1704
|
app.get('/health', (req, res) => {
|
|
1634
1705
|
if (healthState.isRecovering) {
|
|
1635
1706
|
return res.status(503).json({ ok: false, engine: 'camoufox', recovering: true });
|
|
@@ -1658,6 +1729,27 @@ app.get('/health', (req, res) => {
|
|
|
1658
1729
|
});
|
|
1659
1730
|
});
|
|
1660
1731
|
|
|
1732
|
+
/**
|
|
1733
|
+
* @openapi
|
|
1734
|
+
* /metrics:
|
|
1735
|
+
* get:
|
|
1736
|
+
* tags: [System]
|
|
1737
|
+
* summary: Prometheus metrics
|
|
1738
|
+
* description: Returns Prometheus text exposition format. Requires PROMETHEUS_ENABLED=1.
|
|
1739
|
+
* responses:
|
|
1740
|
+
* 200:
|
|
1741
|
+
* description: Prometheus metrics.
|
|
1742
|
+
* content:
|
|
1743
|
+
* text/plain:
|
|
1744
|
+
* schema:
|
|
1745
|
+
* type: string
|
|
1746
|
+
* 404:
|
|
1747
|
+
* description: Metrics disabled.
|
|
1748
|
+
* content:
|
|
1749
|
+
* application/json:
|
|
1750
|
+
* schema:
|
|
1751
|
+
* $ref: '#/components/schemas/Error'
|
|
1752
|
+
*/
|
|
1661
1753
|
app.get('/metrics', async (_req, res) => {
|
|
1662
1754
|
const reg = getRegister();
|
|
1663
1755
|
if (!reg) {
|
|
@@ -1669,24 +1761,92 @@ app.get('/metrics', async (_req, res) => {
|
|
|
1669
1761
|
});
|
|
1670
1762
|
|
|
1671
1763
|
// Create new tab
|
|
1764
|
+
/**
|
|
1765
|
+
* @openapi
|
|
1766
|
+
* /tabs:
|
|
1767
|
+
* post:
|
|
1768
|
+
* tags: [Tabs]
|
|
1769
|
+
* summary: Create a new tab
|
|
1770
|
+
* description: Creates a tab in the given session. Optionally navigates to an initial URL.
|
|
1771
|
+
* requestBody:
|
|
1772
|
+
* required: true
|
|
1773
|
+
* content:
|
|
1774
|
+
* application/json:
|
|
1775
|
+
* schema:
|
|
1776
|
+
* type: object
|
|
1777
|
+
* required: [userId, sessionKey]
|
|
1778
|
+
* properties:
|
|
1779
|
+
* userId:
|
|
1780
|
+
* type: string
|
|
1781
|
+
* description: Session owner.
|
|
1782
|
+
* sessionKey:
|
|
1783
|
+
* type: string
|
|
1784
|
+
* description: Tab group identifier.
|
|
1785
|
+
* listItemId:
|
|
1786
|
+
* type: string
|
|
1787
|
+
* description: Legacy alias for sessionKey.
|
|
1788
|
+
* url:
|
|
1789
|
+
* type: string
|
|
1790
|
+
* description: Optional initial URL.
|
|
1791
|
+
* trace:
|
|
1792
|
+
* type: boolean
|
|
1793
|
+
* description: Enable Playwright tracing for this session (screenshots, DOM snapshots, network). Must be set on first tab creation; cannot be added to an existing session.
|
|
1794
|
+
* responses:
|
|
1795
|
+
* 200:
|
|
1796
|
+
* description: Tab created.
|
|
1797
|
+
* content:
|
|
1798
|
+
* application/json:
|
|
1799
|
+
* schema:
|
|
1800
|
+
* type: object
|
|
1801
|
+
* properties:
|
|
1802
|
+
* tabId:
|
|
1803
|
+
* type: string
|
|
1804
|
+
* url:
|
|
1805
|
+
* type: string
|
|
1806
|
+
* 400:
|
|
1807
|
+
* description: Missing required fields.
|
|
1808
|
+
* content:
|
|
1809
|
+
* application/json:
|
|
1810
|
+
* schema:
|
|
1811
|
+
* $ref: '#/components/schemas/Error'
|
|
1812
|
+
* 429:
|
|
1813
|
+
* description: Tab limit reached.
|
|
1814
|
+
* content:
|
|
1815
|
+
* application/json:
|
|
1816
|
+
* schema:
|
|
1817
|
+
* $ref: '#/components/schemas/Error'
|
|
1818
|
+
* 409:
|
|
1819
|
+
* description: Cannot enable tracing on an existing session.
|
|
1820
|
+
* content:
|
|
1821
|
+
* application/json:
|
|
1822
|
+
* schema:
|
|
1823
|
+
* $ref: '#/components/schemas/Error'
|
|
1824
|
+
*/
|
|
1672
1825
|
app.post('/tabs', async (req, res) => {
|
|
1673
1826
|
try {
|
|
1674
|
-
const { userId, sessionKey, listItemId, url } = req.body;
|
|
1827
|
+
const { userId, sessionKey, listItemId, url, trace } = req.body;
|
|
1675
1828
|
// Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
|
|
1676
1829
|
const resolvedSessionKey = sessionKey || listItemId;
|
|
1677
1830
|
if (!userId || !resolvedSessionKey) {
|
|
1678
1831
|
return res.status(400).json({ error: 'userId and sessionKey required' });
|
|
1679
1832
|
}
|
|
1680
|
-
|
|
1833
|
+
|
|
1681
1834
|
const result = await withTimeout((async () => {
|
|
1682
|
-
const
|
|
1835
|
+
const existing = sessions.get(normalizeUserId(userId));
|
|
1836
|
+
if (trace && existing && !existing.tracePath) {
|
|
1837
|
+
throw Object.assign(
|
|
1838
|
+
new Error('trace must be set on session creation. DELETE /sessions/:userId first to restart with tracing.'),
|
|
1839
|
+
{ statusCode: 409 },
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
const session = await getSession(userId, { trace: !!trace });
|
|
1683
1843
|
|
|
1684
1844
|
let totalTabs = 0;
|
|
1685
1845
|
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
1686
1846
|
|
|
1687
1847
|
// Recycle oldest tab when limits are reached instead of rejecting
|
|
1688
1848
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
1689
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
1849
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
1690
1850
|
if (!recycled) {
|
|
1691
1851
|
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
|
|
1692
1852
|
}
|
|
@@ -1697,7 +1857,7 @@ app.post('/tabs', async (req, res) => {
|
|
|
1697
1857
|
const page = await session.context.newPage();
|
|
1698
1858
|
const tabId = fly.makeTabId();
|
|
1699
1859
|
const tabState = createTabState(page);
|
|
1700
|
-
attachDownloadListener(tabState, tabId);
|
|
1860
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1701
1861
|
group.set(tabId, tabState);
|
|
1702
1862
|
refreshActiveTabsGauge();
|
|
1703
1863
|
|
|
@@ -1709,6 +1869,7 @@ app.post('/tabs', async (req, res) => {
|
|
|
1709
1869
|
tabState.visitedUrls.add(url);
|
|
1710
1870
|
}
|
|
1711
1871
|
|
|
1872
|
+
pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
|
|
1712
1873
|
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
1713
1874
|
return { tabId, url: page.url() };
|
|
1714
1875
|
})(), requestTimeoutMs(), 'tab create');
|
|
@@ -1721,6 +1882,61 @@ app.post('/tabs', async (req, res) => {
|
|
|
1721
1882
|
});
|
|
1722
1883
|
|
|
1723
1884
|
// Navigate
|
|
1885
|
+
/**
|
|
1886
|
+
* @openapi
|
|
1887
|
+
* /tabs/{tabId}/navigate:
|
|
1888
|
+
* post:
|
|
1889
|
+
* tags: [Navigation]
|
|
1890
|
+
* summary: Navigate a tab to a URL or macro
|
|
1891
|
+
* description: Navigate to a URL or expand a search macro. Auto-creates tab if not found.
|
|
1892
|
+
* parameters:
|
|
1893
|
+
* - name: tabId
|
|
1894
|
+
* in: path
|
|
1895
|
+
* required: true
|
|
1896
|
+
* schema:
|
|
1897
|
+
* type: string
|
|
1898
|
+
* requestBody:
|
|
1899
|
+
* required: true
|
|
1900
|
+
* content:
|
|
1901
|
+
* application/json:
|
|
1902
|
+
* schema:
|
|
1903
|
+
* type: object
|
|
1904
|
+
* required: [userId]
|
|
1905
|
+
* properties:
|
|
1906
|
+
* userId:
|
|
1907
|
+
* type: string
|
|
1908
|
+
* url:
|
|
1909
|
+
* type: string
|
|
1910
|
+
* macro:
|
|
1911
|
+
* type: string
|
|
1912
|
+
* description: Search macro (e.g. @google_search).
|
|
1913
|
+
* query:
|
|
1914
|
+
* type: string
|
|
1915
|
+
* description: Search query for macro.
|
|
1916
|
+
* sessionKey:
|
|
1917
|
+
* type: string
|
|
1918
|
+
* listItemId:
|
|
1919
|
+
* type: string
|
|
1920
|
+
* responses:
|
|
1921
|
+
* 200:
|
|
1922
|
+
* description: Navigation result with snapshot.
|
|
1923
|
+
* content:
|
|
1924
|
+
* application/json:
|
|
1925
|
+
* schema:
|
|
1926
|
+
* type: object
|
|
1927
|
+
* 400:
|
|
1928
|
+
* description: Bad request.
|
|
1929
|
+
* content:
|
|
1930
|
+
* application/json:
|
|
1931
|
+
* schema:
|
|
1932
|
+
* $ref: '#/components/schemas/Error'
|
|
1933
|
+
* 404:
|
|
1934
|
+
* description: Tab not found.
|
|
1935
|
+
* content:
|
|
1936
|
+
* application/json:
|
|
1937
|
+
* schema:
|
|
1938
|
+
* $ref: '#/components/schemas/Error'
|
|
1939
|
+
*/
|
|
1724
1940
|
app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
1725
1941
|
const tabId = req.params.tabId;
|
|
1726
1942
|
|
|
@@ -1741,7 +1957,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1741
1957
|
for (const g of session.tabGroups.values()) sessionTabs += g.size;
|
|
1742
1958
|
if (getTotalTabCount() >= MAX_TABS_GLOBAL || sessionTabs >= MAX_TABS_PER_SESSION) {
|
|
1743
1959
|
// Recycle oldest tab to free a slot, then create new page
|
|
1744
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
1960
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
1745
1961
|
if (!recycled) {
|
|
1746
1962
|
throw new Error('Maximum tabs per session reached');
|
|
1747
1963
|
}
|
|
@@ -1749,7 +1965,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1749
1965
|
{
|
|
1750
1966
|
const page = await session.context.newPage();
|
|
1751
1967
|
tabState = createTabState(page);
|
|
1752
|
-
attachDownloadListener(tabState, tabId, log);
|
|
1968
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1753
1969
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
1754
1970
|
group.set(tabId, tabState);
|
|
1755
1971
|
refreshActiveTabsGauge();
|
|
@@ -1758,7 +1974,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1758
1974
|
} else {
|
|
1759
1975
|
tabState = found.tabState;
|
|
1760
1976
|
}
|
|
1761
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1977
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
1762
1978
|
|
|
1763
1979
|
let targetUrl = url;
|
|
1764
1980
|
if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
|
|
@@ -1776,9 +1992,21 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1776
1992
|
|
|
1777
1993
|
const navigateCurrentPage = async () => {
|
|
1778
1994
|
tabState.lastRequestedUrl = targetUrl;
|
|
1779
|
-
|
|
1780
|
-
tabState.
|
|
1781
|
-
|
|
1995
|
+
const ac = tabState.navigateAbort = new AbortController();
|
|
1996
|
+
const gotoP = withPageLoadDuration('navigate', () => tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
1997
|
+
try {
|
|
1998
|
+
await Promise.race([
|
|
1999
|
+
gotoP,
|
|
2000
|
+
new Promise((_, reject) => ac.signal.addEventListener('abort', () => reject(new Error('Navigation aborted: tab deleted')), { once: true })),
|
|
2001
|
+
]);
|
|
2002
|
+
tabState.visitedUrls.add(targetUrl);
|
|
2003
|
+
tabState.lastSnapshot = null;
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
gotoP.catch(() => {}); // suppress unhandled rejection from still-pending goto
|
|
2006
|
+
throw err;
|
|
2007
|
+
} finally {
|
|
2008
|
+
tabState.navigateAbort = null;
|
|
2009
|
+
}
|
|
1782
2010
|
};
|
|
1783
2011
|
|
|
1784
2012
|
const prewarmGoogleHome = async () => {
|
|
@@ -1796,15 +2024,14 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1796
2024
|
const key = normalizeUserId(userId);
|
|
1797
2025
|
const oldSession = sessions.get(key);
|
|
1798
2026
|
if (oldSession) {
|
|
1799
|
-
await
|
|
1800
|
-
sessions.delete(key);
|
|
2027
|
+
await closeSession(key, oldSession, { reason: 'google_blocked_context_rotate', clearDownloads: true, clearLocks: true });
|
|
1801
2028
|
}
|
|
1802
2029
|
session = await getSession(userId);
|
|
1803
2030
|
const group = getTabGroup(session, currentSessionKey);
|
|
1804
2031
|
const page = await session.context.newPage();
|
|
1805
2032
|
tabState = createTabState(page);
|
|
1806
2033
|
tabState.googleRetryCount = previousRetryCount + 1;
|
|
1807
|
-
attachDownloadListener(tabState, tabId, log);
|
|
2034
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
1808
2035
|
group.set(tabId, tabState);
|
|
1809
2036
|
refreshActiveTabsGauge();
|
|
1810
2037
|
};
|
|
@@ -1845,6 +2072,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1845
2072
|
})(), requestTimeoutMs(), 'navigate'));
|
|
1846
2073
|
|
|
1847
2074
|
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
|
|
2075
|
+
pluginEvents.emit('tab:navigated', { userId: req.body.userId, tabId, url: result.url, prevUrl: null });
|
|
1848
2076
|
res.json(result);
|
|
1849
2077
|
} catch (err) {
|
|
1850
2078
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
@@ -1857,6 +2085,69 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
1857
2085
|
});
|
|
1858
2086
|
|
|
1859
2087
|
// Snapshot
|
|
2088
|
+
/**
|
|
2089
|
+
* @openapi
|
|
2090
|
+
* /tabs/{tabId}/snapshot:
|
|
2091
|
+
* get:
|
|
2092
|
+
* tags: [Content]
|
|
2093
|
+
* summary: Accessibility snapshot
|
|
2094
|
+
* description: Returns accessibility tree with element refs. Supports pagination via offset.
|
|
2095
|
+
* parameters:
|
|
2096
|
+
* - name: tabId
|
|
2097
|
+
* in: path
|
|
2098
|
+
* required: true
|
|
2099
|
+
* schema:
|
|
2100
|
+
* type: string
|
|
2101
|
+
* - name: userId
|
|
2102
|
+
* in: query
|
|
2103
|
+
* required: true
|
|
2104
|
+
* schema:
|
|
2105
|
+
* type: string
|
|
2106
|
+
* - name: format
|
|
2107
|
+
* in: query
|
|
2108
|
+
* schema:
|
|
2109
|
+
* type: string
|
|
2110
|
+
* enum: [text, json]
|
|
2111
|
+
* default: text
|
|
2112
|
+
* - name: offset
|
|
2113
|
+
* in: query
|
|
2114
|
+
* schema:
|
|
2115
|
+
* type: integer
|
|
2116
|
+
* description: Character offset for paginated retrieval.
|
|
2117
|
+
* - name: includeScreenshot
|
|
2118
|
+
* in: query
|
|
2119
|
+
* schema:
|
|
2120
|
+
* type: string
|
|
2121
|
+
* enum: ['true', 'false']
|
|
2122
|
+
* responses:
|
|
2123
|
+
* 200:
|
|
2124
|
+
* description: Snapshot.
|
|
2125
|
+
* content:
|
|
2126
|
+
* application/json:
|
|
2127
|
+
* schema:
|
|
2128
|
+
* type: object
|
|
2129
|
+
* properties:
|
|
2130
|
+
* url:
|
|
2131
|
+
* type: string
|
|
2132
|
+
* snapshot:
|
|
2133
|
+
* type: string
|
|
2134
|
+
* refsCount:
|
|
2135
|
+
* type: integer
|
|
2136
|
+
* truncated:
|
|
2137
|
+
* type: boolean
|
|
2138
|
+
* totalChars:
|
|
2139
|
+
* type: integer
|
|
2140
|
+
* hasMore:
|
|
2141
|
+
* type: boolean
|
|
2142
|
+
* nextOffset:
|
|
2143
|
+
* type: integer
|
|
2144
|
+
* 404:
|
|
2145
|
+
* description: Tab not found.
|
|
2146
|
+
* content:
|
|
2147
|
+
* application/json:
|
|
2148
|
+
* schema:
|
|
2149
|
+
* $ref: '#/components/schemas/Error'
|
|
2150
|
+
*/
|
|
1860
2151
|
app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
1861
2152
|
try {
|
|
1862
2153
|
const userId = req.query.userId;
|
|
@@ -1868,7 +2159,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1868
2159
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1869
2160
|
|
|
1870
2161
|
const { tabState } = found;
|
|
1871
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2162
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
1872
2163
|
|
|
1873
2164
|
// Cached chunk retrieval for offset>0 requests
|
|
1874
2165
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -1909,6 +2200,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1909
2200
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
1910
2201
|
tabState.refs = googleRefs;
|
|
1911
2202
|
tabState.lastSnapshot = googleSnapshot;
|
|
2203
|
+
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
1912
2204
|
const annotatedYaml = googleSnapshot;
|
|
1913
2205
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
1914
2206
|
const response = {
|
|
@@ -1965,6 +2257,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1965
2257
|
}
|
|
1966
2258
|
|
|
1967
2259
|
tabState.lastSnapshot = annotatedYaml;
|
|
2260
|
+
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
1968
2261
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
1969
2262
|
|
|
1970
2263
|
const response = {
|
|
@@ -1985,6 +2278,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1985
2278
|
return response;
|
|
1986
2279
|
})(), requestTimeoutMs(), 'snapshot'));
|
|
1987
2280
|
|
|
2281
|
+
pluginEvents.emit('tab:snapshot', { userId: req.query.userId, tabId: req.params.tabId, snapshot: result.snapshot });
|
|
1988
2282
|
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
2283
|
res.json(result);
|
|
1990
2284
|
} catch (err) {
|
|
@@ -1994,6 +2288,50 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1994
2288
|
});
|
|
1995
2289
|
|
|
1996
2290
|
// Wait for page ready
|
|
2291
|
+
/**
|
|
2292
|
+
* @openapi
|
|
2293
|
+
* /tabs/{tabId}/wait:
|
|
2294
|
+
* post:
|
|
2295
|
+
* tags: [Interaction]
|
|
2296
|
+
* summary: Wait for a selector or timeout
|
|
2297
|
+
* parameters:
|
|
2298
|
+
* - name: tabId
|
|
2299
|
+
* in: path
|
|
2300
|
+
* required: true
|
|
2301
|
+
* schema:
|
|
2302
|
+
* type: string
|
|
2303
|
+
* requestBody:
|
|
2304
|
+
* required: true
|
|
2305
|
+
* content:
|
|
2306
|
+
* application/json:
|
|
2307
|
+
* schema:
|
|
2308
|
+
* type: object
|
|
2309
|
+
* required: [userId]
|
|
2310
|
+
* properties:
|
|
2311
|
+
* userId:
|
|
2312
|
+
* type: string
|
|
2313
|
+
* selector:
|
|
2314
|
+
* type: string
|
|
2315
|
+
* timeout:
|
|
2316
|
+
* type: integer
|
|
2317
|
+
* description: Max wait in ms.
|
|
2318
|
+
* responses:
|
|
2319
|
+
* 200:
|
|
2320
|
+
* description: Wait completed.
|
|
2321
|
+
* content:
|
|
2322
|
+
* application/json:
|
|
2323
|
+
* schema:
|
|
2324
|
+
* type: object
|
|
2325
|
+
* properties:
|
|
2326
|
+
* ok:
|
|
2327
|
+
* type: boolean
|
|
2328
|
+
* 404:
|
|
2329
|
+
* description: Tab not found.
|
|
2330
|
+
* content:
|
|
2331
|
+
* application/json:
|
|
2332
|
+
* schema:
|
|
2333
|
+
* $ref: '#/components/schemas/Error'
|
|
2334
|
+
*/
|
|
1997
2335
|
app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
1998
2336
|
try {
|
|
1999
2337
|
const { userId, timeout = 10000, waitForNetwork = true } = req.body;
|
|
@@ -2012,6 +2350,64 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
|
2012
2350
|
});
|
|
2013
2351
|
|
|
2014
2352
|
// Click
|
|
2353
|
+
/**
|
|
2354
|
+
* @openapi
|
|
2355
|
+
* /tabs/{tabId}/click:
|
|
2356
|
+
* post:
|
|
2357
|
+
* tags: [Interaction]
|
|
2358
|
+
* summary: Click an element
|
|
2359
|
+
* description: Click by element ref, CSS selector, or coordinates.
|
|
2360
|
+
* parameters:
|
|
2361
|
+
* - name: tabId
|
|
2362
|
+
* in: path
|
|
2363
|
+
* required: true
|
|
2364
|
+
* schema:
|
|
2365
|
+
* type: string
|
|
2366
|
+
* requestBody:
|
|
2367
|
+
* required: true
|
|
2368
|
+
* content:
|
|
2369
|
+
* application/json:
|
|
2370
|
+
* schema:
|
|
2371
|
+
* type: object
|
|
2372
|
+
* required: [userId]
|
|
2373
|
+
* properties:
|
|
2374
|
+
* userId:
|
|
2375
|
+
* type: string
|
|
2376
|
+
* ref:
|
|
2377
|
+
* type: string
|
|
2378
|
+
* description: Element ref ID (e.g. "e3").
|
|
2379
|
+
* selector:
|
|
2380
|
+
* type: string
|
|
2381
|
+
* description: CSS selector fallback.
|
|
2382
|
+
* doubleClick:
|
|
2383
|
+
* type: boolean
|
|
2384
|
+
* coordinates:
|
|
2385
|
+
* type: object
|
|
2386
|
+
* properties:
|
|
2387
|
+
* x:
|
|
2388
|
+
* type: number
|
|
2389
|
+
* y:
|
|
2390
|
+
* type: number
|
|
2391
|
+
* responses:
|
|
2392
|
+
* 200:
|
|
2393
|
+
* description: Click result with optional post-action snapshot.
|
|
2394
|
+
* content:
|
|
2395
|
+
* application/json:
|
|
2396
|
+
* schema:
|
|
2397
|
+
* type: object
|
|
2398
|
+
* 400:
|
|
2399
|
+
* description: Bad request.
|
|
2400
|
+
* content:
|
|
2401
|
+
* application/json:
|
|
2402
|
+
* schema:
|
|
2403
|
+
* $ref: '#/components/schemas/Error'
|
|
2404
|
+
* 404:
|
|
2405
|
+
* description: Tab not found.
|
|
2406
|
+
* content:
|
|
2407
|
+
* application/json:
|
|
2408
|
+
* schema:
|
|
2409
|
+
* $ref: '#/components/schemas/Error'
|
|
2410
|
+
*/
|
|
2015
2411
|
app.post('/tabs/:tabId/click', async (req, res) => {
|
|
2016
2412
|
const tabId = req.params.tabId;
|
|
2017
2413
|
|
|
@@ -2023,7 +2419,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2023
2419
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2024
2420
|
|
|
2025
2421
|
const { tabState } = found;
|
|
2026
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2422
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2027
2423
|
|
|
2028
2424
|
if (!ref && !selector) {
|
|
2029
2425
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
@@ -2157,6 +2553,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2157
2553
|
}));
|
|
2158
2554
|
|
|
2159
2555
|
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
2556
|
+
pluginEvents.emit('tab:click', { userId: req.body.userId, tabId, ref: req.body.ref, selector: req.body.selector });
|
|
2160
2557
|
res.json(result);
|
|
2161
2558
|
} catch (err) {
|
|
2162
2559
|
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
@@ -2183,37 +2580,117 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
2183
2580
|
});
|
|
2184
2581
|
|
|
2185
2582
|
// Type
|
|
2583
|
+
/**
|
|
2584
|
+
* @openapi
|
|
2585
|
+
* /tabs/{tabId}/type:
|
|
2586
|
+
* post:
|
|
2587
|
+
* tags: [Interaction]
|
|
2588
|
+
* summary: Type text into an element
|
|
2589
|
+
* description: Types text into a focused element or a specific ref/selector.
|
|
2590
|
+
* parameters:
|
|
2591
|
+
* - name: tabId
|
|
2592
|
+
* in: path
|
|
2593
|
+
* required: true
|
|
2594
|
+
* schema:
|
|
2595
|
+
* type: string
|
|
2596
|
+
* requestBody:
|
|
2597
|
+
* required: true
|
|
2598
|
+
* content:
|
|
2599
|
+
* application/json:
|
|
2600
|
+
* schema:
|
|
2601
|
+
* type: object
|
|
2602
|
+
* required: [userId, text]
|
|
2603
|
+
* properties:
|
|
2604
|
+
* userId:
|
|
2605
|
+
* type: string
|
|
2606
|
+
* ref:
|
|
2607
|
+
* type: string
|
|
2608
|
+
* selector:
|
|
2609
|
+
* type: string
|
|
2610
|
+
* text:
|
|
2611
|
+
* type: string
|
|
2612
|
+
* clear:
|
|
2613
|
+
* type: boolean
|
|
2614
|
+
* description: Clear field before typing.
|
|
2615
|
+
* submit:
|
|
2616
|
+
* type: boolean
|
|
2617
|
+
* description: Press Enter after typing.
|
|
2618
|
+
* responses:
|
|
2619
|
+
* 200:
|
|
2620
|
+
* description: Type result.
|
|
2621
|
+
* content:
|
|
2622
|
+
* application/json:
|
|
2623
|
+
* schema:
|
|
2624
|
+
* type: object
|
|
2625
|
+
* 400:
|
|
2626
|
+
* description: Bad request.
|
|
2627
|
+
* content:
|
|
2628
|
+
* application/json:
|
|
2629
|
+
* schema:
|
|
2630
|
+
* $ref: '#/components/schemas/Error'
|
|
2631
|
+
* 404:
|
|
2632
|
+
* description: Tab not found.
|
|
2633
|
+
* content:
|
|
2634
|
+
* application/json:
|
|
2635
|
+
* schema:
|
|
2636
|
+
* $ref: '#/components/schemas/Error'
|
|
2637
|
+
*/
|
|
2186
2638
|
app.post('/tabs/:tabId/type', async (req, res) => {
|
|
2187
2639
|
const tabId = req.params.tabId;
|
|
2188
2640
|
|
|
2189
2641
|
try {
|
|
2190
|
-
const { userId, ref, selector, text } = req.body;
|
|
2642
|
+
const { userId, ref, selector, text, mode = 'fill', delay = 30, submit = false, pressEnter = false } = req.body;
|
|
2191
2643
|
const session = sessions.get(normalizeUserId(userId));
|
|
2192
2644
|
const found = session && findTab(session, tabId);
|
|
2193
2645
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2194
2646
|
|
|
2195
2647
|
const { tabState } = found;
|
|
2196
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2648
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2197
2649
|
|
|
2198
|
-
if (
|
|
2199
|
-
return res.status(400).json({ error: '
|
|
2650
|
+
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
2651
|
+
return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
|
|
2652
|
+
}
|
|
2653
|
+
if (typeof text !== 'string') {
|
|
2654
|
+
return res.status(400).json({ error: 'text is required' });
|
|
2200
2655
|
}
|
|
2656
|
+
// keyboard mode: ref/selector are optional (types into current focus)
|
|
2657
|
+
if (mode === 'fill' && !ref && !selector) {
|
|
2658
|
+
return res.status(400).json({ error: 'ref or selector required for mode=fill' });
|
|
2659
|
+
}
|
|
2660
|
+
const shouldSubmit = submit || pressEnter;
|
|
2201
2661
|
|
|
2202
2662
|
await withTabLock(tabId, async () => {
|
|
2663
|
+
// Resolve and focus the target if ref/selector provided
|
|
2664
|
+
let locator = null;
|
|
2203
2665
|
if (ref) {
|
|
2204
|
-
|
|
2666
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2205
2667
|
if (!locator) {
|
|
2206
|
-
log('info', 'auto-refreshing refs before
|
|
2668
|
+
log('info', 'auto-refreshing refs before type', { ref, hadRefs: tabState.refs.size, mode });
|
|
2207
2669
|
tabState.refs = await refreshTabRefs(tabState, { reason: 'type' });
|
|
2208
2670
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2209
2671
|
}
|
|
2210
2672
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
2211
|
-
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
if (mode === 'fill') {
|
|
2676
|
+
if (locator) {
|
|
2677
|
+
await locator.fill(text, { timeout: 10000 });
|
|
2678
|
+
} else {
|
|
2679
|
+
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
2680
|
+
}
|
|
2212
2681
|
} else {
|
|
2213
|
-
|
|
2682
|
+
// keyboard mode — char-by-char real key events (required for Ember/contenteditable)
|
|
2683
|
+
if (locator) {
|
|
2684
|
+
await locator.focus({ timeout: 10000 });
|
|
2685
|
+
} else if (selector) {
|
|
2686
|
+
await tabState.page.focus(selector, { timeout: 10000 });
|
|
2687
|
+
}
|
|
2688
|
+
await tabState.page.keyboard.type(text, { delay });
|
|
2214
2689
|
}
|
|
2690
|
+
if (shouldSubmit) await tabState.page.keyboard.press('Enter');
|
|
2215
2691
|
});
|
|
2216
2692
|
|
|
2693
|
+
pluginEvents.emit('tab:type', { userId: req.body.userId, tabId, text: req.body.text, ref: req.body.ref, mode: req.body.mode || 'fill' });
|
|
2217
2694
|
res.json({ ok: true });
|
|
2218
2695
|
} catch (err) {
|
|
2219
2696
|
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2240,6 +2717,48 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
2240
2717
|
});
|
|
2241
2718
|
|
|
2242
2719
|
// Press key
|
|
2720
|
+
/**
|
|
2721
|
+
* @openapi
|
|
2722
|
+
* /tabs/{tabId}/press:
|
|
2723
|
+
* post:
|
|
2724
|
+
* tags: [Interaction]
|
|
2725
|
+
* summary: Press a keyboard key
|
|
2726
|
+
* parameters:
|
|
2727
|
+
* - name: tabId
|
|
2728
|
+
* in: path
|
|
2729
|
+
* required: true
|
|
2730
|
+
* schema:
|
|
2731
|
+
* type: string
|
|
2732
|
+
* requestBody:
|
|
2733
|
+
* required: true
|
|
2734
|
+
* content:
|
|
2735
|
+
* application/json:
|
|
2736
|
+
* schema:
|
|
2737
|
+
* type: object
|
|
2738
|
+
* required: [userId, key]
|
|
2739
|
+
* properties:
|
|
2740
|
+
* userId:
|
|
2741
|
+
* type: string
|
|
2742
|
+
* key:
|
|
2743
|
+
* type: string
|
|
2744
|
+
* description: Key name (e.g. "Enter", "Escape", "Tab").
|
|
2745
|
+
* responses:
|
|
2746
|
+
* 200:
|
|
2747
|
+
* description: Key pressed.
|
|
2748
|
+
* content:
|
|
2749
|
+
* application/json:
|
|
2750
|
+
* schema:
|
|
2751
|
+
* type: object
|
|
2752
|
+
* properties:
|
|
2753
|
+
* ok:
|
|
2754
|
+
* type: boolean
|
|
2755
|
+
* 404:
|
|
2756
|
+
* description: Tab not found.
|
|
2757
|
+
* content:
|
|
2758
|
+
* application/json:
|
|
2759
|
+
* schema:
|
|
2760
|
+
* $ref: '#/components/schemas/Error'
|
|
2761
|
+
*/
|
|
2243
2762
|
app.post('/tabs/:tabId/press', async (req, res) => {
|
|
2244
2763
|
const tabId = req.params.tabId;
|
|
2245
2764
|
|
|
@@ -2250,12 +2769,13 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
2250
2769
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2251
2770
|
|
|
2252
2771
|
const { tabState } = found;
|
|
2253
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2772
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2254
2773
|
|
|
2255
2774
|
await withTabLock(tabId, async () => {
|
|
2256
2775
|
await tabState.page.keyboard.press(key);
|
|
2257
2776
|
});
|
|
2258
2777
|
|
|
2778
|
+
pluginEvents.emit('tab:press', { userId, tabId, key });
|
|
2259
2779
|
res.json({ ok: true });
|
|
2260
2780
|
} catch (err) {
|
|
2261
2781
|
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2264,6 +2784,51 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
2264
2784
|
});
|
|
2265
2785
|
|
|
2266
2786
|
// Scroll
|
|
2787
|
+
/**
|
|
2788
|
+
* @openapi
|
|
2789
|
+
* /tabs/{tabId}/scroll:
|
|
2790
|
+
* post:
|
|
2791
|
+
* tags: [Interaction]
|
|
2792
|
+
* summary: Scroll the page
|
|
2793
|
+
* parameters:
|
|
2794
|
+
* - name: tabId
|
|
2795
|
+
* in: path
|
|
2796
|
+
* required: true
|
|
2797
|
+
* schema:
|
|
2798
|
+
* type: string
|
|
2799
|
+
* requestBody:
|
|
2800
|
+
* required: true
|
|
2801
|
+
* content:
|
|
2802
|
+
* application/json:
|
|
2803
|
+
* schema:
|
|
2804
|
+
* type: object
|
|
2805
|
+
* required: [userId]
|
|
2806
|
+
* properties:
|
|
2807
|
+
* userId:
|
|
2808
|
+
* type: string
|
|
2809
|
+
* direction:
|
|
2810
|
+
* type: string
|
|
2811
|
+
* description: '"up" or "down" (default "down").'
|
|
2812
|
+
* amount:
|
|
2813
|
+
* type: integer
|
|
2814
|
+
* description: Pixels to scroll.
|
|
2815
|
+
* responses:
|
|
2816
|
+
* 200:
|
|
2817
|
+
* description: Scroll result.
|
|
2818
|
+
* content:
|
|
2819
|
+
* application/json:
|
|
2820
|
+
* schema:
|
|
2821
|
+
* type: object
|
|
2822
|
+
* properties:
|
|
2823
|
+
* ok:
|
|
2824
|
+
* type: boolean
|
|
2825
|
+
* 404:
|
|
2826
|
+
* description: Tab not found.
|
|
2827
|
+
* content:
|
|
2828
|
+
* application/json:
|
|
2829
|
+
* schema:
|
|
2830
|
+
* $ref: '#/components/schemas/Error'
|
|
2831
|
+
*/
|
|
2267
2832
|
app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
2268
2833
|
try {
|
|
2269
2834
|
const { userId, direction = 'down', amount = 500 } = req.body;
|
|
@@ -2272,13 +2837,14 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
2272
2837
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2273
2838
|
|
|
2274
2839
|
const { tabState } = found;
|
|
2275
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2840
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2276
2841
|
|
|
2277
2842
|
const isVertical = direction === 'up' || direction === 'down';
|
|
2278
2843
|
const delta = (direction === 'up' || direction === 'left') ? -amount : amount;
|
|
2279
2844
|
await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
|
|
2280
2845
|
await tabState.page.waitForTimeout(300);
|
|
2281
2846
|
|
|
2847
|
+
pluginEvents.emit('tab:scroll', { userId, tabId: req.params.tabId, direction, amount });
|
|
2282
2848
|
res.json({ ok: true });
|
|
2283
2849
|
} catch (err) {
|
|
2284
2850
|
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
@@ -2287,6 +2853,47 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
2287
2853
|
});
|
|
2288
2854
|
|
|
2289
2855
|
// Back
|
|
2856
|
+
/**
|
|
2857
|
+
* @openapi
|
|
2858
|
+
* /tabs/{tabId}/back:
|
|
2859
|
+
* post:
|
|
2860
|
+
* tags: [Navigation]
|
|
2861
|
+
* summary: Go back
|
|
2862
|
+
* parameters:
|
|
2863
|
+
* - name: tabId
|
|
2864
|
+
* in: path
|
|
2865
|
+
* required: true
|
|
2866
|
+
* schema:
|
|
2867
|
+
* type: string
|
|
2868
|
+
* requestBody:
|
|
2869
|
+
* required: true
|
|
2870
|
+
* content:
|
|
2871
|
+
* application/json:
|
|
2872
|
+
* schema:
|
|
2873
|
+
* type: object
|
|
2874
|
+
* required: [userId]
|
|
2875
|
+
* properties:
|
|
2876
|
+
* userId:
|
|
2877
|
+
* type: string
|
|
2878
|
+
* responses:
|
|
2879
|
+
* 200:
|
|
2880
|
+
* description: Navigated back.
|
|
2881
|
+
* content:
|
|
2882
|
+
* application/json:
|
|
2883
|
+
* schema:
|
|
2884
|
+
* type: object
|
|
2885
|
+
* properties:
|
|
2886
|
+
* ok:
|
|
2887
|
+
* type: boolean
|
|
2888
|
+
* url:
|
|
2889
|
+
* type: string
|
|
2890
|
+
* 404:
|
|
2891
|
+
* description: Tab not found.
|
|
2892
|
+
* content:
|
|
2893
|
+
* application/json:
|
|
2894
|
+
* schema:
|
|
2895
|
+
* $ref: '#/components/schemas/Error'
|
|
2896
|
+
*/
|
|
2290
2897
|
app.post('/tabs/:tabId/back', async (req, res) => {
|
|
2291
2898
|
const tabId = req.params.tabId;
|
|
2292
2899
|
|
|
@@ -2297,7 +2904,7 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
2297
2904
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2298
2905
|
|
|
2299
2906
|
const { tabState } = found;
|
|
2300
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2907
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2301
2908
|
|
|
2302
2909
|
const result = await withTabLock(tabId, async () => {
|
|
2303
2910
|
try {
|
|
@@ -2323,6 +2930,47 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
2323
2930
|
});
|
|
2324
2931
|
|
|
2325
2932
|
// Forward
|
|
2933
|
+
/**
|
|
2934
|
+
* @openapi
|
|
2935
|
+
* /tabs/{tabId}/forward:
|
|
2936
|
+
* post:
|
|
2937
|
+
* tags: [Navigation]
|
|
2938
|
+
* summary: Go forward
|
|
2939
|
+
* parameters:
|
|
2940
|
+
* - name: tabId
|
|
2941
|
+
* in: path
|
|
2942
|
+
* required: true
|
|
2943
|
+
* schema:
|
|
2944
|
+
* type: string
|
|
2945
|
+
* requestBody:
|
|
2946
|
+
* required: true
|
|
2947
|
+
* content:
|
|
2948
|
+
* application/json:
|
|
2949
|
+
* schema:
|
|
2950
|
+
* type: object
|
|
2951
|
+
* required: [userId]
|
|
2952
|
+
* properties:
|
|
2953
|
+
* userId:
|
|
2954
|
+
* type: string
|
|
2955
|
+
* responses:
|
|
2956
|
+
* 200:
|
|
2957
|
+
* description: Navigated forward.
|
|
2958
|
+
* content:
|
|
2959
|
+
* application/json:
|
|
2960
|
+
* schema:
|
|
2961
|
+
* type: object
|
|
2962
|
+
* properties:
|
|
2963
|
+
* ok:
|
|
2964
|
+
* type: boolean
|
|
2965
|
+
* url:
|
|
2966
|
+
* type: string
|
|
2967
|
+
* 404:
|
|
2968
|
+
* description: Tab not found.
|
|
2969
|
+
* content:
|
|
2970
|
+
* application/json:
|
|
2971
|
+
* schema:
|
|
2972
|
+
* $ref: '#/components/schemas/Error'
|
|
2973
|
+
*/
|
|
2326
2974
|
app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
2327
2975
|
const tabId = req.params.tabId;
|
|
2328
2976
|
|
|
@@ -2333,7 +2981,7 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
2333
2981
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2334
2982
|
|
|
2335
2983
|
const { tabState } = found;
|
|
2336
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
2984
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2337
2985
|
|
|
2338
2986
|
const result = await withTabLock(tabId, async () => {
|
|
2339
2987
|
await tabState.page.goForward({ timeout: 10000 });
|
|
@@ -2349,6 +2997,47 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
2349
2997
|
});
|
|
2350
2998
|
|
|
2351
2999
|
// Refresh
|
|
3000
|
+
/**
|
|
3001
|
+
* @openapi
|
|
3002
|
+
* /tabs/{tabId}/refresh:
|
|
3003
|
+
* post:
|
|
3004
|
+
* tags: [Navigation]
|
|
3005
|
+
* summary: Refresh page
|
|
3006
|
+
* parameters:
|
|
3007
|
+
* - name: tabId
|
|
3008
|
+
* in: path
|
|
3009
|
+
* required: true
|
|
3010
|
+
* schema:
|
|
3011
|
+
* type: string
|
|
3012
|
+
* requestBody:
|
|
3013
|
+
* required: true
|
|
3014
|
+
* content:
|
|
3015
|
+
* application/json:
|
|
3016
|
+
* schema:
|
|
3017
|
+
* type: object
|
|
3018
|
+
* required: [userId]
|
|
3019
|
+
* properties:
|
|
3020
|
+
* userId:
|
|
3021
|
+
* type: string
|
|
3022
|
+
* responses:
|
|
3023
|
+
* 200:
|
|
3024
|
+
* description: Page refreshed.
|
|
3025
|
+
* content:
|
|
3026
|
+
* application/json:
|
|
3027
|
+
* schema:
|
|
3028
|
+
* type: object
|
|
3029
|
+
* properties:
|
|
3030
|
+
* ok:
|
|
3031
|
+
* type: boolean
|
|
3032
|
+
* url:
|
|
3033
|
+
* type: string
|
|
3034
|
+
* 404:
|
|
3035
|
+
* description: Tab not found.
|
|
3036
|
+
* content:
|
|
3037
|
+
* application/json:
|
|
3038
|
+
* schema:
|
|
3039
|
+
* $ref: '#/components/schemas/Error'
|
|
3040
|
+
*/
|
|
2352
3041
|
app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
2353
3042
|
const tabId = req.params.tabId;
|
|
2354
3043
|
|
|
@@ -2359,7 +3048,7 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
2359
3048
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
2360
3049
|
|
|
2361
3050
|
const { tabState } = found;
|
|
2362
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
3051
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2363
3052
|
|
|
2364
3053
|
const result = await withTabLock(tabId, async () => {
|
|
2365
3054
|
await tabState.page.reload({ timeout: 30000 });
|
|
@@ -2375,6 +3064,49 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
2375
3064
|
});
|
|
2376
3065
|
|
|
2377
3066
|
// Get links
|
|
3067
|
+
/**
|
|
3068
|
+
* @openapi
|
|
3069
|
+
* /tabs/{tabId}/links:
|
|
3070
|
+
* get:
|
|
3071
|
+
* tags: [Content]
|
|
3072
|
+
* summary: Extract page links
|
|
3073
|
+
* parameters:
|
|
3074
|
+
* - name: tabId
|
|
3075
|
+
* in: path
|
|
3076
|
+
* required: true
|
|
3077
|
+
* schema:
|
|
3078
|
+
* type: string
|
|
3079
|
+
* - name: userId
|
|
3080
|
+
* in: query
|
|
3081
|
+
* required: true
|
|
3082
|
+
* schema:
|
|
3083
|
+
* type: string
|
|
3084
|
+
* responses:
|
|
3085
|
+
* 200:
|
|
3086
|
+
* description: Links extracted.
|
|
3087
|
+
* content:
|
|
3088
|
+
* application/json:
|
|
3089
|
+
* schema:
|
|
3090
|
+
* type: object
|
|
3091
|
+
* properties:
|
|
3092
|
+
* links:
|
|
3093
|
+
* type: array
|
|
3094
|
+
* items:
|
|
3095
|
+
* type: object
|
|
3096
|
+
* properties:
|
|
3097
|
+
* text:
|
|
3098
|
+
* type: string
|
|
3099
|
+
* href:
|
|
3100
|
+
* type: string
|
|
3101
|
+
* ref:
|
|
3102
|
+
* type: string
|
|
3103
|
+
* 404:
|
|
3104
|
+
* description: Tab not found.
|
|
3105
|
+
* content:
|
|
3106
|
+
* application/json:
|
|
3107
|
+
* schema:
|
|
3108
|
+
* $ref: '#/components/schemas/Error'
|
|
3109
|
+
*/
|
|
2378
3110
|
app.get('/tabs/:tabId/links', async (req, res) => {
|
|
2379
3111
|
try {
|
|
2380
3112
|
const userId = req.query.userId;
|
|
@@ -2388,7 +3120,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
2388
3120
|
}
|
|
2389
3121
|
|
|
2390
3122
|
const { tabState } = found;
|
|
2391
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
3123
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2392
3124
|
|
|
2393
3125
|
const allLinks = await tabState.page.evaluate(() => {
|
|
2394
3126
|
const links = [];
|
|
@@ -2416,6 +3148,49 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
2416
3148
|
});
|
|
2417
3149
|
|
|
2418
3150
|
// Get captured downloads
|
|
3151
|
+
/**
|
|
3152
|
+
* @openapi
|
|
3153
|
+
* /tabs/{tabId}/downloads:
|
|
3154
|
+
* get:
|
|
3155
|
+
* tags: [Content]
|
|
3156
|
+
* summary: List tab downloads
|
|
3157
|
+
* parameters:
|
|
3158
|
+
* - name: tabId
|
|
3159
|
+
* in: path
|
|
3160
|
+
* required: true
|
|
3161
|
+
* schema:
|
|
3162
|
+
* type: string
|
|
3163
|
+
* - name: userId
|
|
3164
|
+
* in: query
|
|
3165
|
+
* required: true
|
|
3166
|
+
* schema:
|
|
3167
|
+
* type: string
|
|
3168
|
+
* responses:
|
|
3169
|
+
* 200:
|
|
3170
|
+
* description: Downloads list.
|
|
3171
|
+
* content:
|
|
3172
|
+
* application/json:
|
|
3173
|
+
* schema:
|
|
3174
|
+
* type: object
|
|
3175
|
+
* properties:
|
|
3176
|
+
* downloads:
|
|
3177
|
+
* type: array
|
|
3178
|
+
* items:
|
|
3179
|
+
* type: object
|
|
3180
|
+
* properties:
|
|
3181
|
+
* filename:
|
|
3182
|
+
* type: string
|
|
3183
|
+
* url:
|
|
3184
|
+
* type: string
|
|
3185
|
+
* state:
|
|
3186
|
+
* type: string
|
|
3187
|
+
* 404:
|
|
3188
|
+
* description: Tab not found.
|
|
3189
|
+
* content:
|
|
3190
|
+
* application/json:
|
|
3191
|
+
* schema:
|
|
3192
|
+
* $ref: '#/components/schemas/Error'
|
|
3193
|
+
*/
|
|
2419
3194
|
app.get('/tabs/:tabId/downloads', async (req, res) => {
|
|
2420
3195
|
try {
|
|
2421
3196
|
const userId = req.query.userId;
|
|
@@ -2445,6 +3220,51 @@ app.get('/tabs/:tabId/downloads', async (req, res) => {
|
|
|
2445
3220
|
});
|
|
2446
3221
|
|
|
2447
3222
|
// Get image elements from current page
|
|
3223
|
+
/**
|
|
3224
|
+
* @openapi
|
|
3225
|
+
* /tabs/{tabId}/images:
|
|
3226
|
+
* get:
|
|
3227
|
+
* tags: [Content]
|
|
3228
|
+
* summary: Extract page images
|
|
3229
|
+
* parameters:
|
|
3230
|
+
* - name: tabId
|
|
3231
|
+
* in: path
|
|
3232
|
+
* required: true
|
|
3233
|
+
* schema:
|
|
3234
|
+
* type: string
|
|
3235
|
+
* - name: userId
|
|
3236
|
+
* in: query
|
|
3237
|
+
* required: true
|
|
3238
|
+
* schema:
|
|
3239
|
+
* type: string
|
|
3240
|
+
* responses:
|
|
3241
|
+
* 200:
|
|
3242
|
+
* description: Images extracted.
|
|
3243
|
+
* content:
|
|
3244
|
+
* application/json:
|
|
3245
|
+
* schema:
|
|
3246
|
+
* type: object
|
|
3247
|
+
* properties:
|
|
3248
|
+
* images:
|
|
3249
|
+
* type: array
|
|
3250
|
+
* items:
|
|
3251
|
+
* type: object
|
|
3252
|
+
* properties:
|
|
3253
|
+
* src:
|
|
3254
|
+
* type: string
|
|
3255
|
+
* alt:
|
|
3256
|
+
* type: string
|
|
3257
|
+
* width:
|
|
3258
|
+
* type: integer
|
|
3259
|
+
* height:
|
|
3260
|
+
* type: integer
|
|
3261
|
+
* 404:
|
|
3262
|
+
* description: Tab not found.
|
|
3263
|
+
* content:
|
|
3264
|
+
* application/json:
|
|
3265
|
+
* schema:
|
|
3266
|
+
* $ref: '#/components/schemas/Error'
|
|
3267
|
+
*/
|
|
2448
3268
|
app.get('/tabs/:tabId/images', async (req, res) => {
|
|
2449
3269
|
try {
|
|
2450
3270
|
const userId = req.query.userId;
|
|
@@ -2471,6 +3291,46 @@ app.get('/tabs/:tabId/images', async (req, res) => {
|
|
|
2471
3291
|
});
|
|
2472
3292
|
|
|
2473
3293
|
// Screenshot
|
|
3294
|
+
/**
|
|
3295
|
+
* @openapi
|
|
3296
|
+
* /tabs/{tabId}/screenshot:
|
|
3297
|
+
* get:
|
|
3298
|
+
* tags: [Content]
|
|
3299
|
+
* summary: Take a screenshot
|
|
3300
|
+
* description: Returns a base64-encoded PNG screenshot.
|
|
3301
|
+
* parameters:
|
|
3302
|
+
* - name: tabId
|
|
3303
|
+
* in: path
|
|
3304
|
+
* required: true
|
|
3305
|
+
* schema:
|
|
3306
|
+
* type: string
|
|
3307
|
+
* - name: userId
|
|
3308
|
+
* in: query
|
|
3309
|
+
* required: true
|
|
3310
|
+
* schema:
|
|
3311
|
+
* type: string
|
|
3312
|
+
* responses:
|
|
3313
|
+
* 200:
|
|
3314
|
+
* description: Screenshot.
|
|
3315
|
+
* content:
|
|
3316
|
+
* application/json:
|
|
3317
|
+
* schema:
|
|
3318
|
+
* type: object
|
|
3319
|
+
* properties:
|
|
3320
|
+
* screenshot:
|
|
3321
|
+
* type: object
|
|
3322
|
+
* properties:
|
|
3323
|
+
* data:
|
|
3324
|
+
* type: string
|
|
3325
|
+
* mimeType:
|
|
3326
|
+
* type: string
|
|
3327
|
+
* 404:
|
|
3328
|
+
* description: Tab not found.
|
|
3329
|
+
* content:
|
|
3330
|
+
* application/json:
|
|
3331
|
+
* schema:
|
|
3332
|
+
* $ref: '#/components/schemas/Error'
|
|
3333
|
+
*/
|
|
2474
3334
|
app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
2475
3335
|
try {
|
|
2476
3336
|
const userId = req.query.userId;
|
|
@@ -2481,6 +3341,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
2481
3341
|
|
|
2482
3342
|
const { tabState } = found;
|
|
2483
3343
|
const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
|
|
3344
|
+
pluginEvents.emit('tab:screenshot', { userId, tabId: req.params.tabId, buffer });
|
|
2484
3345
|
res.set('Content-Type', 'image/png');
|
|
2485
3346
|
res.send(buffer);
|
|
2486
3347
|
} catch (err) {
|
|
@@ -2490,6 +3351,53 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
2490
3351
|
});
|
|
2491
3352
|
|
|
2492
3353
|
// Stats
|
|
3354
|
+
/**
|
|
3355
|
+
* @openapi
|
|
3356
|
+
* /tabs/{tabId}/stats:
|
|
3357
|
+
* get:
|
|
3358
|
+
* tags: [Tabs]
|
|
3359
|
+
* summary: Tab statistics
|
|
3360
|
+
* description: Returns tab metadata including URL, tool call count, visited URLs, download/failure counts.
|
|
3361
|
+
* parameters:
|
|
3362
|
+
* - name: tabId
|
|
3363
|
+
* in: path
|
|
3364
|
+
* required: true
|
|
3365
|
+
* schema:
|
|
3366
|
+
* type: string
|
|
3367
|
+
* - name: userId
|
|
3368
|
+
* in: query
|
|
3369
|
+
* required: true
|
|
3370
|
+
* schema:
|
|
3371
|
+
* type: string
|
|
3372
|
+
* responses:
|
|
3373
|
+
* 200:
|
|
3374
|
+
* description: Tab stats.
|
|
3375
|
+
* content:
|
|
3376
|
+
* application/json:
|
|
3377
|
+
* schema:
|
|
3378
|
+
* type: object
|
|
3379
|
+
* properties:
|
|
3380
|
+
* tabId:
|
|
3381
|
+
* type: string
|
|
3382
|
+
* url:
|
|
3383
|
+
* type: string
|
|
3384
|
+
* toolCalls:
|
|
3385
|
+
* type: integer
|
|
3386
|
+
* visitedUrls:
|
|
3387
|
+
* type: array
|
|
3388
|
+
* items:
|
|
3389
|
+
* type: string
|
|
3390
|
+
* downloadCount:
|
|
3391
|
+
* type: integer
|
|
3392
|
+
* consecutiveFailures:
|
|
3393
|
+
* type: integer
|
|
3394
|
+
* 404:
|
|
3395
|
+
* description: Tab not found.
|
|
3396
|
+
* content:
|
|
3397
|
+
* application/json:
|
|
3398
|
+
* schema:
|
|
3399
|
+
* $ref: '#/components/schemas/Error'
|
|
3400
|
+
*/
|
|
2493
3401
|
app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
2494
3402
|
try {
|
|
2495
3403
|
const userId = req.query.userId;
|
|
@@ -2515,6 +3423,56 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
2515
3423
|
});
|
|
2516
3424
|
|
|
2517
3425
|
// Evaluate JavaScript in page context
|
|
3426
|
+
/**
|
|
3427
|
+
* @openapi
|
|
3428
|
+
* /tabs/{tabId}/evaluate:
|
|
3429
|
+
* post:
|
|
3430
|
+
* tags: [Interaction]
|
|
3431
|
+
* summary: Evaluate JavaScript in tab
|
|
3432
|
+
* description: Runs arbitrary JS in the page context and returns the result.
|
|
3433
|
+
* parameters:
|
|
3434
|
+
* - name: tabId
|
|
3435
|
+
* in: path
|
|
3436
|
+
* required: true
|
|
3437
|
+
* schema:
|
|
3438
|
+
* type: string
|
|
3439
|
+
* requestBody:
|
|
3440
|
+
* required: true
|
|
3441
|
+
* content:
|
|
3442
|
+
* application/json:
|
|
3443
|
+
* schema:
|
|
3444
|
+
* type: object
|
|
3445
|
+
* required: [userId, expression]
|
|
3446
|
+
* properties:
|
|
3447
|
+
* userId:
|
|
3448
|
+
* type: string
|
|
3449
|
+
* expression:
|
|
3450
|
+
* type: string
|
|
3451
|
+
* description: JavaScript expression to evaluate.
|
|
3452
|
+
* responses:
|
|
3453
|
+
* 200:
|
|
3454
|
+
* description: Evaluation result.
|
|
3455
|
+
* content:
|
|
3456
|
+
* application/json:
|
|
3457
|
+
* schema:
|
|
3458
|
+
* type: object
|
|
3459
|
+
* properties:
|
|
3460
|
+
* ok:
|
|
3461
|
+
* type: boolean
|
|
3462
|
+
* result: {}
|
|
3463
|
+
* 400:
|
|
3464
|
+
* description: Bad request.
|
|
3465
|
+
* content:
|
|
3466
|
+
* application/json:
|
|
3467
|
+
* schema:
|
|
3468
|
+
* $ref: '#/components/schemas/Error'
|
|
3469
|
+
* 404:
|
|
3470
|
+
* description: Tab not found.
|
|
3471
|
+
* content:
|
|
3472
|
+
* application/json:
|
|
3473
|
+
* schema:
|
|
3474
|
+
* $ref: '#/components/schemas/Error'
|
|
3475
|
+
*/
|
|
2518
3476
|
app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
|
|
2519
3477
|
try {
|
|
2520
3478
|
const { userId, expression } = req.body;
|
|
@@ -2527,9 +3485,11 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
|
|
|
2527
3485
|
|
|
2528
3486
|
session.lastAccess = Date.now();
|
|
2529
3487
|
const { tabState } = found;
|
|
2530
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
3488
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2531
3489
|
|
|
3490
|
+
pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
|
|
2532
3491
|
const result = await tabState.page.evaluate(expression);
|
|
3492
|
+
pluginEvents.emit('tab:evaluated', { userId, tabId: req.params.tabId, result });
|
|
2533
3493
|
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
|
|
2534
3494
|
res.json({ ok: true, result });
|
|
2535
3495
|
} catch (err) {
|
|
@@ -2539,7 +3499,192 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
|
|
|
2539
3499
|
}
|
|
2540
3500
|
});
|
|
2541
3501
|
|
|
3502
|
+
// Structured extraction using JSON Schema with x-ref hints
|
|
3503
|
+
/**
|
|
3504
|
+
* @openapi
|
|
3505
|
+
* /tabs/{tabId}/extract:
|
|
3506
|
+
* post:
|
|
3507
|
+
* tags: [Content]
|
|
3508
|
+
* summary: Structured data extraction via JSON Schema
|
|
3509
|
+
* description: |
|
|
3510
|
+
* Extracts structured data from the current page using a JSON Schema whose properties
|
|
3511
|
+
* carry `x-ref` hints pointing at snapshot element refs (e.g. `e1`, `e2`).
|
|
3512
|
+
* Call `GET /tabs/{tabId}/snapshot` first to populate the ref table.
|
|
3513
|
+
* parameters:
|
|
3514
|
+
* - name: tabId
|
|
3515
|
+
* in: path
|
|
3516
|
+
* required: true
|
|
3517
|
+
* schema:
|
|
3518
|
+
* type: string
|
|
3519
|
+
* requestBody:
|
|
3520
|
+
* required: true
|
|
3521
|
+
* content:
|
|
3522
|
+
* application/json:
|
|
3523
|
+
* schema:
|
|
3524
|
+
* type: object
|
|
3525
|
+
* required: [userId, schema]
|
|
3526
|
+
* properties:
|
|
3527
|
+
* userId:
|
|
3528
|
+
* type: string
|
|
3529
|
+
* schema:
|
|
3530
|
+
* type: object
|
|
3531
|
+
* description: |
|
|
3532
|
+
* JSON Schema with `type: "object"` and a `properties` map.
|
|
3533
|
+
* Each property may include `x-ref` (a snapshot element ref) and an optional
|
|
3534
|
+
* `type` (`string`, `number`, `integer`, `boolean`).
|
|
3535
|
+
* required: [type, properties]
|
|
3536
|
+
* properties:
|
|
3537
|
+
* type:
|
|
3538
|
+
* type: string
|
|
3539
|
+
* enum: [object]
|
|
3540
|
+
* properties:
|
|
3541
|
+
* type: object
|
|
3542
|
+
* additionalProperties:
|
|
3543
|
+
* type: object
|
|
3544
|
+
* properties:
|
|
3545
|
+
* type:
|
|
3546
|
+
* type: string
|
|
3547
|
+
* enum: [string, number, integer, boolean, object, "null"]
|
|
3548
|
+
* x-ref:
|
|
3549
|
+
* type: string
|
|
3550
|
+
* description: Snapshot element ref (e.g. `e1`).
|
|
3551
|
+
* required:
|
|
3552
|
+
* type: array
|
|
3553
|
+
* items:
|
|
3554
|
+
* type: string
|
|
3555
|
+
* description: Property names that must resolve to a non-null value.
|
|
3556
|
+
* responses:
|
|
3557
|
+
* 200:
|
|
3558
|
+
* description: Extraction succeeded.
|
|
3559
|
+
* content:
|
|
3560
|
+
* application/json:
|
|
3561
|
+
* schema:
|
|
3562
|
+
* type: object
|
|
3563
|
+
* properties:
|
|
3564
|
+
* ok:
|
|
3565
|
+
* type: boolean
|
|
3566
|
+
* data:
|
|
3567
|
+
* type: object
|
|
3568
|
+
* description: Extracted key-value pairs matching the input schema.
|
|
3569
|
+
* 400:
|
|
3570
|
+
* description: Missing userId, missing schema, or invalid schema.
|
|
3571
|
+
* content:
|
|
3572
|
+
* application/json:
|
|
3573
|
+
* schema:
|
|
3574
|
+
* $ref: '#/components/schemas/Error'
|
|
3575
|
+
* 404:
|
|
3576
|
+
* description: Tab not found.
|
|
3577
|
+
* content:
|
|
3578
|
+
* application/json:
|
|
3579
|
+
* schema:
|
|
3580
|
+
* $ref: '#/components/schemas/Error'
|
|
3581
|
+
* 409:
|
|
3582
|
+
* description: No refs available — call snapshot first.
|
|
3583
|
+
* content:
|
|
3584
|
+
* application/json:
|
|
3585
|
+
* schema:
|
|
3586
|
+
* type: object
|
|
3587
|
+
* properties:
|
|
3588
|
+
* error:
|
|
3589
|
+
* type: string
|
|
3590
|
+
* snapshot:
|
|
3591
|
+
* type: string
|
|
3592
|
+
* nullable: true
|
|
3593
|
+
* 422:
|
|
3594
|
+
* description: Extraction failed (e.g. required ref not found).
|
|
3595
|
+
* content:
|
|
3596
|
+
* application/json:
|
|
3597
|
+
* schema:
|
|
3598
|
+
* type: object
|
|
3599
|
+
* properties:
|
|
3600
|
+
* ok:
|
|
3601
|
+
* type: boolean
|
|
3602
|
+
* error:
|
|
3603
|
+
* type: string
|
|
3604
|
+
* snapshot:
|
|
3605
|
+
* type: string
|
|
3606
|
+
* nullable: true
|
|
3607
|
+
* 500:
|
|
3608
|
+
* description: Internal server error.
|
|
3609
|
+
* content:
|
|
3610
|
+
* application/json:
|
|
3611
|
+
* schema:
|
|
3612
|
+
* $ref: '#/components/schemas/Error'
|
|
3613
|
+
*/
|
|
3614
|
+
app.post('/tabs/:tabId/extract', express.json({ limit: '256kb' }), async (req, res) => {
|
|
3615
|
+
try {
|
|
3616
|
+
const { userId, schema } = req.body;
|
|
3617
|
+
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
3618
|
+
if (!schema) return res.status(400).json({ error: 'schema is required' });
|
|
3619
|
+
|
|
3620
|
+
const check = validateExtractSchema(schema);
|
|
3621
|
+
if (!check.ok) return res.status(400).json({ error: check.error });
|
|
3622
|
+
|
|
3623
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
3624
|
+
const found = session && findTab(session, req.params.tabId);
|
|
3625
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
3626
|
+
|
|
3627
|
+
session.lastAccess = Date.now();
|
|
3628
|
+
const { tabState } = found;
|
|
3629
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
3630
|
+
|
|
3631
|
+
if (!tabState.refs || tabState.refs.size === 0) {
|
|
3632
|
+
return res.status(409).json({
|
|
3633
|
+
error: 'no refs available — call GET /tabs/:tabId/snapshot first to build the ref table',
|
|
3634
|
+
snapshot: tabState.lastSnapshot || null,
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
try {
|
|
3639
|
+
const data = extractDeterministic({ schema, refs: tabState.refs });
|
|
3640
|
+
log('info', 'extract', { reqId: req.reqId, tabId: req.params.tabId, userId, keys: Object.keys(data) });
|
|
3641
|
+
res.json({ ok: true, data });
|
|
3642
|
+
} catch (extractErr) {
|
|
3643
|
+
log('warn', 'extract failed', { reqId: req.reqId, error: extractErr.message });
|
|
3644
|
+
res.status(422).json({ ok: false, error: extractErr.message, snapshot: tabState.lastSnapshot || null });
|
|
3645
|
+
}
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
failuresTotal.labels(classifyError(err), 'extract').inc();
|
|
3648
|
+
log('error', 'extract error', { reqId: req.reqId, error: err.message });
|
|
3649
|
+
res.status(500).json({ error: safeError(err) });
|
|
3650
|
+
}
|
|
3651
|
+
});
|
|
3652
|
+
|
|
2542
3653
|
// Close tab
|
|
3654
|
+
/**
|
|
3655
|
+
* @openapi
|
|
3656
|
+
* /tabs/{tabId}:
|
|
3657
|
+
* delete:
|
|
3658
|
+
* tags: [Tabs]
|
|
3659
|
+
* summary: Close a tab
|
|
3660
|
+
* parameters:
|
|
3661
|
+
* - name: tabId
|
|
3662
|
+
* in: path
|
|
3663
|
+
* required: true
|
|
3664
|
+
* schema:
|
|
3665
|
+
* type: string
|
|
3666
|
+
* - name: userId
|
|
3667
|
+
* in: query
|
|
3668
|
+
* required: true
|
|
3669
|
+
* schema:
|
|
3670
|
+
* type: string
|
|
3671
|
+
* responses:
|
|
3672
|
+
* 200:
|
|
3673
|
+
* description: Tab closed.
|
|
3674
|
+
* content:
|
|
3675
|
+
* application/json:
|
|
3676
|
+
* schema:
|
|
3677
|
+
* type: object
|
|
3678
|
+
* properties:
|
|
3679
|
+
* ok:
|
|
3680
|
+
* type: boolean
|
|
3681
|
+
* 404:
|
|
3682
|
+
* description: Tab not found.
|
|
3683
|
+
* content:
|
|
3684
|
+
* application/json:
|
|
3685
|
+
* schema:
|
|
3686
|
+
* $ref: '#/components/schemas/Error'
|
|
3687
|
+
*/
|
|
2543
3688
|
app.delete('/tabs/:tabId', async (req, res) => {
|
|
2544
3689
|
try {
|
|
2545
3690
|
const userId = req.query.userId || req.body?.userId;
|
|
@@ -2547,6 +3692,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
2547
3692
|
const session = sessions.get(normalizeUserId(userId));
|
|
2548
3693
|
const found = session && findTab(session, req.params.tabId);
|
|
2549
3694
|
if (found) {
|
|
3695
|
+
if (found.tabState.navigateAbort) found.tabState.navigateAbort.abort();
|
|
2550
3696
|
await clearTabDownloads(found.tabState);
|
|
2551
3697
|
await safePageClose(found.tabState.page);
|
|
2552
3698
|
found.group.delete(req.params.tabId);
|
|
@@ -2565,6 +3711,42 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
2565
3711
|
});
|
|
2566
3712
|
|
|
2567
3713
|
// Close tab group
|
|
3714
|
+
/**
|
|
3715
|
+
* @openapi
|
|
3716
|
+
* /tabs/group/{listItemId}:
|
|
3717
|
+
* delete:
|
|
3718
|
+
* tags: [Tabs]
|
|
3719
|
+
* summary: Close all tabs in a group
|
|
3720
|
+
* parameters:
|
|
3721
|
+
* - name: listItemId
|
|
3722
|
+
* in: path
|
|
3723
|
+
* required: true
|
|
3724
|
+
* schema:
|
|
3725
|
+
* type: string
|
|
3726
|
+
* - name: userId
|
|
3727
|
+
* in: query
|
|
3728
|
+
* required: true
|
|
3729
|
+
* schema:
|
|
3730
|
+
* type: string
|
|
3731
|
+
* responses:
|
|
3732
|
+
* 200:
|
|
3733
|
+
* description: Group closed.
|
|
3734
|
+
* content:
|
|
3735
|
+
* application/json:
|
|
3736
|
+
* schema:
|
|
3737
|
+
* type: object
|
|
3738
|
+
* properties:
|
|
3739
|
+
* ok:
|
|
3740
|
+
* type: boolean
|
|
3741
|
+
* closed:
|
|
3742
|
+
* type: integer
|
|
3743
|
+
* 404:
|
|
3744
|
+
* description: Session not found.
|
|
3745
|
+
* content:
|
|
3746
|
+
* application/json:
|
|
3747
|
+
* schema:
|
|
3748
|
+
* $ref: '#/components/schemas/Error'
|
|
3749
|
+
*/
|
|
2568
3750
|
app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
2569
3751
|
try {
|
|
2570
3752
|
const userId = req.query.userId || req.body?.userId;
|
|
@@ -2593,27 +3775,260 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
2593
3775
|
}
|
|
2594
3776
|
});
|
|
2595
3777
|
|
|
3778
|
+
// List trace files for a session
|
|
3779
|
+
/**
|
|
3780
|
+
* @openapi
|
|
3781
|
+
* /sessions/{userId}/traces:
|
|
3782
|
+
* get:
|
|
3783
|
+
* tags: [Sessions]
|
|
3784
|
+
* summary: List trace files
|
|
3785
|
+
* description: Returns all Playwright trace zip files for the given user session, sorted newest first.
|
|
3786
|
+
* security:
|
|
3787
|
+
* - BearerAuth: []
|
|
3788
|
+
* parameters:
|
|
3789
|
+
* - name: userId
|
|
3790
|
+
* in: path
|
|
3791
|
+
* required: true
|
|
3792
|
+
* schema:
|
|
3793
|
+
* type: string
|
|
3794
|
+
* description: Session owner identifier.
|
|
3795
|
+
* responses:
|
|
3796
|
+
* 200:
|
|
3797
|
+
* description: Trace list.
|
|
3798
|
+
* content:
|
|
3799
|
+
* application/json:
|
|
3800
|
+
* schema:
|
|
3801
|
+
* type: object
|
|
3802
|
+
* properties:
|
|
3803
|
+
* traces:
|
|
3804
|
+
* type: array
|
|
3805
|
+
* items:
|
|
3806
|
+
* type: object
|
|
3807
|
+
* properties:
|
|
3808
|
+
* filename:
|
|
3809
|
+
* type: string
|
|
3810
|
+
* sizeBytes:
|
|
3811
|
+
* type: integer
|
|
3812
|
+
* createdAt:
|
|
3813
|
+
* type: number
|
|
3814
|
+
* modifiedAt:
|
|
3815
|
+
* type: number
|
|
3816
|
+
* 403:
|
|
3817
|
+
* description: Forbidden.
|
|
3818
|
+
* content:
|
|
3819
|
+
* application/json:
|
|
3820
|
+
* schema:
|
|
3821
|
+
* $ref: '#/components/schemas/Error'
|
|
3822
|
+
* 500:
|
|
3823
|
+
* description: Server error.
|
|
3824
|
+
* content:
|
|
3825
|
+
* application/json:
|
|
3826
|
+
* schema:
|
|
3827
|
+
* $ref: '#/components/schemas/Error'
|
|
3828
|
+
*/
|
|
3829
|
+
app.get('/sessions/:userId/traces', authMiddleware(), async (req, res) => {
|
|
3830
|
+
try {
|
|
3831
|
+
const userId = normalizeUserId(req.params.userId);
|
|
3832
|
+
const traces = await listUserTraces(CONFIG.tracesDir, userId);
|
|
3833
|
+
res.json({ traces });
|
|
3834
|
+
} catch (err) {
|
|
3835
|
+
log('error', 'list traces failed', { error: err.message });
|
|
3836
|
+
res.status(500).json({ error: err.message });
|
|
3837
|
+
}
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
// Stream one trace file
|
|
3841
|
+
/**
|
|
3842
|
+
* @openapi
|
|
3843
|
+
* /sessions/{userId}/traces/{filename}:
|
|
3844
|
+
* get:
|
|
3845
|
+
* tags: [Sessions]
|
|
3846
|
+
* summary: Download a trace file
|
|
3847
|
+
* description: Streams a Playwright trace zip for viewing in trace.playwright.dev.
|
|
3848
|
+
* security:
|
|
3849
|
+
* - BearerAuth: []
|
|
3850
|
+
* parameters:
|
|
3851
|
+
* - name: userId
|
|
3852
|
+
* in: path
|
|
3853
|
+
* required: true
|
|
3854
|
+
* schema:
|
|
3855
|
+
* type: string
|
|
3856
|
+
* description: Session owner identifier.
|
|
3857
|
+
* - name: filename
|
|
3858
|
+
* in: path
|
|
3859
|
+
* required: true
|
|
3860
|
+
* schema:
|
|
3861
|
+
* type: string
|
|
3862
|
+
* description: Trace zip filename.
|
|
3863
|
+
* responses:
|
|
3864
|
+
* 200:
|
|
3865
|
+
* description: Trace zip stream.
|
|
3866
|
+
* content:
|
|
3867
|
+
* application/zip:
|
|
3868
|
+
* schema:
|
|
3869
|
+
* type: string
|
|
3870
|
+
* format: binary
|
|
3871
|
+
* 400:
|
|
3872
|
+
* description: Invalid filename.
|
|
3873
|
+
* content:
|
|
3874
|
+
* application/json:
|
|
3875
|
+
* schema:
|
|
3876
|
+
* $ref: '#/components/schemas/Error'
|
|
3877
|
+
* 404:
|
|
3878
|
+
* description: Trace not found.
|
|
3879
|
+
* content:
|
|
3880
|
+
* application/json:
|
|
3881
|
+
* schema:
|
|
3882
|
+
* $ref: '#/components/schemas/Error'
|
|
3883
|
+
* 403:
|
|
3884
|
+
* description: Forbidden.
|
|
3885
|
+
* content:
|
|
3886
|
+
* application/json:
|
|
3887
|
+
* schema:
|
|
3888
|
+
* $ref: '#/components/schemas/Error'
|
|
3889
|
+
* 500:
|
|
3890
|
+
* description: Server error.
|
|
3891
|
+
* content:
|
|
3892
|
+
* application/json:
|
|
3893
|
+
* schema:
|
|
3894
|
+
* $ref: '#/components/schemas/Error'
|
|
3895
|
+
*/
|
|
3896
|
+
app.get('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
|
|
3897
|
+
try {
|
|
3898
|
+
const userId = normalizeUserId(req.params.userId);
|
|
3899
|
+
const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
|
|
3900
|
+
if (!full) return res.status(400).json({ error: 'invalid filename' });
|
|
3901
|
+
const st = await statTrace(full);
|
|
3902
|
+
if (!st) return res.status(404).json({ error: 'not found' });
|
|
3903
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
3904
|
+
res.setHeader('Content-Length', String(st.size));
|
|
3905
|
+
const stream = fs.createReadStream(full);
|
|
3906
|
+
stream.on('error', (err) => {
|
|
3907
|
+
if (!res.headersSent) res.status(404).json({ error: 'not found' });
|
|
3908
|
+
else res.destroy();
|
|
3909
|
+
});
|
|
3910
|
+
stream.pipe(res);
|
|
3911
|
+
} catch (err) {
|
|
3912
|
+
log('error', 'stream trace failed', { error: err.message });
|
|
3913
|
+
res.status(500).json({ error: err.message });
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
|
|
3917
|
+
// Delete one trace file
|
|
3918
|
+
/**
|
|
3919
|
+
* @openapi
|
|
3920
|
+
* /sessions/{userId}/traces/{filename}:
|
|
3921
|
+
* delete:
|
|
3922
|
+
* tags: [Sessions]
|
|
3923
|
+
* summary: Delete a trace file
|
|
3924
|
+
* description: Removes a specific Playwright trace zip from the server.
|
|
3925
|
+
* security:
|
|
3926
|
+
* - BearerAuth: []
|
|
3927
|
+
* parameters:
|
|
3928
|
+
* - name: userId
|
|
3929
|
+
* in: path
|
|
3930
|
+
* required: true
|
|
3931
|
+
* schema:
|
|
3932
|
+
* type: string
|
|
3933
|
+
* description: Session owner identifier.
|
|
3934
|
+
* - name: filename
|
|
3935
|
+
* in: path
|
|
3936
|
+
* required: true
|
|
3937
|
+
* schema:
|
|
3938
|
+
* type: string
|
|
3939
|
+
* description: Trace zip filename.
|
|
3940
|
+
* responses:
|
|
3941
|
+
* 200:
|
|
3942
|
+
* description: Trace deleted.
|
|
3943
|
+
* content:
|
|
3944
|
+
* application/json:
|
|
3945
|
+
* schema:
|
|
3946
|
+
* type: object
|
|
3947
|
+
* properties:
|
|
3948
|
+
* ok:
|
|
3949
|
+
* type: boolean
|
|
3950
|
+
* 400:
|
|
3951
|
+
* description: Invalid filename.
|
|
3952
|
+
* content:
|
|
3953
|
+
* application/json:
|
|
3954
|
+
* schema:
|
|
3955
|
+
* $ref: '#/components/schemas/Error'
|
|
3956
|
+
* 404:
|
|
3957
|
+
* description: Trace not found.
|
|
3958
|
+
* content:
|
|
3959
|
+
* application/json:
|
|
3960
|
+
* schema:
|
|
3961
|
+
* $ref: '#/components/schemas/Error'
|
|
3962
|
+
* 403:
|
|
3963
|
+
* description: Forbidden.
|
|
3964
|
+
* content:
|
|
3965
|
+
* application/json:
|
|
3966
|
+
* schema:
|
|
3967
|
+
* $ref: '#/components/schemas/Error'
|
|
3968
|
+
* 500:
|
|
3969
|
+
* description: Server error.
|
|
3970
|
+
* content:
|
|
3971
|
+
* application/json:
|
|
3972
|
+
* schema:
|
|
3973
|
+
* $ref: '#/components/schemas/Error'
|
|
3974
|
+
*/
|
|
3975
|
+
app.delete('/sessions/:userId/traces/:filename', authMiddleware(), async (req, res) => {
|
|
3976
|
+
try {
|
|
3977
|
+
const userId = normalizeUserId(req.params.userId);
|
|
3978
|
+
const full = resolveTracePath(CONFIG.tracesDir, userId, req.params.filename);
|
|
3979
|
+
if (!full) return res.status(400).json({ error: 'invalid filename' });
|
|
3980
|
+
try {
|
|
3981
|
+
await deleteTrace(full);
|
|
3982
|
+
} catch (err) {
|
|
3983
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
|
|
3984
|
+
throw err;
|
|
3985
|
+
}
|
|
3986
|
+
res.json({ ok: true });
|
|
3987
|
+
} catch (err) {
|
|
3988
|
+
log('error', 'delete trace failed', { error: err.message });
|
|
3989
|
+
res.status(500).json({ error: err.message });
|
|
3990
|
+
}
|
|
3991
|
+
});
|
|
3992
|
+
|
|
2596
3993
|
// Close session
|
|
3994
|
+
/**
|
|
3995
|
+
* @openapi
|
|
3996
|
+
* /sessions/{userId}:
|
|
3997
|
+
* delete:
|
|
3998
|
+
* tags: [Sessions]
|
|
3999
|
+
* summary: Destroy a user session
|
|
4000
|
+
* description: Closes all tabs and cleans up state for the given userId.
|
|
4001
|
+
* parameters:
|
|
4002
|
+
* - name: userId
|
|
4003
|
+
* in: path
|
|
4004
|
+
* required: true
|
|
4005
|
+
* schema:
|
|
4006
|
+
* type: string
|
|
4007
|
+
* responses:
|
|
4008
|
+
* 200:
|
|
4009
|
+
* description: Session destroyed.
|
|
4010
|
+
* content:
|
|
4011
|
+
* application/json:
|
|
4012
|
+
* schema:
|
|
4013
|
+
* type: object
|
|
4014
|
+
* properties:
|
|
4015
|
+
* ok:
|
|
4016
|
+
* type: boolean
|
|
4017
|
+
* closed:
|
|
4018
|
+
* type: integer
|
|
4019
|
+
* 404:
|
|
4020
|
+
* description: Session not found.
|
|
4021
|
+
* content:
|
|
4022
|
+
* application/json:
|
|
4023
|
+
* schema:
|
|
4024
|
+
* $ref: '#/components/schemas/Error'
|
|
4025
|
+
*/
|
|
2597
4026
|
app.delete('/sessions/:userId', async (req, res) => {
|
|
2598
4027
|
try {
|
|
2599
4028
|
const userId = normalizeUserId(req.params.userId);
|
|
2600
4029
|
const session = sessions.get(userId);
|
|
2601
4030
|
if (session) {
|
|
2602
|
-
await
|
|
2603
|
-
await session.context.close();
|
|
2604
|
-
sessions.delete(userId);
|
|
2605
|
-
// Remove any lingering tab locks for the session
|
|
2606
|
-
for (const [listItemId, group] of session.tabGroups) {
|
|
2607
|
-
for (const tabId of group.keys()) {
|
|
2608
|
-
const lock = tabLocks.get(tabId);
|
|
2609
|
-
if (lock) {
|
|
2610
|
-
lock.drain();
|
|
2611
|
-
tabLocks.delete(tabId);
|
|
2612
|
-
}
|
|
2613
|
-
}
|
|
2614
|
-
}
|
|
2615
|
-
refreshTabLockQueueDepth();
|
|
2616
|
-
refreshActiveTabsGauge();
|
|
4031
|
+
await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true });
|
|
2617
4032
|
log('info', 'session closed', { userId });
|
|
2618
4033
|
}
|
|
2619
4034
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
@@ -2627,13 +4042,13 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
2627
4042
|
// Cleanup stale sessions
|
|
2628
4043
|
setInterval(() => {
|
|
2629
4044
|
const now = Date.now();
|
|
2630
|
-
for (const [userId, session] of sessions) {
|
|
4045
|
+
for (const [userId, session] of Array.from(sessions.entries())) {
|
|
2631
4046
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
4047
|
+
session._closing = true;
|
|
4048
|
+
const idleMs = now - session.lastAccess;
|
|
2632
4049
|
sessionsExpiredTotal.inc();
|
|
2633
|
-
|
|
2634
|
-
session
|
|
2635
|
-
sessions.delete(userId);
|
|
2636
|
-
refreshActiveTabsGauge();
|
|
4050
|
+
pluginEvents.emit('session:expired', { userId, idleMs });
|
|
4051
|
+
closeSession(userId, session, { reason: 'session_timeout', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
2637
4052
|
log('info', 'session expired', { userId });
|
|
2638
4053
|
}
|
|
2639
4054
|
}
|
|
@@ -2677,12 +4092,10 @@ setInterval(() => {
|
|
|
2677
4092
|
}
|
|
2678
4093
|
// Clean up sessions with zero tabs remaining — free browser context memory
|
|
2679
4094
|
if (session.tabGroups.size === 0) {
|
|
4095
|
+
session._closing = true;
|
|
2680
4096
|
log('info', 'session empty after tab reaper, closing', { userId });
|
|
2681
|
-
|
|
2682
|
-
session.context.close().catch(() => {});
|
|
2683
|
-
sessions.delete(userId);
|
|
4097
|
+
closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
|
|
2684
4098
|
sessionsExpiredTotal.inc();
|
|
2685
|
-
refreshActiveTabsGauge();
|
|
2686
4099
|
}
|
|
2687
4100
|
}
|
|
2688
4101
|
if (sessions.size === 0) scheduleBrowserIdleShutdown();
|
|
@@ -2694,6 +4107,34 @@ setInterval(() => {
|
|
|
2694
4107
|
// =============================================================================
|
|
2695
4108
|
|
|
2696
4109
|
// GET / - Status (passive — does not launch browser)
|
|
4110
|
+
/**
|
|
4111
|
+
* @openapi
|
|
4112
|
+
* /:
|
|
4113
|
+
* get:
|
|
4114
|
+
* tags: [System]
|
|
4115
|
+
* summary: Server status
|
|
4116
|
+
* description: Returns basic server liveness and browser state.
|
|
4117
|
+
* responses:
|
|
4118
|
+
* 200:
|
|
4119
|
+
* description: Server status.
|
|
4120
|
+
* content:
|
|
4121
|
+
* application/json:
|
|
4122
|
+
* schema:
|
|
4123
|
+
* type: object
|
|
4124
|
+
* properties:
|
|
4125
|
+
* ok:
|
|
4126
|
+
* type: boolean
|
|
4127
|
+
* enabled:
|
|
4128
|
+
* type: boolean
|
|
4129
|
+
* running:
|
|
4130
|
+
* type: boolean
|
|
4131
|
+
* engine:
|
|
4132
|
+
* type: string
|
|
4133
|
+
* browserConnected:
|
|
4134
|
+
* type: boolean
|
|
4135
|
+
* browserRunning:
|
|
4136
|
+
* type: boolean
|
|
4137
|
+
*/
|
|
2697
4138
|
app.get('/', (req, res) => {
|
|
2698
4139
|
const running = browser !== null && (browser.isConnected?.() ?? false);
|
|
2699
4140
|
res.json({
|
|
@@ -2707,6 +4148,45 @@ app.get('/', (req, res) => {
|
|
|
2707
4148
|
});
|
|
2708
4149
|
|
|
2709
4150
|
// GET /tabs - List all tabs (OpenClaw expects this)
|
|
4151
|
+
/**
|
|
4152
|
+
* @openapi
|
|
4153
|
+
* /tabs:
|
|
4154
|
+
* get:
|
|
4155
|
+
* tags: [Tabs]
|
|
4156
|
+
* summary: List open tabs
|
|
4157
|
+
* description: Returns all tabs for a given userId.
|
|
4158
|
+
* parameters:
|
|
4159
|
+
* - name: userId
|
|
4160
|
+
* in: query
|
|
4161
|
+
* schema:
|
|
4162
|
+
* type: string
|
|
4163
|
+
* description: Filter by session owner.
|
|
4164
|
+
* responses:
|
|
4165
|
+
* 200:
|
|
4166
|
+
* description: Tab list.
|
|
4167
|
+
* content:
|
|
4168
|
+
* application/json:
|
|
4169
|
+
* schema:
|
|
4170
|
+
* type: object
|
|
4171
|
+
* properties:
|
|
4172
|
+
* running:
|
|
4173
|
+
* type: boolean
|
|
4174
|
+
* tabs:
|
|
4175
|
+
* type: array
|
|
4176
|
+
* items:
|
|
4177
|
+
* type: object
|
|
4178
|
+
* properties:
|
|
4179
|
+
* tabId:
|
|
4180
|
+
* type: string
|
|
4181
|
+
* targetId:
|
|
4182
|
+
* type: string
|
|
4183
|
+
* url:
|
|
4184
|
+
* type: string
|
|
4185
|
+
* title:
|
|
4186
|
+
* type: string
|
|
4187
|
+
* listItemId:
|
|
4188
|
+
* type: string
|
|
4189
|
+
*/
|
|
2710
4190
|
app.get('/tabs', async (req, res) => {
|
|
2711
4191
|
try {
|
|
2712
4192
|
const userId = req.query.userId;
|
|
@@ -2737,6 +4217,41 @@ app.get('/tabs', async (req, res) => {
|
|
|
2737
4217
|
});
|
|
2738
4218
|
|
|
2739
4219
|
// POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
|
|
4220
|
+
/**
|
|
4221
|
+
* @openapi
|
|
4222
|
+
* /tabs/open:
|
|
4223
|
+
* post:
|
|
4224
|
+
* tags: [Legacy]
|
|
4225
|
+
* summary: Open tab (OpenClaw format)
|
|
4226
|
+
* deprecated: true
|
|
4227
|
+
* requestBody:
|
|
4228
|
+
* required: true
|
|
4229
|
+
* content:
|
|
4230
|
+
* application/json:
|
|
4231
|
+
* schema:
|
|
4232
|
+
* type: object
|
|
4233
|
+
* required: [userId, url]
|
|
4234
|
+
* properties:
|
|
4235
|
+
* userId:
|
|
4236
|
+
* type: string
|
|
4237
|
+
* url:
|
|
4238
|
+
* type: string
|
|
4239
|
+
* listItemId:
|
|
4240
|
+
* type: string
|
|
4241
|
+
* responses:
|
|
4242
|
+
* 200:
|
|
4243
|
+
* description: Tab opened.
|
|
4244
|
+
* content:
|
|
4245
|
+
* application/json:
|
|
4246
|
+
* schema:
|
|
4247
|
+
* type: object
|
|
4248
|
+
* 400:
|
|
4249
|
+
* description: Bad request.
|
|
4250
|
+
* content:
|
|
4251
|
+
* application/json:
|
|
4252
|
+
* schema:
|
|
4253
|
+
* $ref: '#/components/schemas/Error'
|
|
4254
|
+
*/
|
|
2740
4255
|
app.post('/tabs/open', async (req, res) => {
|
|
2741
4256
|
try {
|
|
2742
4257
|
const { url, userId, listItemId = 'default' } = req.body;
|
|
@@ -2756,7 +4271,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2756
4271
|
let totalTabs = 0;
|
|
2757
4272
|
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
2758
4273
|
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
2759
|
-
const recycled = await recycleOldestTab(session, req.reqId);
|
|
4274
|
+
const recycled = await recycleOldestTab(session, req.reqId, userId);
|
|
2760
4275
|
if (!recycled) {
|
|
2761
4276
|
return res.status(429).json({ error: 'Maximum tabs per session reached' });
|
|
2762
4277
|
}
|
|
@@ -2767,7 +4282,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2767
4282
|
const page = await session.context.newPage();
|
|
2768
4283
|
const tabId = fly.makeTabId();
|
|
2769
4284
|
const tabState = createTabState(page);
|
|
2770
|
-
attachDownloadListener(tabState, tabId, log);
|
|
4285
|
+
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
|
|
2771
4286
|
group.set(tabId, tabState);
|
|
2772
4287
|
refreshActiveTabsGauge();
|
|
2773
4288
|
|
|
@@ -2789,6 +4304,32 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
2789
4304
|
});
|
|
2790
4305
|
|
|
2791
4306
|
// POST /start - Start browser (OpenClaw expects this)
|
|
4307
|
+
/**
|
|
4308
|
+
* @openapi
|
|
4309
|
+
* /start:
|
|
4310
|
+
* post:
|
|
4311
|
+
* tags: [Browser]
|
|
4312
|
+
* summary: Start browser
|
|
4313
|
+
* description: Ensures the browser process is running. Idempotent.
|
|
4314
|
+
* responses:
|
|
4315
|
+
* 200:
|
|
4316
|
+
* description: Browser started.
|
|
4317
|
+
* content:
|
|
4318
|
+
* application/json:
|
|
4319
|
+
* schema:
|
|
4320
|
+
* type: object
|
|
4321
|
+
* properties:
|
|
4322
|
+
* ok:
|
|
4323
|
+
* type: boolean
|
|
4324
|
+
* profile:
|
|
4325
|
+
* type: string
|
|
4326
|
+
* 500:
|
|
4327
|
+
* description: Launch failed.
|
|
4328
|
+
* content:
|
|
4329
|
+
* application/json:
|
|
4330
|
+
* schema:
|
|
4331
|
+
* $ref: '#/components/schemas/Error'
|
|
4332
|
+
*/
|
|
2792
4333
|
app.post('/start', async (req, res) => {
|
|
2793
4334
|
try {
|
|
2794
4335
|
await ensureBrowser();
|
|
@@ -2800,6 +4341,36 @@ app.post('/start', async (req, res) => {
|
|
|
2800
4341
|
});
|
|
2801
4342
|
|
|
2802
4343
|
// POST /stop - Stop browser (OpenClaw expects this)
|
|
4344
|
+
/**
|
|
4345
|
+
* @openapi
|
|
4346
|
+
* /stop:
|
|
4347
|
+
* post:
|
|
4348
|
+
* tags: [Browser]
|
|
4349
|
+
* summary: Stop browser
|
|
4350
|
+
* description: Stops the browser and closes all sessions. Requires x-admin-key header.
|
|
4351
|
+
* security:
|
|
4352
|
+
* - BearerAuth: []
|
|
4353
|
+
* responses:
|
|
4354
|
+
* 200:
|
|
4355
|
+
* description: Browser stopped.
|
|
4356
|
+
* content:
|
|
4357
|
+
* application/json:
|
|
4358
|
+
* schema:
|
|
4359
|
+
* type: object
|
|
4360
|
+
* properties:
|
|
4361
|
+
* ok:
|
|
4362
|
+
* type: boolean
|
|
4363
|
+
* stopped:
|
|
4364
|
+
* type: boolean
|
|
4365
|
+
* profile:
|
|
4366
|
+
* type: string
|
|
4367
|
+
* 403:
|
|
4368
|
+
* description: Forbidden.
|
|
4369
|
+
* content:
|
|
4370
|
+
* application/json:
|
|
4371
|
+
* schema:
|
|
4372
|
+
* $ref: '#/components/schemas/Error'
|
|
4373
|
+
*/
|
|
2803
4374
|
app.post('/stop', async (req, res) => {
|
|
2804
4375
|
try {
|
|
2805
4376
|
const adminKey = req.headers['x-admin-key'];
|
|
@@ -2810,26 +4381,7 @@ app.post('/stop', async (req, res) => {
|
|
|
2810
4381
|
await browser.close().catch(() => {});
|
|
2811
4382
|
browser = null;
|
|
2812
4383
|
}
|
|
2813
|
-
|
|
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();
|
|
4384
|
+
await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
|
|
2833
4385
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
2834
4386
|
} catch (err) {
|
|
2835
4387
|
res.status(500).json({ ok: false, error: safeError(err) });
|
|
@@ -2837,6 +4389,48 @@ app.post('/stop', async (req, res) => {
|
|
|
2837
4389
|
});
|
|
2838
4390
|
|
|
2839
4391
|
// POST /navigate - Navigate (OpenClaw format with targetId in body)
|
|
4392
|
+
/**
|
|
4393
|
+
* @openapi
|
|
4394
|
+
* /navigate:
|
|
4395
|
+
* post:
|
|
4396
|
+
* tags: [Legacy]
|
|
4397
|
+
* summary: Navigate (OpenClaw format)
|
|
4398
|
+
* description: Navigate with targetId in body instead of path param.
|
|
4399
|
+
* deprecated: true
|
|
4400
|
+
* requestBody:
|
|
4401
|
+
* required: true
|
|
4402
|
+
* content:
|
|
4403
|
+
* application/json:
|
|
4404
|
+
* schema:
|
|
4405
|
+
* type: object
|
|
4406
|
+
* required: [userId, url]
|
|
4407
|
+
* properties:
|
|
4408
|
+
* userId:
|
|
4409
|
+
* type: string
|
|
4410
|
+
* targetId:
|
|
4411
|
+
* type: string
|
|
4412
|
+
* url:
|
|
4413
|
+
* type: string
|
|
4414
|
+
* responses:
|
|
4415
|
+
* 200:
|
|
4416
|
+
* description: Navigation result.
|
|
4417
|
+
* content:
|
|
4418
|
+
* application/json:
|
|
4419
|
+
* schema:
|
|
4420
|
+
* type: object
|
|
4421
|
+
* 400:
|
|
4422
|
+
* description: Bad request.
|
|
4423
|
+
* content:
|
|
4424
|
+
* application/json:
|
|
4425
|
+
* schema:
|
|
4426
|
+
* $ref: '#/components/schemas/Error'
|
|
4427
|
+
* 404:
|
|
4428
|
+
* description: Tab not found.
|
|
4429
|
+
* content:
|
|
4430
|
+
* application/json:
|
|
4431
|
+
* schema:
|
|
4432
|
+
* $ref: '#/components/schemas/Error'
|
|
4433
|
+
*/
|
|
2840
4434
|
app.post('/navigate', async (req, res) => {
|
|
2841
4435
|
try {
|
|
2842
4436
|
const { targetId, url, userId } = req.body;
|
|
@@ -2857,7 +4451,7 @@ app.post('/navigate', async (req, res) => {
|
|
|
2857
4451
|
}
|
|
2858
4452
|
|
|
2859
4453
|
const { tabState } = found;
|
|
2860
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
4454
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2861
4455
|
|
|
2862
4456
|
const result = await withTabLock(targetId, async () => {
|
|
2863
4457
|
await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
|
|
@@ -2882,6 +4476,58 @@ app.post('/navigate', async (req, res) => {
|
|
|
2882
4476
|
});
|
|
2883
4477
|
|
|
2884
4478
|
// GET /snapshot - Snapshot (OpenClaw format with query params)
|
|
4479
|
+
/**
|
|
4480
|
+
* @openapi
|
|
4481
|
+
* /snapshot:
|
|
4482
|
+
* get:
|
|
4483
|
+
* tags: [Legacy]
|
|
4484
|
+
* summary: Snapshot (OpenClaw format)
|
|
4485
|
+
* description: Snapshot with targetId/userId as query params.
|
|
4486
|
+
* deprecated: true
|
|
4487
|
+
* parameters:
|
|
4488
|
+
* - name: targetId
|
|
4489
|
+
* in: query
|
|
4490
|
+
* required: true
|
|
4491
|
+
* schema:
|
|
4492
|
+
* type: string
|
|
4493
|
+
* - name: userId
|
|
4494
|
+
* in: query
|
|
4495
|
+
* required: true
|
|
4496
|
+
* schema:
|
|
4497
|
+
* type: string
|
|
4498
|
+
* - name: format
|
|
4499
|
+
* in: query
|
|
4500
|
+
* schema:
|
|
4501
|
+
* type: string
|
|
4502
|
+
* - name: offset
|
|
4503
|
+
* in: query
|
|
4504
|
+
* schema:
|
|
4505
|
+
* type: integer
|
|
4506
|
+
* - name: includeScreenshot
|
|
4507
|
+
* in: query
|
|
4508
|
+
* schema:
|
|
4509
|
+
* type: string
|
|
4510
|
+
* enum: ['true', 'false']
|
|
4511
|
+
* responses:
|
|
4512
|
+
* 200:
|
|
4513
|
+
* description: Snapshot.
|
|
4514
|
+
* content:
|
|
4515
|
+
* application/json:
|
|
4516
|
+
* schema:
|
|
4517
|
+
* type: object
|
|
4518
|
+
* 400:
|
|
4519
|
+
* description: Bad request.
|
|
4520
|
+
* content:
|
|
4521
|
+
* application/json:
|
|
4522
|
+
* schema:
|
|
4523
|
+
* $ref: '#/components/schemas/Error'
|
|
4524
|
+
* 404:
|
|
4525
|
+
* description: Tab not found.
|
|
4526
|
+
* content:
|
|
4527
|
+
* application/json:
|
|
4528
|
+
* schema:
|
|
4529
|
+
* $ref: '#/components/schemas/Error'
|
|
4530
|
+
*/
|
|
2885
4531
|
app.get('/snapshot', async (req, res) => {
|
|
2886
4532
|
try {
|
|
2887
4533
|
const { targetId, userId, format = 'text' } = req.query;
|
|
@@ -2897,7 +4543,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2897
4543
|
}
|
|
2898
4544
|
|
|
2899
4545
|
const { tabState } = found;
|
|
2900
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
4546
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
2901
4547
|
|
|
2902
4548
|
// Cached chunk retrieval
|
|
2903
4549
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -2917,6 +4563,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2917
4563
|
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
2918
4564
|
tabState.refs = googleRefs;
|
|
2919
4565
|
tabState.lastSnapshot = googleSnapshot;
|
|
4566
|
+
snapshotBytes.labels('google_serp').observe(Buffer.byteLength(googleSnapshot, 'utf8'));
|
|
2920
4567
|
const annotatedYaml = googleSnapshot;
|
|
2921
4568
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
2922
4569
|
const response = {
|
|
@@ -2961,6 +4608,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2961
4608
|
}
|
|
2962
4609
|
|
|
2963
4610
|
tabState.lastSnapshot = annotatedYaml;
|
|
4611
|
+
if (annotatedYaml) snapshotBytes.labels('full').observe(Buffer.byteLength(annotatedYaml, 'utf8'));
|
|
2964
4612
|
const win = windowSnapshot(annotatedYaml, 0);
|
|
2965
4613
|
|
|
2966
4614
|
const response = {
|
|
@@ -2990,6 +4638,61 @@ app.get('/snapshot', async (req, res) => {
|
|
|
2990
4638
|
|
|
2991
4639
|
// POST /act - Combined action endpoint (OpenClaw format)
|
|
2992
4640
|
// Routes to click/type/scroll/press/etc based on 'kind' parameter
|
|
4641
|
+
/**
|
|
4642
|
+
* @openapi
|
|
4643
|
+
* /act:
|
|
4644
|
+
* post:
|
|
4645
|
+
* tags: [Legacy]
|
|
4646
|
+
* summary: Combined action (OpenClaw format)
|
|
4647
|
+
* description: Routes to click/type/scroll/press/etc based on "kind" parameter.
|
|
4648
|
+
* deprecated: true
|
|
4649
|
+
* requestBody:
|
|
4650
|
+
* required: true
|
|
4651
|
+
* content:
|
|
4652
|
+
* application/json:
|
|
4653
|
+
* schema:
|
|
4654
|
+
* type: object
|
|
4655
|
+
* required: [userId, kind]
|
|
4656
|
+
* properties:
|
|
4657
|
+
* userId:
|
|
4658
|
+
* type: string
|
|
4659
|
+
* kind:
|
|
4660
|
+
* type: string
|
|
4661
|
+
* description: 'Action kind: click, type, scroll, press, key, select_option, drag, hover, screenshot, wait, back, forward.'
|
|
4662
|
+
* targetId:
|
|
4663
|
+
* type: string
|
|
4664
|
+
* ref:
|
|
4665
|
+
* type: string
|
|
4666
|
+
* selector:
|
|
4667
|
+
* type: string
|
|
4668
|
+
* text:
|
|
4669
|
+
* type: string
|
|
4670
|
+
* key:
|
|
4671
|
+
* type: string
|
|
4672
|
+
* direction:
|
|
4673
|
+
* type: string
|
|
4674
|
+
* url:
|
|
4675
|
+
* type: string
|
|
4676
|
+
* responses:
|
|
4677
|
+
* 200:
|
|
4678
|
+
* description: Action result.
|
|
4679
|
+
* content:
|
|
4680
|
+
* application/json:
|
|
4681
|
+
* schema:
|
|
4682
|
+
* type: object
|
|
4683
|
+
* 400:
|
|
4684
|
+
* description: Bad request.
|
|
4685
|
+
* content:
|
|
4686
|
+
* application/json:
|
|
4687
|
+
* schema:
|
|
4688
|
+
* $ref: '#/components/schemas/Error'
|
|
4689
|
+
* 404:
|
|
4690
|
+
* description: Tab not found.
|
|
4691
|
+
* content:
|
|
4692
|
+
* application/json:
|
|
4693
|
+
* schema:
|
|
4694
|
+
* $ref: '#/components/schemas/Error'
|
|
4695
|
+
*/
|
|
2993
4696
|
app.post('/act', async (req, res) => {
|
|
2994
4697
|
try {
|
|
2995
4698
|
const { kind, targetId, userId, ...params } = req.body;
|
|
@@ -3008,7 +4711,7 @@ app.post('/act', async (req, res) => {
|
|
|
3008
4711
|
}
|
|
3009
4712
|
|
|
3010
4713
|
const { tabState } = found;
|
|
3011
|
-
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
4714
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
|
|
3012
4715
|
|
|
3013
4716
|
const result = await withTabLock(targetId, async () => {
|
|
3014
4717
|
switch (kind) {
|
|
@@ -3053,28 +4756,43 @@ app.post('/act', async (req, res) => {
|
|
|
3053
4756
|
}
|
|
3054
4757
|
|
|
3055
4758
|
case 'type': {
|
|
3056
|
-
const { ref, selector, text, submit } = params;
|
|
3057
|
-
if (!ref && !selector) {
|
|
3058
|
-
throw new Error('ref or selector required');
|
|
4759
|
+
const { ref, selector, text, submit, mode = 'fill', delay = 30 } = params;
|
|
4760
|
+
if (mode === 'fill' && !ref && !selector) {
|
|
4761
|
+
throw new Error('ref or selector required for mode=fill');
|
|
3059
4762
|
}
|
|
3060
4763
|
if (typeof text !== 'string') {
|
|
3061
4764
|
throw new Error('text is required');
|
|
3062
4765
|
}
|
|
4766
|
+
if (mode !== 'fill' && mode !== 'keyboard') {
|
|
4767
|
+
throw new Error("mode must be 'fill' or 'keyboard'");
|
|
4768
|
+
}
|
|
3063
4769
|
|
|
4770
|
+
let locator = null;
|
|
3064
4771
|
if (ref) {
|
|
3065
|
-
|
|
4772
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
3066
4773
|
if (!locator) {
|
|
3067
|
-
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
4774
|
+
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size, mode });
|
|
3068
4775
|
tabState.refs = await buildRefs(tabState.page);
|
|
3069
4776
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
3070
4777
|
}
|
|
3071
4778
|
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
3072
|
-
|
|
3073
|
-
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
if (mode === 'fill') {
|
|
4782
|
+
if (locator) {
|
|
4783
|
+
await locator.fill(text, { timeout: 10000 });
|
|
4784
|
+
} else {
|
|
4785
|
+
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
4786
|
+
}
|
|
3074
4787
|
} else {
|
|
3075
|
-
|
|
3076
|
-
|
|
4788
|
+
if (locator) {
|
|
4789
|
+
await locator.focus({ timeout: 10000 });
|
|
4790
|
+
} else if (selector) {
|
|
4791
|
+
await tabState.page.focus(selector, { timeout: 10000 });
|
|
4792
|
+
}
|
|
4793
|
+
await tabState.page.keyboard.type(text, { delay });
|
|
3077
4794
|
}
|
|
4795
|
+
if (submit) await tabState.page.keyboard.press('Enter');
|
|
3078
4796
|
return { ok: true, targetId };
|
|
3079
4797
|
}
|
|
3080
4798
|
|
|
@@ -3208,7 +4926,9 @@ setInterval(async () => {
|
|
|
3208
4926
|
|
|
3209
4927
|
// Crash logging
|
|
3210
4928
|
process.on('uncaughtException', (err) => {
|
|
4929
|
+
pluginEvents.emit('browser:error', { error: err });
|
|
3211
4930
|
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
|
|
4931
|
+
reporter.reportCrash(err);
|
|
3212
4932
|
process.exit(1);
|
|
3213
4933
|
});
|
|
3214
4934
|
process.on('unhandledRejection', (reason) => {
|
|
@@ -3222,6 +4942,7 @@ async function gracefulShutdown(signal) {
|
|
|
3222
4942
|
if (shuttingDown) return;
|
|
3223
4943
|
shuttingDown = true;
|
|
3224
4944
|
log('info', 'shutting down', { signal });
|
|
4945
|
+
pluginEvents.emit('server:shutdown', { signal });
|
|
3225
4946
|
|
|
3226
4947
|
const forceTimeout = setTimeout(() => {
|
|
3227
4948
|
log('error', 'shutdown timed out, forcing exit');
|
|
@@ -3232,9 +4953,11 @@ async function gracefulShutdown(signal) {
|
|
|
3232
4953
|
server.close();
|
|
3233
4954
|
stopMemoryReporter();
|
|
3234
4955
|
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
4956
|
+
await closeAllSessions(`shutdown:${signal}`, {
|
|
4957
|
+
clearDownloads: false,
|
|
4958
|
+
clearLocks: false,
|
|
4959
|
+
});
|
|
4960
|
+
|
|
3238
4961
|
if (browser) await browser.close().catch(() => {});
|
|
3239
4962
|
process.exit(0);
|
|
3240
4963
|
}
|
|
@@ -3248,15 +4971,61 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
3248
4971
|
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
|
|
3249
4972
|
|
|
3250
4973
|
const PORT = CONFIG.port;
|
|
4974
|
+
pluginEvents.emit('server:starting', { port: PORT });
|
|
4975
|
+
|
|
4976
|
+
// Load plugins before starting the server
|
|
4977
|
+
const pluginCtx = {
|
|
4978
|
+
sessions,
|
|
4979
|
+
config: CONFIG,
|
|
4980
|
+
log,
|
|
4981
|
+
events: pluginEvents,
|
|
4982
|
+
auth: authMiddleware,
|
|
4983
|
+
ensureBrowser,
|
|
4984
|
+
getSession,
|
|
4985
|
+
destroySession,
|
|
4986
|
+
closeSession,
|
|
4987
|
+
withUserLimit,
|
|
4988
|
+
safePageClose,
|
|
4989
|
+
normalizeUserId,
|
|
4990
|
+
validateUrl,
|
|
4991
|
+
safeError,
|
|
4992
|
+
buildProxyUrl,
|
|
4993
|
+
proxyPool,
|
|
4994
|
+
failuresTotal,
|
|
4995
|
+
metricsRegistry: getRegister,
|
|
4996
|
+
createMetric,
|
|
4997
|
+
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
|
|
4998
|
+
createVirtualDisplay: () => new VirtualDisplay(),
|
|
4999
|
+
/** The upstream VirtualDisplay class — plugins can subclass it. */
|
|
5000
|
+
VirtualDisplay,
|
|
5001
|
+
};
|
|
5002
|
+
const loadedPlugins = await loadPlugins(app, pluginCtx);
|
|
5003
|
+
|
|
5004
|
+
// --- OpenAPI docs (after all routes are registered) ---
|
|
5005
|
+
mountDocs(app);
|
|
5006
|
+
|
|
3251
5007
|
const server = app.listen(PORT, async () => {
|
|
3252
5008
|
startMemoryReporter();
|
|
3253
5009
|
refreshActiveTabsGauge();
|
|
3254
5010
|
refreshTabLockQueueDepth();
|
|
5011
|
+
pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
|
|
3255
5012
|
if (FLY_MACHINE_ID) {
|
|
3256
5013
|
log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
|
|
3257
5014
|
} else {
|
|
3258
5015
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
3259
5016
|
}
|
|
5017
|
+
const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
|
|
5018
|
+
if (tmpCleanup.removed > 0) {
|
|
5019
|
+
log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
|
|
5020
|
+
}
|
|
5021
|
+
const traceSweep = sweepOldTraces({
|
|
5022
|
+
baseDir: CONFIG.tracesDir,
|
|
5023
|
+
ttlMs: CONFIG.tracesTtlHours * 3600 * 1000,
|
|
5024
|
+
maxBytesPerFile: CONFIG.tracesMaxBytes,
|
|
5025
|
+
});
|
|
5026
|
+
if (traceSweep.removedTtl > 0 || traceSweep.removedOversized > 0) {
|
|
5027
|
+
log('info', 'swept old traces', traceSweep);
|
|
5028
|
+
}
|
|
3260
5029
|
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
3261
5030
|
try {
|
|
3262
5031
|
const start = Date.now();
|