@phi-code-admin/camofox-browser 1.0.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 (56) hide show
  1. package/AGENTS.md +571 -0
  2. package/Dockerfile +86 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -0
  5. package/camofox.config.json +10 -0
  6. package/dist/plugin.js +616 -0
  7. package/lib/auth.js +134 -0
  8. package/lib/camoufox-executable.js +189 -0
  9. package/lib/config.js +153 -0
  10. package/lib/cookies.js +119 -0
  11. package/lib/downloads.js +168 -0
  12. package/lib/extract.js +74 -0
  13. package/lib/fly.js +54 -0
  14. package/lib/images.js +88 -0
  15. package/lib/inflight.js +16 -0
  16. package/lib/launcher.js +47 -0
  17. package/lib/macros.js +31 -0
  18. package/lib/metrics.js +184 -0
  19. package/lib/openapi.js +105 -0
  20. package/lib/persistence.js +89 -0
  21. package/lib/plugins.js +175 -0
  22. package/lib/proxy.js +277 -0
  23. package/lib/reporter.js +1102 -0
  24. package/lib/request-utils.js +59 -0
  25. package/lib/resources.js +76 -0
  26. package/lib/snapshot.js +41 -0
  27. package/lib/tmp-cleanup.js +108 -0
  28. package/lib/tracing.js +137 -0
  29. package/openclaw.plugin.json +268 -0
  30. package/package.json +148 -0
  31. package/plugin.js +616 -0
  32. package/plugin.ts +758 -0
  33. package/plugins/persistence/AGENTS.md +37 -0
  34. package/plugins/persistence/README.md +48 -0
  35. package/plugins/persistence/index.js +124 -0
  36. package/plugins/vnc/AGENTS.md +42 -0
  37. package/plugins/vnc/README.md +165 -0
  38. package/plugins/vnc/apt.txt +7 -0
  39. package/plugins/vnc/index.js +142 -0
  40. package/plugins/vnc/spawn.js +8 -0
  41. package/plugins/vnc/vnc-launcher.js +64 -0
  42. package/plugins/vnc/vnc-watcher.sh +82 -0
  43. package/plugins/youtube/AGENTS.md +25 -0
  44. package/plugins/youtube/apt.txt +1 -0
  45. package/plugins/youtube/index.js +206 -0
  46. package/plugins/youtube/post-install.sh +5 -0
  47. package/plugins/youtube/youtube.js +301 -0
  48. package/run.sh +37 -0
  49. package/scripts/exec.js +8 -0
  50. package/scripts/generate-openapi.js +24 -0
  51. package/scripts/install-plugin-deps.sh +63 -0
  52. package/scripts/plugin.js +342 -0
  53. package/scripts/postinstall.js +20 -0
  54. package/scripts/sync-version.js +25 -0
  55. package/server.js +6059 -0
  56. package/tsconfig.json +12 -0
@@ -0,0 +1,59 @@
1
+ // HTTP request classification helpers -- kept separate from metrics.js
2
+ // Separated from server.js to keep HTTP method classification in its own module.
3
+
4
+ /**
5
+ * Derive a short action name from an Express request for metrics labeling.
6
+ */
7
+ export function actionFromReq(req) {
8
+ const method = req.method;
9
+ const path = req.route?.path || req.path;
10
+ if (path === '/tabs' && method === 'POST') return 'create_tab';
11
+ if (path === '/tabs/:tabId' && method === 'DELETE') return 'delete_tab';
12
+ if (path === '/tabs/group/:listItemId' && method === 'DELETE') return 'delete_tab_group';
13
+ if (path === '/sessions/:userId' && method === 'DELETE') return 'delete_session';
14
+ if (path === '/sessions/:userId/cookies' && method === 'POST') return 'set_cookies';
15
+ if (path === '/tabs/open' && method === 'POST') return 'open_url';
16
+ if (path === '/tabs' && method === 'GET') return 'list_tabs';
17
+ // /tabs/:tabId/<action>
18
+ const m = path.match(/^\/tabs\/:tabId\/(\w+)$/);
19
+ if (m) return m[1]; // navigate, snapshot, click, type, scroll, etc.
20
+ // legacy compat routes
21
+ if (['/start', '/stop', '/navigate', '/snapshot', '/act'].includes(path)) return path.slice(1);
22
+ if (path === '/youtube/transcript') return 'youtube_transcript';
23
+ if (path === '/health') return 'health';
24
+ if (path === '/metrics') return 'metrics';
25
+ return `${method.toLowerCase()}_${path.replace(/[/:]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
26
+ }
27
+
28
+ /**
29
+ * Classify an error into a failure type string for metrics labeling.
30
+ */
31
+ export function classifyError(err) {
32
+ if (!err) return 'unknown';
33
+ const msg = err.message || '';
34
+
35
+ if (err.code === 'stale_refs' || err.name === 'StaleRefsError') return 'stale_refs';
36
+ if (msg === 'Tab lock queue timeout') return 'tab_lock_timeout';
37
+ if (msg === 'Tab destroyed') return 'tab_destroyed';
38
+ if (msg.includes('Target page, context or browser has been closed') ||
39
+ msg.includes('browser has been closed') ||
40
+ msg.includes('Context closed') ||
41
+ msg.includes('Browser closed')) return 'dead_context';
42
+ if (msg.includes('timed out after') ||
43
+ (msg.includes('Timeout') && msg.includes('exceeded'))) return 'timeout';
44
+ if (msg.includes('Maximum concurrent sessions')) return 'session_limit';
45
+ if (msg.includes('Maximum tabs per session') || msg.includes('Maximum global tabs')) return 'tab_limit';
46
+ if (msg.includes('concurrency limit reached')) return 'concurrency_limit';
47
+ if (msg.includes('NS_ERROR_PROXY') || msg.includes('proxy connection') ||
48
+ msg.includes('Proxy connection')) return 'proxy';
49
+ if (msg.includes('Browser launch timeout') || msg.includes('Failed to launch')) return 'browser_launch';
50
+ if (msg.includes('intercepts pointer events')) return 'click_intercepted';
51
+ if (msg.includes('not visible') || msg.includes('not an <input>')) return 'element_error';
52
+ if (msg.includes('Blocked URL scheme') || msg.includes('Invalid URL')) return 'invalid_url';
53
+ if (msg.includes('net::') || msg.includes('ERR_NAME') || msg.includes('ERR_CONNECTION')) return 'network';
54
+ if (msg.includes('Navigation aborted: tab deleted')) return 'tab_destroyed';
55
+ if (msg.includes('NS_ERROR_ABORT')) return 'nav_aborted';
56
+ if (msg.includes('Page crashed')) return 'page_crashed';
57
+ if (msg.includes('Navigation failed') || msg.includes('ERR_ABORTED')) return 'nav_aborted';
58
+ return 'unknown';
59
+ }
@@ -0,0 +1,76 @@
1
+ // lib/resources.js -- Process resource metrics and proxy error classification.
2
+ // Isolated from reporter.js so that fs reads and network sends are never
3
+ // in the same file (keeps fs reads and network sends in separate modules).
4
+
5
+ import fs from 'fs';
6
+
7
+ // ============================================================================
8
+ // Process resource snapshot (memory, handles, FDs, browser RSS)
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Collect process-level resource metrics. Safe to call at any time.
13
+ * Returns anonymized metrics -- no PIDs, paths, or user data.
14
+ */
15
+ export function collectResourceSnapshot(opts = {}) {
16
+ const mem = process.memoryUsage();
17
+ const snap = {
18
+ nodeRssMb: Math.round(mem.rss / 1048576),
19
+ nodeHeapUsedMb: Math.round(mem.heapUsed / 1048576),
20
+ nodeHeapTotalMb: Math.round(mem.heapTotal / 1048576),
21
+ nodeExternalMb: Math.round(mem.external / 1048576),
22
+ eventLoopLagMs: null,
23
+ activeHandles: null,
24
+ activeRequests: null,
25
+ openFds: null,
26
+ browserRssMb: null,
27
+ };
28
+
29
+ // Active libuv handles/requests (private API, guarded)
30
+ try { snap.activeHandles = process._getActiveHandles().length; } catch { /* unavailable */ }
31
+ try { snap.activeRequests = process._getActiveRequests().length; } catch { /* unavailable */ }
32
+
33
+ // Open file descriptors (Linux only)
34
+ try {
35
+ if (process.platform === 'linux') {
36
+ snap.openFds = fs.readdirSync('/proc/self/fd').length;
37
+ }
38
+ } catch { /* not available or permission denied */ }
39
+
40
+ // Browser process RSS (the one people miss -- browser OOMs, not Node)
41
+ if (opts.browserPid && Number.isInteger(opts.browserPid) && opts.browserPid > 0) {
42
+ try {
43
+ if (process.platform === 'linux') {
44
+ const status = fs.readFileSync(`/proc/${opts.browserPid}/status`, 'utf8');
45
+ const match = status.match(/VmRSS:\s+(\d+)\s+kB/);
46
+ if (match) snap.browserRssMb = Math.round(parseInt(match[1], 10) / 1024);
47
+ }
48
+ } catch { /* process gone or permission denied */ }
49
+ }
50
+
51
+ // Session/tab counts from caller
52
+ if (opts.sessionCount != null) snap.browserContexts = opts.sessionCount;
53
+ if (opts.tabCount != null) snap.activeTabs = opts.tabCount;
54
+
55
+ return snap;
56
+ }
57
+
58
+ // ============================================================================
59
+ // Proxy error classification
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Classify proxy errors from Playwright navigation error messages.
64
+ * Returns { proxyError: string|null, proxyTlsError: bool } -- no IPs or credentials.
65
+ */
66
+ export function classifyProxyError(errorMessage) {
67
+ if (!errorMessage || typeof errorMessage !== 'string') return { proxyError: null, proxyTlsError: false };
68
+ const msg = errorMessage.toUpperCase();
69
+ if (msg.includes('ERR_PROXY_CONNECTION_FAILED')) return { proxyError: 'ERR_PROXY_CONNECTION_FAILED', proxyTlsError: false };
70
+ if (msg.includes('ERR_TUNNEL_CONNECTION_FAILED')) return { proxyError: 'ERR_TUNNEL_CONNECTION_FAILED', proxyTlsError: false };
71
+ if (msg.includes('ERR_PROXY_AUTH_REQUESTED') || msg.includes('407')) return { proxyError: 'ERR_PROXY_AUTH_REQUESTED', proxyTlsError: false };
72
+ if (msg.includes('ERR_PROXY_CERTIFICATE_INVALID') || (msg.includes('PROXY') && msg.includes('SSL'))) return { proxyError: 'ERR_PROXY_TLS', proxyTlsError: true };
73
+ if (msg.includes('ECONNREFUSED') && msg.includes('PROXY')) return { proxyError: 'ECONNREFUSED', proxyTlsError: false };
74
+ if (msg.includes('ETIMEDOUT') && msg.includes('PROXY')) return { proxyError: 'ETIMEDOUT', proxyTlsError: false };
75
+ return { proxyError: null, proxyTlsError: false };
76
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Snapshot windowing -- truncate large accessibility snapshots while
3
+ * preserving pagination/navigation links at the tail.
4
+ */
5
+
6
+ const MAX_SNAPSHOT_CHARS = 80000; // ~20K tokens
7
+ const SNAPSHOT_TAIL_CHARS = 5000; // keep last ~5K for pagination/nav links
8
+
9
+ /**
10
+ * Return a window of the snapshot YAML.
11
+ * offset=0 (default): head chunk + tail (pagination/nav).
12
+ * offset=N: chars N..N+budget from the full snapshot.
13
+ * Always appends pagination tail so nav refs are available in every chunk.
14
+ */
15
+ function windowSnapshot(yaml, offset = 0) {
16
+ if (!yaml) return { text: '', truncated: false, totalChars: 0, offset: 0 };
17
+ const total = yaml.length;
18
+ if (total <= MAX_SNAPSHOT_CHARS) return { text: yaml, truncated: false, totalChars: total, offset: 0 };
19
+
20
+ const contentBudget = MAX_SNAPSHOT_CHARS - SNAPSHOT_TAIL_CHARS - 200; // room for marker
21
+ const tail = yaml.slice(-SNAPSHOT_TAIL_CHARS);
22
+ const clampedOffset = Math.min(Math.max(0, offset), total - SNAPSHOT_TAIL_CHARS);
23
+ const chunk = yaml.slice(clampedOffset, clampedOffset + contentBudget);
24
+ const chunkEnd = clampedOffset + contentBudget;
25
+ const hasMore = chunkEnd < total - SNAPSHOT_TAIL_CHARS;
26
+
27
+ const marker = hasMore
28
+ ? `\n[... truncated at char ${chunkEnd} of ${total}. Call snapshot with offset=${chunkEnd} to see more. Pagination links below. ...]\n`
29
+ : '\n';
30
+
31
+ return {
32
+ text: chunk + marker + tail,
33
+ truncated: true,
34
+ totalChars: total,
35
+ offset: clampedOffset,
36
+ hasMore,
37
+ nextOffset: hasMore ? chunkEnd : null
38
+ };
39
+ }
40
+
41
+ export { windowSnapshot, MAX_SNAPSHOT_CHARS, SNAPSHOT_TAIL_CHARS };
@@ -0,0 +1,108 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ const ORPHAN_PATTERNS = [
6
+ /^\.fea5[a-f0-9]+\.so$/,
7
+ /^\.5ef7[a-f0-9]+\.node$/,
8
+ ];
9
+
10
+ // Firefox temp profile directories created by Playwright/Camoufox
11
+ const FIREFOX_PROFILE_PATTERN = /^playwright_firefoxdev_profile-/;
12
+ // Camoufox also creates these
13
+ const CAMOUFOX_TMP_PATTERN = /^camoufox[-_]/;
14
+
15
+ export function cleanupOrphanedTempFiles({ tmpDir, minAgeMs = 5 * 60 * 1000, now = Date.now() } = {}) {
16
+ const result = { scanned: 0, removed: 0, bytes: 0, skipped: 0 };
17
+ if (!tmpDir) return result;
18
+
19
+ let entries;
20
+ try {
21
+ entries = fs.readdirSync(tmpDir);
22
+ } catch {
23
+ return result;
24
+ }
25
+
26
+ for (const name of entries) {
27
+ if (!ORPHAN_PATTERNS.some((re) => re.test(name))) continue;
28
+ result.scanned++;
29
+ const full = path.join(tmpDir, name);
30
+ try {
31
+ const st = fs.statSync(full);
32
+ if (!st.isFile()) continue;
33
+ if (now - st.mtimeMs < minAgeMs) {
34
+ result.skipped++;
35
+ continue;
36
+ }
37
+ fs.unlinkSync(full);
38
+ result.removed++;
39
+ result.bytes += st.size;
40
+ } catch {
41
+ // file vanished, permission denied, or race with another process - skip silently
42
+ }
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ /**
49
+ * Clean up stale Firefox/Camoufox temp profile directories.
50
+ * These accumulate when browser.close() doesn't fully clean up
51
+ * (especially with enable_cache: true). Each profile can be 10-100MB+.
52
+ *
53
+ * Only removes profiles older than minAgeMs (default 2 minutes)
54
+ * to avoid killing profiles belonging to an actively launching browser.
55
+ */
56
+ export function cleanupStaleFirefoxProfiles({ tmpDir, minAgeMs = 2 * 60 * 1000, now = Date.now() } = {}) {
57
+ const dir = tmpDir || os.tmpdir();
58
+ const result = { scanned: 0, removed: 0, bytes: 0, skipped: 0 };
59
+
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir);
63
+ } catch {
64
+ return result;
65
+ }
66
+
67
+ for (const name of entries) {
68
+ if (!FIREFOX_PROFILE_PATTERN.test(name) && !CAMOUFOX_TMP_PATTERN.test(name)) continue;
69
+ result.scanned++;
70
+ const full = path.join(dir, name);
71
+ try {
72
+ const st = fs.statSync(full);
73
+ if (!st.isDirectory()) continue;
74
+ if (now - st.mtimeMs < minAgeMs) {
75
+ result.skipped++;
76
+ continue;
77
+ }
78
+ // Calculate directory size before removing
79
+ const dirBytes = _dirSizeSync(full);
80
+ fs.rmSync(full, { recursive: true, force: true, maxRetries: 3 });
81
+ result.removed++;
82
+ result.bytes += dirBytes;
83
+ } catch {
84
+ // directory vanished, permission denied, or in-use -- skip
85
+ }
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /** Recursively calculate directory size (best effort, fast). */
92
+ function _dirSizeSync(dirPath) {
93
+ let total = 0;
94
+ try {
95
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
96
+ for (const entry of entries) {
97
+ const full = path.join(dirPath, entry.name);
98
+ try {
99
+ if (entry.isDirectory()) {
100
+ total += _dirSizeSync(full);
101
+ } else {
102
+ total += fs.statSync(full).size;
103
+ }
104
+ } catch { /* skip */ }
105
+ }
106
+ } catch { /* skip */ }
107
+ return total;
108
+ }
package/lib/tracing.js ADDED
@@ -0,0 +1,137 @@
1
+ import fs from 'fs';
2
+ import fsp from 'fs/promises';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+
6
+ function hashUserId(userId) {
7
+ return crypto.createHash('sha256').update(String(userId)).digest('hex').slice(0, 16);
8
+ }
9
+
10
+ export function userTracesDir(baseDir, userId) {
11
+ return path.join(baseDir, hashUserId(userId));
12
+ }
13
+
14
+ export function ensureTracesDir(baseDir, userId) {
15
+ const dir = userTracesDir(baseDir, userId);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ return dir;
18
+ }
19
+
20
+ export function makeTraceFilename() {
21
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
22
+ const suffix = crypto.randomBytes(3).toString('hex');
23
+ return `trace-${ts}-${suffix}.zip`;
24
+ }
25
+
26
+ export function tracePathFor(baseDir, userId, filename) {
27
+ return path.join(ensureTracesDir(baseDir, userId), filename);
28
+ }
29
+
30
+ export function resolveTracePath(baseDir, userId, filename) {
31
+ if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..') || filename.startsWith('.')) {
32
+ return null;
33
+ }
34
+ const userDir = userTracesDir(baseDir, userId);
35
+ const full = path.join(userDir, filename);
36
+ const resolved = path.resolve(full);
37
+ if (!resolved.startsWith(path.resolve(userDir) + path.sep)) return null;
38
+ return resolved;
39
+ }
40
+
41
+ export async function listUserTraces(baseDir, userId) {
42
+ const dir = userTracesDir(baseDir, userId);
43
+ let names;
44
+ try {
45
+ names = await fsp.readdir(dir);
46
+ } catch {
47
+ return [];
48
+ }
49
+ const out = [];
50
+ for (const name of names) {
51
+ if (!name.endsWith('.zip')) continue;
52
+ const full = path.join(dir, name);
53
+ try {
54
+ const st = await fsp.stat(full);
55
+ if (!st.isFile()) continue;
56
+ out.push({
57
+ filename: name,
58
+ sizeBytes: st.size,
59
+ createdAt: st.birthtimeMs || st.ctimeMs,
60
+ modifiedAt: st.mtimeMs,
61
+ });
62
+ } catch {
63
+ // vanished mid-scan
64
+ }
65
+ }
66
+ out.sort((a, b) => b.modifiedAt - a.modifiedAt);
67
+ return out;
68
+ }
69
+
70
+ export async function statTrace(fullPath) {
71
+ try {
72
+ const st = await fsp.stat(fullPath);
73
+ if (!st.isFile()) return null;
74
+ return st;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export async function deleteTrace(fullPath) {
81
+ await fsp.unlink(fullPath);
82
+ }
83
+
84
+ export function sweepOldTraces({ baseDir, ttlMs, maxBytesPerFile, now = Date.now() } = {}) {
85
+ const result = { scanned: 0, removedTtl: 0, removedOversized: 0, bytes: 0 };
86
+ if (!baseDir) return result;
87
+
88
+ let userDirs;
89
+ try {
90
+ userDirs = fs.readdirSync(baseDir);
91
+ } catch {
92
+ return result;
93
+ }
94
+
95
+ for (const userDir of userDirs) {
96
+ const dir = path.join(baseDir, userDir);
97
+ let st;
98
+ try {
99
+ st = fs.statSync(dir);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (!st.isDirectory()) continue;
104
+
105
+ let files;
106
+ try {
107
+ files = fs.readdirSync(dir);
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ for (const name of files) {
113
+ if (!name.endsWith('.zip')) continue;
114
+ result.scanned++;
115
+ const full = path.join(dir, name);
116
+ try {
117
+ const fst = fs.statSync(full);
118
+ if (!fst.isFile()) continue;
119
+ const tooOld = ttlMs && (now - fst.mtimeMs) > ttlMs;
120
+ const tooBig = maxBytesPerFile && fst.size > maxBytesPerFile;
121
+ if (tooOld) {
122
+ fs.unlinkSync(full);
123
+ result.removedTtl++;
124
+ result.bytes += fst.size;
125
+ } else if (tooBig) {
126
+ fs.unlinkSync(full);
127
+ result.removedOversized++;
128
+ result.bytes += fst.size;
129
+ }
130
+ } catch {
131
+ // vanished or permission denied
132
+ }
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }