Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
1969 lines (1710 loc) · 65.4 KB
/
Copy pathserver.js
File metadata and controls
1969 lines (1710 loc) · 65.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const { Camoufox, launchOptions } = require('camoufox-js');
const { firefox } = require('playwright-core');
const express = require('express');
const crypto = require('crypto');
const os = require('os');
const { expandMacro } = require('./lib/macros');
const { loadConfig } = require('./lib/config');
const { windowSnapshot } = require('./lib/snapshot');
const { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } = require('./lib/youtube');
const CONFIG = loadConfig();
// --- Structured logging ---
function log(level, msg, fields = {}) {
const entry = {
ts: new Date().toISOString(),
level,
msg,
...fields,
};
const line = JSON.stringify(entry);
if (level === 'error') {
process.stderr.write(line + '\n');
} else {
process.stdout.write(line + '\n');
}
}
const app = express();
app.use(express.json({ limit: '100kb' }));
// Request logging middleware
app.use((req, res, next) => {
if (req.path === '/health') return next();
const reqId = crypto.randomUUID().slice(0, 8);
req.reqId = reqId;
req.startTime = Date.now();
const userId = req.body?.userId || req.query?.userId || '-';
log('info', 'req', { reqId, method: req.method, path: req.path, userId });
const origEnd = res.end.bind(res);
res.end = function (...args) {
const ms = Date.now() - req.startTime;
log('info', 'res', { reqId, status: res.statusCode, ms });
return origEnd(...args);
};
next();
});
const ALLOWED_URL_SCHEMES = ['http:', 'https:'];
// Interactive roles to include - exclude combobox to avoid opening complex widgets
// (date pickers, dropdowns) that can interfere with navigation
const INTERACTIVE_ROLES = [
'button', 'link', 'textbox', 'checkbox', 'radio',
'menuitem', 'tab', 'searchbox', 'slider', 'spinbutton', 'switch'
// 'combobox' excluded - can trigger date pickers and complex dropdowns
];
// Patterns to skip (date pickers, calendar widgets)
const SKIP_PATTERNS = [
/date/i, /calendar/i, /picker/i, /datepicker/i
];
function timingSafeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function safeError(err) {
if (CONFIG.nodeEnv === 'production') {
log('error', 'internal error', { error: err.message, stack: err.stack });
return 'Internal server error';
}
return err.message;
}
function validateUrl(url) {
try {
const parsed = new URL(url);
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
return `Blocked URL scheme: ${parsed.protocol} (only http/https allowed)`;
}
return null;
} catch {
return `Invalid URL: ${url}`;
}
}
// Import cookies into a user's browser context (Playwright cookies format)
// POST /sessions/:userId/cookies { cookies: Cookie[] }
//
// SECURITY:
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
// This endpoint is DISABLED unless CAMOFOX_API_KEY is set.
// When enabled, caller must send: Authorization: Bearer <CAMOFOX_API_KEY>
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
try {
if (!CONFIG.apiKey) {
return res.status(403).json({
error: 'Cookie import is disabled. Set CAMOFOX_API_KEY to enable this endpoint.',
});
}
const apiKey = CONFIG.apiKey;
const auth = String(req.headers['authorization'] || '');
const match = auth.match(/^Bearer\s+(.+)$/i);
if (!match || !timingSafeCompare(match[1], apiKey)) {
return res.status(403).json({ error: 'Forbidden' });
}
const userId = req.params.userId;
if (!req.body || !('cookies' in req.body)) {
return res.status(400).json({ error: 'Missing "cookies" field in request body' });
}
const cookies = req.body.cookies;
if (!Array.isArray(cookies)) {
return res.status(400).json({ error: 'cookies must be an array' });
}
if (cookies.length > 500) {
return res.status(400).json({ error: 'Too many cookies. Maximum 500 per request.' });
}
const invalid = [];
for (let i = 0; i < cookies.length; i++) {
const c = cookies[i];
const missing = [];
if (!c || typeof c !== 'object') {
invalid.push({ index: i, error: 'cookie must be an object' });
continue;
}
if (typeof c.name !== 'string' || !c.name) missing.push('name');
if (typeof c.value !== 'string') missing.push('value');
if (typeof c.domain !== 'string' || !c.domain) missing.push('domain');
if (missing.length) invalid.push({ index: i, missing });
}
if (invalid.length) {
return res.status(400).json({
error: 'Invalid cookie objects: each cookie must include name, value, and domain',
invalid,
});
}
const allowedFields = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite'];
const sanitized = cookies.map(c => {
const clean = {};
for (const k of allowedFields) {
if (c[k] !== undefined) clean[k] = c[k];
}
return clean;
});
const session = await getSession(userId);
await session.context.addCookies(sanitized);
const result = { ok: true, userId: String(userId), count: sanitized.length };
log('info', 'cookies imported', { reqId: req.reqId, userId: String(userId), count: sanitized.length });
res.json(result);
} catch (err) {
log('error', 'cookie import failed', { reqId: req.reqId, error: err.message });
res.status(500).json({ error: safeError(err) });
}
});
let browser = null;
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
// Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
const sessions = new Map();
const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
const MAX_SNAPSHOT_NODES = 500;
const MAX_SESSIONS = CONFIG.maxSessions;
const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
const HANDLER_TIMEOUT_MS = CONFIG.handlerTimeoutMs;
const MAX_CONCURRENT_PER_USER = CONFIG.maxConcurrentPerUser;
const PAGE_CLOSE_TIMEOUT_MS = 5000;
const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
const FAILURE_THRESHOLD = 3;
const TAB_LOCK_TIMEOUT_MS = 30000;
// Per-tab locks to serialize operations on the same tab
// tabId -> Promise (the currently executing operation)
const tabLocks = new Map();
async function withTabLock(tabId, operation) {
// Wait for any pending operation on this tab to complete
const pending = tabLocks.get(tabId);
if (pending) {
try {
await Promise.race([
pending,
new Promise((_, reject) => setTimeout(() => reject(new Error('Tab lock timeout')), TAB_LOCK_TIMEOUT_MS))
]);
} catch (e) {
if (e.message === 'Tab lock timeout') {
log('warn', 'tab lock timeout, proceeding', { tabId });
}
}
}
// Execute this operation and store the promise
const promise = operation();
tabLocks.set(tabId, promise);
try {
return await promise;
} finally {
// Clean up if this is still the active lock
if (tabLocks.get(tabId) === promise) {
tabLocks.delete(tabId);
}
}
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
)
]);
}
const userConcurrency = new Map();
async function withUserLimit(userId, operation) {
const key = normalizeUserId(userId);
let state = userConcurrency.get(key);
if (!state) {
state = { active: 0, queue: [] };
userConcurrency.set(key, state);
}
if (state.active >= MAX_CONCURRENT_PER_USER) {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('User concurrency limit reached, try again')), 30000);
state.queue.push(() => { clearTimeout(timer); resolve(); });
});
}
state.active++;
healthState.activeOps++;
try {
const result = await operation();
healthState.lastSuccessfulNav = Date.now();
return result;
} finally {
healthState.activeOps--;
state.active--;
if (state.queue.length > 0) {
const next = state.queue.shift();
next();
}
if (state.active === 0 && state.queue.length === 0) {
userConcurrency.delete(key);
}
}
}
async function safePageClose(page) {
try {
await Promise.race([
page.close(),
new Promise(resolve => setTimeout(resolve, PAGE_CLOSE_TIMEOUT_MS))
]);
} catch (e) {
log('warn', 'page close failed', { error: e.message });
}
}
// Detect host OS for fingerprint generation
function getHostOS() {
const platform = os.platform();
if (platform === 'darwin') return 'macos';
if (platform === 'win32') return 'windows';
return 'linux';
}
function buildProxyConfig() {
const { host, port, username, password } = CONFIG.proxy;
if (!host || !port) {
log('info', 'no proxy configured');
return null;
}
log('info', 'proxy configured', { host, port });
return {
server: `http://${host}:${port}`,
username,
password,
};
}
const BROWSER_IDLE_TIMEOUT_MS = CONFIG.browserIdleTimeoutMs;
let browserIdleTimer = null;
let browserLaunchPromise = null;
function scheduleBrowserIdleShutdown() {
clearBrowserIdleTimer();
if (sessions.size === 0 && browser) {
browserIdleTimer = setTimeout(async () => {
if (sessions.size === 0 && browser) {
log('info', 'browser idle shutdown (no sessions)');
const b = browser;
browser = null;
await b.close().catch(() => {});
}
}, BROWSER_IDLE_TIMEOUT_MS);
}
}
function clearBrowserIdleTimer() {
if (browserIdleTimer) {
clearTimeout(browserIdleTimer);
browserIdleTimer = null;
}
}
// --- Browser health tracking ---
const healthState = {
consecutiveNavFailures: 0,
lastSuccessfulNav: Date.now(),
isRecovering: false,
activeOps: 0,
};
function recordNavSuccess() {
healthState.consecutiveNavFailures = 0;
healthState.lastSuccessfulNav = Date.now();
}
function recordNavFailure() {
healthState.consecutiveNavFailures++;
return healthState.consecutiveNavFailures >= FAILURE_THRESHOLD;
}
async function restartBrowser(reason) {
if (healthState.isRecovering) return;
healthState.isRecovering = true;
log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
try {
for (const [, session] of sessions) {
await session.context.close().catch(() => {});
}
sessions.clear();
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
browserLaunchPromise = null;
await ensureBrowser();
healthState.consecutiveNavFailures = 0;
healthState.lastSuccessfulNav = Date.now();
log('info', 'browser restarted successfully');
} catch (err) {
log('error', 'browser restart failed', { error: err.message });
} finally {
healthState.isRecovering = false;
}
}
function getTotalTabCount() {
let total = 0;
for (const session of sessions.values()) {
for (const group of session.tabGroups.values()) {
total += group.size;
}
}
return total;
}
async function launchBrowserInstance() {
const hostOS = getHostOS();
const proxy = buildProxyConfig();
log('info', 'launching camoufox', { hostOS, geoip: !!proxy });
const options = await launchOptions({
headless: true,
os: hostOS,
humanize: true,
enable_cache: true,
proxy: proxy,
geoip: !!proxy,
});
browser = await firefox.launch(options);
log('info', 'camoufox launched');
return browser;
}
async function ensureBrowser() {
clearBrowserIdleTimer();
if (browser && !browser.isConnected()) {
log('warn', 'browser disconnected, clearing dead sessions and relaunching', {
deadSessions: sessions.size,
});
for (const [userId, session] of sessions) {
await session.context.close().catch(() => {});
}
sessions.clear();
browser = null;
}
if (browser) return browser;
if (browserLaunchPromise) return browserLaunchPromise;
browserLaunchPromise = Promise.race([
launchBrowserInstance(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser launch timeout (30s)')), 30000)),
]).finally(() => { browserLaunchPromise = null; });
return browserLaunchPromise;
}
// Helper to normalize userId to string (JSON body may parse as number)
function normalizeUserId(userId) {
return String(userId);
}
async function getSession(userId) {
const key = normalizeUserId(userId);
let session = sessions.get(key);
if (!session) {
if (sessions.size >= MAX_SESSIONS) {
throw new Error('Maximum concurrent sessions reached');
}
const b = await ensureBrowser();
const contextOptions = {
viewport: { width: 1280, height: 720 },
permissions: ['geolocation'],
};
// When geoip is active (proxy configured), camoufox auto-configures
// locale/timezone/geolocation from the proxy IP. Without proxy, use defaults.
if (!CONFIG.proxy.host) {
contextOptions.locale = 'en-US';
contextOptions.timezoneId = 'America/Los_Angeles';
contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 };
}
const context = await b.newContext(contextOptions);
session = { context, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set(key, session);
log('info', 'session created', { userId: key });
}
session.lastAccess = Date.now();
return session;
}
function getTabGroup(session, listItemId) {
let group = session.tabGroups.get(listItemId);
if (!group) {
group = new Map();
session.tabGroups.set(listItemId, group);
}
return group;
}
function findTab(session, tabId) {
for (const [listItemId, group] of session.tabGroups) {
if (group.has(tabId)) {
const tabState = group.get(tabId);
return { tabState, listItemId, group };
}
}
return null;
}
function createTabState(page) {
return {
page,
refs: new Map(),
visitedUrls: new Set(),
toolCalls: 0,
lastSnapshot: null,
};
}
async function waitForPageReady(page, options = {}) {
const { timeout = 10000, waitForNetwork = true } = options;
try {
await page.waitForLoadState('domcontentloaded', { timeout });
if (waitForNetwork) {
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
log('warn', 'networkidle timeout, continuing');
});
}
// Framework hydration wait (React/Next.js/Vue) - mirrors Swift WebView.swift logic
// Wait for readyState === 'complete' + network quiet (40 iterations × 250ms max)
await page.evaluate(async () => {
for (let i = 0; i < 40; i++) {
// Check if network is quiet (no recent resource loads)
const entries = performance.getEntriesByType('resource');
const recentEntries = entries.slice(-5);
const netQuiet = recentEntries.every(e => (performance.now() - e.responseEnd) > 400);
if (document.readyState === 'complete' && netQuiet) {
// Double RAF to ensure paint is complete
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
break;
}
await new Promise(r => setTimeout(r, 250));
}
}).catch(() => {
log('warn', 'hydration wait failed, continuing');
});
await page.waitForTimeout(200);
// Auto-dismiss common consent/privacy dialogs
await dismissConsentDialogs(page);
return true;
} catch (err) {
log('warn', 'page ready failed', { error: err.message });
return false;
}
}
async function dismissConsentDialogs(page) {
// Common consent/privacy dialog selectors (matches Swift WebView.swift patterns)
const dismissSelectors = [
// OneTrust (very common)
'#onetrust-banner-sdk button#onetrust-accept-btn-handler',
'#onetrust-banner-sdk button#onetrust-reject-all-handler',
'#onetrust-close-btn-container button',
// Generic patterns
'button[data-test="cookie-accept-all"]',
'button[aria-label="Accept all"]',
'button[aria-label="Accept All"]',
'button[aria-label="Close"]',
'button[aria-label="Dismiss"]',
// Dialog close buttons
'dialog button:has-text("Close")',
'dialog button:has-text("Accept")',
'dialog button:has-text("I Accept")',
'dialog button:has-text("Got it")',
'dialog button:has-text("OK")',
// GDPR/CCPA specific
'[class*="consent"] button[class*="accept"]',
'[class*="consent"] button[class*="close"]',
'[class*="privacy"] button[class*="close"]',
'[class*="cookie"] button[class*="accept"]',
'[class*="cookie"] button[class*="close"]',
// Overlay close buttons
'[class*="modal"] button[class*="close"]',
'[class*="overlay"] button[class*="close"]',
];
for (const selector of dismissSelectors) {
try {
const button = page.locator(selector).first();
if (await button.isVisible({ timeout: 100 })) {
await button.click({ timeout: 1000 }).catch(() => {});
log('info', 'dismissed consent dialog', { selector });
await page.waitForTimeout(300); // Brief pause after dismiss
break; // Only dismiss one dialog per page load
}
} catch (e) {
// Selector not found or not clickable, continue
}
}
}
async function buildRefs(page) {
const refs = new Map();
if (!page || page.isClosed()) {
log('warn', 'buildRefs: page closed or invalid');
return refs;
}
const start = Date.now();
// Hard total timeout on the entire buildRefs operation
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('buildRefs_timeout')), BUILDREFS_TIMEOUT_MS)
);
try {
return await Promise.race([
_buildRefsInner(page, refs, start),
timeoutPromise
]);
} catch (err) {
if (err.message === 'buildRefs_timeout') {
log('warn', 'buildRefs: total timeout exceeded', { elapsed: Date.now() - start });
return refs;
}
throw err;
}
}
async function _buildRefsInner(page, refs, start) {
await waitForPageReady(page, { waitForNetwork: false });
// Budget remaining time for ariaSnapshot
const elapsed = Date.now() - start;
const remaining = BUILDREFS_TIMEOUT_MS - elapsed;
if (remaining < 2000) {
log('warn', 'buildRefs: insufficient time for ariaSnapshot', { elapsed });
return refs;
}
let ariaYaml;
try {
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: Math.min(remaining - 1000, 5000) });
} catch (err) {
log('warn', 'ariaSnapshot failed, retrying');
const retryBudget = BUILDREFS_TIMEOUT_MS - (Date.now() - start);
if (retryBudget < 2000) return refs;
try {
ariaYaml = await page.locator('body').ariaSnapshot({ timeout: Math.min(retryBudget - 500, 5000) });
} catch (retryErr) {
log('warn', 'ariaSnapshot retry failed, returning empty refs', { error: retryErr.message });
return refs;
}
}
if (!ariaYaml) {
log('warn', 'buildRefs: no aria snapshot');
return refs;
}
const lines = ariaYaml.split('\n');
let refCounter = 1;
// Track occurrences of each role+name combo for nth disambiguation
const seenCounts = new Map(); // "role:name" -> count
for (const line of lines) {
if (refCounter > MAX_SNAPSHOT_NODES) break;
const match = line.match(/^\s*-\s+(\w+)(?:\s+"([^"]*)")?/);
if (match) {
const [, role, name] = match;
const normalizedRole = role.toLowerCase();
if (normalizedRole === 'combobox') continue;
if (name && SKIP_PATTERNS.some(p => p.test(name))) continue;
if (INTERACTIVE_ROLES.includes(normalizedRole)) {
const normalizedName = name || '';
const key = `${normalizedRole}:${normalizedName}`;
// Get current count and increment
const nth = seenCounts.get(key) || 0;
seenCounts.set(key, nth + 1);
const refId = `e${refCounter++}`;
refs.set(refId, { role: normalizedRole, name: normalizedName, nth });
}
}
}
return refs;
}
async function getAriaSnapshot(page) {
if (!page || page.isClosed()) {
return null;
}
await waitForPageReady(page, { waitForNetwork: false });
try {
return await page.locator('body').ariaSnapshot({ timeout: 5000 });
} catch (err) {
log('warn', 'getAriaSnapshot failed', { error: err.message });
return null;
}
}
function refToLocator(page, ref, refs) {
const info = refs.get(ref);
if (!info) return null;
const { role, name, nth } = info;
let locator = page.getByRole(role, name ? { name } : undefined);
// Always use .nth() to disambiguate duplicate role+name combinations
// This avoids "strict mode violation" when multiple elements match
locator = locator.nth(nth);
return locator;
}
// --- YouTube transcript ---
// Implementation extracted to lib/youtube.js to avoid scanner false positives
// (child_process + app.post in same file triggers OpenClaw skill-scanner)
detectYtDlp(log);
app.post('/youtube/transcript', async (req, res) => {
const reqId = req.reqId;
try {
const { url, languages = ['en'] } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' });
const urlErr = validateUrl(url);
if (urlErr) return res.status(400).json({ error: urlErr });
const videoIdMatch = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
);
if (!videoIdMatch) {
return res.status(400).json({ error: 'Could not extract YouTube video ID from URL' });
}
const videoId = videoIdMatch[1];
const lang = languages[0] || 'en';
log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser' });
let result;
if (hasYtDlp()) {
result = await ytDlpTranscript(reqId, url, videoId, lang);
} else {
result = await browserTranscript(reqId, url, videoId, lang);
}
log('info', 'youtube transcript: done', { reqId, videoId, status: result.status, words: result.total_words });
res.json(result);
} catch (err) {
log('error', 'youtube transcript failed', { reqId, error: err.message, stack: err.stack });
res.status(500).json({ error: safeError(err) });
}
});
// Browser fallback — play video, intercept timedtext network response
async function browserTranscript(reqId, url, videoId, lang) {
return await withUserLimit('__yt_transcript__', async () => {
await ensureBrowser();
const session = await getSession('__yt_transcript__');
const page = await session.context.newPage();
try {
await page.addInitScript(() => {
const origPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() { this.volume = 0; this.muted = true; return origPlay.call(this); };
});
let interceptedCaptions = null;
page.on('response', async (response) => {
const respUrl = response.url();
if (respUrl.includes('/api/timedtext') && respUrl.includes(`v=${videoId}`) && !interceptedCaptions) {
try {
const body = await response.text();
if (body && body.length > 0) interceptedCaptions = body;
} catch {}
}
});
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
await page.waitForTimeout(2000);
const meta = await page.evaluate(() => {
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
if (!r) return { title: '' };
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
return {
title: r?.videoDetails?.title || '',
languages: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual' })),
};
});
await page.evaluate(() => {
const v = document.querySelector('video');
if (v) { v.muted = true; v.play().catch(() => {}); }
}).catch(() => {});
for (let i = 0; i < 40 && !interceptedCaptions; i++) {
await page.waitForTimeout(500);
}
if (!interceptedCaptions) {
return {
status: 'error', code: 404,
message: 'No captions loaded during playback (video may have no captions, or ad blocked it)',
video_url: url, video_id: videoId, title: meta.title,
};
}
log('info', 'youtube transcript: intercepted captions', { reqId, len: interceptedCaptions.length });
let transcriptText = null;
if (interceptedCaptions.trimStart().startsWith('{')) transcriptText = parseJson3(interceptedCaptions);
else if (interceptedCaptions.includes('WEBVTT')) transcriptText = parseVtt(interceptedCaptions);
else if (interceptedCaptions.includes('<text')) transcriptText = parseXml(interceptedCaptions);
if (!transcriptText || !transcriptText.trim()) {
return {
status: 'error', code: 404,
message: 'Caption data intercepted but could not be parsed',
video_url: url, video_id: videoId, title: meta.title,
};
}
return {
status: 'ok', transcript: transcriptText,
video_url: url, video_id: videoId, video_title: meta.title,
language: lang, total_words: transcriptText.split(/\s+/).length,
available_languages: meta.languages,
};
} finally {
await safePageClose(page);
}
});
}
app.get('/health', (req, res) => {
if (healthState.isRecovering) {
return res.status(503).json({ ok: false, engine: 'camoufox', recovering: true });
}
const running = browser !== null && (browser.isConnected?.() ?? false);
res.json({
ok: true,
engine: 'camoufox',
browserConnected: running,
browserRunning: running,
activeTabs: getTotalTabCount(),
consecutiveFailures: healthState.consecutiveNavFailures,
});
});
// Create new tab
app.post('/tabs', async (req, res) => {
try {
const { userId, sessionKey, listItemId, url } = req.body;
// Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility
const resolvedSessionKey = sessionKey || listItemId;
if (!userId || !resolvedSessionKey) {
return res.status(400).json({ error: 'userId and sessionKey required' });
}
const session = await getSession(userId);
let totalTabs = 0;
for (const group of session.tabGroups.values()) totalTabs += group.size;
if (totalTabs >= MAX_TABS_PER_SESSION) {
return res.status(429).json({ error: 'Maximum tabs per session reached' });
}
const group = getTabGroup(session, resolvedSessionKey);
const page = await session.context.newPage();
const tabId = crypto.randomUUID();
const tabState = createTabState(page);
group.set(tabId, tabState);
if (url) {
const urlErr = validateUrl(url);
if (urlErr) return res.status(400).json({ error: urlErr });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
tabState.visitedUrls.add(url);
}
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
res.json({ tabId, url: page.url() });
} catch (err) {
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
res.status(500).json({ error: safeError(err) });
}
});
// Navigate
app.post('/tabs/:tabId/navigate', async (req, res) => {
const tabId = req.params.tabId;
try {
const { userId, url, macro, query, sessionKey, listItemId } = req.body;
if (!userId) return res.status(400).json({ error: 'userId required' });
const result = await withUserLimit(userId, () => withTimeout((async () => {
await ensureBrowser();
let session = sessions.get(normalizeUserId(userId));
let found = session && findTab(session, tabId);
let tabState;
if (!found) {
const resolvedSessionKey = sessionKey || listItemId || 'default';
session = await getSession(userId);
let sessionTabs = 0;
for (const g of session.tabGroups.values()) sessionTabs += g.size;
if (getTotalTabCount() >= MAX_TABS_GLOBAL || sessionTabs >= MAX_TABS_PER_SESSION) {
// Reuse oldest tab in session instead of rejecting
let oldestTab = null;
let oldestGroup = null;
let oldestTabId = null;
for (const [gKey, group] of session.tabGroups) {
for (const [tid, ts] of group) {
if (!oldestTab || ts.toolCalls < oldestTab.toolCalls) {
oldestTab = ts;
oldestGroup = group;
oldestTabId = tid;
}
}
}
if (oldestTab) {
tabState = oldestTab;
const group = getTabGroup(session, resolvedSessionKey);
if (oldestGroup) oldestGroup.delete(oldestTabId);
group.set(tabId, tabState);
tabLocks.delete(oldestTabId);
log('info', 'tab recycled (limit reached)', { reqId: req.reqId, tabId, recycledFrom: oldestTabId, userId });
} else {
throw new Error('Maximum tabs per session reached');
}
} else {
const page = await session.context.newPage();
tabState = createTabState(page);
const group = getTabGroup(session, resolvedSessionKey);
group.set(tabId, tabState);
log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
}
} else {
tabState = found.tabState;
}
tabState.toolCalls++;
let targetUrl = url;
if (macro) {
targetUrl = expandMacro(macro, query) || url;
}
if (!targetUrl) throw new Error('url or macro required');
const urlErr = validateUrl(targetUrl);
if (urlErr) throw new Error(urlErr);
return await withTabLock(tabId, async () => {
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
tabState.visitedUrls.add(targetUrl);
tabState.lastSnapshot = null;
tabState.refs = await buildRefs(tabState.page);
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
});
})(), HANDLER_TIMEOUT_MS, 'navigate'));
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
res.json(result);
} catch (err) {
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
const status = err.message && err.message.startsWith('Blocked URL scheme') ? 400 : 500;
res.status(status).json({ error: safeError(err) });
}
});
// Snapshot
app.get('/tabs/:tabId/snapshot', async (req, res) => {
try {
const userId = req.query.userId;
if (!userId) return res.status(400).json({ error: 'userId required' });
const format = req.query.format || 'text';
const offset = parseInt(req.query.offset) || 0;
const session = sessions.get(normalizeUserId(userId));
const found = session && findTab(session, req.params.tabId);
if (!found) return res.status(404).json({ error: 'Tab not found' });
const { tabState } = found;
tabState.toolCalls++;
// Cached chunk retrieval for offset>0 requests
if (offset > 0 && tabState.lastSnapshot) {
const win = windowSnapshot(tabState.lastSnapshot, offset);
const response = { url: tabState.page.url(), snapshot: win.text, refsCount: tabState.refs.size, truncated: win.truncated, totalChars: win.totalChars, hasMore: win.hasMore, nextOffset: win.nextOffset };
if (req.query.includeScreenshot === 'true') {
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
}
log('info', 'snapshot (cached offset)', { reqId: req.reqId, tabId: req.params.tabId, offset, totalChars: win.totalChars });
return res.json(response);
}
const result = await withUserLimit(userId, () => withTimeout((async () => {
tabState.refs = await buildRefs(tabState.page);
const ariaYaml = await getAriaSnapshot(tabState.page);
let annotatedYaml = ariaYaml || '';
if (annotatedYaml && tabState.refs.size > 0) {
const refsByKey = new Map();
for (const [refId, info] of tabState.refs) {
const key = `${info.role}:${info.name}:${info.nth}`;
refsByKey.set(key, refId);
}
const annotationCounts = new Map();
const lines = annotatedYaml.split('\n');
annotatedYaml = lines.map(line => {
const match = line.match(/^(\s*-\s+)(\w+)(\s+"([^"]*)")?(.*)$/);
if (match) {
const [, prefix, role, nameMatch, name, suffix] = match;
const normalizedRole = role.toLowerCase();
if (normalizedRole === 'combobox') return line;