camofox-browser 2.1.1 → 2.4.1

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +304 -33
  3. package/dist/src/cli/commands/content.d.ts.map +1 -1
  4. package/dist/src/cli/commands/content.js +37 -0
  5. package/dist/src/cli/commands/content.js.map +1 -1
  6. package/dist/src/cli/commands/core.d.ts.map +1 -1
  7. package/dist/src/cli/commands/core.js +21 -4
  8. package/dist/src/cli/commands/core.js.map +1 -1
  9. package/dist/src/cli/commands/interaction.d.ts.map +1 -1
  10. package/dist/src/cli/commands/interaction.js +5 -14
  11. package/dist/src/cli/commands/interaction.js.map +1 -1
  12. package/dist/src/cli/commands/navigation.d.ts.map +1 -1
  13. package/dist/src/cli/commands/navigation.js +12 -6
  14. package/dist/src/cli/commands/navigation.js.map +1 -1
  15. package/dist/src/cli/commands/server.d.ts.map +1 -1
  16. package/dist/src/cli/commands/server.js +9 -3
  17. package/dist/src/cli/commands/server.js.map +1 -1
  18. package/dist/src/cli/commands/session.d.ts.map +1 -1
  19. package/dist/src/cli/commands/session.js +23 -5
  20. package/dist/src/cli/commands/session.js.map +1 -1
  21. package/dist/src/cli/server/manager.d.ts +1 -0
  22. package/dist/src/cli/server/manager.d.ts.map +1 -1
  23. package/dist/src/cli/server/manager.js +7 -12
  24. package/dist/src/cli/server/manager.js.map +1 -1
  25. package/dist/src/middleware/lifecycle-activity.d.ts +9 -0
  26. package/dist/src/middleware/lifecycle-activity.d.ts.map +1 -0
  27. package/dist/src/middleware/lifecycle-activity.js +21 -0
  28. package/dist/src/middleware/lifecycle-activity.js.map +1 -0
  29. package/dist/src/openapi/spec.d.ts +4 -0
  30. package/dist/src/openapi/spec.d.ts.map +1 -0
  31. package/dist/src/openapi/spec.js +730 -0
  32. package/dist/src/openapi/spec.js.map +1 -0
  33. package/dist/src/routes/core.d.ts.map +1 -1
  34. package/dist/src/routes/core.js +428 -53
  35. package/dist/src/routes/core.js.map +1 -1
  36. package/dist/src/routes/docs.d.ts +3 -0
  37. package/dist/src/routes/docs.d.ts.map +1 -0
  38. package/dist/src/routes/docs.js +23 -0
  39. package/dist/src/routes/docs.js.map +1 -0
  40. package/dist/src/routes/openclaw.d.ts.map +1 -1
  41. package/dist/src/routes/openclaw.js +244 -90
  42. package/dist/src/routes/openclaw.js.map +1 -1
  43. package/dist/src/server.js +55 -4
  44. package/dist/src/server.js.map +1 -1
  45. package/dist/src/services/context-pool.d.ts +19 -3
  46. package/dist/src/services/context-pool.d.ts.map +1 -1
  47. package/dist/src/services/context-pool.js +248 -65
  48. package/dist/src/services/context-pool.js.map +1 -1
  49. package/dist/src/services/download.d.ts +2 -0
  50. package/dist/src/services/download.d.ts.map +1 -1
  51. package/dist/src/services/download.js +110 -80
  52. package/dist/src/services/download.js.map +1 -1
  53. package/dist/src/services/lifecycle-controller.d.ts +40 -0
  54. package/dist/src/services/lifecycle-controller.d.ts.map +1 -0
  55. package/dist/src/services/lifecycle-controller.js +106 -0
  56. package/dist/src/services/lifecycle-controller.js.map +1 -0
  57. package/dist/src/services/resource-extractor.d.ts +1 -0
  58. package/dist/src/services/resource-extractor.d.ts.map +1 -1
  59. package/dist/src/services/resource-extractor.js +7 -0
  60. package/dist/src/services/resource-extractor.js.map +1 -1
  61. package/dist/src/services/session.d.ts +84 -2
  62. package/dist/src/services/session.d.ts.map +1 -1
  63. package/dist/src/services/session.js +349 -47
  64. package/dist/src/services/session.js.map +1 -1
  65. package/dist/src/services/structured-extractor.d.ts +39 -0
  66. package/dist/src/services/structured-extractor.d.ts.map +1 -0
  67. package/dist/src/services/structured-extractor.js +487 -0
  68. package/dist/src/services/structured-extractor.js.map +1 -0
  69. package/dist/src/services/tab.d.ts +30 -3
  70. package/dist/src/services/tab.d.ts.map +1 -1
  71. package/dist/src/services/tab.js +872 -124
  72. package/dist/src/services/tab.js.map +1 -1
  73. package/dist/src/services/tracing.d.ts +7 -0
  74. package/dist/src/services/tracing.d.ts.map +1 -1
  75. package/dist/src/services/tracing.js +162 -19
  76. package/dist/src/services/tracing.js.map +1 -1
  77. package/dist/src/services/vnc.d.ts.map +1 -1
  78. package/dist/src/services/vnc.js +5 -3
  79. package/dist/src/services/vnc.js.map +1 -1
  80. package/dist/src/services/youtube.js +1 -1
  81. package/dist/src/services/youtube.js.map +1 -1
  82. package/dist/src/types.d.ts +71 -1
  83. package/dist/src/types.d.ts.map +1 -1
  84. package/dist/src/utils/config.d.ts +79 -3
  85. package/dist/src/utils/config.d.ts.map +1 -1
  86. package/dist/src/utils/config.js +145 -3
  87. package/dist/src/utils/config.js.map +1 -1
  88. package/dist/src/utils/presets.d.ts.map +1 -1
  89. package/dist/src/utils/presets.js +3 -1
  90. package/dist/src/utils/presets.js.map +1 -1
  91. package/dist/src/utils/proxy-profiles.d.ts +18 -0
  92. package/dist/src/utils/proxy-profiles.d.ts.map +1 -0
  93. package/dist/src/utils/proxy-profiles.js +197 -0
  94. package/dist/src/utils/proxy-profiles.js.map +1 -0
  95. package/dist/src/utils/sidecar-version.d.ts +12 -0
  96. package/dist/src/utils/sidecar-version.d.ts.map +1 -0
  97. package/dist/src/utils/sidecar-version.js +63 -0
  98. package/dist/src/utils/sidecar-version.js.map +1 -0
  99. package/dist/tsconfig.tsbuildinfo +1 -1
  100. package/openclaw.plugin.json +39 -0
  101. package/package.json +16 -4
  102. package/plugin.ts +949 -0
@@ -47,11 +47,13 @@ const rate_limit_1 = require("../middleware/rate-limit");
47
47
  const config_1 = require("../utils/config");
48
48
  const presets_1 = require("../utils/presets");
49
49
  const context_pool_1 = require("../services/context-pool");
50
+ const lifecycle_controller_1 = require("../services/lifecycle-controller");
50
51
  const vnc_1 = require("../services/vnc");
51
52
  const session_1 = require("../services/session");
52
53
  const tab_1 = require("../services/tab");
53
54
  const download_1 = require("../services/download");
54
55
  const resource_extractor_1 = require("../services/resource-extractor");
56
+ const structured_extractor_1 = require("../services/structured-extractor");
55
57
  const batch_downloader_1 = require("../services/batch-downloader");
56
58
  const tracing_1 = require("../services/tracing");
57
59
  const CONFIG = (0, config_1.loadConfig)();
@@ -82,6 +84,27 @@ function getTracingErrorStatus(err) {
82
84
  return 409;
83
85
  if (message.includes('No active chunk'))
84
86
  return 400;
87
+ if (message.includes('Invalid trace output path'))
88
+ return 400;
89
+ return 500;
90
+ }
91
+ function getTraceArtifactErrorStatus(err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ if (message.includes('Invalid trace filename'))
94
+ return 400;
95
+ if (message.includes('does not belong to this user'))
96
+ return 404;
97
+ if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT')
98
+ return 404;
99
+ return 500;
100
+ }
101
+ function getRouteErrorStatus(err) {
102
+ if (typeof err === 'object' && err !== null && 'statusCode' in err && typeof err.statusCode === 'number') {
103
+ return err.statusCode;
104
+ }
105
+ if (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number') {
106
+ return err.status;
107
+ }
85
108
  return 500;
86
109
  }
87
110
  // Import cookies into a user's browser context (Playwright cookies format)
@@ -147,7 +170,23 @@ router.post('/sessions/:userId/cookies', express_1.default.json({ limit: '512kb'
147
170
  session = found.session;
148
171
  }
149
172
  else {
150
- session = await (0, session_1.getSession)(userId);
173
+ const canonical = (0, session_1.getCanonicalProfile)(userId);
174
+ if (!canonical) {
175
+ (0, logging_1.log)('warn', 'cookie import rejected: no canonical profile', { userId: String(userId) });
176
+ return res.status(409).json({
177
+ error: 'No canonical profile',
178
+ message: 'Cannot import cookies without an established canonical profile. Create a tab via POST /tabs first.',
179
+ });
180
+ }
181
+ const existingSessions = (0, session_1.getSessionsForUser)(userId);
182
+ if (existingSessions.length === 0) {
183
+ (0, logging_1.log)('warn', 'cookie import rejected: no active session', { userId: String(userId) });
184
+ return res.status(409).json({
185
+ error: 'No active session',
186
+ message: 'Cannot import cookies without an active session. Create a tab via POST /tabs first.',
187
+ });
188
+ }
189
+ session = existingSessions[0][1];
151
190
  }
152
191
  await session.context.addCookies(sanitized);
153
192
  const result = { ok: true, userId: String(userId), count: sanitized.length };
@@ -232,12 +271,53 @@ router.get('/presets', (_req, res) => {
232
271
  });
233
272
  // Create new tab
234
273
  router.post('/tabs', async (req, res) => {
274
+ let createUserId;
275
+ let isFirstCreator = false;
276
+ let stagedGeneration;
235
277
  try {
278
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
279
+ return res.status(403).json({ error: 'Forbidden' });
280
+ }
281
+ // Record activity at START to prevent idle cleanup race during tab creation
282
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
236
283
  const { userId, sessionKey, listItemId, url, preset, locale, timezoneId, geolocation, viewport } = req.body;
284
+ const { proxy, proxyProfile, geoMode } = req.body;
237
285
  const resolvedSessionKey = sessionKey || listItemId;
238
286
  if (!userId || !resolvedSessionKey) {
239
287
  return res.status(400).json({ error: 'userId and sessionKey required' });
240
288
  }
289
+ createUserId = String(userId);
290
+ // Check for session profile conflict (proxy/geo drift)
291
+ if (proxy || proxyProfile || geoMode) {
292
+ const { resolveSessionProfileInput, getConfiguredServerProxy, loadProxyProfiles } = await Promise.resolve().then(() => __importStar(require('../utils/proxy-profiles')));
293
+ const { establishSessionProfile } = await Promise.resolve().then(() => __importStar(require('../services/session')));
294
+ const profileInput = {
295
+ preset,
296
+ locale,
297
+ timezoneId,
298
+ geolocation: geolocation,
299
+ viewport: viewport,
300
+ proxy: proxy,
301
+ proxyProfile,
302
+ geoMode,
303
+ };
304
+ const deps = {
305
+ serverProxy: getConfiguredServerProxy(CONFIG.proxy),
306
+ proxyProfiles: loadProxyProfiles(CONFIG.proxyProfilesFile),
307
+ };
308
+ try {
309
+ const resolvedProfileBase = resolveSessionProfileInput(profileInput, deps);
310
+ const resolvedProfile = { ...resolvedProfileBase, sessionKey: resolvedSessionKey };
311
+ establishSessionProfile(userId, resolvedSessionKey, resolvedProfile);
312
+ }
313
+ catch (err) {
314
+ const message = err instanceof Error ? err.message : String(err);
315
+ if (message === 'Session profile conflict') {
316
+ return res.status(409).json({ error: 'Session profile conflict' });
317
+ }
318
+ return res.status(400).json({ error: message });
319
+ }
320
+ }
241
321
  let contextOverrides = null;
242
322
  try {
243
323
  contextOverrides = (0, presets_1.resolveContextOptions)({ preset, locale, timezoneId, geolocation, viewport });
@@ -251,40 +331,125 @@ router.post('/tabs', async (req, res) => {
251
331
  if (validationError)
252
332
  return res.status(400).json({ error: validationError });
253
333
  }
254
- const sessionMapKey = (0, session_1.getSessionMapKey)(userId, contextOverrides);
255
- const session = await (0, session_1.getSession)(userId, contextOverrides);
256
- const totalTabs = (0, session_1.countTotalTabsForSessions)([[sessionMapKey, session]]);
257
- if (totalTabs >= session_1.MAX_TABS_PER_SESSION) {
258
- return res.status(429).json({ error: 'Maximum tabs per session reached' });
259
- }
260
- const group = (0, session_1.getTabGroup)(session, resolvedSessionKey);
261
- const page = await session.context.newPage();
262
- const tabId = node_crypto_1.default.randomUUID();
263
- page.__camofox_tabId = tabId;
264
- const tabState = (0, tab_1.createTabState)(page);
265
- group.set(tabId, tabState);
266
- (0, session_1.indexTab)(tabId, sessionMapKey);
267
- (0, download_1.registerDownloadListener)(tabId, String(userId), page);
268
334
  if (url) {
269
- const urlErr = (0, tab_1.validateUrl)(url);
335
+ const urlErr = await (0, tab_1.validateNavigationUrl)(url, {
336
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
337
+ });
270
338
  if (urlErr)
271
339
  return res.status(400).json({ error: urlErr });
272
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
273
- tabState.visitedUrls.add(url);
340
+ }
341
+ const requestedProfile = (0, session_1.createCanonicalProfile)(contextOverrides);
342
+ const requestHash = requestedProfile.hash;
343
+ const MAX_CANONICAL_RETRIES = 3;
344
+ for (let attempt = 0; attempt < MAX_CANONICAL_RETRIES; attempt++) {
345
+ const existingProfile = (0, session_1.getCanonicalProfile)(userId);
346
+ if (existingProfile) {
347
+ if (contextOverrides === null) {
348
+ contextOverrides = existingProfile.resolvedOverrides;
349
+ }
350
+ else if (requestHash !== existingProfile.hash) {
351
+ (0, logging_1.log)('warn', 'canonical profile conflict', { userId: String(userId) });
352
+ return res.status(409).json({
353
+ error: 'Context override conflict',
354
+ message: 'A canonical profile already exists for this user with different overrides. Close the session first to reconfigure.',
355
+ });
356
+ }
357
+ else {
358
+ contextOverrides = existingProfile.resolvedOverrides;
359
+ }
360
+ break;
361
+ }
362
+ const mutex = (0, session_1.acquireFirstCreateMutex)(userId);
363
+ if (mutex.acquired) {
364
+ isFirstCreator = true;
365
+ break;
366
+ }
367
+ await mutex.wait;
368
+ }
369
+ if (!(0, session_1.getCanonicalProfile)(userId) && !isFirstCreator) {
370
+ (0, logging_1.log)('error', 'canonical profile acquisition failed after retries', { userId: String(userId) });
371
+ return res.status(503).json({ error: 'Could not acquire canonical profile, try again' });
372
+ }
373
+ const sessionMapKey = (0, session_1.getSessionMapKey)(userId, contextOverrides);
374
+ let tabId;
375
+ let pageUrl;
376
+ if (isFirstCreator) {
377
+ const staged = await (0, session_1.createStagedSession)(userId, contextOverrides);
378
+ stagedGeneration = staged.generation;
379
+ const { session, generation } = staged;
380
+ const page = await session.context.newPage();
381
+ tabId = node_crypto_1.default.randomUUID();
382
+ page.__camofox_tabId = tabId;
383
+ const tabState = await (0, tab_1.createTabState)(page);
384
+ (0, download_1.registerDownloadListener)(tabId, String(userId), page);
385
+ (0, download_1.markDownloadsStaged)(tabId);
386
+ if (url) {
387
+ await (0, tab_1.navigateWithSafetyGuard)(page, url, {
388
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
389
+ waitUntil: 'domcontentloaded',
390
+ timeout: 30000,
391
+ });
392
+ tabState.visitedUrls.add(url);
393
+ }
394
+ const committed = (0, session_1.commitStagedFirstUse)(userId, session, contextOverrides, {
395
+ tabId,
396
+ sessionMapKey,
397
+ sessionKey: resolvedSessionKey,
398
+ tabState,
399
+ }, generation);
400
+ if (!committed) {
401
+ await (0, session_1.rollbackStagedFirstUse)(createUserId ?? userId, generation).catch(() => { });
402
+ return res.status(409).json({ error: 'Session closed during creation' });
403
+ }
404
+ (0, download_1.commitStagedDownloads)(tabId);
405
+ pageUrl = page.url();
406
+ }
407
+ else {
408
+ const session = await (0, session_1.getSession)(userId, contextOverrides);
409
+ const totalTabs = (0, session_1.countTotalTabsForSessions)([[sessionMapKey, session]]);
410
+ if (totalTabs >= session_1.MAX_TABS_PER_SESSION) {
411
+ return res.status(429).json({ error: 'Maximum tabs per session reached' });
412
+ }
413
+ const group = (0, session_1.getTabGroup)(session, resolvedSessionKey);
414
+ const page = await session.context.newPage();
415
+ tabId = node_crypto_1.default.randomUUID();
416
+ page.__camofox_tabId = tabId;
417
+ const tabState = await (0, tab_1.createTabState)(page);
418
+ group.set(tabId, tabState);
419
+ (0, session_1.indexTab)(tabId, sessionMapKey);
420
+ (0, download_1.registerDownloadListener)(tabId, String(userId), page);
421
+ if (url) {
422
+ await (0, tab_1.navigateWithSafetyGuard)(page, url, {
423
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
424
+ waitUntil: 'domcontentloaded',
425
+ timeout: 30000,
426
+ });
427
+ tabState.visitedUrls.add(url);
428
+ }
429
+ pageUrl = page.url();
274
430
  }
275
431
  (0, logging_1.log)('info', 'tab created', {
276
432
  reqId: req.reqId,
277
433
  tabId,
278
434
  userId,
279
435
  sessionKey: resolvedSessionKey,
280
- url: page.url(),
436
+ url: pageUrl,
281
437
  });
282
- return res.json({ tabId, url: page.url() });
438
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
439
+ return res.json({ tabId, url: pageUrl });
283
440
  }
284
441
  catch (err) {
442
+ if (isFirstCreator && createUserId) {
443
+ if (stagedGeneration) {
444
+ await (0, session_1.rollbackStagedFirstUse)(createUserId, stagedGeneration).catch(() => { });
445
+ }
446
+ else {
447
+ (0, session_1.rollbackCanonicalMutex)(createUserId);
448
+ }
449
+ }
285
450
  const message = err instanceof Error ? err.message : String(err);
286
451
  (0, logging_1.log)('error', 'tab create failed', { reqId: req.reqId, error: message });
287
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
452
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
288
453
  }
289
454
  });
290
455
  // GET /tabs - List all tabs (OpenClaw expects this)
@@ -320,6 +485,9 @@ router.get('/tabs', async (req, res) => {
320
485
  router.post('/tabs/:tabId/navigate', async (req, res) => {
321
486
  const tabId = req.params.tabId;
322
487
  try {
488
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
489
+ return res.status(403).json({ error: 'Forbidden' });
490
+ }
323
491
  const { userId, url, macro, query } = req.body;
324
492
  if (!userId)
325
493
  return res.status(400).json({ error: 'userId required' });
@@ -336,24 +504,30 @@ router.post('/tabs/:tabId/navigate', async (req, res) => {
336
504
  }
337
505
  if (!targetUrl)
338
506
  return { status: 400, body: { error: 'url or macro required' } };
339
- const urlErr = (0, tab_1.validateUrl)(targetUrl);
507
+ const urlErr = await (0, tab_1.validateNavigationUrl)(targetUrl, {
508
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
509
+ });
340
510
  if (urlErr)
341
511
  return { status: 400, body: { error: urlErr } };
342
- await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
512
+ await (0, tab_1.navigateWithSafetyGuard)(tabState.page, targetUrl, {
513
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
514
+ waitUntil: 'domcontentloaded',
515
+ timeout: 30000,
516
+ });
343
517
  tabState.visitedUrls.add(targetUrl);
344
518
  tabState.refs = await (0, tab_1.buildRefs)(tabState.page);
345
519
  return { status: 200, body: { ok: true, url: tabState.page.url() } };
346
520
  }), CONFIG.handlerTimeoutMs, 'navigate'));
347
521
  if (result.status !== 200)
348
522
  return res.status(result.status).json(result.body);
523
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
349
524
  (0, logging_1.log)('info', 'navigated', { reqId: req.reqId, tabId, url: result.body.url });
350
525
  return res.json(result.body);
351
526
  }
352
527
  catch (err) {
353
528
  const message = err instanceof Error ? err.message : String(err);
354
529
  (0, logging_1.log)('error', 'navigate failed', { reqId: req.reqId, tabId, error: message });
355
- const status = err instanceof Error && err.message?.startsWith('Blocked URL scheme') ? 400 : 500;
356
- return res.status(status).json({ error: (0, errors_1.safeError)(err) });
530
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
357
531
  }
358
532
  });
359
533
  // Snapshot
@@ -368,12 +542,15 @@ router.get('/tabs/:tabId/snapshot', async (req, res) => {
368
542
  return res.status(404).json({ error: 'Tab not found' });
369
543
  const { tabState } = found;
370
544
  tabState.toolCalls++;
371
- const result = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, () => (0, tab_1.withTimeout)((0, tab_1.snapshotTab)(tabState), CONFIG.handlerTimeoutMs, 'snapshot'));
545
+ const rawOffset = Number(req.query.offset);
546
+ const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0;
547
+ const raw = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, () => (0, tab_1.withTimeout)((0, tab_1.snapshotTab)(tabState), CONFIG.handlerTimeoutMs, 'snapshot'));
548
+ const result = (0, tab_1.buildSnapshotPayload)(raw, offset);
372
549
  (0, logging_1.log)('info', 'snapshot', {
373
550
  reqId: req.reqId,
374
551
  tabId,
375
552
  url: result.url,
376
- snapshotLen: result.snapshot?.length,
553
+ snapshotLen: result.snapshot.length,
377
554
  refsCount: result.refsCount,
378
555
  });
379
556
  return res.json(result);
@@ -381,30 +558,37 @@ router.get('/tabs/:tabId/snapshot', async (req, res) => {
381
558
  catch (err) {
382
559
  const message = err instanceof Error ? err.message : String(err);
383
560
  (0, logging_1.log)('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: message });
384
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
561
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
385
562
  }
386
563
  });
387
564
  // Wait for page ready
388
565
  router.post('/tabs/:tabId/wait', async (req, res) => {
389
566
  try {
567
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
568
+ return res.status(403).json({ error: 'Forbidden' });
569
+ }
390
570
  const { userId, timeout = 10000, waitForNetwork = true } = req.body;
391
571
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
392
572
  if (!found)
393
573
  return res.status(404).json({ error: 'Tab not found' });
394
574
  const { tabState } = found;
395
575
  const ready = await (0, tab_1.waitForPageReady)(tabState.page, { timeout, waitForNetwork });
576
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
396
577
  return res.json({ ok: true, ready });
397
578
  }
398
579
  catch (err) {
399
580
  const message = err instanceof Error ? err.message : String(err);
400
581
  (0, logging_1.log)('error', 'wait failed', { reqId: req.reqId, error: message });
401
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
582
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
402
583
  }
403
584
  });
404
585
  // Click
405
586
  router.post('/tabs/:tabId/click', async (req, res) => {
406
587
  const tabId = req.params.tabId;
407
588
  try {
589
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
590
+ return res.status(403).json({ error: 'Forbidden' });
591
+ }
408
592
  const { userId, ref, selector } = req.body;
409
593
  if (!userId)
410
594
  return res.status(400).json({ error: 'userId required' });
@@ -425,21 +609,22 @@ router.post('/tabs/:tabId/click', async (req, res) => {
425
609
  }));
426
610
  }
427
611
  (0, logging_1.log)('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
612
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
428
613
  return res.json(responseObj);
429
614
  }
430
615
  catch (err) {
431
- const statusCode = err?.statusCode;
432
616
  const message = err instanceof Error ? err.message : String(err);
433
617
  (0, logging_1.log)('error', 'click failed', { reqId: req.reqId, tabId, error: message });
434
- if (statusCode === 400)
435
- return res.status(400).json({ error: message });
436
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
618
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
437
619
  }
438
620
  });
439
621
  // Type
440
622
  router.post('/tabs/:tabId/type', async (req, res) => {
441
623
  const tabId = req.params.tabId;
442
624
  try {
625
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
626
+ return res.status(403).json({ error: 'Forbidden' });
627
+ }
443
628
  const { userId, ref, selector, text } = req.body;
444
629
  const found = (0, session_1.findTabById)(tabId, userId);
445
630
  if (!found)
@@ -448,21 +633,22 @@ router.post('/tabs/:tabId/type', async (req, res) => {
448
633
  tabState.toolCalls++;
449
634
  const textValue = String(text ?? '');
450
635
  const result = await (0, tab_1.withTimeout)((0, tab_1.typeTab)(tabId, tabState, { ref, selector, text: textValue }), (0, tab_1.calculateTypeTimeoutMs)(textValue), 'type');
636
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
451
637
  return res.json(result);
452
638
  }
453
639
  catch (err) {
454
- const statusCode = err?.statusCode;
455
640
  const message = err instanceof Error ? err.message : String(err);
456
641
  (0, logging_1.log)('error', 'type failed', { reqId: req.reqId, error: message });
457
- if (statusCode === 400)
458
- return res.status(400).json({ error: message });
459
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
642
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
460
643
  }
461
644
  });
462
645
  // Press key
463
646
  router.post('/tabs/:tabId/press', async (req, res) => {
464
647
  const tabId = req.params.tabId;
465
648
  try {
649
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
650
+ return res.status(403).json({ error: 'Forbidden' });
651
+ }
466
652
  const { userId, key } = req.body;
467
653
  const found = (0, session_1.findTabById)(tabId, userId);
468
654
  if (!found)
@@ -470,38 +656,44 @@ router.post('/tabs/:tabId/press', async (req, res) => {
470
656
  const { tabState } = found;
471
657
  tabState.toolCalls++;
472
658
  await (0, tab_1.withTimeout)((0, tab_1.pressTab)(tabId, tabState, String(key ?? '')), CONFIG.handlerTimeoutMs, 'press');
659
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
473
660
  return res.json({ ok: true });
474
661
  }
475
662
  catch (err) {
476
663
  const message = err instanceof Error ? err.message : String(err);
477
664
  (0, logging_1.log)('error', 'press failed', { reqId: req.reqId, error: message });
478
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
665
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
479
666
  }
480
667
  });
481
668
  // Scroll
482
669
  router.post('/tabs/:tabId/scroll', async (req, res) => {
483
670
  try {
671
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
672
+ return res.status(403).json({ error: 'Forbidden' });
673
+ }
484
674
  const { userId, direction = 'down', amount = 500 } = req.body;
485
675
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
486
676
  if (!found)
487
677
  return res.status(404).json({ error: 'Tab not found' });
488
678
  const { tabState } = found;
489
679
  tabState.toolCalls++;
490
- const delta = direction === 'up' ? -amount : amount;
491
- await tabState.page.mouse.wheel(0, delta);
492
- await tabState.page.waitForTimeout(300);
493
- return res.json({ ok: true });
680
+ const result = await (0, tab_1.withTimeout)((0, tab_1.scrollTab)(tabState, { direction, amount }), CONFIG.handlerTimeoutMs, 'scroll');
681
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
682
+ return res.json(result);
494
683
  }
495
684
  catch (err) {
496
685
  const message = err instanceof Error ? err.message : String(err);
497
686
  (0, logging_1.log)('error', 'scroll failed', { reqId: req.reqId, error: message });
498
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
687
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
499
688
  }
500
689
  });
501
690
  // Scroll element (selector or ref)
502
691
  router.post('/tabs/:tabId/scroll-element', async (req, res) => {
503
692
  const tabId = req.params.tabId;
504
693
  try {
694
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
695
+ return res.status(403).json({ error: 'Forbidden' });
696
+ }
505
697
  const { userId, selector, ref, deltaX, deltaY, scrollTo } = req.body;
506
698
  const found = (0, session_1.findTabById)(tabId, userId);
507
699
  if (!found)
@@ -509,15 +701,13 @@ router.post('/tabs/:tabId/scroll-element', async (req, res) => {
509
701
  const { tabState } = found;
510
702
  tabState.toolCalls++;
511
703
  const result = await (0, tab_1.scrollElementTab)(tabId, tabState, { selector, ref, deltaX, deltaY, scrollTo });
704
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
512
705
  return res.json(result);
513
706
  }
514
707
  catch (err) {
515
- const statusCode = err?.statusCode;
516
708
  const message = err instanceof Error ? err.message : String(err);
517
709
  (0, logging_1.log)('error', 'scroll-element failed', { reqId: req.reqId, tabId, error: message });
518
- if (statusCode === 400)
519
- return res.status(400).json({ error: message });
520
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
710
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
521
711
  }
522
712
  });
523
713
  // Evaluate JS (API key optional)
@@ -540,12 +730,13 @@ router.post('/tabs/:tabId/evaluate', express_1.default.json({ limit: '64kb' }),
540
730
  const { tabState } = found;
541
731
  tabState.toolCalls++;
542
732
  const result = await (0, tab_1.evaluateTab)(tabId, tabState, { expression, timeout });
733
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
543
734
  return res.json(result);
544
735
  }
545
736
  catch (err) {
546
737
  const message = err instanceof Error ? err.message : String(err);
547
738
  (0, logging_1.log)('error', 'evaluate failed', { reqId: req.reqId, tabId, error: message });
548
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
739
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
549
740
  }
550
741
  });
551
742
  // Evaluate JS extended (API key optional)
@@ -586,6 +777,7 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
586
777
  const outerTimeout = effectiveTimeout + 10000;
587
778
  const result = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, async () => (0, tab_1.withTimeout)((0, tab_1.evaluateTabExtended)(tabId, tabState, { expression, timeout: effectiveTimeout }), outerTimeout, `Evaluate-extended timed out after ${outerTimeout}ms`));
588
779
  if (result.ok) {
780
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
589
781
  return res.json(result);
590
782
  }
591
783
  if (result.errorType === 'timeout') {
@@ -596,6 +788,10 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
596
788
  catch (err) {
597
789
  const message = err instanceof Error ? err.message : String(err);
598
790
  (0, logging_1.log)('error', 'evaluate-extended failed', { reqId: req.reqId, tabId, error: message });
791
+ const status = getRouteErrorStatus(err);
792
+ if (status === 400) {
793
+ return res.status(400).json({ ok: false, error: (0, errors_1.safeError)(err), errorType: 'js_error' });
794
+ }
599
795
  if (message.includes('timed out') || message.includes('Timeout')) {
600
796
  return res.status(408).json({ ok: false, error: message, errorType: 'timeout' });
601
797
  }
@@ -611,6 +807,9 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
611
807
  router.post('/tabs/:tabId/back', async (req, res) => {
612
808
  const tabId = req.params.tabId;
613
809
  try {
810
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
811
+ return res.status(403).json({ error: 'Forbidden' });
812
+ }
614
813
  const { userId } = req.body;
615
814
  const found = (0, session_1.findTabById)(tabId, userId);
616
815
  if (!found)
@@ -618,18 +817,22 @@ router.post('/tabs/:tabId/back', async (req, res) => {
618
817
  const { tabState } = found;
619
818
  tabState.toolCalls++;
620
819
  const result = await (0, tab_1.withTimeout)((0, tab_1.backTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'back');
820
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
621
821
  return res.json(result);
622
822
  }
623
823
  catch (err) {
624
824
  const message = err instanceof Error ? err.message : String(err);
625
825
  (0, logging_1.log)('error', 'back failed', { reqId: req.reqId, error: message });
626
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
826
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
627
827
  }
628
828
  });
629
829
  // Forward
630
830
  router.post('/tabs/:tabId/forward', async (req, res) => {
631
831
  const tabId = req.params.tabId;
632
832
  try {
833
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
834
+ return res.status(403).json({ error: 'Forbidden' });
835
+ }
633
836
  const { userId } = req.body;
634
837
  const found = (0, session_1.findTabById)(tabId, userId);
635
838
  if (!found)
@@ -637,18 +840,22 @@ router.post('/tabs/:tabId/forward', async (req, res) => {
637
840
  const { tabState } = found;
638
841
  tabState.toolCalls++;
639
842
  const result = await (0, tab_1.withTimeout)((0, tab_1.forwardTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'forward');
843
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
640
844
  return res.json(result);
641
845
  }
642
846
  catch (err) {
643
847
  const message = err instanceof Error ? err.message : String(err);
644
848
  (0, logging_1.log)('error', 'forward failed', { reqId: req.reqId, error: message });
645
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
849
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
646
850
  }
647
851
  });
648
852
  // Refresh
649
853
  router.post('/tabs/:tabId/refresh', async (req, res) => {
650
854
  const tabId = req.params.tabId;
651
855
  try {
856
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
857
+ return res.status(403).json({ error: 'Forbidden' });
858
+ }
652
859
  const { userId } = req.body;
653
860
  const found = (0, session_1.findTabById)(tabId, userId);
654
861
  if (!found)
@@ -656,12 +863,13 @@ router.post('/tabs/:tabId/refresh', async (req, res) => {
656
863
  const { tabState } = found;
657
864
  tabState.toolCalls++;
658
865
  const result = await (0, tab_1.withTimeout)((0, tab_1.refreshTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'refresh');
866
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
659
867
  return res.json(result);
660
868
  }
661
869
  catch (err) {
662
870
  const message = err instanceof Error ? err.message : String(err);
663
871
  (0, logging_1.log)('error', 'refresh failed', { reqId: req.reqId, error: message });
664
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
872
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
665
873
  }
666
874
  });
667
875
  // Get links
@@ -708,6 +916,54 @@ router.get('/tabs/:tabId/screenshot', async (req, res) => {
708
916
  return res.status(500).json({ error: (0, errors_1.safeError)(err) });
709
917
  }
710
918
  });
919
+ // Images
920
+ router.get('/tabs/:tabId/images', async (req, res) => {
921
+ try {
922
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
923
+ return res.status(403).json({ ok: false, error: 'Forbidden' });
924
+ }
925
+ const userId = req.query.userId;
926
+ const found = (0, session_1.findTabById)(req.params.tabId, userId);
927
+ if (!found) {
928
+ (0, logging_1.log)('warn', 'images: tab not found', { reqId: req.reqId, tabId: req.params.tabId, userId });
929
+ return res.status(404).json({ error: 'Tab not found' });
930
+ }
931
+ const selector = typeof req.query.selector === 'string' && req.query.selector ? req.query.selector : undefined;
932
+ const extensions = Array.isArray(req.query.extensions)
933
+ ? req.query.extensions.map((value) => String(value)).filter(Boolean)
934
+ : typeof req.query.extensions === 'string' && req.query.extensions
935
+ ? req.query.extensions
936
+ .split(',')
937
+ .map((value) => value.trim())
938
+ .filter(Boolean)
939
+ : undefined;
940
+ const resolveBlobs = String(req.query.resolveBlobs || '').toLowerCase() === 'true';
941
+ const triggerLazyLoad = String(req.query.triggerLazyLoad || '').toLowerCase() === 'true';
942
+ const { tabState } = found;
943
+ tabState.toolCalls++;
944
+ const result = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, () => (0, tab_1.withTimeout)((0, tab_1.withTabLock)(req.params.tabId, async () => (0, resource_extractor_1.extractImages)(tabState.page, {
945
+ selector,
946
+ extensions,
947
+ resolveBlobs,
948
+ triggerLazyLoad,
949
+ })), CONFIG.handlerTimeoutMs, 'images'));
950
+ return res.json({
951
+ ok: result.ok,
952
+ container: result.container,
953
+ images: result.resources.images,
954
+ totals: {
955
+ images: result.totals.images,
956
+ total: result.totals.images,
957
+ },
958
+ metadata: result.metadata,
959
+ });
960
+ }
961
+ catch (err) {
962
+ const message = err instanceof Error ? err.message : String(err);
963
+ (0, logging_1.log)('error', 'images failed', { reqId: req.reqId, error: message });
964
+ return res.status(500).json({ error: (0, errors_1.safeError)(err) });
965
+ }
966
+ });
711
967
  // Stats
712
968
  router.get('/tabs/:tabId/stats', async (req, res) => {
713
969
  try {
@@ -735,6 +991,9 @@ router.get('/tabs/:tabId/stats', async (req, res) => {
735
991
  // Close tab
736
992
  router.delete('/tabs/:tabId', async (req, res) => {
737
993
  try {
994
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
995
+ return res.status(403).json({ error: 'Forbidden' });
996
+ }
738
997
  const { userId } = req.body;
739
998
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
740
999
  if (found) {
@@ -746,6 +1005,7 @@ router.delete('/tabs/:tabId', async (req, res) => {
746
1005
  }
747
1006
  (0, logging_1.log)('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
748
1007
  }
1008
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
749
1009
  return res.json({ ok: true });
750
1010
  }
751
1011
  catch (err) {
@@ -757,6 +1017,9 @@ router.delete('/tabs/:tabId', async (req, res) => {
757
1017
  // Close tab group
758
1018
  router.delete('/tabs/group/:listItemId', async (req, res) => {
759
1019
  try {
1020
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1021
+ return res.status(403).json({ error: 'Forbidden' });
1022
+ }
760
1023
  const { userId } = req.body;
761
1024
  const sessionsForUser = (0, session_1.getSessionsForUser)(userId);
762
1025
  for (const [sessionKey, session] of sessionsForUser) {
@@ -775,6 +1038,7 @@ router.delete('/tabs/group/:listItemId', async (req, res) => {
775
1038
  sessionKey,
776
1039
  });
777
1040
  }
1041
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
778
1042
  return res.json({ ok: true });
779
1043
  }
780
1044
  catch (err) {
@@ -786,10 +1050,14 @@ router.delete('/tabs/group/:listItemId', async (req, res) => {
786
1050
  // Close session
787
1051
  router.delete('/sessions/:userId', async (req, res) => {
788
1052
  try {
1053
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1054
+ return res.status(403).json({ error: 'Forbidden' });
1055
+ }
789
1056
  const userId = (0, session_1.normalizeUserId)(req.params.userId);
790
1057
  await (0, session_1.closeSessionsForUser)(userId);
791
1058
  // Ensure downloads are cleaned even if the session was already partially removed.
792
1059
  (0, download_1.cleanupUserDownloads)(userId);
1060
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
793
1061
  return res.json({ ok: true });
794
1062
  }
795
1063
  catch (err) {
@@ -801,6 +1069,9 @@ router.delete('/sessions/:userId', async (req, res) => {
801
1069
  // Toggle display mode (headless/headed/virtual) for a user session
802
1070
  router.post('/sessions/:userId/toggle-display', async (req, res) => {
803
1071
  try {
1072
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1073
+ return res.status(403).json({ error: 'Forbidden' });
1074
+ }
804
1075
  const userId = (0, session_1.normalizeUserId)(req.params.userId);
805
1076
  const { headless } = req.body ?? {};
806
1077
  if (typeof headless !== 'boolean' && headless !== 'virtual') {
@@ -952,6 +1223,9 @@ router.get('/downloads/:downloadId/content', async (req, res) => {
952
1223
  // Downloads: delete
953
1224
  router.delete('/downloads/:downloadId', async (req, res) => {
954
1225
  try {
1226
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1227
+ return res.status(403).json({ error: 'Forbidden' });
1228
+ }
955
1229
  const userId = req.body?.userId || req.query.userId;
956
1230
  if (!userId)
957
1231
  return res.status(400).json({ ok: false, error: 'userId required' });
@@ -969,6 +1243,9 @@ router.delete('/downloads/:downloadId', async (req, res) => {
969
1243
  // Extract resources from a scoped container
970
1244
  router.post('/tabs/:tabId/extract-resources', async (req, res) => {
971
1245
  try {
1246
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1247
+ return res.status(403).json({ error: 'Forbidden' });
1248
+ }
972
1249
  const { tabId } = req.params;
973
1250
  const { userId, selector, types, extensions, resolveBlobs, triggerLazyLoad } = req.body;
974
1251
  if (!userId)
@@ -994,9 +1271,50 @@ router.post('/tabs/:tabId/extract-resources', async (req, res) => {
994
1271
  res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
995
1272
  }
996
1273
  });
1274
+ router.post('/tabs/:tabId/extract-structured', async (req, res) => {
1275
+ try {
1276
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1277
+ return res.status(403).json({ error: 'Forbidden' });
1278
+ }
1279
+ const { tabId } = req.params;
1280
+ const { userId, schema } = req.body;
1281
+ if (!userId || typeof userId !== 'string') {
1282
+ return res.status(400).json({ ok: false, error: 'userId required' });
1283
+ }
1284
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
1285
+ return res.status(400).json({ ok: false, error: 'schema must be an object' });
1286
+ }
1287
+ const found = (0, session_1.findTabById)(tabId, userId);
1288
+ if (!found)
1289
+ return res.status(404).json({ ok: false, error: 'Tab not found' });
1290
+ const { tabState } = found;
1291
+ tabState.toolCalls++;
1292
+ const result = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, () => (0, tab_1.withTimeout)((0, tab_1.withTabLock)(tabId, async () => (0, structured_extractor_1.extractStructuredData)(tabState.page, schema)), CONFIG.handlerTimeoutMs, 'extract-structured'));
1293
+ return res.json(result);
1294
+ }
1295
+ catch (err) {
1296
+ if (err instanceof structured_extractor_1.StructuredExtractSchemaError) {
1297
+ return res.status(err.statusCode).json({ ok: false, error: err.message });
1298
+ }
1299
+ if (err instanceof structured_extractor_1.StructuredExtractRuntimeError) {
1300
+ return res.status(err.statusCode).json({
1301
+ ok: false,
1302
+ error: 'Structured extraction failed',
1303
+ fieldPath: err.fieldPath,
1304
+ reason: err.reason,
1305
+ });
1306
+ }
1307
+ const message = err instanceof Error ? err.message : String(err);
1308
+ (0, logging_1.log)('error', 'structured extract failed', { error: message });
1309
+ return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1310
+ }
1311
+ });
997
1312
  // Batch download from a scoped container
998
1313
  router.post('/tabs/:tabId/batch-download', async (req, res) => {
999
1314
  try {
1315
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1316
+ return res.status(403).json({ error: 'Forbidden' });
1317
+ }
1000
1318
  const { tabId } = req.params;
1001
1319
  const body = req.body;
1002
1320
  const userId = body?.userId;
@@ -1093,12 +1411,13 @@ router.post('/tabs/:tabId/trace/stop', async (req, res) => {
1093
1411
  return res.json({
1094
1412
  ok: true,
1095
1413
  path: result.path,
1414
+ filename: result.path ? (0, node_path_1.basename)(result.path) : undefined,
1096
1415
  size: result.size,
1097
1416
  alreadyStopped: true,
1098
1417
  message: 'Trace was already stopped by chunk stop',
1099
1418
  });
1100
1419
  }
1101
- return res.json({ ok: true, ...result });
1420
+ return res.json({ ok: true, ...result, filename: (0, node_path_1.basename)(result.path) });
1102
1421
  }
1103
1422
  catch (err) {
1104
1423
  const status = getTracingErrorStatus(err);
@@ -1143,7 +1462,7 @@ router.post('/tabs/:tabId/trace/chunk/stop', async (req, res) => {
1143
1462
  if (!tab)
1144
1463
  return res.status(404).json({ ok: false, error: 'Tab not found' });
1145
1464
  const result = await (0, tracing_1.stopTracingChunk)(userId, tab.page.context(), path);
1146
- return res.json({ ok: true, ...result });
1465
+ return res.json({ ok: true, ...result, filename: (0, node_path_1.basename)(result.path) });
1147
1466
  }
1148
1467
  catch (err) {
1149
1468
  const status = getTracingErrorStatus(err);
@@ -1170,6 +1489,62 @@ router.get('/tabs/:tabId/trace/status', async (req, res) => {
1170
1489
  return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1171
1490
  }
1172
1491
  });
1492
+ router.get('/sessions/:userId/traces', async (req, res) => {
1493
+ try {
1494
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1495
+ return res.status(403).json({ error: 'Forbidden' });
1496
+ }
1497
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1498
+ return res.json({ ok: true, traces: (0, tracing_1.listTraceArtifacts)(userId) });
1499
+ }
1500
+ catch (err) {
1501
+ return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1502
+ }
1503
+ });
1504
+ router.get('/sessions/:userId/traces/:filename', async (req, res) => {
1505
+ try {
1506
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1507
+ return res.status(403).json({ error: 'Forbidden' });
1508
+ }
1509
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1510
+ const filename = req.params.filename;
1511
+ const filePath = (0, tracing_1.resolveTraceArtifactPath)(userId, filename);
1512
+ const safeName = filename.replace(/[\r\n\0"]/g, '');
1513
+ res.setHeader('Content-Type', 'application/zip');
1514
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`);
1515
+ const stream = node_fs_1.default.createReadStream(filePath);
1516
+ stream.on('error', (streamErr) => {
1517
+ if (!res.headersSent) {
1518
+ const status = getTraceArtifactErrorStatus(streamErr);
1519
+ res.removeHeader('Content-Disposition');
1520
+ res.removeHeader('Content-Type');
1521
+ res.status(status).json({ ok: false, error: (0, errors_1.safeError)(streamErr) });
1522
+ return;
1523
+ }
1524
+ res.destroy();
1525
+ });
1526
+ stream.pipe(res);
1527
+ }
1528
+ catch (err) {
1529
+ const status = getTraceArtifactErrorStatus(err);
1530
+ return res.status(status).json({ ok: false, error: (0, errors_1.safeError)(err) });
1531
+ }
1532
+ });
1533
+ router.delete('/sessions/:userId/traces/:filename', async (req, res) => {
1534
+ try {
1535
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1536
+ return res.status(403).json({ error: 'Forbidden' });
1537
+ }
1538
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1539
+ const filename = req.params.filename;
1540
+ (0, tracing_1.deleteTraceArtifact)(userId, filename);
1541
+ return res.json({ ok: true });
1542
+ }
1543
+ catch (err) {
1544
+ const status = getTraceArtifactErrorStatus(err);
1545
+ return res.status(status).json({ ok: false, error: (0, errors_1.safeError)(err) });
1546
+ }
1547
+ });
1173
1548
  router.get('/tabs/:tabId/console', async (req, res) => {
1174
1549
  try {
1175
1550
  if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {