@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.
- package/Dockerfile +17 -2
- package/README.md +138 -8
- package/camofox.config.json +18 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +27 -1
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/extract.js +74 -0
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/openapi.js +100 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +175 -0
- package/lib/reporter.js +751 -0
- package/lib/tmp-cleanup.js +40 -0
- package/lib/tracing.js +137 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -2
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +124 -0
- package/plugins/persistence/persistence.test.js +117 -0
- package/plugins/persistence/plugin.test.js +98 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/spawn.js +8 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/vnc/vnc.test.js +204 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.test.js +41 -0
- package/scripts/exec.js +8 -0
- package/scripts/generate-openapi.js +24 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/plugin.test.js +117 -0
- package/server.js +2124 -355
- /package/{lib → plugins/youtube}/youtube.js +0 -0
package/lib/reporter.js
ADDED
|
@@ -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
|
+
}
|