@askjo/camofox-browser 1.5.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/Dockerfile +17 -2
  2. package/README.md +138 -8
  3. package/camofox.config.json +18 -0
  4. package/lib/auth.js +71 -0
  5. package/lib/config.js +27 -1
  6. package/lib/cookies.js +38 -1
  7. package/lib/downloads.js +10 -2
  8. package/lib/extract.js +74 -0
  9. package/lib/inflight.js +16 -0
  10. package/lib/metrics.js +29 -0
  11. package/lib/openapi.js +100 -0
  12. package/lib/persistence.js +89 -0
  13. package/lib/plugins.js +175 -0
  14. package/lib/reporter.js +751 -0
  15. package/lib/tmp-cleanup.js +40 -0
  16. package/lib/tracing.js +137 -0
  17. package/openclaw.plugin.json +1 -1
  18. package/package.json +8 -2
  19. package/plugins/persistence/AGENTS.md +37 -0
  20. package/plugins/persistence/README.md +48 -0
  21. package/plugins/persistence/index.js +124 -0
  22. package/plugins/persistence/persistence.test.js +117 -0
  23. package/plugins/persistence/plugin.test.js +98 -0
  24. package/plugins/vnc/AGENTS.md +42 -0
  25. package/plugins/vnc/README.md +165 -0
  26. package/plugins/vnc/apt.txt +7 -0
  27. package/plugins/vnc/index.js +142 -0
  28. package/plugins/vnc/spawn.js +8 -0
  29. package/plugins/vnc/vnc-launcher.js +64 -0
  30. package/plugins/vnc/vnc-watcher.sh +82 -0
  31. package/plugins/vnc/vnc.test.js +204 -0
  32. package/plugins/youtube/AGENTS.md +25 -0
  33. package/plugins/youtube/apt.txt +1 -0
  34. package/plugins/youtube/index.js +206 -0
  35. package/plugins/youtube/post-install.sh +5 -0
  36. package/plugins/youtube/youtube.test.js +41 -0
  37. package/scripts/exec.js +8 -0
  38. package/scripts/generate-openapi.js +24 -0
  39. package/scripts/install-plugin-deps.sh +63 -0
  40. package/scripts/plugin.js +342 -0
  41. package/scripts/plugin.test.js +117 -0
  42. package/server.js +2124 -355
  43. /package/{lib → plugins/youtube}/youtube.js +0 -0
@@ -0,0 +1,751 @@
1
+ // lib/reporter.js — Crash/hang reporter for camofox-browser
2
+ // Files GitHub issues with paranoid anonymization. No env reads here.
3
+ // Config passed via createReporter(config) from lib/config.js.
4
+
5
+ import crypto from 'crypto';
6
+
7
+ // ============================================================================
8
+ // Anonymization
9
+ // ============================================================================
10
+
11
+ const SAFE_HOSTS = new Set([
12
+ 'github.com', 'api.github.com', 'npmjs.com', 'registry.npmjs.org',
13
+ 'nodejs.org', 'localhost', '127.0.0.1', '::1',
14
+ ]);
15
+
16
+ const SECRET_PREFIXES = [
17
+ 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_',
18
+ 'sk-', 'sk_live_', 'sk_test_', 'pk_live_', 'pk_test_',
19
+ 'AKIA', 'ASIA',
20
+ 'xox', 'Bearer ', 'Basic ',
21
+ 'eyJ',
22
+ ];
23
+
24
+ /**
25
+ * Paranoid anonymization of arbitrary text (stack traces, error messages, etc.)
26
+ * Better to over-strip than leak. Order matters — more specific patterns first.
27
+ */
28
+ export function anonymize(text) {
29
+ if (!text || typeof text !== 'string') return text || '';
30
+
31
+ let s = text;
32
+
33
+ // 1. Strip known secret-prefixed tokens
34
+ for (const prefix of SECRET_PREFIXES) {
35
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ s = s.replace(new RegExp(escaped + '[A-Za-z0-9_\\-\\.=+/]{8,}', 'g'), '<token>');
37
+ }
38
+
39
+ // 2. Strip Bearer/Basic auth headers
40
+ s = s.replace(/(?:Bearer|Basic)\s+[A-Za-z0-9_\-\.=+/]{8,}/gi, '<token>');
41
+
42
+ // 3. Strip proxy URLs with credentials (before email — email regex eats user:pass@host)
43
+ s = s.replace(/(?:https?|socks[45]?):\/\/[^:]+:[^@]+@[^\s]+/gi, '<proxy-url>');
44
+
45
+ // 4. Strip email addresses
46
+ s = s.replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '<email>');
47
+
48
+ // 5. Strip full URLs (preserve scheme for context)
49
+ s = s.replace(/(https?|wss?|ftp):\/\/[^\s'",)}\]>]+/g, (match, scheme) => {
50
+ try {
51
+ const u = new URL(match);
52
+ if (SAFE_HOSTS.has(u.hostname)) return match;
53
+ } catch { /* not a valid URL, strip it */ }
54
+ return `<${scheme}-url>`;
55
+ });
56
+
57
+ // 6. Strip absolute file paths (Unix + Windows), preserve last filename
58
+ s = s.replace(
59
+ /(?:\/(?:Users|home|root|tmp|var|opt|data|app|srv|etc|mnt|run|snap|proc)\/[^\s:;,'")\]}]+|[A-Z]:\\(?:Users|Documents and Settings)\\[^\s:;,'")\]}]+)/g,
60
+ (match) => {
61
+ const parts = match.replace(/\\/g, '/').split('/');
62
+ const filename = parts[parts.length - 1] || parts[parts.length - 2] || 'unknown';
63
+ return `<path>/${filename}`;
64
+ }
65
+ );
66
+
67
+ // 7. Strip IPv4 addresses (except localhost)
68
+ s = s.replace(/\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g, (match) => {
69
+ if (match === '127.0.0.1') return match;
70
+ return '<ip>';
71
+ });
72
+
73
+ // 8. Strip IPv6 addresses (except ::1)
74
+ s = s.replace(/\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b/g, (match) => {
75
+ if (match === '::1') return match;
76
+ return '<ipv6>';
77
+ });
78
+ s = s.replace(/::(?:ffff:)?(?:\d{1,3}\.){3}\d{1,3}/g, '<ipv6>');
79
+
80
+ // 9. Strip hostnames in connection errors
81
+ s = s.replace(
82
+ /(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EHOSTUNREACH)\s+([a-zA-Z0-9.\-]+):(\d+)/g,
83
+ (match, host, port) => {
84
+ if (SAFE_HOSTS.has(host)) return match;
85
+ return match.replace(host, '<host>');
86
+ }
87
+ );
88
+
89
+ // 10. Strip Fly machine IDs (14-char hex), Docker container IDs (12+ hex)
90
+ s = s.replace(/\b[0-9a-f]{12,64}\b/g, '<id>');
91
+
92
+ // 11. Strip jo-* app names
93
+ s = s.replace(/\bjo-(?:machine|browser|whatsapp|discord|bot)[a-z0-9\-]*/gi, '<app>');
94
+
95
+ // 12. Strip environment variable assignments
96
+ s = s.replace(/\b[A-Z][A-Z0-9_]{3,}=[^\s]{4,}/g, '<env-var>');
97
+
98
+ // 13. Strip long alphanumeric strings (40+ chars)
99
+ s = s.replace(/[A-Za-z0-9_\-]{40,}/g, '<redacted>');
100
+
101
+ // 14. Strip base64 blobs (20+ chars with mixed case)
102
+ s = s.replace(/[A-Za-z0-9+/]{20,}={0,3}/g, (match) => {
103
+ if (/[a-z]/.test(match) && /[A-Z]/.test(match)) return '<redacted>';
104
+ return match;
105
+ });
106
+
107
+ return s;
108
+ }
109
+
110
+ /**
111
+ * Generate a stable signature for dedup. Uses error name + first meaningful
112
+ * stack frame (file:line, not column — columns shift with minor edits).
113
+ */
114
+ export function stackSignature(type, error) {
115
+ const name = error?.name || error?.code || 'unknown';
116
+ const message = error?.message || String(error || '');
117
+
118
+ const stack = error?.stack || '';
119
+ const frames = stack.split('\n').slice(1);
120
+ let keyFrame = '';
121
+ for (const frame of frames) {
122
+ const trimmed = frame.trim();
123
+ if (trimmed.startsWith('at ') && !trimmed.includes('node_modules') && !trimmed.includes('node:internal')) {
124
+ const fileMatch = trimmed.match(/\(([^)]+)\)/) || trimmed.match(/at\s+(.+)$/);
125
+ if (fileMatch) {
126
+ const loc = fileMatch[1];
127
+ const parts = loc.replace(/\\/g, '/').split('/');
128
+ const last = parts[parts.length - 1];
129
+ const [file, line] = last.split(':');
130
+ keyFrame = `${file}:${line || '?'}`;
131
+ break;
132
+ }
133
+ }
134
+ }
135
+
136
+ const raw = `${type}|${name}|${keyFrame || anonymize(message).slice(0, 80)}`;
137
+ return fnv1a(raw);
138
+ }
139
+
140
+ /** FNV-1a hash → 8-char hex. Stable bucketing, not crypto. */
141
+ function fnv1a(str) {
142
+ let hash = 0x811c9dc5;
143
+ for (let i = 0; i < str.length; i++) {
144
+ hash ^= str.charCodeAt(i);
145
+ hash = (hash * 0x01000193) >>> 0;
146
+ }
147
+ return hash.toString(16).padStart(8, '0');
148
+ }
149
+
150
+ // ============================================================================
151
+ // URL anonymization (per-report salted HMAC for private domains)
152
+ // ============================================================================
153
+
154
+ // Public domains safe to show verbatim in reports.
155
+ // These are public knowledge — showing "amazon.com" in a crash report is not PII.
156
+ // Matched by suffix. NEVER add multi-tenant hosting (herokuapp.com, vercel.app, etc.)
157
+ const PUBLIC_DOMAINS = [
158
+ // CDN & edge
159
+ 'cloudflare.com', 'cloudflare-dns.com', 'cloudflareinsights.com',
160
+ 'fastly.net', 'fastlylb.net',
161
+ 'akamaized.net', 'akamai.net', 'cloudfront.net',
162
+ 'cdn.jsdelivr.net', 'unpkg.com', 'cdnjs.com',
163
+ // Google
164
+ 'google.com', 'googleapis.com', 'gstatic.com',
165
+ 'googleusercontent.com', 'google-analytics.com', 'googletagmanager.com',
166
+ 'googlesyndication.com', 'doubleclick.net', 'youtube.com', 'ytimg.com',
167
+ 'recaptcha.net',
168
+ // Microsoft
169
+ 'microsoft.com', 'msecnd.net', 'azureedge.net', 'bing.com', 'live.com',
170
+ 'outlook.com', 'office.com', 'linkedin.com',
171
+ // Meta
172
+ 'facebook.com', 'facebook.net', 'fbcdn.net', 'instagram.com', 'threads.net',
173
+ 'whatsapp.com',
174
+ // X/Twitter
175
+ 'twitter.com', 'x.com', 'twimg.com',
176
+ // GitHub
177
+ 'github.com', 'githubusercontent.com', 'githubassets.com',
178
+ // Major sites (common anti-bot / frustration sources)
179
+ 'amazon.com', 'amazon.co.uk', 'amazon.de', 'amazon.co.jp',
180
+ 'reddit.com', 'redd.it',
181
+ 'apple.com', 'icloud.com',
182
+ 'netflix.com', 'spotify.com', 'discord.com', 'discord.gg',
183
+ 'tiktok.com', 'pinterest.com', 'tumblr.com',
184
+ 'stackoverflow.com', 'stackexchange.com',
185
+ 'medium.com', 'substack.com',
186
+ 'nytimes.com', 'washingtonpost.com', 'bbc.co.uk', 'bbc.com', 'cnn.com',
187
+ 'ebay.com', 'etsy.com', 'walmart.com', 'target.com', 'shopify.com',
188
+ 'stripe.com', 'paypal.com',
189
+ 'twitch.tv', 'vimeo.com', 'dailymotion.com',
190
+ 'yahoo.com', 'duckduckgo.com', 'baidu.com',
191
+ 'zoom.us', 'slack.com', 'notion.so', 'figma.com',
192
+ 'dropbox.com', 'box.com',
193
+ 'archive.org', 'web.archive.org',
194
+ // Prediction markets & crypto (heavy anti-bot, commonly scraped)
195
+ 'polymarket.com', 'kalshi.com', 'metaculus.com', 'manifold.markets',
196
+ 'predictit.org', 'augur.net',
197
+ 'coinbase.com', 'binance.com', 'kraken.com', 'gemini.com',
198
+ 'coingecko.com', 'coinmarketcap.com',
199
+ 'opensea.io', 'blur.io', 'rarible.com',
200
+ 'etherscan.io', 'solscan.io', 'blockchair.com',
201
+ 'uniswap.org', 'dexscreener.com', 'dextools.io',
202
+ // Data / scraping targets (aggressive anti-bot)
203
+ 'zillow.com', 'realtor.com', 'redfin.com', 'trulia.com',
204
+ 'indeed.com', 'glassdoor.com', 'lever.co', 'greenhouse.io',
205
+ 'airbnb.com', 'booking.com', 'expedia.com', 'tripadvisor.com',
206
+ 'yelp.com', 'trustpilot.com',
207
+ 'craigslist.org', 'nextdoor.com',
208
+ 'ticketmaster.com', 'stubhub.com', 'seatgeek.com',
209
+ // Finance / trading
210
+ 'tradingview.com', 'investing.com', 'seekingalpha.com',
211
+ 'finance.yahoo.com', 'bloomberg.com', 'reuters.com', 'wsj.com',
212
+ 'robinhood.com', 'schwab.com', 'fidelity.com', 'etrade.com',
213
+ // AI / developer tools
214
+ 'openai.com', 'anthropic.com', 'huggingface.co',
215
+ 'vercel.com', 'netlify.com', 'render.com', 'fly.io',
216
+ 'npmjs.com', 'pypi.org', 'crates.io', 'pkg.go.dev',
217
+ // Social / forums
218
+ 'quora.com', 'hackernews.com', 'news.ycombinator.com',
219
+ 'producthunt.com', 'indiehackers.com',
220
+ // Reference
221
+ 'wikipedia.org', 'wikimedia.org', 'mozilla.org', 'mozilla.net',
222
+ // Anti-bot / CAPTCHA
223
+ 'hcaptcha.com',
224
+ // Fonts
225
+ 'typekit.net', 'fontawesome.com',
226
+ ].sort((a, b) => b.length - a.length); // longest-suffix-first
227
+
228
+ // Stable key for domain hashing — NOT a secret, just ensures consistent hashes
229
+ // across reports so we can correlate "site-a1b2c3d4 caused 12 hangs this week".
230
+ const DOMAIN_HASH_KEY = 'camofox-domain-hash-v1';
231
+
232
+ /**
233
+ * Create a URL anonymizer.
234
+ * Public domains shown verbatim. Private domains get a stable hash
235
+ * (same domain → same hash across all reports, enabling correlation).
236
+ */
237
+ export function createUrlAnonymizer() {
238
+
239
+ function isPublicDomain(hostname) {
240
+ for (const d of PUBLIC_DOMAINS) {
241
+ if (hostname === d || hostname.endsWith('.' + d)) return true;
242
+ }
243
+ return false;
244
+ }
245
+
246
+ function hashHost(hostname) {
247
+ return 'site-' + crypto.createHmac('sha256', DOMAIN_HASH_KEY).update(hostname).digest('hex').slice(0, 8);
248
+ }
249
+
250
+ /**
251
+ * Anonymize a URL. Preserves: scheme, public infra hostnames, path depth,
252
+ * query param count, fragment presence. Strips everything else.
253
+ *
254
+ * Examples:
255
+ * https://challenges.cloudflare.com/•/•/•
256
+ * https://site-a1b2c3d4:8443/•/• ?[3] #[frag]
257
+ */
258
+ function anonymizeUrl(rawUrl) {
259
+ if (!rawUrl || typeof rawUrl !== 'string') return '[empty]';
260
+ if (rawUrl.startsWith('data:')) return '[data-uri]';
261
+ if (rawUrl.startsWith('blob:')) return '[blob-uri]';
262
+ if (rawUrl.startsWith('about:')) return rawUrl;
263
+ if (rawUrl.startsWith('javascript:')) return '[javascript-uri]';
264
+
265
+ let url;
266
+ try { url = new URL(rawUrl); } catch { return '[invalid-url]'; }
267
+
268
+ const parts = [url.protocol + '//'];
269
+ const h = url.hostname.toLowerCase();
270
+
271
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1') {
272
+ parts.push('localhost');
273
+ } else if (/^(\d{1,3}\.){3}\d{1,3}$/.test(h) || h.includes(':')) {
274
+ parts.push(hashHost(h));
275
+ } else if (isPublicDomain(h)) {
276
+ parts.push(h);
277
+ } else {
278
+ parts.push(hashHost(h));
279
+ }
280
+
281
+ if (url.port) parts.push(':' + url.port);
282
+
283
+ const segs = url.pathname.split('/').filter(Boolean);
284
+ parts.push(segs.length > 0 ? '/' + segs.map(() => '\u2022').join('/') : '/');
285
+
286
+ const paramCount = [...url.searchParams].length;
287
+ if (paramCount > 0) parts.push(` ?[${paramCount}]`);
288
+ if (url.hash && url.hash.length > 1) parts.push(' #[frag]');
289
+
290
+ return parts.join('');
291
+ }
292
+
293
+ function anonymizeChain(urls) {
294
+ if (!Array.isArray(urls) || urls.length === 0) return '[empty-chain]';
295
+ return urls.map(u => anonymizeUrl(u)).join(' \u2192 ');
296
+ }
297
+
298
+ return { anonymizeUrl, anonymizeChain };
299
+ }
300
+
301
+ // ============================================================================
302
+ // Per-tab health tracker (count-only, no content)
303
+ // ============================================================================
304
+
305
+ /**
306
+ * Create a health tracker for a tab. Attaches to Playwright page events.
307
+ * Tracks: crashes, page errors, console errors, request failures,
308
+ * dialog storms, redirect depth, HTTP status histogram, frame count.
309
+ * All count-based — no URLs or content stored.
310
+ */
311
+ export function createTabHealthTracker(page) {
312
+ const health = {
313
+ crashes: 0,
314
+ pageErrors: 0,
315
+ consoleErrors: 0,
316
+ requestFailures: 0,
317
+ dialogCount: 0,
318
+ maxRedirectDepth: 0,
319
+ statusCounts: {}, // { 403: 5, 429: 2, ... }
320
+ frameCount: 0,
321
+ _redirectDepth: 0,
322
+ };
323
+
324
+ // Renderer crash (OOM, segfault)
325
+ page.on('crash', () => { health.crashes++; });
326
+
327
+ // Uncaught JS exceptions on the page
328
+ page.on('pageerror', () => { health.pageErrors++; });
329
+
330
+ // Console errors (rate, not content)
331
+ page.on('console', (msg) => {
332
+ if (msg.type() === 'error') health.consoleErrors++;
333
+ });
334
+
335
+ // Failed requests (blocked, DNS failure, etc.)
336
+ page.on('requestfailed', () => { health.requestFailures++; });
337
+
338
+ // HTTP status tracking (non-2xx only)
339
+ page.on('response', (resp) => {
340
+ const s = resp.status();
341
+ if (s >= 400) health.statusCounts[s] = (health.statusCounts[s] || 0) + 1;
342
+ });
343
+
344
+ // Dialog tracking (alert/confirm/prompt storms)
345
+ page.on('dialog', async (dialog) => {
346
+ health.dialogCount++;
347
+ try { await dialog.dismiss(); } catch { /* page might be closed */ }
348
+ });
349
+
350
+ // Redirect depth per navigation
351
+ page.on('request', (req) => {
352
+ if (req.isNavigationRequest()) {
353
+ if (req.redirectedFrom()) {
354
+ health._redirectDepth++;
355
+ if (health._redirectDepth > health.maxRedirectDepth) {
356
+ health.maxRedirectDepth = health._redirectDepth;
357
+ }
358
+ } else {
359
+ health._redirectDepth = 0; // new navigation, reset
360
+ }
361
+ }
362
+ });
363
+
364
+ /** Snapshot current health counters for inclusion in reports. */
365
+ function snapshot() {
366
+ try { health.frameCount = page.frames().length; } catch { /* closed */ }
367
+ const { _redirectDepth, ...clean } = health;
368
+ return { ...clean };
369
+ }
370
+
371
+ return { health, snapshot };
372
+ }
373
+
374
+ // ============================================================================
375
+ // Rate limiter (sliding window, 1 hour)
376
+ // ============================================================================
377
+
378
+ class RateLimiter {
379
+ constructor(maxPerHour) {
380
+ this.maxPerHour = maxPerHour;
381
+ this.timestamps = [];
382
+ }
383
+
384
+ tryAcquire() {
385
+ const now = Date.now();
386
+ this.timestamps = this.timestamps.filter(t => t > now - 3600_000);
387
+ if (this.timestamps.length >= this.maxPerHour) return false;
388
+ this.timestamps.push(now);
389
+ return true;
390
+ }
391
+ }
392
+
393
+ // ============================================================================
394
+ // GitHub App auth (embedded credentials, short-lived installation tokens)
395
+ // ============================================================================
396
+
397
+ // Credentials loaded at createReporter() time from camofox.config.json crashReporter section.
398
+ // Split base64 key halves avoid GitHub push protection auto-revocation.
399
+ let _GH_APP_ID = null;
400
+ let _GH_INSTALL_ID = null;
401
+ let _GH_USER_AGENT = 'camofox-crash-reporter';
402
+ let _K_A = null;
403
+ let _K_B = null;
404
+
405
+ function _getAppKey() {
406
+ if (!_K_A || !_K_B) return null;
407
+ return Buffer.from(_K_A + _K_B, 'base64').toString('utf8');
408
+ }
409
+
410
+ /** Sign a JWT for GitHub App authentication (10-min expiry). */
411
+ function _signAppJwt() {
412
+ const key = _getAppKey();
413
+ if (!key || !_GH_APP_ID) return null;
414
+ const header = { alg: 'RS256', typ: 'JWT' };
415
+ const now = Math.floor(Date.now() / 1000);
416
+ const payload = { iss: _GH_APP_ID, iat: now - 60, exp: now + 600 };
417
+
418
+ const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
419
+ const unsigned = b64(header) + '.' + b64(payload);
420
+ const signature = crypto.sign('RSA-SHA256', Buffer.from(unsigned), key);
421
+ return unsigned + '.' + signature.toString('base64url');
422
+ }
423
+
424
+ // Cached installation token (1-hour TTL from GitHub, we refresh at 50 min)
425
+ let _cachedToken = null;
426
+ let _tokenExpiresAt = 0;
427
+
428
+ async function _getInstallationToken() {
429
+ if (!_GH_INSTALL_ID) return null;
430
+ if (_cachedToken && Date.now() < _tokenExpiresAt) return _cachedToken;
431
+
432
+ const jwt = _signAppJwt();
433
+ if (!jwt) return null;
434
+ const resp = await fetchWithTimeout(
435
+ `${GITHUB_API}/app/installations/${_GH_INSTALL_ID}/access_tokens`,
436
+ {
437
+ method: 'POST',
438
+ headers: {
439
+ 'Authorization': `Bearer ${jwt}`,
440
+ 'Accept': 'application/vnd.github+json',
441
+ 'User-Agent': _GH_USER_AGENT,
442
+ },
443
+ },
444
+ );
445
+ if (!resp.ok) return null;
446
+ const data = await resp.json();
447
+ _cachedToken = data.token;
448
+ // Refresh 10 min before actual expiry
449
+ _tokenExpiresAt = Date.now() + 50 * 60 * 1000;
450
+ return _cachedToken;
451
+ }
452
+
453
+ const FETCH_TIMEOUT_MS = 5000;
454
+ const GITHUB_API = 'https://api.github.com';
455
+
456
+ async function fetchWithTimeout(url, options) {
457
+ const controller = new AbortController();
458
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
459
+ try {
460
+ return await fetch(url, { ...options, signal: controller.signal });
461
+ } finally {
462
+ clearTimeout(timer);
463
+ }
464
+ }
465
+
466
+ async function _ghHeaders() {
467
+ const token = await _getInstallationToken();
468
+ if (!token) return null;
469
+ return {
470
+ 'Authorization': `token ${token}`,
471
+ 'Accept': 'application/vnd.github+json',
472
+ 'User-Agent': _GH_USER_AGENT,
473
+ };
474
+ }
475
+
476
+ async function findExistingIssue(repo, signature) {
477
+ const headers = await _ghHeaders();
478
+ if (!headers) return null;
479
+ const query = encodeURIComponent(`repo:${repo} is:issue is:open "[${signature}]" in:title`);
480
+ const resp = await fetchWithTimeout(`${GITHUB_API}/search/issues?q=${query}&per_page=1`, { headers });
481
+ if (!resp.ok) return null;
482
+ const data = await resp.json();
483
+ if (data.items?.length > 0) {
484
+ return { issueNumber: data.items[0].number, issueUrl: data.items[0].html_url };
485
+ }
486
+ return null;
487
+ }
488
+
489
+ async function commentOnIssue(repo, issueNumber, body) {
490
+ const headers = await _ghHeaders();
491
+ if (!headers) return false;
492
+ const resp = await fetchWithTimeout(`${GITHUB_API}/repos/${repo}/issues/${issueNumber}/comments`, {
493
+ method: 'POST',
494
+ headers: { ...headers, 'Content-Type': 'application/json' },
495
+ body: JSON.stringify({ body }),
496
+ });
497
+ return resp.ok;
498
+ }
499
+
500
+ async function createIssue(repo, title, body, labels) {
501
+ const headers = await _ghHeaders();
502
+ if (!headers) return null;
503
+ const resp = await fetchWithTimeout(`${GITHUB_API}/repos/${repo}/issues`, {
504
+ method: 'POST',
505
+ headers: { ...headers, 'Content-Type': 'application/json' },
506
+ body: JSON.stringify({ title, body, labels }),
507
+ });
508
+ if (!resp.ok) return null;
509
+ const data = await resp.json();
510
+ return data.html_url || null;
511
+ }
512
+
513
+ // ============================================================================
514
+ // Issue formatting
515
+ // ============================================================================
516
+
517
+ function formatIssueBody(type, detail) {
518
+ const sections = [
519
+ '> Auto-reported by ' + _GH_USER_AGENT + '. All data is anonymized.',
520
+ '',
521
+ `**Type:** ${type}`,
522
+ `**Version:** ${detail.version || 'unknown'}`,
523
+ `**Node:** ${detail.nodeVersion || 'unknown'}`,
524
+ `**Platform:** ${detail.platform || 'unknown'}`,
525
+ `**Uptime:** ${detail.uptimeMinutes != null ? detail.uptimeMinutes + ' min' : 'unknown'}`,
526
+ ];
527
+
528
+ if (detail.message) {
529
+ sections.push('', '### Error', '```', anonymize(detail.message), '```');
530
+ }
531
+ if (detail.stack) {
532
+ sections.push('', '### Stack Trace', '```', anonymize(detail.stack), '```');
533
+ }
534
+ if (detail.context) {
535
+ sections.push('', '### Context', '```', anonymize(JSON.stringify(detail.context, null, 2)), '```');
536
+ }
537
+ if (detail.metrics) {
538
+ sections.push('', '### Metrics', '```json', JSON.stringify(detail.metrics, null, 2), '```');
539
+ }
540
+
541
+ return sections.join('\n');
542
+ }
543
+
544
+ function formatCommentBody(type, detail) {
545
+ const ts = new Date().toISOString();
546
+ const lines = [
547
+ `**+1** — ${ts}`,
548
+ `Version: ${detail.version || 'unknown'}, Uptime: ${detail.uptimeMinutes != null ? detail.uptimeMinutes + ' min' : '?'}`,
549
+ ];
550
+ if (detail.message) {
551
+ lines.push('```', anonymize(detail.message).slice(0, 500), '```');
552
+ }
553
+ return lines.join('\n');
554
+ }
555
+
556
+ // ============================================================================
557
+ // Core reporter factory
558
+ // ============================================================================
559
+
560
+ /**
561
+ * Create a reporter instance.
562
+ *
563
+ * @param {object} config
564
+ * @param {boolean} config.crashReportEnabled
565
+ * @param {string} config.crashReportRepo - "owner/repo" (env override)
566
+ * @param {number} config.crashReportRateLimit - max reports per hour
567
+ * @param {object} config.crashReporterConfig - from camofox.config.json crashReporter section
568
+ * @param {string} [config.version] - package version
569
+ */
570
+ export function createReporter(config) {
571
+ const cr = config.crashReporterConfig || {};
572
+
573
+ // Initialize module-level credentials from config file
574
+ _GH_APP_ID = cr.appId || null;
575
+ _GH_INSTALL_ID = cr.installationId || null;
576
+ _K_A = cr.keyA || null;
577
+ _K_B = cr.keyB || null;
578
+ _GH_USER_AGENT = cr.userAgent || 'camofox-crash-reporter';
579
+
580
+ const enabled = config.crashReportEnabled !== false && !!_GH_APP_ID;
581
+ const repo = config.crashReportRepo || cr.repo || 'jo-inc/camofox-browser';
582
+ const rateLimiter = new RateLimiter(config.crashReportRateLimit || 10);
583
+ const version = config.version || 'unknown';
584
+
585
+ let watchdogInterval = null;
586
+ let lastTick = Date.now();
587
+ const inFlight = new Set();
588
+
589
+ // No-op when disabled
590
+ if (!enabled) {
591
+ return {
592
+ reportCrash: async () => {},
593
+ reportHang: async () => {},
594
+ reportStuckLoop: async () => {},
595
+ startWatchdog: () => {},
596
+ stop: () => {},
597
+ _anonymize: anonymize,
598
+ _stackSignature: stackSignature,
599
+ };
600
+ }
601
+
602
+ /** Core: file or deduplicate a report. NEVER throws. */
603
+ async function fileReport(type, label, detail) {
604
+ if (!rateLimiter.tryAcquire()) return;
605
+
606
+ const reportPromise = (async () => {
607
+ try {
608
+ const sig = stackSignature(type, detail.error || { message: detail.message, stack: detail.stack });
609
+ const safeMessage = anonymize(detail.message || detail.error?.message || type);
610
+ const title = `[${sig}] ${type}: ${safeMessage.slice(0, 120)}`;
611
+
612
+ const existing = await findExistingIssue(repo, sig);
613
+ if (existing) {
614
+ await commentOnIssue(repo, existing.issueNumber, formatCommentBody(type, {
615
+ ...detail,
616
+ version,
617
+ nodeVersion: typeof process !== 'undefined' ? process.version : 'unknown',
618
+ platform: typeof process !== 'undefined' ? process.platform : 'unknown',
619
+ }));
620
+ return;
621
+ }
622
+
623
+ const body = formatIssueBody(type, {
624
+ ...detail,
625
+ version,
626
+ nodeVersion: typeof process !== 'undefined' ? process.version : 'unknown',
627
+ platform: typeof process !== 'undefined' ? process.platform : 'unknown',
628
+ });
629
+
630
+ await createIssue(repo, title, body, [label, 'auto-report']);
631
+ } catch {
632
+ // Swallow — reporter must never crash the server
633
+ }
634
+ })();
635
+
636
+ inFlight.add(reportPromise);
637
+ reportPromise.finally(() => inFlight.delete(reportPromise));
638
+ }
639
+
640
+ async function reportCrash(error, opts = {}) {
641
+ const err = error instanceof Error ? error : new Error(String(error));
642
+ const uptimeMinutes = typeof process !== 'undefined'
643
+ ? Math.round(process.uptime() / 60) : undefined;
644
+
645
+ await fileReport(
646
+ opts.signal ? `signal:${opts.signal}` : (err.name || 'crash'),
647
+ 'crash',
648
+ {
649
+ error: err,
650
+ message: err.message,
651
+ stack: err.stack,
652
+ uptimeMinutes,
653
+ context: opts.context,
654
+ },
655
+ );
656
+ }
657
+
658
+ async function reportHang(operation, durationMs, opts = {}) {
659
+ const uptimeMinutes = typeof process !== 'undefined'
660
+ ? Math.round(process.uptime() / 60) : undefined;
661
+
662
+ // Create per-report URL anonymizer (fresh salt each time)
663
+ const urlAnon = createUrlAnonymizer();
664
+ const context = { operation, durationMs, ...opts.context };
665
+
666
+ // Anonymize any URLs in the journal
667
+ if (context.journal) {
668
+ context.journal = context.journal.map(j => {
669
+ if (typeof j === 'string') return j; // already "type:action" format
670
+ return j;
671
+ });
672
+ }
673
+ // Include anonymized URL if provided
674
+ if (opts.url) context.url = urlAnon.anonymizeUrl(opts.url);
675
+ if (opts.redirectChain) context.redirectChain = urlAnon.anonymizeChain(opts.redirectChain);
676
+
677
+ // Include tab health snapshot if provided
678
+ if (opts.healthSnapshot) context.health = opts.healthSnapshot;
679
+
680
+ await fileReport(
681
+ `hang:${operation}`,
682
+ 'hang',
683
+ {
684
+ message: `Operation "${operation}" hung for ${Math.round(durationMs / 1000)}s`,
685
+ stack: opts.error?.stack,
686
+ uptimeMinutes,
687
+ context,
688
+ },
689
+ );
690
+ }
691
+
692
+ async function reportStuckLoop(durationMs, opts = {}) {
693
+ const uptimeMinutes = typeof process !== 'undefined'
694
+ ? Math.round(process.uptime() / 60) : undefined;
695
+
696
+ await fileReport(
697
+ 'stuck:tab-lock',
698
+ 'stuck',
699
+ {
700
+ message: `Tab lock held for ${Math.round(durationMs / 1000)}s (tab destroyed)`,
701
+ uptimeMinutes,
702
+ context: { durationMs, ...opts.context },
703
+ },
704
+ );
705
+ }
706
+
707
+ function startWatchdog(thresholdMs = 5000, getContext) {
708
+ if (watchdogInterval) return;
709
+
710
+ const checkMs = 1000;
711
+ lastTick = Date.now();
712
+
713
+ watchdogInterval = setInterval(() => {
714
+ const now = Date.now();
715
+ const drift = now - lastTick - checkMs;
716
+ lastTick = now;
717
+
718
+ if (drift > thresholdMs) {
719
+ let extra = {};
720
+ try { if (getContext) extra = getContext(); } catch { /* swallow */ }
721
+ fileReport('stuck:event-loop', 'stuck', {
722
+ message: `Event loop stalled for ${Math.round(drift / 1000)}s (threshold: ${Math.round(thresholdMs / 1000)}s)`,
723
+ uptimeMinutes: typeof process !== 'undefined'
724
+ ? Math.round(process.uptime() / 60) : undefined,
725
+ context: { driftMs: drift, thresholdMs, ...extra },
726
+ });
727
+ }
728
+ }, checkMs);
729
+
730
+ if (watchdogInterval.unref) watchdogInterval.unref();
731
+ }
732
+
733
+ function stop() {
734
+ if (watchdogInterval) {
735
+ clearInterval(watchdogInterval);
736
+ watchdogInterval = null;
737
+ }
738
+ return Promise.allSettled([...inFlight]);
739
+ }
740
+
741
+ return {
742
+ reportCrash,
743
+ reportHang,
744
+ reportStuckLoop,
745
+ startWatchdog,
746
+ stop,
747
+ _anonymize: anonymize,
748
+ _stackSignature: stackSignature,
749
+ _rateLimiter: rateLimiter,
750
+ };
751
+ }