@askjo/camofox-browser 1.6.0 → 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/server.js CHANGED
@@ -3,6 +3,7 @@ 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';
@@ -19,6 +20,11 @@ import {
19
20
  getDownloadsList,
20
21
  } from './lib/downloads.js';
21
22
  import { extractPageImages } from './lib/images.js';
23
+ import { extractDeterministic, validateSchema as validateExtractSchema } from './lib/extract.js';
24
+ import {
25
+ ensureTracesDir, resolveTracePath, tracePathFor, makeTraceFilename,
26
+ listUserTraces, statTrace, deleteTrace, sweepOldTraces,
27
+ } from './lib/tracing.js';
22
28
 
23
29
  import {
24
30
  initMetrics, getRegister, isMetricsEnabled, createMetric,
@@ -27,9 +33,29 @@ import {
27
33
  import { actionFromReq, classifyError } from './lib/request-utils.js';
28
34
  import { cleanupOrphanedTempFiles } from './lib/tmp-cleanup.js';
29
35
  import { coalesceInflight } from './lib/inflight.js';
36
+ import { createReporter, createTabHealthTracker } from './lib/reporter.js';
37
+ import { mountDocs } from './lib/openapi.js';
30
38
 
31
39
  const CONFIG = loadConfig();
32
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
+
33
59
  // --- Plugin event bus ---
34
60
  const pluginEvents = createPluginEvents();
35
61
 
@@ -168,10 +194,81 @@ function validateUrl(url) {
168
194
  //
169
195
  // SECURITY:
170
196
  // Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
171
- // By default, this endpoint is protected by CAMOFOX_API_KEY.
172
- // For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
173
- // unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
174
- // when NODE_ENV != production.
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) {
@@ -726,6 +823,16 @@ async function closeSession(userId, session, {
726
823
  await clearSessionDownloads(session).catch(() => {});
727
824
  }
728
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
+
729
836
  await session.context.close().catch(() => {});
730
837
  sessions.delete(key);
731
838
  await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
@@ -744,7 +851,7 @@ async function closeAllSessions(reason, { clearDownloads = true, clearLocks = tr
744
851
  }
745
852
  }
746
853
 
747
- async function getSession(userId) {
854
+ async function getSession(userId, { trace = false } = {}) {
748
855
  const key = normalizeUserId(userId);
749
856
  let session = sessions.get(key);
750
857
 
@@ -794,8 +901,21 @@ async function getSession(userId) {
794
901
  }
795
902
  await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
796
903
  const context = await b.newContext(contextOptions);
797
-
798
- const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
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 };
799
919
  sessions.set(key, created);
800
920
  await pluginEvents.emitAsync('session:created', { userId: key, context });
801
921
  log('info', 'session created', {
@@ -889,7 +1009,6 @@ function handleRouteError(err, req, res, extraFields = {}) {
889
1009
  }
890
1010
  // Lock queue timeout = tab is stuck. Destroy immediately.
891
1011
  if (userId && isTabLockQueueTimeout(err)) {
892
- const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
893
1012
  const session = sessions.get(normalizeUserId(userId));
894
1013
  if (session && tabId) {
895
1014
  destroyTab(session, tabId, 'lock_queue', userId);
@@ -900,6 +1019,34 @@ function handleRouteError(err, req, res, extraFields = {}) {
900
1019
  if (isTabDestroyedError(err)) {
901
1020
  return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
902
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
+ }
903
1050
  sendError(res, err, extraFields);
904
1051
  }
905
1052
 
@@ -980,6 +1127,7 @@ function findTab(session, tabId) {
980
1127
  }
981
1128
 
982
1129
  function createTabState(page) {
1130
+ const healthTracker = createTabHealthTracker(page);
983
1131
  return {
984
1132
  page,
985
1133
  refs: new Map(),
@@ -987,6 +1135,9 @@ function createTabState(page) {
987
1135
  downloads: [],
988
1136
  toolCalls: 0,
989
1137
  consecutiveTimeouts: 0,
1138
+ consecutiveFailures: 0,
1139
+ failureJournal: [],
1140
+ healthTracker,
990
1141
  lastSnapshot: null,
991
1142
  lastRequestedUrl: null,
992
1143
  googleRetryCount: 0,
@@ -1509,6 +1660,47 @@ async function refreshTabRefs(tabState, options = {}) {
1509
1660
  }
1510
1661
 
1511
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
+ */
1512
1704
  app.get('/health', (req, res) => {
1513
1705
  if (healthState.isRecovering) {
1514
1706
  return res.status(503).json({ ok: false, engine: 'camoufox', recovering: true });
@@ -1537,6 +1729,27 @@ app.get('/health', (req, res) => {
1537
1729
  });
1538
1730
  });
1539
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
+ */
1540
1753
  app.get('/metrics', async (_req, res) => {
1541
1754
  const reg = getRegister();
1542
1755
  if (!reg) {
@@ -1548,17 +1761,85 @@ app.get('/metrics', async (_req, res) => {
1548
1761
  });
1549
1762
 
1550
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
+ */
1551
1825
  app.post('/tabs', async (req, res) => {
1552
1826
  try {
1553
- const { userId, sessionKey, listItemId, url } = req.body;
1827
+ const { userId, sessionKey, listItemId, url, trace } = req.body;
1554
1828
  // Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
1555
1829
  const resolvedSessionKey = sessionKey || listItemId;
1556
1830
  if (!userId || !resolvedSessionKey) {
1557
1831
  return res.status(400).json({ error: 'userId and sessionKey required' });
1558
1832
  }
1559
-
1833
+
1560
1834
  const result = await withTimeout((async () => {
1561
- const session = await getSession(userId);
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 });
1562
1843
 
1563
1844
  let totalTabs = 0;
1564
1845
  for (const group of session.tabGroups.values()) totalTabs += group.size;
@@ -1601,6 +1882,61 @@ app.post('/tabs', async (req, res) => {
1601
1882
  });
1602
1883
 
1603
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
+ */
1604
1940
  app.post('/tabs/:tabId/navigate', async (req, res) => {
1605
1941
  const tabId = req.params.tabId;
1606
1942
 
@@ -1638,7 +1974,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1638
1974
  } else {
1639
1975
  tabState = found.tabState;
1640
1976
  }
1641
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
1977
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
1642
1978
 
1643
1979
  let targetUrl = url;
1644
1980
  if (macro && macro !== '__NO__' && macro !== 'none' && macro !== 'null') {
@@ -1749,6 +2085,69 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
1749
2085
  });
1750
2086
 
1751
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
+ */
1752
2151
  app.get('/tabs/:tabId/snapshot', async (req, res) => {
1753
2152
  try {
1754
2153
  const userId = req.query.userId;
@@ -1760,7 +2159,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1760
2159
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1761
2160
 
1762
2161
  const { tabState } = found;
1763
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2162
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
1764
2163
 
1765
2164
  // Cached chunk retrieval for offset>0 requests
1766
2165
  if (offset > 0 && tabState.lastSnapshot) {
@@ -1889,6 +2288,50 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
1889
2288
  });
1890
2289
 
1891
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
+ */
1892
2335
  app.post('/tabs/:tabId/wait', async (req, res) => {
1893
2336
  try {
1894
2337
  const { userId, timeout = 10000, waitForNetwork = true } = req.body;
@@ -1907,6 +2350,64 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
1907
2350
  });
1908
2351
 
1909
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
+ */
1910
2411
  app.post('/tabs/:tabId/click', async (req, res) => {
1911
2412
  const tabId = req.params.tabId;
1912
2413
 
@@ -1918,7 +2419,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
1918
2419
  if (!found) return res.status(404).json({ error: 'Tab not found' });
1919
2420
 
1920
2421
  const { tabState } = found;
1921
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2422
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
1922
2423
 
1923
2424
  if (!ref && !selector) {
1924
2425
  return res.status(400).json({ error: 'ref or selector required' });
@@ -2079,6 +2580,61 @@ app.post('/tabs/:tabId/click', async (req, res) => {
2079
2580
  });
2080
2581
 
2081
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
+ */
2082
2638
  app.post('/tabs/:tabId/type', async (req, res) => {
2083
2639
  const tabId = req.params.tabId;
2084
2640
 
@@ -2089,7 +2645,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
2089
2645
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2090
2646
 
2091
2647
  const { tabState } = found;
2092
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2648
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2093
2649
 
2094
2650
  if (mode !== 'fill' && mode !== 'keyboard') {
2095
2651
  return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
@@ -2161,6 +2717,48 @@ app.post('/tabs/:tabId/type', async (req, res) => {
2161
2717
  });
2162
2718
 
2163
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
+ */
2164
2762
  app.post('/tabs/:tabId/press', async (req, res) => {
2165
2763
  const tabId = req.params.tabId;
2166
2764
 
@@ -2171,7 +2769,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
2171
2769
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2172
2770
 
2173
2771
  const { tabState } = found;
2174
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2772
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2175
2773
 
2176
2774
  await withTabLock(tabId, async () => {
2177
2775
  await tabState.page.keyboard.press(key);
@@ -2186,6 +2784,51 @@ app.post('/tabs/:tabId/press', async (req, res) => {
2186
2784
  });
2187
2785
 
2188
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
+ */
2189
2832
  app.post('/tabs/:tabId/scroll', async (req, res) => {
2190
2833
  try {
2191
2834
  const { userId, direction = 'down', amount = 500 } = req.body;
@@ -2194,7 +2837,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
2194
2837
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2195
2838
 
2196
2839
  const { tabState } = found;
2197
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2840
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2198
2841
 
2199
2842
  const isVertical = direction === 'up' || direction === 'down';
2200
2843
  const delta = (direction === 'up' || direction === 'left') ? -amount : amount;
@@ -2210,6 +2853,47 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
2210
2853
  });
2211
2854
 
2212
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
+ */
2213
2897
  app.post('/tabs/:tabId/back', async (req, res) => {
2214
2898
  const tabId = req.params.tabId;
2215
2899
 
@@ -2220,7 +2904,7 @@ app.post('/tabs/:tabId/back', async (req, res) => {
2220
2904
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2221
2905
 
2222
2906
  const { tabState } = found;
2223
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2907
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2224
2908
 
2225
2909
  const result = await withTabLock(tabId, async () => {
2226
2910
  try {
@@ -2246,6 +2930,47 @@ app.post('/tabs/:tabId/back', async (req, res) => {
2246
2930
  });
2247
2931
 
2248
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
+ */
2249
2974
  app.post('/tabs/:tabId/forward', async (req, res) => {
2250
2975
  const tabId = req.params.tabId;
2251
2976
 
@@ -2256,7 +2981,7 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
2256
2981
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2257
2982
 
2258
2983
  const { tabState } = found;
2259
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
2984
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2260
2985
 
2261
2986
  const result = await withTabLock(tabId, async () => {
2262
2987
  await tabState.page.goForward({ timeout: 10000 });
@@ -2272,6 +2997,47 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
2272
2997
  });
2273
2998
 
2274
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
+ */
2275
3041
  app.post('/tabs/:tabId/refresh', async (req, res) => {
2276
3042
  const tabId = req.params.tabId;
2277
3043
 
@@ -2282,7 +3048,7 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
2282
3048
  if (!found) return res.status(404).json({ error: 'Tab not found' });
2283
3049
 
2284
3050
  const { tabState } = found;
2285
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3051
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2286
3052
 
2287
3053
  const result = await withTabLock(tabId, async () => {
2288
3054
  await tabState.page.reload({ timeout: 30000 });
@@ -2298,6 +3064,49 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
2298
3064
  });
2299
3065
 
2300
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
+ */
2301
3110
  app.get('/tabs/:tabId/links', async (req, res) => {
2302
3111
  try {
2303
3112
  const userId = req.query.userId;
@@ -2311,7 +3120,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
2311
3120
  }
2312
3121
 
2313
3122
  const { tabState } = found;
2314
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3123
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2315
3124
 
2316
3125
  const allLinks = await tabState.page.evaluate(() => {
2317
3126
  const links = [];
@@ -2339,6 +3148,49 @@ app.get('/tabs/:tabId/links', async (req, res) => {
2339
3148
  });
2340
3149
 
2341
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
+ */
2342
3194
  app.get('/tabs/:tabId/downloads', async (req, res) => {
2343
3195
  try {
2344
3196
  const userId = req.query.userId;
@@ -2368,6 +3220,51 @@ app.get('/tabs/:tabId/downloads', async (req, res) => {
2368
3220
  });
2369
3221
 
2370
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
+ */
2371
3268
  app.get('/tabs/:tabId/images', async (req, res) => {
2372
3269
  try {
2373
3270
  const userId = req.query.userId;
@@ -2394,6 +3291,46 @@ app.get('/tabs/:tabId/images', async (req, res) => {
2394
3291
  });
2395
3292
 
2396
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
+ */
2397
3334
  app.get('/tabs/:tabId/screenshot', async (req, res) => {
2398
3335
  try {
2399
3336
  const userId = req.query.userId;
@@ -2414,6 +3351,53 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
2414
3351
  });
2415
3352
 
2416
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
+ */
2417
3401
  app.get('/tabs/:tabId/stats', async (req, res) => {
2418
3402
  try {
2419
3403
  const userId = req.query.userId;
@@ -2439,6 +3423,56 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
2439
3423
  });
2440
3424
 
2441
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
+ */
2442
3476
  app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
2443
3477
  try {
2444
3478
  const { userId, expression } = req.body;
@@ -2451,7 +3485,7 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
2451
3485
 
2452
3486
  session.lastAccess = Date.now();
2453
3487
  const { tabState } = found;
2454
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
3488
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2455
3489
 
2456
3490
  pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
2457
3491
  const result = await tabState.page.evaluate(expression);
@@ -2465,7 +3499,192 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
2465
3499
  }
2466
3500
  });
2467
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
+
2468
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
+ */
2469
3688
  app.delete('/tabs/:tabId', async (req, res) => {
2470
3689
  try {
2471
3690
  const userId = req.query.userId || req.body?.userId;
@@ -2492,6 +3711,42 @@ app.delete('/tabs/:tabId', async (req, res) => {
2492
3711
  });
2493
3712
 
2494
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
+ */
2495
3750
  app.delete('/tabs/group/:listItemId', async (req, res) => {
2496
3751
  try {
2497
3752
  const userId = req.query.userId || req.body?.userId;
@@ -2520,7 +3775,254 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
2520
3775
  }
2521
3776
  });
2522
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
+
2523
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
+ */
2524
4026
  app.delete('/sessions/:userId', async (req, res) => {
2525
4027
  try {
2526
4028
  const userId = normalizeUserId(req.params.userId);
@@ -2605,6 +4107,34 @@ setInterval(() => {
2605
4107
  // =============================================================================
2606
4108
 
2607
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
+ */
2608
4138
  app.get('/', (req, res) => {
2609
4139
  const running = browser !== null && (browser.isConnected?.() ?? false);
2610
4140
  res.json({
@@ -2618,6 +4148,45 @@ app.get('/', (req, res) => {
2618
4148
  });
2619
4149
 
2620
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
+ */
2621
4190
  app.get('/tabs', async (req, res) => {
2622
4191
  try {
2623
4192
  const userId = req.query.userId;
@@ -2648,6 +4217,41 @@ app.get('/tabs', async (req, res) => {
2648
4217
  });
2649
4218
 
2650
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
+ */
2651
4255
  app.post('/tabs/open', async (req, res) => {
2652
4256
  try {
2653
4257
  const { url, userId, listItemId = 'default' } = req.body;
@@ -2700,6 +4304,32 @@ app.post('/tabs/open', async (req, res) => {
2700
4304
  });
2701
4305
 
2702
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
+ */
2703
4333
  app.post('/start', async (req, res) => {
2704
4334
  try {
2705
4335
  await ensureBrowser();
@@ -2711,6 +4341,36 @@ app.post('/start', async (req, res) => {
2711
4341
  });
2712
4342
 
2713
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
+ */
2714
4374
  app.post('/stop', async (req, res) => {
2715
4375
  try {
2716
4376
  const adminKey = req.headers['x-admin-key'];
@@ -2729,6 +4389,48 @@ app.post('/stop', async (req, res) => {
2729
4389
  });
2730
4390
 
2731
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
+ */
2732
4434
  app.post('/navigate', async (req, res) => {
2733
4435
  try {
2734
4436
  const { targetId, url, userId } = req.body;
@@ -2749,7 +4451,7 @@ app.post('/navigate', async (req, res) => {
2749
4451
  }
2750
4452
 
2751
4453
  const { tabState } = found;
2752
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4454
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2753
4455
 
2754
4456
  const result = await withTabLock(targetId, async () => {
2755
4457
  await withPageLoadDuration('navigate', () => tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }));
@@ -2774,6 +4476,58 @@ app.post('/navigate', async (req, res) => {
2774
4476
  });
2775
4477
 
2776
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
+ */
2777
4531
  app.get('/snapshot', async (req, res) => {
2778
4532
  try {
2779
4533
  const { targetId, userId, format = 'text' } = req.query;
@@ -2789,7 +4543,7 @@ app.get('/snapshot', async (req, res) => {
2789
4543
  }
2790
4544
 
2791
4545
  const { tabState } = found;
2792
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4546
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2793
4547
 
2794
4548
  // Cached chunk retrieval
2795
4549
  if (offset > 0 && tabState.lastSnapshot) {
@@ -2884,6 +4638,61 @@ app.get('/snapshot', async (req, res) => {
2884
4638
 
2885
4639
  // POST /act - Combined action endpoint (OpenClaw format)
2886
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
+ */
2887
4696
  app.post('/act', async (req, res) => {
2888
4697
  try {
2889
4698
  const { kind, targetId, userId, ...params } = req.body;
@@ -2902,7 +4711,7 @@ app.post('/act', async (req, res) => {
2902
4711
  }
2903
4712
 
2904
4713
  const { tabState } = found;
2905
- tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
4714
+ tabState.toolCalls++; tabState.consecutiveTimeouts = 0; tabState.consecutiveFailures = 0;
2906
4715
 
2907
4716
  const result = await withTabLock(targetId, async () => {
2908
4717
  switch (kind) {
@@ -3119,6 +4928,7 @@ setInterval(async () => {
3119
4928
  process.on('uncaughtException', (err) => {
3120
4929
  pluginEvents.emit('browser:error', { error: err });
3121
4930
  log('error', 'uncaughtException', { error: err.message, stack: err.stack });
4931
+ reporter.reportCrash(err);
3122
4932
  process.exit(1);
3123
4933
  });
3124
4934
  process.on('unhandledRejection', (reason) => {
@@ -3191,6 +5001,9 @@ const pluginCtx = {
3191
5001
  };
3192
5002
  const loadedPlugins = await loadPlugins(app, pluginCtx);
3193
5003
 
5004
+ // --- OpenAPI docs (after all routes are registered) ---
5005
+ mountDocs(app);
5006
+
3194
5007
  const server = app.listen(PORT, async () => {
3195
5008
  startMemoryReporter();
3196
5009
  refreshActiveTabsGauge();
@@ -3205,6 +5018,14 @@ const server = app.listen(PORT, async () => {
3205
5018
  if (tmpCleanup.removed > 0) {
3206
5019
  log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
3207
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
+ }
3208
5029
  // Pre-warm browser so first request doesn't eat a 6-7s cold start
3209
5030
  try {
3210
5031
  const start = Date.now();