camofox-browser 2.1.1 → 2.4.3

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 +150 -0
  2. package/README.md +310 -34
  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 +545 -58
  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 +317 -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 +21 -4
  46. package/dist/src/services/context-pool.d.ts.map +1 -1
  47. package/dist/src/services/context-pool.js +290 -71
  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 +109 -4
  62. package/dist/src/services/session.d.ts.map +1 -1
  63. package/dist/src/services/session.js +622 -64
  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 +200 -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,33 @@ 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
+ if (existingSessions.length > 1) {
190
+ (0, logging_1.log)('warn', 'cookie import rejected: ambiguous active sessions', {
191
+ userId: String(userId),
192
+ sessionCount: existingSessions.length,
193
+ });
194
+ return res.status(409).json({
195
+ error: 'Ambiguous active sessions',
196
+ message: 'Multiple active browser contexts exist for this user. Provide tabId to import cookies into a specific context.',
197
+ });
198
+ }
199
+ session = existingSessions[0][1];
151
200
  }
152
201
  await session.context.addCookies(sanitized);
153
202
  const result = { ok: true, userId: String(userId), count: sanitized.length };
@@ -232,12 +281,55 @@ router.get('/presets', (_req, res) => {
232
281
  });
233
282
  // Create new tab
234
283
  router.post('/tabs', async (req, res) => {
284
+ let createUserId;
285
+ let createSessionKey;
286
+ let createdSessionProfile = false;
287
+ let createdSessionProfileSignature;
288
+ let createdDefaultSessionProfileClaim = false;
289
+ let releaseSessionProfileCreate;
290
+ let isFirstCreator = false;
291
+ let stagedGeneration;
235
292
  try {
293
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
294
+ return res.status(403).json({ error: 'Forbidden' });
295
+ }
296
+ // Record activity at START to prevent idle cleanup race during tab creation
297
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
236
298
  const { userId, sessionKey, listItemId, url, preset, locale, timezoneId, geolocation, viewport } = req.body;
299
+ const { proxy, proxyProfile, geoMode } = req.body;
237
300
  const resolvedSessionKey = sessionKey || listItemId;
238
301
  if (!userId || !resolvedSessionKey) {
239
302
  return res.status(400).json({ error: 'userId and sessionKey required' });
240
303
  }
304
+ createUserId = String(userId);
305
+ createSessionKey = String(resolvedSessionKey);
306
+ // Check for session profile conflict (proxy/geo drift)
307
+ let resolvedSessionProfile;
308
+ if (proxy || proxyProfile || geoMode) {
309
+ const { resolveSessionProfileInput, getConfiguredServerProxy, loadProxyProfiles } = await Promise.resolve().then(() => __importStar(require('../utils/proxy-profiles')));
310
+ const profileInput = {
311
+ preset,
312
+ locale,
313
+ timezoneId,
314
+ geolocation: geolocation,
315
+ viewport: viewport,
316
+ proxy: proxy,
317
+ proxyProfile,
318
+ geoMode,
319
+ };
320
+ const deps = {
321
+ serverProxy: getConfiguredServerProxy(CONFIG.proxy),
322
+ proxyProfiles: loadProxyProfiles(CONFIG.proxyProfilesFile),
323
+ };
324
+ try {
325
+ const resolvedProfileBase = resolveSessionProfileInput(profileInput, deps);
326
+ resolvedSessionProfile = { ...resolvedProfileBase, sessionKey: resolvedSessionKey };
327
+ }
328
+ catch (err) {
329
+ const message = err instanceof Error ? err.message : String(err);
330
+ return res.status(400).json({ error: message });
331
+ }
332
+ }
241
333
  let contextOverrides = null;
242
334
  try {
243
335
  contextOverrides = (0, presets_1.resolveContextOptions)({ preset, locale, timezoneId, geolocation, viewport });
@@ -251,40 +343,203 @@ router.post('/tabs', async (req, res) => {
251
343
  if (validationError)
252
344
  return res.status(400).json({ error: validationError });
253
345
  }
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
346
  if (url) {
269
- const urlErr = (0, tab_1.validateUrl)(url);
347
+ const urlErr = await (0, tab_1.validateNavigationUrl)(url, {
348
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
349
+ });
270
350
  if (urlErr)
271
351
  return res.status(400).json({ error: urlErr });
272
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
273
- tabState.visitedUrls.add(url);
352
+ }
353
+ const requestedProfile = (0, session_1.createCanonicalProfile)(contextOverrides);
354
+ const requestHash = requestedProfile.hash;
355
+ const MAX_CANONICAL_RETRIES = 3;
356
+ for (let attempt = 0; attempt < MAX_CANONICAL_RETRIES; attempt++) {
357
+ const existingProfile = (0, session_1.getCanonicalProfile)(userId);
358
+ if (existingProfile) {
359
+ if (contextOverrides === null) {
360
+ contextOverrides = existingProfile.resolvedOverrides;
361
+ }
362
+ else if (requestHash !== existingProfile.hash) {
363
+ (0, logging_1.log)('warn', 'canonical profile conflict', { userId: String(userId) });
364
+ return res.status(409).json({
365
+ error: 'Context override conflict',
366
+ message: 'A canonical profile already exists for this user with different overrides. Close the session first to reconfigure.',
367
+ });
368
+ }
369
+ else {
370
+ contextOverrides = existingProfile.resolvedOverrides;
371
+ }
372
+ break;
373
+ }
374
+ const mutex = (0, session_1.acquireFirstCreateMutex)(userId);
375
+ if (mutex.acquired) {
376
+ isFirstCreator = true;
377
+ break;
378
+ }
379
+ await mutex.wait;
380
+ }
381
+ if (!(0, session_1.getCanonicalProfile)(userId) && !isFirstCreator) {
382
+ (0, logging_1.log)('error', 'canonical profile acquisition failed after retries', { userId: String(userId) });
383
+ return res.status(503).json({ error: 'Could not acquire canonical profile, try again' });
384
+ }
385
+ if (resolvedSessionProfile) {
386
+ while (true) {
387
+ await (0, session_1.waitForSessionProfileCreate)(userId, resolvedSessionKey);
388
+ const existingProfile = (0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey);
389
+ if (existingProfile) {
390
+ if (existingProfile.signature !== resolvedSessionProfile.signature) {
391
+ return res.status(409).json({ error: 'Session profile conflict' });
392
+ }
393
+ break;
394
+ }
395
+ if ((0, session_1.hasDefaultSessionProfileRuntime)(userId, resolvedSessionKey)) {
396
+ return res.status(409).json({ error: 'Session profile conflict' });
397
+ }
398
+ const mutex = (0, session_1.acquireSessionProfileCreateMutex)(userId, resolvedSessionKey, resolvedSessionProfile.signature);
399
+ if (mutex.acquired) {
400
+ releaseSessionProfileCreate = mutex.release;
401
+ try {
402
+ (0, session_1.establishSessionProfile)(userId, resolvedSessionKey, resolvedSessionProfile);
403
+ createdSessionProfile = true;
404
+ createdSessionProfileSignature = resolvedSessionProfile.signature;
405
+ }
406
+ catch (err) {
407
+ releaseSessionProfileCreate(false);
408
+ releaseSessionProfileCreate = undefined;
409
+ const message = err instanceof Error ? err.message : String(err);
410
+ if (message === 'Session profile conflict') {
411
+ return res.status(409).json({ error: 'Session profile conflict' });
412
+ }
413
+ return res.status(400).json({ error: message });
414
+ }
415
+ break;
416
+ }
417
+ await mutex.wait;
418
+ }
419
+ }
420
+ else {
421
+ await (0, session_1.waitForSessionProfileCreate)(userId, resolvedSessionKey);
422
+ if (!(0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey)) {
423
+ createdDefaultSessionProfileClaim = (0, session_1.claimDefaultSessionProfileRuntime)(userId, resolvedSessionKey);
424
+ }
425
+ }
426
+ const establishedProfile = (0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey);
427
+ const sessionMapKey = establishedProfile
428
+ ? (0, session_1.getSessionMapKey)(userId, resolvedSessionKey, establishedProfile.signature)
429
+ : (0, session_1.getSessionMapKey)(userId, contextOverrides);
430
+ let tabId;
431
+ let pageUrl;
432
+ if (isFirstCreator) {
433
+ const staged = await (0, session_1.createStagedSession)(userId, contextOverrides, resolvedSessionKey);
434
+ stagedGeneration = staged.generation;
435
+ const { session, generation } = staged;
436
+ const page = await session.context.newPage();
437
+ tabId = node_crypto_1.default.randomUUID();
438
+ page.__camofox_tabId = tabId;
439
+ const tabState = await (0, tab_1.createTabState)(page);
440
+ (0, download_1.registerDownloadListener)(tabId, String(userId), page);
441
+ (0, download_1.markDownloadsStaged)(tabId);
442
+ if (url) {
443
+ await (0, tab_1.navigateWithSafetyGuard)(page, url, {
444
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
445
+ waitUntil: 'domcontentloaded',
446
+ timeout: 30000,
447
+ });
448
+ tabState.visitedUrls.add(url);
449
+ }
450
+ const committed = (0, session_1.commitStagedFirstUse)(userId, session, contextOverrides, {
451
+ tabId,
452
+ sessionMapKey,
453
+ sessionKey: resolvedSessionKey,
454
+ tabState,
455
+ }, generation);
456
+ if (!committed) {
457
+ await (0, session_1.rollbackStagedFirstUse)(createUserId ?? userId, generation).catch(() => { });
458
+ if (createdSessionProfile && createdSessionProfileSignature) {
459
+ await (0, session_1.rollbackSessionProfileRuntime)(userId, resolvedSessionKey, createdSessionProfileSignature);
460
+ }
461
+ if (createdDefaultSessionProfileClaim) {
462
+ (0, session_1.clearDefaultSessionProfileClaim)(userId, resolvedSessionKey);
463
+ createdDefaultSessionProfileClaim = false;
464
+ }
465
+ releaseSessionProfileCreate?.(false);
466
+ releaseSessionProfileCreate = undefined;
467
+ return res.status(409).json({ error: 'Session closed during creation' });
468
+ }
469
+ (0, download_1.commitStagedDownloads)(tabId);
470
+ pageUrl = page.url();
471
+ }
472
+ else {
473
+ const session = await (0, session_1.getSession)(userId, contextOverrides, resolvedSessionKey);
474
+ const totalTabs = (0, session_1.countTotalTabsForSessions)([[sessionMapKey, session]]);
475
+ if (totalTabs >= session_1.MAX_TABS_PER_SESSION) {
476
+ if (createdSessionProfile && createdSessionProfileSignature) {
477
+ await (0, session_1.rollbackSessionProfileRuntime)(userId, resolvedSessionKey, createdSessionProfileSignature);
478
+ }
479
+ if (createdDefaultSessionProfileClaim) {
480
+ (0, session_1.clearDefaultSessionProfileClaim)(userId, resolvedSessionKey);
481
+ createdDefaultSessionProfileClaim = false;
482
+ }
483
+ releaseSessionProfileCreate?.(false);
484
+ releaseSessionProfileCreate = undefined;
485
+ return res.status(429).json({ error: 'Maximum tabs per session reached' });
486
+ }
487
+ const group = (0, session_1.getTabGroup)(session, resolvedSessionKey);
488
+ const page = await session.context.newPage();
489
+ tabId = node_crypto_1.default.randomUUID();
490
+ page.__camofox_tabId = tabId;
491
+ const tabState = await (0, tab_1.createTabState)(page);
492
+ group.set(tabId, tabState);
493
+ (0, session_1.indexTab)(tabId, sessionMapKey);
494
+ (0, download_1.registerDownloadListener)(tabId, String(userId), page);
495
+ if (url) {
496
+ await (0, tab_1.navigateWithSafetyGuard)(page, url, {
497
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
498
+ waitUntil: 'domcontentloaded',
499
+ timeout: 30000,
500
+ });
501
+ tabState.visitedUrls.add(url);
502
+ }
503
+ pageUrl = page.url();
274
504
  }
275
505
  (0, logging_1.log)('info', 'tab created', {
276
506
  reqId: req.reqId,
277
507
  tabId,
278
508
  userId,
279
509
  sessionKey: resolvedSessionKey,
280
- url: page.url(),
510
+ url: pageUrl,
281
511
  });
282
- return res.json({ tabId, url: page.url() });
512
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
513
+ releaseSessionProfileCreate?.(true);
514
+ releaseSessionProfileCreate = undefined;
515
+ createdSessionProfile = false;
516
+ return res.json({ tabId, url: pageUrl });
283
517
  }
284
518
  catch (err) {
519
+ if (isFirstCreator && createUserId) {
520
+ if (stagedGeneration) {
521
+ await (0, session_1.rollbackStagedFirstUse)(createUserId, stagedGeneration).catch(() => { });
522
+ }
523
+ else {
524
+ (0, session_1.rollbackCanonicalMutex)(createUserId);
525
+ }
526
+ }
527
+ if (createdSessionProfile && createUserId && createSessionKey) {
528
+ if (createdSessionProfileSignature) {
529
+ await (0, session_1.rollbackSessionProfileRuntime)(createUserId, createSessionKey, createdSessionProfileSignature);
530
+ }
531
+ else {
532
+ (0, session_1.clearSessionProfile)(createUserId, createSessionKey);
533
+ }
534
+ }
535
+ if (createdDefaultSessionProfileClaim && createUserId && createSessionKey) {
536
+ (0, session_1.clearDefaultSessionProfileClaim)(createUserId, createSessionKey);
537
+ }
538
+ releaseSessionProfileCreate?.(false);
539
+ releaseSessionProfileCreate = undefined;
285
540
  const message = err instanceof Error ? err.message : String(err);
286
541
  (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) });
542
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
288
543
  }
289
544
  });
290
545
  // GET /tabs - List all tabs (OpenClaw expects this)
@@ -320,6 +575,9 @@ router.get('/tabs', async (req, res) => {
320
575
  router.post('/tabs/:tabId/navigate', async (req, res) => {
321
576
  const tabId = req.params.tabId;
322
577
  try {
578
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
579
+ return res.status(403).json({ error: 'Forbidden' });
580
+ }
323
581
  const { userId, url, macro, query } = req.body;
324
582
  if (!userId)
325
583
  return res.status(400).json({ error: 'userId required' });
@@ -336,24 +594,30 @@ router.post('/tabs/:tabId/navigate', async (req, res) => {
336
594
  }
337
595
  if (!targetUrl)
338
596
  return { status: 400, body: { error: 'url or macro required' } };
339
- const urlErr = (0, tab_1.validateUrl)(targetUrl);
597
+ const urlErr = await (0, tab_1.validateNavigationUrl)(targetUrl, {
598
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
599
+ });
340
600
  if (urlErr)
341
601
  return { status: 400, body: { error: urlErr } };
342
- await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
602
+ await (0, tab_1.navigateWithSafetyGuard)(tabState.page, targetUrl, {
603
+ allowPrivateNetworkTargets: CONFIG.allowPrivateNetworkTargets,
604
+ waitUntil: 'domcontentloaded',
605
+ timeout: 30000,
606
+ });
343
607
  tabState.visitedUrls.add(targetUrl);
344
608
  tabState.refs = await (0, tab_1.buildRefs)(tabState.page);
345
609
  return { status: 200, body: { ok: true, url: tabState.page.url() } };
346
610
  }), CONFIG.handlerTimeoutMs, 'navigate'));
347
611
  if (result.status !== 200)
348
612
  return res.status(result.status).json(result.body);
613
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
349
614
  (0, logging_1.log)('info', 'navigated', { reqId: req.reqId, tabId, url: result.body.url });
350
615
  return res.json(result.body);
351
616
  }
352
617
  catch (err) {
353
618
  const message = err instanceof Error ? err.message : String(err);
354
619
  (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) });
620
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
357
621
  }
358
622
  });
359
623
  // Snapshot
@@ -368,12 +632,15 @@ router.get('/tabs/:tabId/snapshot', async (req, res) => {
368
632
  return res.status(404).json({ error: 'Tab not found' });
369
633
  const { tabState } = found;
370
634
  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'));
635
+ const rawOffset = Number(req.query.offset);
636
+ const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0;
637
+ const raw = await (0, session_1.withUserLimit)(String(userId), CONFIG.maxConcurrentPerUser, () => (0, tab_1.withTimeout)((0, tab_1.snapshotTab)(tabState), CONFIG.handlerTimeoutMs, 'snapshot'));
638
+ const result = (0, tab_1.buildSnapshotPayload)(raw, offset);
372
639
  (0, logging_1.log)('info', 'snapshot', {
373
640
  reqId: req.reqId,
374
641
  tabId,
375
642
  url: result.url,
376
- snapshotLen: result.snapshot?.length,
643
+ snapshotLen: result.snapshot.length,
377
644
  refsCount: result.refsCount,
378
645
  });
379
646
  return res.json(result);
@@ -381,30 +648,37 @@ router.get('/tabs/:tabId/snapshot', async (req, res) => {
381
648
  catch (err) {
382
649
  const message = err instanceof Error ? err.message : String(err);
383
650
  (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) });
651
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
385
652
  }
386
653
  });
387
654
  // Wait for page ready
388
655
  router.post('/tabs/:tabId/wait', async (req, res) => {
389
656
  try {
657
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
658
+ return res.status(403).json({ error: 'Forbidden' });
659
+ }
390
660
  const { userId, timeout = 10000, waitForNetwork = true } = req.body;
391
661
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
392
662
  if (!found)
393
663
  return res.status(404).json({ error: 'Tab not found' });
394
664
  const { tabState } = found;
395
665
  const ready = await (0, tab_1.waitForPageReady)(tabState.page, { timeout, waitForNetwork });
666
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
396
667
  return res.json({ ok: true, ready });
397
668
  }
398
669
  catch (err) {
399
670
  const message = err instanceof Error ? err.message : String(err);
400
671
  (0, logging_1.log)('error', 'wait failed', { reqId: req.reqId, error: message });
401
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
672
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
402
673
  }
403
674
  });
404
675
  // Click
405
676
  router.post('/tabs/:tabId/click', async (req, res) => {
406
677
  const tabId = req.params.tabId;
407
678
  try {
679
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
680
+ return res.status(403).json({ error: 'Forbidden' });
681
+ }
408
682
  const { userId, ref, selector } = req.body;
409
683
  if (!userId)
410
684
  return res.status(400).json({ error: 'userId required' });
@@ -425,21 +699,22 @@ router.post('/tabs/:tabId/click', async (req, res) => {
425
699
  }));
426
700
  }
427
701
  (0, logging_1.log)('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
702
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
428
703
  return res.json(responseObj);
429
704
  }
430
705
  catch (err) {
431
- const statusCode = err?.statusCode;
432
706
  const message = err instanceof Error ? err.message : String(err);
433
707
  (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) });
708
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
437
709
  }
438
710
  });
439
711
  // Type
440
712
  router.post('/tabs/:tabId/type', async (req, res) => {
441
713
  const tabId = req.params.tabId;
442
714
  try {
715
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
716
+ return res.status(403).json({ error: 'Forbidden' });
717
+ }
443
718
  const { userId, ref, selector, text } = req.body;
444
719
  const found = (0, session_1.findTabById)(tabId, userId);
445
720
  if (!found)
@@ -448,21 +723,22 @@ router.post('/tabs/:tabId/type', async (req, res) => {
448
723
  tabState.toolCalls++;
449
724
  const textValue = String(text ?? '');
450
725
  const result = await (0, tab_1.withTimeout)((0, tab_1.typeTab)(tabId, tabState, { ref, selector, text: textValue }), (0, tab_1.calculateTypeTimeoutMs)(textValue), 'type');
726
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
451
727
  return res.json(result);
452
728
  }
453
729
  catch (err) {
454
- const statusCode = err?.statusCode;
455
730
  const message = err instanceof Error ? err.message : String(err);
456
731
  (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) });
732
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
460
733
  }
461
734
  });
462
735
  // Press key
463
736
  router.post('/tabs/:tabId/press', async (req, res) => {
464
737
  const tabId = req.params.tabId;
465
738
  try {
739
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
740
+ return res.status(403).json({ error: 'Forbidden' });
741
+ }
466
742
  const { userId, key } = req.body;
467
743
  const found = (0, session_1.findTabById)(tabId, userId);
468
744
  if (!found)
@@ -470,38 +746,44 @@ router.post('/tabs/:tabId/press', async (req, res) => {
470
746
  const { tabState } = found;
471
747
  tabState.toolCalls++;
472
748
  await (0, tab_1.withTimeout)((0, tab_1.pressTab)(tabId, tabState, String(key ?? '')), CONFIG.handlerTimeoutMs, 'press');
749
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
473
750
  return res.json({ ok: true });
474
751
  }
475
752
  catch (err) {
476
753
  const message = err instanceof Error ? err.message : String(err);
477
754
  (0, logging_1.log)('error', 'press failed', { reqId: req.reqId, error: message });
478
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
755
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
479
756
  }
480
757
  });
481
758
  // Scroll
482
759
  router.post('/tabs/:tabId/scroll', async (req, res) => {
483
760
  try {
761
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
762
+ return res.status(403).json({ error: 'Forbidden' });
763
+ }
484
764
  const { userId, direction = 'down', amount = 500 } = req.body;
485
765
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
486
766
  if (!found)
487
767
  return res.status(404).json({ error: 'Tab not found' });
488
768
  const { tabState } = found;
489
769
  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 });
770
+ const result = await (0, tab_1.withTimeout)((0, tab_1.scrollTab)(tabState, { direction, amount }), CONFIG.handlerTimeoutMs, 'scroll');
771
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
772
+ return res.json(result);
494
773
  }
495
774
  catch (err) {
496
775
  const message = err instanceof Error ? err.message : String(err);
497
776
  (0, logging_1.log)('error', 'scroll failed', { reqId: req.reqId, error: message });
498
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
777
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
499
778
  }
500
779
  });
501
780
  // Scroll element (selector or ref)
502
781
  router.post('/tabs/:tabId/scroll-element', async (req, res) => {
503
782
  const tabId = req.params.tabId;
504
783
  try {
784
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
785
+ return res.status(403).json({ error: 'Forbidden' });
786
+ }
505
787
  const { userId, selector, ref, deltaX, deltaY, scrollTo } = req.body;
506
788
  const found = (0, session_1.findTabById)(tabId, userId);
507
789
  if (!found)
@@ -509,15 +791,13 @@ router.post('/tabs/:tabId/scroll-element', async (req, res) => {
509
791
  const { tabState } = found;
510
792
  tabState.toolCalls++;
511
793
  const result = await (0, tab_1.scrollElementTab)(tabId, tabState, { selector, ref, deltaX, deltaY, scrollTo });
794
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
512
795
  return res.json(result);
513
796
  }
514
797
  catch (err) {
515
- const statusCode = err?.statusCode;
516
798
  const message = err instanceof Error ? err.message : String(err);
517
799
  (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) });
800
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
521
801
  }
522
802
  });
523
803
  // Evaluate JS (API key optional)
@@ -540,12 +820,13 @@ router.post('/tabs/:tabId/evaluate', express_1.default.json({ limit: '64kb' }),
540
820
  const { tabState } = found;
541
821
  tabState.toolCalls++;
542
822
  const result = await (0, tab_1.evaluateTab)(tabId, tabState, { expression, timeout });
823
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
543
824
  return res.json(result);
544
825
  }
545
826
  catch (err) {
546
827
  const message = err instanceof Error ? err.message : String(err);
547
828
  (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) });
829
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
549
830
  }
550
831
  });
551
832
  // Evaluate JS extended (API key optional)
@@ -586,6 +867,7 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
586
867
  const outerTimeout = effectiveTimeout + 10000;
587
868
  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
869
  if (result.ok) {
870
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
589
871
  return res.json(result);
590
872
  }
591
873
  if (result.errorType === 'timeout') {
@@ -596,6 +878,10 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
596
878
  catch (err) {
597
879
  const message = err instanceof Error ? err.message : String(err);
598
880
  (0, logging_1.log)('error', 'evaluate-extended failed', { reqId: req.reqId, tabId, error: message });
881
+ const status = getRouteErrorStatus(err);
882
+ if (status === 400) {
883
+ return res.status(400).json({ ok: false, error: (0, errors_1.safeError)(err), errorType: 'js_error' });
884
+ }
599
885
  if (message.includes('timed out') || message.includes('Timeout')) {
600
886
  return res.status(408).json({ ok: false, error: message, errorType: 'timeout' });
601
887
  }
@@ -611,6 +897,9 @@ router.post('/tabs/:tabId/evaluate-extended', express_1.default.json({ limit: '6
611
897
  router.post('/tabs/:tabId/back', async (req, res) => {
612
898
  const tabId = req.params.tabId;
613
899
  try {
900
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
901
+ return res.status(403).json({ error: 'Forbidden' });
902
+ }
614
903
  const { userId } = req.body;
615
904
  const found = (0, session_1.findTabById)(tabId, userId);
616
905
  if (!found)
@@ -618,18 +907,22 @@ router.post('/tabs/:tabId/back', async (req, res) => {
618
907
  const { tabState } = found;
619
908
  tabState.toolCalls++;
620
909
  const result = await (0, tab_1.withTimeout)((0, tab_1.backTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'back');
910
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
621
911
  return res.json(result);
622
912
  }
623
913
  catch (err) {
624
914
  const message = err instanceof Error ? err.message : String(err);
625
915
  (0, logging_1.log)('error', 'back failed', { reqId: req.reqId, error: message });
626
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
916
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
627
917
  }
628
918
  });
629
919
  // Forward
630
920
  router.post('/tabs/:tabId/forward', async (req, res) => {
631
921
  const tabId = req.params.tabId;
632
922
  try {
923
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
924
+ return res.status(403).json({ error: 'Forbidden' });
925
+ }
633
926
  const { userId } = req.body;
634
927
  const found = (0, session_1.findTabById)(tabId, userId);
635
928
  if (!found)
@@ -637,18 +930,22 @@ router.post('/tabs/:tabId/forward', async (req, res) => {
637
930
  const { tabState } = found;
638
931
  tabState.toolCalls++;
639
932
  const result = await (0, tab_1.withTimeout)((0, tab_1.forwardTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'forward');
933
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
640
934
  return res.json(result);
641
935
  }
642
936
  catch (err) {
643
937
  const message = err instanceof Error ? err.message : String(err);
644
938
  (0, logging_1.log)('error', 'forward failed', { reqId: req.reqId, error: message });
645
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
939
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
646
940
  }
647
941
  });
648
942
  // Refresh
649
943
  router.post('/tabs/:tabId/refresh', async (req, res) => {
650
944
  const tabId = req.params.tabId;
651
945
  try {
946
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
947
+ return res.status(403).json({ error: 'Forbidden' });
948
+ }
652
949
  const { userId } = req.body;
653
950
  const found = (0, session_1.findTabById)(tabId, userId);
654
951
  if (!found)
@@ -656,12 +953,13 @@ router.post('/tabs/:tabId/refresh', async (req, res) => {
656
953
  const { tabState } = found;
657
954
  tabState.toolCalls++;
658
955
  const result = await (0, tab_1.withTimeout)((0, tab_1.refreshTab)(tabId, tabState), CONFIG.handlerTimeoutMs, 'refresh');
956
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
659
957
  return res.json(result);
660
958
  }
661
959
  catch (err) {
662
960
  const message = err instanceof Error ? err.message : String(err);
663
961
  (0, logging_1.log)('error', 'refresh failed', { reqId: req.reqId, error: message });
664
- return res.status(500).json({ error: (0, errors_1.safeError)(err) });
962
+ return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
665
963
  }
666
964
  });
667
965
  // Get links
@@ -708,6 +1006,54 @@ router.get('/tabs/:tabId/screenshot', async (req, res) => {
708
1006
  return res.status(500).json({ error: (0, errors_1.safeError)(err) });
709
1007
  }
710
1008
  });
1009
+ // Images
1010
+ router.get('/tabs/:tabId/images', async (req, res) => {
1011
+ try {
1012
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1013
+ return res.status(403).json({ ok: false, error: 'Forbidden' });
1014
+ }
1015
+ const userId = req.query.userId;
1016
+ const found = (0, session_1.findTabById)(req.params.tabId, userId);
1017
+ if (!found) {
1018
+ (0, logging_1.log)('warn', 'images: tab not found', { reqId: req.reqId, tabId: req.params.tabId, userId });
1019
+ return res.status(404).json({ error: 'Tab not found' });
1020
+ }
1021
+ const selector = typeof req.query.selector === 'string' && req.query.selector ? req.query.selector : undefined;
1022
+ const extensions = Array.isArray(req.query.extensions)
1023
+ ? req.query.extensions.map((value) => String(value)).filter(Boolean)
1024
+ : typeof req.query.extensions === 'string' && req.query.extensions
1025
+ ? req.query.extensions
1026
+ .split(',')
1027
+ .map((value) => value.trim())
1028
+ .filter(Boolean)
1029
+ : undefined;
1030
+ const resolveBlobs = String(req.query.resolveBlobs || '').toLowerCase() === 'true';
1031
+ const triggerLazyLoad = String(req.query.triggerLazyLoad || '').toLowerCase() === 'true';
1032
+ const { tabState } = found;
1033
+ tabState.toolCalls++;
1034
+ 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, {
1035
+ selector,
1036
+ extensions,
1037
+ resolveBlobs,
1038
+ triggerLazyLoad,
1039
+ })), CONFIG.handlerTimeoutMs, 'images'));
1040
+ return res.json({
1041
+ ok: result.ok,
1042
+ container: result.container,
1043
+ images: result.resources.images,
1044
+ totals: {
1045
+ images: result.totals.images,
1046
+ total: result.totals.images,
1047
+ },
1048
+ metadata: result.metadata,
1049
+ });
1050
+ }
1051
+ catch (err) {
1052
+ const message = err instanceof Error ? err.message : String(err);
1053
+ (0, logging_1.log)('error', 'images failed', { reqId: req.reqId, error: message });
1054
+ return res.status(500).json({ error: (0, errors_1.safeError)(err) });
1055
+ }
1056
+ });
711
1057
  // Stats
712
1058
  router.get('/tabs/:tabId/stats', async (req, res) => {
713
1059
  try {
@@ -735,6 +1081,9 @@ router.get('/tabs/:tabId/stats', async (req, res) => {
735
1081
  // Close tab
736
1082
  router.delete('/tabs/:tabId', async (req, res) => {
737
1083
  try {
1084
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1085
+ return res.status(403).json({ error: 'Forbidden' });
1086
+ }
738
1087
  const { userId } = req.body;
739
1088
  const found = (0, session_1.findTabById)(req.params.tabId, userId);
740
1089
  if (found) {
@@ -746,6 +1095,7 @@ router.delete('/tabs/:tabId', async (req, res) => {
746
1095
  }
747
1096
  (0, logging_1.log)('info', 'tab closed', { reqId: req.reqId, tabId: req.params.tabId, userId });
748
1097
  }
1098
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
749
1099
  return res.json({ ok: true });
750
1100
  }
751
1101
  catch (err) {
@@ -757,6 +1107,9 @@ router.delete('/tabs/:tabId', async (req, res) => {
757
1107
  // Close tab group
758
1108
  router.delete('/tabs/group/:listItemId', async (req, res) => {
759
1109
  try {
1110
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1111
+ return res.status(403).json({ error: 'Forbidden' });
1112
+ }
760
1113
  const { userId } = req.body;
761
1114
  const sessionsForUser = (0, session_1.getSessionsForUser)(userId);
762
1115
  for (const [sessionKey, session] of sessionsForUser) {
@@ -775,6 +1128,7 @@ router.delete('/tabs/group/:listItemId', async (req, res) => {
775
1128
  sessionKey,
776
1129
  });
777
1130
  }
1131
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
778
1132
  return res.json({ ok: true });
779
1133
  }
780
1134
  catch (err) {
@@ -786,10 +1140,14 @@ router.delete('/tabs/group/:listItemId', async (req, res) => {
786
1140
  // Close session
787
1141
  router.delete('/sessions/:userId', async (req, res) => {
788
1142
  try {
1143
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1144
+ return res.status(403).json({ error: 'Forbidden' });
1145
+ }
789
1146
  const userId = (0, session_1.normalizeUserId)(req.params.userId);
790
1147
  await (0, session_1.closeSessionsForUser)(userId);
791
1148
  // Ensure downloads are cleaned even if the session was already partially removed.
792
1149
  (0, download_1.cleanupUserDownloads)(userId);
1150
+ lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
793
1151
  return res.json({ ok: true });
794
1152
  }
795
1153
  catch (err) {
@@ -801,6 +1159,9 @@ router.delete('/sessions/:userId', async (req, res) => {
801
1159
  // Toggle display mode (headless/headed/virtual) for a user session
802
1160
  router.post('/sessions/:userId/toggle-display', async (req, res) => {
803
1161
  try {
1162
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1163
+ return res.status(403).json({ error: 'Forbidden' });
1164
+ }
804
1165
  const userId = (0, session_1.normalizeUserId)(req.params.userId);
805
1166
  const { headless } = req.body ?? {};
806
1167
  if (typeof headless !== 'boolean' && headless !== 'virtual') {
@@ -808,14 +1169,28 @@ router.post('/sessions/:userId/toggle-display', async (req, res) => {
808
1169
  error: 'headless must be a boolean or "virtual"',
809
1170
  });
810
1171
  }
811
- // Existing tabs become invalid after context restart.
812
- await (0, session_1.closeSessionsForUser)(userId);
813
- await context_pool_1.contextPool.restartContext(userId, headless);
1172
+ const existingSessions = (0, session_1.getSessionsForUser)(userId);
1173
+ const prewarmProfileKey = existingSessions.length === 1 ? existingSessions[0][0] : undefined;
1174
+ const prewarmEntry = prewarmProfileKey ? context_pool_1.contextPool.getEntry(prewarmProfileKey) : undefined;
1175
+ const prewarmLaunchSettings = prewarmProfileKey && !prewarmEntry ? (0, session_1.getSessionProfileLaunchSettings)(userId, prewarmProfileKey) : undefined;
1176
+ const prewarmOptions = prewarmEntry ? prewarmEntry.seedOptions : prewarmLaunchSettings?.contextOverrides ?? undefined;
1177
+ const prewarmProxy = prewarmEntry ? prewarmEntry.proxyConfig : prewarmLaunchSettings?.proxy ?? null;
1178
+ let tabsInvalidated = false;
814
1179
  let vncUrl;
815
1180
  if (headless === true) {
1181
+ if (prewarmProfileKey) {
1182
+ // Existing tabs become invalid after context restart/close.
1183
+ await (0, session_1.closeSessionsForUser)(userId, { clearProfiles: false });
1184
+ tabsInvalidated = true;
1185
+ }
1186
+ context_pool_1.contextPool.setHeadlessOverride(userId, headless);
816
1187
  await (0, vnc_1.stopVnc)(userId).catch(() => { });
817
1188
  }
818
- else {
1189
+ else if (prewarmProfileKey) {
1190
+ // Existing tabs become invalid after context restart.
1191
+ await (0, session_1.closeSessionsForUser)(userId, { clearProfiles: false });
1192
+ tabsInvalidated = true;
1193
+ await context_pool_1.contextPool.restartContext(userId, headless, prewarmProfileKey, prewarmOptions, prewarmProxy);
819
1194
  const displayNum = (0, context_pool_1.getDisplayForUser)(userId);
820
1195
  if (displayNum) {
821
1196
  try {
@@ -828,13 +1203,21 @@ router.post('/sessions/:userId/toggle-display', async (req, res) => {
828
1203
  }
829
1204
  }
830
1205
  }
1206
+ else {
1207
+ context_pool_1.contextPool.setHeadlessOverride(userId, headless);
1208
+ }
831
1209
  const modeLabel = headless === false ? 'headed mode' : headless === 'virtual' ? 'virtual display mode' : 'headless mode';
832
1210
  return res.json({
833
1211
  ok: true,
834
1212
  headless,
1213
+ tabsInvalidated,
835
1214
  ...(vncUrl
836
1215
  ? { vncUrl, message: 'Browser visible via VNC' }
837
- : { message: `Browser restarted in ${modeLabel}. Previous tabs invalidated — create new tabs.` }),
1216
+ : {
1217
+ message: tabsInvalidated
1218
+ ? `Display mode updated to ${modeLabel}. Previous tabs invalidated — create new tabs.`
1219
+ : `Display mode override saved as ${modeLabel}. Existing tabs preserved; new contexts use the requested mode.`,
1220
+ }),
838
1221
  userId,
839
1222
  });
840
1223
  }
@@ -952,6 +1335,9 @@ router.get('/downloads/:downloadId/content', async (req, res) => {
952
1335
  // Downloads: delete
953
1336
  router.delete('/downloads/:downloadId', async (req, res) => {
954
1337
  try {
1338
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1339
+ return res.status(403).json({ error: 'Forbidden' });
1340
+ }
955
1341
  const userId = req.body?.userId || req.query.userId;
956
1342
  if (!userId)
957
1343
  return res.status(400).json({ ok: false, error: 'userId required' });
@@ -969,6 +1355,9 @@ router.delete('/downloads/:downloadId', async (req, res) => {
969
1355
  // Extract resources from a scoped container
970
1356
  router.post('/tabs/:tabId/extract-resources', async (req, res) => {
971
1357
  try {
1358
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1359
+ return res.status(403).json({ error: 'Forbidden' });
1360
+ }
972
1361
  const { tabId } = req.params;
973
1362
  const { userId, selector, types, extensions, resolveBlobs, triggerLazyLoad } = req.body;
974
1363
  if (!userId)
@@ -994,9 +1383,50 @@ router.post('/tabs/:tabId/extract-resources', async (req, res) => {
994
1383
  res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
995
1384
  }
996
1385
  });
1386
+ router.post('/tabs/:tabId/extract-structured', async (req, res) => {
1387
+ try {
1388
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1389
+ return res.status(403).json({ error: 'Forbidden' });
1390
+ }
1391
+ const { tabId } = req.params;
1392
+ const { userId, schema } = req.body;
1393
+ if (!userId || typeof userId !== 'string') {
1394
+ return res.status(400).json({ ok: false, error: 'userId required' });
1395
+ }
1396
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
1397
+ return res.status(400).json({ ok: false, error: 'schema must be an object' });
1398
+ }
1399
+ const found = (0, session_1.findTabById)(tabId, userId);
1400
+ if (!found)
1401
+ return res.status(404).json({ ok: false, error: 'Tab not found' });
1402
+ const { tabState } = found;
1403
+ tabState.toolCalls++;
1404
+ 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'));
1405
+ return res.json(result);
1406
+ }
1407
+ catch (err) {
1408
+ if (err instanceof structured_extractor_1.StructuredExtractSchemaError) {
1409
+ return res.status(err.statusCode).json({ ok: false, error: err.message });
1410
+ }
1411
+ if (err instanceof structured_extractor_1.StructuredExtractRuntimeError) {
1412
+ return res.status(err.statusCode).json({
1413
+ ok: false,
1414
+ error: 'Structured extraction failed',
1415
+ fieldPath: err.fieldPath,
1416
+ reason: err.reason,
1417
+ });
1418
+ }
1419
+ const message = err instanceof Error ? err.message : String(err);
1420
+ (0, logging_1.log)('error', 'structured extract failed', { error: message });
1421
+ return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1422
+ }
1423
+ });
997
1424
  // Batch download from a scoped container
998
1425
  router.post('/tabs/:tabId/batch-download', async (req, res) => {
999
1426
  try {
1427
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1428
+ return res.status(403).json({ error: 'Forbidden' });
1429
+ }
1000
1430
  const { tabId } = req.params;
1001
1431
  const body = req.body;
1002
1432
  const userId = body?.userId;
@@ -1093,12 +1523,13 @@ router.post('/tabs/:tabId/trace/stop', async (req, res) => {
1093
1523
  return res.json({
1094
1524
  ok: true,
1095
1525
  path: result.path,
1526
+ filename: result.path ? (0, node_path_1.basename)(result.path) : undefined,
1096
1527
  size: result.size,
1097
1528
  alreadyStopped: true,
1098
1529
  message: 'Trace was already stopped by chunk stop',
1099
1530
  });
1100
1531
  }
1101
- return res.json({ ok: true, ...result });
1532
+ return res.json({ ok: true, ...result, filename: (0, node_path_1.basename)(result.path) });
1102
1533
  }
1103
1534
  catch (err) {
1104
1535
  const status = getTracingErrorStatus(err);
@@ -1143,7 +1574,7 @@ router.post('/tabs/:tabId/trace/chunk/stop', async (req, res) => {
1143
1574
  if (!tab)
1144
1575
  return res.status(404).json({ ok: false, error: 'Tab not found' });
1145
1576
  const result = await (0, tracing_1.stopTracingChunk)(userId, tab.page.context(), path);
1146
- return res.json({ ok: true, ...result });
1577
+ return res.json({ ok: true, ...result, filename: (0, node_path_1.basename)(result.path) });
1147
1578
  }
1148
1579
  catch (err) {
1149
1580
  const status = getTracingErrorStatus(err);
@@ -1170,6 +1601,62 @@ router.get('/tabs/:tabId/trace/status', async (req, res) => {
1170
1601
  return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1171
1602
  }
1172
1603
  });
1604
+ router.get('/sessions/:userId/traces', async (req, res) => {
1605
+ try {
1606
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1607
+ return res.status(403).json({ error: 'Forbidden' });
1608
+ }
1609
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1610
+ return res.json({ ok: true, traces: (0, tracing_1.listTraceArtifacts)(userId) });
1611
+ }
1612
+ catch (err) {
1613
+ return res.status(500).json({ ok: false, error: (0, errors_1.safeError)(err) });
1614
+ }
1615
+ });
1616
+ router.get('/sessions/:userId/traces/:filename', async (req, res) => {
1617
+ try {
1618
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1619
+ return res.status(403).json({ error: 'Forbidden' });
1620
+ }
1621
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1622
+ const filename = req.params.filename;
1623
+ const filePath = (0, tracing_1.resolveTraceArtifactPath)(userId, filename);
1624
+ const safeName = filename.replace(/[\r\n\0"]/g, '');
1625
+ res.setHeader('Content-Type', 'application/zip');
1626
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`);
1627
+ const stream = node_fs_1.default.createReadStream(filePath);
1628
+ stream.on('error', (streamErr) => {
1629
+ if (!res.headersSent) {
1630
+ const status = getTraceArtifactErrorStatus(streamErr);
1631
+ res.removeHeader('Content-Disposition');
1632
+ res.removeHeader('Content-Type');
1633
+ res.status(status).json({ ok: false, error: (0, errors_1.safeError)(streamErr) });
1634
+ return;
1635
+ }
1636
+ res.destroy();
1637
+ });
1638
+ stream.pipe(res);
1639
+ }
1640
+ catch (err) {
1641
+ const status = getTraceArtifactErrorStatus(err);
1642
+ return res.status(status).json({ ok: false, error: (0, errors_1.safeError)(err) });
1643
+ }
1644
+ });
1645
+ router.delete('/sessions/:userId/traces/:filename', async (req, res) => {
1646
+ try {
1647
+ if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {
1648
+ return res.status(403).json({ error: 'Forbidden' });
1649
+ }
1650
+ const userId = (0, session_1.normalizeUserId)(req.params.userId);
1651
+ const filename = req.params.filename;
1652
+ (0, tracing_1.deleteTraceArtifact)(userId, filename);
1653
+ return res.json({ ok: true });
1654
+ }
1655
+ catch (err) {
1656
+ const status = getTraceArtifactErrorStatus(err);
1657
+ return res.status(status).json({ ok: false, error: (0, errors_1.safeError)(err) });
1658
+ }
1659
+ });
1173
1660
  router.get('/tabs/:tabId/console', async (req, res) => {
1174
1661
  try {
1175
1662
  if (CONFIG.apiKey && !(0, auth_1.isAuthorizedWithApiKey)(req, CONFIG.apiKey)) {