Merge pull request #67 from jo-inc/next

feat: plugin system, session persistence, VNC interactive login
This commit is contained in:
Pradeep Elankumaran
2026-04-18 20:22:18 -07:00
committed by GitHub
44 changed files with 3836 additions and 390 deletions
+337 -6
View File
@@ -126,9 +126,12 @@ Firefox-based with anti-detection. Bypasses Google captcha.
## Testing
```bash
npm test # E2E tests
npm run test:live # Live Google tests
npm run test:debug # With server output
npm test # All tests (unit + e2e + plugin)
npm run test:plugins # All plugin tests
npm run test:e2e # E2E tests
npm run test:live # Live Google tests
npm run test:debug # With server output
npx jest plugins/youtube # Single plugin's tests
```
## Docker
@@ -142,14 +145,26 @@ docker run -p 9377:9377 camofox-browser
- `server.js` - Camoufox engine (routes + browser logic only — NO `process.env` or `child_process`)
- `lib/config.js` - All `process.env` reads centralized here
- `lib/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)
- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)
- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)
- `lib/cookies.js` - Cookie file I/O
- `lib/metrics.js` - Prometheus metrics (lazy-loaded, off by default — set `PROMETHEUS_ENABLED=1`)
- `lib/request-utils.js` - HTTP request classification helpers (`actionFromReq`, `classifyError`)
- `lib/snapshot.js` - Accessibility tree snapshot
- `lib/macros.js` - Search macro URL expansion
- `Dockerfile` - Production container
- `lib/plugins.js` - Plugin loader and event bus
- `lib/auth.js` - Shared auth middleware (API key / loopback)
- `camofox.config.json` - Plugin configuration (which plugins to load)
- `plugins/` - Plugin directory (loaded per camofox.config.json)
- `plugins/youtube/` - Default plugin: YouTube transcript extraction
- `scripts/install-plugin-deps.sh` - Installs plugin deps (apt.txt + post-install.sh)
- `plugins/vnc/index.js` - VNC plugin routes (no `child_process` — spawning isolated in `vnc-launcher.js`)
- `plugins/vnc/vnc-launcher.js` - VNC process management (`child_process` isolated here)
- `plugins/persistence/index.js` - Session persistence lifecycle hooks
- `lib/persistence.js` - Atomic storage state read/write
- `lib/inflight.js` - Inflight request coalescing
- `lib/tmp-cleanup.js` - Orphaned temp file cleanup
- `Dockerfile` - Production container with default plugin deps pre-installed
## OpenClaw Scanner Isolation (CRITICAL)
@@ -157,7 +172,7 @@ OpenClaw's skill-scanner flags plugins that have `process.env` + network calls (
**Rule: No single `.js` file may contain both halves of a scanner rule pair:**
- `process.env` lives ONLY in `lib/config.js`
- `child_process` / `execFile` / `spawn` live ONLY in `lib/youtube.js` and `lib/launcher.js`
- `child_process` / `execFile` / `spawn` live ONLY in `plugins/youtube/youtube.js`, `plugins/vnc/vnc-launcher.js`, and `lib/launcher.js`
- `server.js` has the Express routes (`app.post`, `app.get`) but ZERO `process.env` reads and ZERO `child_process` imports
- `lib/metrics.js` has NO `process.env` and NO HTTP method strings (`POST`, `fetch`). Prometheus is lazy-loaded only when `PROMETHEUS_ENABLED=1`.
- `lib/request-utils.js` has HTTP method strings (`POST`) but NO `process.env` — safe.
@@ -169,3 +184,319 @@ OpenClaw's skill-scanner flags plugins that have `process.env` + network calls (
- `potential-exfiltration` (WARN): `readFile` + `fetch`/`post`/`http.request` in same file
This was broken in 1.3.0 (YouTube `child_process` in server.js), fixed in 1.3.1. Broken again in 1.4.1 (`metrics.js` had `process.env` in a comment + `'POST'` in `actionFromReq`), fixed in 1.5.1 by lazy-loading prom-client and splitting `actionFromReq` into `lib/request-utils.js`.
## Plugin System
Plugins extend camofox-browser with new endpoints, background processes, and lifecycle hooks. The server auto-loads all plugins from `plugins/<name>/index.js` on startup.
### Creating a Plugin
```
plugins/
my-plugin/
index.js Required — exports register(app, ctx)
apt.txt Optional — system packages (one per line)
post-install.sh Optional — executable hook for binary downloads
*.test.js Optional — Jest tests (auto-discovered)
```
```js
// plugins/my-plugin/index.js
export function register(app, ctx) {
const { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession,
withUserLimit, safePageClose, normalizeUserId, validateUrl, safeError,
buildProxyUrl, proxyPool, failuresTotal } = ctx;
// Register Express routes (auth() enforces API key or loopback)
app.get('/my-endpoint', auth(), async (req, res) => {
const session = sessions.get(req.params.userId);
res.json({ ok: true });
});
// Listen to lifecycle events
events.on('browser:launched', ({ browser, display }) => {
log('info', 'browser is up', { display });
});
events.on('session:created', ({ userId, context }) => {
log('info', 'new session', { userId });
});
events.on('tab:navigated', ({ userId, tabId, url }) => {
log('info', 'navigation', { userId, tabId, url });
});
}
```
### Plugin Context (`ctx`)
| Property | Type | Description |
|----------|------|-------------|
| `sessions` | `Map` | Live sessions: `userId → { context, tabGroups, lastAccess }` |
| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |
| `log` | `function` | `log(level, msg, fields)` — structured JSON logging |
| `events` | `EventEmitter` | Plugin event bus (29 events — see below) |
| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |
| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |
| `getSession` | `async function` | `getSession(userId)` — get or create a session |
| `destroySession` | `function` | `destroySession(userId)` — tear down a session |
| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` — run `fn` within per-user concurrency limit |
| `safePageClose` | `async function` | `safePageClose(page)` — close a page with timeout guard |
| `normalizeUserId` | `function` | `normalizeUserId(id)` — coerce to string for map keys |
| `validateUrl` | `function` | `validateUrl(url)` — returns error string or null |
| `safeError` | `function` | `safeError(err)` — sanitize error for client response |
| `buildProxyUrl` | `function` | `buildProxyUrl(pool, proxyConfig)` — get proxy URL for external requests |
| `proxyPool` | `object\|null` | Proxy pool instance (null if no proxy configured) |
| `failuresTotal` | `Counter` | Prometheus counter: `failuresTotal.labels(type, action).inc()` |
| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |
| `metricsRegistry` | `function` | `metricsRegistry()` — raw prom-client Registry or null |
### Events (29)
28 emitted by core, 1 (`session:storage:export`) emitted by plugins.
#### Browser Lifecycle
| Event | Payload | Mutating? |
|-------|---------|-----------|
| `browser:launching` | `{ options }` | ✅ Modify launch options in-place |
| `browser:launched` | `{ browser, display }` | |
| `browser:restart` | `{ reason }` | |
| `browser:closed` | `{ reason }` | |
| `browser:error` | `{ error }` | |
#### Session Lifecycle
| Event | Payload | Mutating? |
|-------|---------|-----------|
| `session:creating` | `{ userId, contextOptions }` | ✅ Modify context options in-place |
| `session:created` | `{ userId, context }` | |
| `session:destroyed` | `{ userId, reason }` | |
| `session:expired` | `{ userId, idleMs }` | |
#### Tab Lifecycle
| Event | Payload |
|-------|---------|
| `tab:created` | `{ userId, tabId, page, url }` |
| `tab:navigated` | `{ userId, tabId, url, prevUrl }` |
| `tab:destroyed` | `{ userId, tabId, reason }` |
| `tab:recycled` | `{ userId, tabId }` |
| `tab:error` | `{ userId, tabId, error }` |
#### Content
| Event | Payload |
|-------|---------|
| `tab:snapshot` | `{ userId, tabId, snapshot }` |
| `tab:screenshot` | `{ userId, tabId, buffer }` |
| `tab:evaluate` | `{ userId, tabId, expression }` |
| `tab:evaluated` | `{ userId, tabId, result }` |
#### Input
| Event | Payload |
|-------|---------|
| `tab:click` | `{ userId, tabId, ref, selector }` |
| `tab:type` | `{ userId, tabId, text, ref, mode }` |
| `tab:scroll` | `{ userId, tabId, direction, amount }` |
| `tab:press` | `{ userId, tabId, key }` |
#### Downloads
| Event | Payload |
|-------|---------|
| `tab:download:start` | `{ userId, tabId, filename, url }` |
| `tab:download:complete` | `{ userId, tabId, filename, path, size }` |
#### Cookies / Auth
| Event | Payload |
|-------|---------|
| `session:cookies:import` | `{ userId, count }` |
| `session:storage:export` | `{ userId }` |
#### Server
| Event | Payload |
|-------|---------|
| `server:starting` | `{ port }` |
| `server:started` | `{ port, pid }` |
| `server:shutdown` | `{ signal }` |
### Mutating Hooks
`browser:launching`, `session:creating`, `session:created`, and `session:destroyed` are emitted via `events.emitAsync()` — the server awaits all listeners (including async ones) before proceeding. This ensures async work like loading storage state from disk completes before the context is created.
Other events use regular `events.emit()` (fire-and-forget).
Modify payload objects in-place:
```js
// Change Xvfb resolution (e.g., for VNC plugin)
events.on('browser:launching', ({ options }) => {
options.virtual_display_resolution = '1920x1080x24';
});
// Inject saved auth state into new sessions
events.on('session:creating', ({ userId, contextOptions }) => {
const saved = loadStorageState(userId);
if (saved) contextOptions.storageState = saved;
});
```
### System Packages (`apt.txt`) and Post-Install Hooks
Plugins that need system packages list them one per line in `apt.txt`:
```
# plugins/vnc/apt.txt
x11vnc
novnc
python3-websockify
```
For binary downloads or setup not available via apt, add an executable `post-install.sh`:
```bash
# plugins/youtube/post-install.sh
#!/bin/sh
set -e
curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
chmod +x /usr/local/bin/yt-dlp
```
Both are run by `scripts/install-plugin-deps.sh` during Docker build.
### Configuration (`camofox.config.json`)
`camofox.config.json` controls which plugins are loaded at runtime and during Docker build:
```json
{
"id": "camofox-browser",
"name": "Camofox Browser",
"version": "1.5.2",
"plugins": ["youtube"]
}
```
- **`plugins`** — array of plugin directory names to load. Only these are loaded at startup and have deps installed during build.
- If the file is missing or has no `plugins` key, **all** plugins in `plugins/` are loaded (backward-compatible).
- This is camofox's own config. `openclaw.plugin.json` is separate — it tells the OpenClaw Gateway how to configure camofox as an external service.
### Installing Plugins
Use the plugin manager to install third-party plugins from git or local paths:
```bash
# Install from git
npm run plugin install https://github.com/user/camofox-screenshot-plugin
npm run plugin install git:github.com/user/my-plugin
# Install from local directory
npm run plugin install ./path/to/my-plugin
# List installed plugins
npm run plugin list
# Remove a plugin
npm run plugin remove my-plugin
```
The installer copies the plugin into `plugins/`, adds it to `camofox.config.json`, and runs `npm install` for any npm dependencies. System deps (`apt.txt`, `post-install.sh`) are flagged but must be installed manually or via Docker rebuild.
Plugin sources can be:
- **Git repos** where the root has `index.js` with `register()` (installed as one plugin)
- **Git repos** with a `plugins/` subdirectory (each subdirectory installed as a separate plugin)
- **Local directories** with `index.js` and `register()`
### Default Plugins
Three plugins ship by default:
- **youtube** — YouTube transcript extraction (enabled by default)
- **persistence** — Per-user session state persistence to `~/.camofox/profiles/` (enabled by default)
- **vnc** — Interactive browser login via noVNC (disabled by default, requires `ENABLE_VNC=1`)
The `youtube` plugin ships as a default plugin — it's listed in `camofox.config.json` and included in the base Docker image with its deps pre-installed. The base image runs `scripts/install-plugin-deps.sh` which reads the config and installs `apt.txt` packages + `post-install.sh` hooks for listed plugins.
The `with-plugins` Dockerfile stage is for rebuilding after adding third-party plugins:
```bash
docker build --target with-plugins -t camofox-browser .
```
The `with-plugins` stage re-runs `install-plugin-deps.sh` to pick up any new plugins added to `plugins/`.
### OpenClaw Scanner Rules
Plugins must follow the same isolation rules as core (see "OpenClaw Scanner Isolation" above):
- **No `process.env` in plugin files that also have route handlers** — read config from `ctx.config`
- **No `child_process` in plugin files that also have route handlers** — spawn from a separate `lib/` module
- Violations trigger OpenClaw's `env-harvesting` or `dangerous-exec` scanner alerts
### Custom Metrics
Plugins create Prometheus metrics via `ctx.createMetric()`. Returns a no-op stub when Prometheus is disabled — no null checks needed.
```js
// In register(app, ctx):
const transcriptsTotal = await ctx.createMetric('counter', {
name: 'camofox_youtube_transcripts_total',
help: 'YouTube transcripts extracted',
labelNames: ['method'],
});
// Use anywhere — works whether Prometheus is enabled or not
transcriptsTotal.labels('yt-dlp').inc();
```
Supported types: `'counter'`, `'histogram'`, `'gauge'`. Options are standard [prom-client](https://github.com/siimon/prom-client) options (`name`, `help`, `labelNames`, `buckets`, etc.). Metrics auto-register to the shared registry and appear on `/metrics`.
For advanced use, `ctx.metricsRegistry()` returns the raw prom-client `Registry` (or `null` when disabled).
### Example: YouTube Transcript Plugin
The YouTube plugin (`plugins/youtube/`) is the reference implementation. It extracts transcripts via yt-dlp with browser fallback, using `ctx` helpers for auth, logging, browser access, and concurrency control.
```
plugins/
youtube/
index.js # register(app, ctx) — route handler + browser fallback
youtube.js # yt-dlp process management + transcript parsing
youtube.test.js # parser unit tests
apt.txt # python3-minimal (yt-dlp runtime dep)
post-install.sh # downloads yt-dlp binary
```
```js
// plugins/youtube/index.js (simplified)
import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript } from './youtube.js';
import { classifyError } from '../../lib/request-utils.js';
export async function register(app, ctx) {
const { log, config, sessions, ensureBrowser, getSession,
withUserLimit, safePageClose, normalizeUserId,
validateUrl, safeError, buildProxyUrl, proxyPool,
failuresTotal } = ctx;
await detectYtDlp(log);
app.post('/youtube/transcript', ctx.auth(), async (req, res) => {
// ... validate URL, extract videoId, try yt-dlp then browser fallback
});
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();
// ... intercept captions, parse transcript
await safePageClose(page);
});
}
}
```
Key patterns:
- **Auth**: `ctx.auth()` middleware on the route
- **Logging**: `ctx.log('info', ...)` — never `console.log`
- **Browser access**: `ctx.ensureBrowser()` + `ctx.getSession()` for browser-backed features
- **Concurrency**: `ctx.withUserLimit()` to respect per-user limits
- **Metrics**: `ctx.failuresTotal.labels(...)` for core counters, `ctx.createMetric()` for custom
- **Scanner compliance**: `child_process` in `youtube.js`, route handler in `index.js` — separate files
- **System deps**: `apt.txt` lists packages installed via `scripts/install-plugin-deps.sh`
+17 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-slim
FROM node:20-slim AS camofox-browser
# Pinned Camoufox version for reproducible builds
# Update these when upgrading Camoufox
@@ -36,6 +36,7 @@ RUN apt-get update && apt-get install -y \
fontconfig \
# Utils
ca-certificates \
curl \
unzip \
# yt-dlp runtime dependency
python3-minimal \
@@ -60,11 +61,25 @@ COPY package.json ./
RUN npm install --production
COPY server.js ./
COPY camofox.config.json ./
COPY lib/ ./lib/
COPY plugins/ ./plugins/
COPY scripts/ ./scripts/
# Install default plugin dependencies (apt packages + post-install hooks)
RUN scripts/install-plugin-deps.sh
ENV NODE_ENV=production
ENV CAMOFOX_PORT=3000
ENV CAMOFOX_PORT=9377
EXPOSE 9377
CMD ["sh", "-c", "node --max-old-space-size=${MAX_OLD_SPACE_SIZE:-128} server.js"]
# Optional: rebuild plugin deps after adding third-party plugins
# Usage: docker build --target with-plugins -t camofox-browser .
FROM camofox-browser AS with-plugins
COPY plugins/ ./plugins/
COPY camofox.config.json ./
COPY scripts/install-plugin-deps.sh /tmp/install-plugin-deps.sh
RUN /tmp/install-plugin-deps.sh && rm /tmp/install-plugin-deps.sh
+20
View File
@@ -50,6 +50,7 @@ This project wraps that engine in a REST API built for agents: accessibility sna
- **Download Capture** - capture browser downloads and fetch them via API (optional inline base64)
- **DOM Image Extraction** - list `<img>` src/alt and optionally return inline data URLs
- **Deploy Anywhere** - Docker, Fly.io, Railway
- **VNC Interactive Login** - log into sites visually via noVNC, export storage state for agent reuse
## Optional Dependencies
@@ -178,6 +179,20 @@ Camoufox browser session (authenticated browsing)
- Max 500 cookies per request, 5MB file size limit
- Cookie objects are sanitized to an allowlist of Playwright fields
### Session Persistence
By default, camofox persists each user's cookies and localStorage to `~/.camofox/profiles/`. Sessions survive browser restarts — log in once (via cookies or VNC), and subsequent sessions restore the authenticated state automatically.
```
~/.camofox/
├── cookies/ # Bootstrap cookie files (Netscape format)
└── profiles/ # Persisted session state (auto-managed)
└── <hashed-userId>/
└── storage_state.json
```
Override the directory with `CAMOFOX_PROFILE_DIR` or set `"profileDir"` in the persistence plugin config. To disable persistence, set `"persistence": { "enabled": false }` in `camofox.config.json`.
#### Standalone server usage
```bash
@@ -346,6 +361,7 @@ Uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) when available (fast, no browser
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/sessions/:userId/cookies` | Add cookies to a user session (Playwright cookie objects) |
| `GET` | `/sessions/:userId/storage_state` | Export cookies + localStorage ([VNC plugin](plugins/vnc/)) |
## Search Macros
@@ -364,6 +380,7 @@ Reddit macros return JSON directly (no HTML parsing needed):
| `CAMOFOX_API_KEY` | Enable cookie import endpoint (disabled if unset) | - |
| `CAMOFOX_ADMIN_KEY` | Required for `POST /stop` | - |
| `CAMOFOX_COOKIES_DIR` | Directory for cookie files | `~/.camofox/cookies` |
| `CAMOFOX_PROFILE_DIR` | Directory for persisted session profiles | `~/.camofox/profiles` |
| `MAX_SESSIONS` | Max concurrent browser sessions | `50` |
| `MAX_TABS_PER_SESSION` | Max tabs per session | `10` |
| `SESSION_TIMEOUT_MS` | Session inactivity timeout | `1800000` (30min) |
@@ -382,6 +399,9 @@ Reddit macros return JSON directly (no HTML parsing needed):
| `PROXY_COUNTRY` | Target country for proxy geo-targeting | - |
| `PROXY_STATE` | Target state/region for proxy geo-targeting | - |
| `TAB_INACTIVITY_MS` | Close tabs idle longer than this | `300000` (5min) |
| `ENABLE_VNC` | Enable VNC plugin for interactive browser access (`1`) | - |
| `VNC_PASSWORD` | Password for VNC access (recommended in production) | - |
| `NOVNC_PORT` | noVNC web UI port | `6080` |
## Architecture
+10
View File
@@ -0,0 +1,10 @@
{
"id": "camofox-browser",
"name": "Camofox Browser",
"version": "1.6.0",
"plugins": {
"youtube": { "enabled": true },
"persistence": { "enabled": true },
"vnc": { "resolution": "1920x1080" }
}
}
+3 -1
View File
@@ -9,7 +9,9 @@ module.exports = {
// Test file patterns
testMatch: [
'**/tests/**/*.test.js'
'**/tests/**/*.test.js',
'**/plugins/**/*.test.js',
'**/scripts/**/*.test.js'
],
// Ignore patterns
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared auth middleware for camofox-browser.
*
* Extracts the duplicated auth pattern from cookie/storage_state endpoints
* into a reusable Express middleware factory.
*
* Policy:
* - If CAMOFOX_API_KEY is set, require Bearer token match (timing-safe).
* - If not set and NODE_ENV !== production, allow loopback (127.0.0.1 / ::1).
* - Otherwise, reject.
*/
import crypto from 'crypto';
/**
* Timing-safe string comparison.
*/
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) {
// Compare against self to burn constant time, then return false
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* Check if an address is loopback.
*/
function isLoopbackAddress(address) {
if (!address) return false;
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
}
/**
* Create an Express middleware that enforces API key auth.
*
* @param {object} config - Must have { apiKey, nodeEnv }
* @param {object} [options]
* @param {string} [options.errorMessage] - Custom error message when rejecting unauthenticated requests
* @returns {function} Express middleware (req, res, next)
*/
export function requireAuth(config, options = {}) {
const errorMessage = options.errorMessage ||
'This endpoint requires CAMOFOX_API_KEY except for loopback requests in non-production environments.';
return (req, res, next) => {
if (config.apiKey) {
const auth = String(req.headers['authorization'] || '');
const match = auth.match(/^Bearer\s+(.+)$/i);
if (!match || !timingSafeCompare(match[1], config.apiKey)) {
return res.status(403).json({ error: 'Forbidden' });
}
return next();
}
const remoteAddress = req.socket?.remoteAddress || '';
const allowUnauthedLocal = config.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
if (!allowUnauthedLocal) {
return res.status(403).json({ error: errorMessage });
}
next();
};
}
// Re-export utilities so server.js can still use them directly
export { timingSafeCompare, isLoopbackAddress };
+1
View File
@@ -46,6 +46,7 @@ function loadConfig() {
adminKey: process.env.CAMOFOX_ADMIN_KEY || '',
apiKey: process.env.CAMOFOX_API_KEY || '',
cookiesDir: process.env.CAMOFOX_COOKIES_DIR || join(os.homedir(), '.camofox', 'cookies'),
profileDir: process.env.CAMOFOX_PROFILE_DIR || join(os.homedir(), '.camofox', 'profiles'),
handlerTimeoutMs: parseInt(process.env.HANDLER_TIMEOUT_MS) || 30000,
maxConcurrentPerUser: parseInt(process.env.MAX_CONCURRENT_PER_USER) || 3,
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS) || 600000,
+38 -1
View File
@@ -79,4 +79,41 @@ async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes
}));
}
export { parseNetscapeCookieFile, readCookieFile };
/**
* Import all cookies from the default bootstrap cookie file into a Playwright context.
* Intended for first-run session seeding before any persistent storage state exists.
* Missing file is treated as a no-op.
* @param {object} opts
* @param {string} opts.cookiesDir - Base directory for cookie files
* @param {object} opts.context - Playwright BrowserContext
* @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
* @param {object} [opts.logger=console] - Logger with warn()
* @returns {Promise<{imported: number, source: string|null}>}
*/
async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
if (!cookiesDir || !context) {
return { imported: 0, source: null };
}
const resolved = path.resolve(cookiesDir, cookiesPath);
try {
const cookies = await readCookieFile({ cookiesDir, cookiesPath });
if (cookies.length === 0) {
return { imported: 0, source: resolved };
}
await context.addCookies(cookies);
return { imported: cookies.length, source: resolved };
} catch (err) {
if (err?.code === 'ENOENT') {
return { imported: 0, source: null };
}
logger?.warn?.('failed to import bootstrap cookies', {
cookiesPath: resolved,
error: err?.message || String(err),
});
return { imported: 0, source: resolved };
}
}
export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
+10 -2
View File
@@ -60,7 +60,7 @@ async function clearSessionDownloads(session) {
await Promise.all(tasks);
}
function attachDownloadListener(tabState, tabId, log) {
function attachDownloadListener(tabState, tabId, log, pluginEvents, userId) {
if (tabState.downloadListenerAttached) return;
tabState.downloadListenerAttached = true;
@@ -69,6 +69,11 @@ function attachDownloadListener(tabState, tabId, log) {
const suggestedFilename = sanitizeFilename(download.suggestedFilename?.() || `download-${downloadId}.bin`);
const filePath = path.join(os.tmpdir(), `camofox-download-${downloadId}-${suggestedFilename}`);
const url = String(download.url?.() || '').trim();
if (pluginEvents) {
pluginEvents.emit('tab:download:start', { userId: userId || null, tabId, filename: suggestedFilename, url });
}
let failure = null;
let bytes = null;
@@ -86,7 +91,6 @@ function attachDownloadListener(tabState, tabId, log) {
failure = reportedFailure;
}
const url = String(download.url?.() || '').trim();
if (url) {
tabState.visitedUrls.add(url);
}
@@ -104,6 +108,10 @@ function attachDownloadListener(tabState, tabId, log) {
failure,
});
if (pluginEvents && !failure) {
pluginEvents.emit('tab:download:complete', { userId: userId || null, tabId, filename: suggestedFilename, path: filePath, size: bytes });
}
await trimTabDownloads(tabState);
log('info', 'download captured', {
tabId, downloadId, suggestedFilename, mimeType, bytes,
+16
View File
@@ -0,0 +1,16 @@
async function coalesceInflight(map, key, factory) {
const existing = map.get(key);
if (existing) return existing;
const promise = (async () => {
try {
return await factory();
} finally {
map.delete(key);
}
})();
map.set(key, promise);
return promise;
}
export { coalesceInflight };
+21
View File
@@ -12,6 +12,27 @@ const noopCounter = { inc() {}, labels() { return this; } };
const noopHistogram = { observe() {}, startTimer() { return () => {}; }, labels() { return this; } };
const noopGauge = { set() {}, inc() {}, dec() {}, labels() { return this; } };
/**
* Create a metric (Counter, Histogram, or Gauge) registered to the shared registry.
* Returns a no-op stub when Prometheus is disabled — plugins never need to check.
*
* @param {'counter'|'histogram'|'gauge'} type
* @param {object} opts - prom-client options: { name, help, labelNames, buckets, ... }
* @returns {object} The metric instance or a no-op stub
*/
export async function createMetric(type, opts) {
if (!_register) {
if (type === 'histogram') return noopHistogram;
if (type === 'gauge') return noopGauge;
return noopCounter;
}
const client = (await import('prom-client')).default;
const MetricClass = type === 'histogram' ? client.Histogram
: type === 'gauge' ? client.Gauge
: client.Counter;
return new MetricClass({ ...opts, registers: [_register] });
}
function buildNoopMetrics() {
return {
requestsTotal: noopCounter,
+89
View File
@@ -0,0 +1,89 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
function getUserPersistencePaths(profileDir, userId) {
const rootDir = path.resolve(profileDir);
const safeUserDir = crypto
.createHash('sha256')
.update(String(userId))
.digest('hex')
.slice(0, 32);
const userDir = path.join(rootDir, safeUserDir);
return {
rootDir,
userDir,
storageStatePath: path.join(userDir, 'storage-state.json'),
metaPath: path.join(userDir, 'meta.json'),
};
}
async function loadPersistedStorageState(profileDir, userId, logger = console) {
if (!profileDir) return undefined;
const { storageStatePath } = getUserPersistencePaths(profileDir, userId);
try {
const raw = await fs.readFile(storageStatePath, 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return undefined;
if (!Array.isArray(parsed.cookies)) return undefined;
if (parsed.origins !== undefined && !Array.isArray(parsed.origins)) return undefined;
return storageStatePath;
} catch (err) {
if (err?.code === 'ENOENT') return undefined;
logger?.warn?.('failed to load persisted storage state', {
userId: String(userId),
storageStatePath,
error: err?.message || String(err),
});
return undefined;
}
}
async function persistStorageState({ profileDir, userId, context, logger = console }) {
if (!profileDir || !context) {
return { persisted: false, reason: 'disabled' };
}
const { userDir, storageStatePath, metaPath } = getUserPersistencePaths(profileDir, userId);
const suffix = `.tmp-${process.pid}-${Date.now()}`;
const tmpStoragePath = `${storageStatePath}${suffix}`;
const tmpMetaPath = `${metaPath}${suffix}`;
try {
await fs.mkdir(userDir, { recursive: true });
await context.storageState({ path: tmpStoragePath });
await fs.rename(tmpStoragePath, storageStatePath);
await fs.writeFile(
tmpMetaPath,
JSON.stringify(
{
userId: String(userId),
updatedAt: new Date().toISOString(),
storageStatePath,
},
null,
2
)
);
await fs.rename(tmpMetaPath, metaPath);
return { persisted: true, userDir, storageStatePath, metaPath };
} catch (err) {
await fs.unlink(tmpStoragePath).catch(() => {});
await fs.unlink(tmpMetaPath).catch(() => {});
logger?.warn?.('failed to persist storage state', {
userId: String(userId),
storageStatePath,
error: err?.message || String(err),
});
return { persisted: false, reason: 'error', error: err };
}
}
export {
getUserPersistencePaths,
loadPersistedStorageState,
persistStorageState,
};
+174
View File
@@ -0,0 +1,174 @@
/**
* Camofox-browser plugin system.
*
* Plugins live in plugins/<name>/index.js and export a register(app, ctx) function.
* The ctx object provides access to sessions, config, logging, auth middleware,
* core functions, and an EventEmitter for lifecycle hooks.
*
* 29 events across 7 categories:
*
* BROWSER LIFECYCLE
* browser:launching { options } — mutate launch options
* browser:launched { browser, display } — after launch
* browser:restart { reason } — before restart cycle
* browser:closed { reason } — after browser closed
* browser:error { error } — uncaught browser error
*
* SESSION LIFECYCLE
* session:creating { userId, contextOptions } — mutate context options
* session:created { userId, context } — after context stored
* session:destroyed { userId, reason } — after cleanup
* session:expired { userId, idleMs } — reaper triggered
*
* TAB LIFECYCLE
* tab:created { userId, tabId, page, url }
* tab:navigated { userId, tabId, url, prevUrl }
* tab:destroyed { userId, tabId, reason }
* tab:recycled { userId, tabId }
* tab:error { userId, tabId, error }
*
* CONTENT
* tab:snapshot { userId, tabId, snapshot }
* tab:screenshot { userId, tabId, buffer }
* tab:evaluate { userId, tabId, expression }
* tab:evaluated { userId, tabId, result }
*
* INPUT
* tab:click { userId, tabId, ref, selector }
* tab:type { userId, tabId, text, ref, mode }
* tab:scroll { userId, tabId, direction, amount }
* tab:press { userId, tabId, key }
*
* DOWNLOADS
* tab:download:start { userId, tabId, filename, url }
* tab:download:complete { userId, tabId, filename, path, size }
*
* COOKIES / AUTH
* session:cookies:import { userId, count }
* session:storage:export { userId }
*
* SERVER
* server:starting { port }
* server:started { port, pid }
* server:shutdown { signal }
*
* Mutating hooks (browser:launching, session:creating) pass the options object
* by reference — plugins can modify it in place before core uses it.
*/
import { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, '..');
const PLUGINS_DIR = path.join(ROOT_DIR, 'plugins');
const CONFIG_PATH = path.join(ROOT_DIR, 'camofox.config.json');
/**
* Read plugin configuration from camofox.config.json.
* Supports two formats:
* - Array of strings: ["youtube", "persistence"] (no per-plugin config)
* - Object with per-plugin config: { "youtube": { "enabled": true }, "persistence": { "enabled": true, "profileDir": "/data" } }
* Returns { list: string[] | null, configs: Map<string, object> }
*/
function readPluginConfig() {
const configs = new Map();
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
const config = JSON.parse(raw);
if (!config.plugins) return { list: null, configs };
if (Array.isArray(config.plugins)) {
return { list: config.plugins, configs };
}
if (typeof config.plugins === 'object') {
const list = [];
for (const [name, pluginConf] of Object.entries(config.plugins)) {
if (pluginConf === false || (typeof pluginConf === 'object' && pluginConf.enabled === false)) continue;
list.push(name);
if (typeof pluginConf === 'object') configs.set(name, pluginConf);
}
return { list, configs };
}
} catch {}
return { list: null, configs };
}
/**
* Create the plugin event bus.
*/
export function createPluginEvents() {
const events = new EventEmitter();
events.setMaxListeners(50); // generous for many plugins
/**
* Emit an event and await all listeners (including async ones).
* Use for mutating hooks where plugins must finish before core continues.
* Regular emit() is still used for fire-and-forget observational events.
*/
events.emitAsync = async function emitAsync(eventName, payload) {
const listeners = this.listeners(eventName);
await Promise.all(listeners.map(fn => fn(payload)));
};
return events;
}
/**
* Load and register all plugins from plugins/<name>/index.js.
*
* @param {object} app - Express app
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
* Mutable — plugins can replace ctx.createVirtualDisplay etc.
* @returns {string[]} - Names of loaded plugins
*/
export async function loadPlugins(app, ctx) {
const loaded = [];
if (!fs.existsSync(PLUGINS_DIR)) {
ctx.log('info', 'no plugins directory found, skipping plugin load');
return loaded;
}
const { list: allowList, configs: pluginConfigs } = readPluginConfig();
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
// Skip directories starting with _ or .
if (name.startsWith('_') || name.startsWith('.')) continue;
// If camofox.config.json specifies a plugins list, only load those
if (allowList && !allowList.includes(name)) {
ctx.log('debug', `plugin "${name}" not in camofox.config.json plugins list, skipping`);
continue;
}
const indexPath = path.join(PLUGINS_DIR, name, 'index.js');
if (!fs.existsSync(indexPath)) {
ctx.log('warn', `plugin "${name}" has no index.js, skipping`);
continue;
}
try {
const mod = await import(indexPath);
const register = mod.default || mod.register;
if (typeof register !== 'function') {
ctx.log('warn', `plugin "${name}" does not export a register function, skipping`);
continue;
}
const pluginConfig = pluginConfigs.get(name) || {};
await register(app, ctx, pluginConfig);
loaded.push(name);
ctx.log('info', 'plugin loaded', { plugin: name });
} catch (err) {
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
}
}
return loaded;
}
+40
View File
@@ -0,0 +1,40 @@
import fs from 'fs';
import path from 'path';
const ORPHAN_PATTERNS = [
/^\.fea5[a-f0-9]+\.so$/,
/^\.5ef7[a-f0-9]+\.node$/,
];
export function cleanupOrphanedTempFiles({ tmpDir, minAgeMs = 5 * 60 * 1000, now = Date.now() } = {}) {
const result = { scanned: 0, removed: 0, bytes: 0, skipped: 0 };
if (!tmpDir) return result;
let entries;
try {
entries = fs.readdirSync(tmpDir);
} catch {
return result;
}
for (const name of entries) {
if (!ORPHAN_PATTERNS.some((re) => re.test(name))) continue;
result.scanned++;
const full = path.join(tmpDir, name);
try {
const st = fs.statSync(full);
if (!st.isFile()) continue;
if (now - st.mtimeMs < minAgeMs) {
result.skipped++;
continue;
}
fs.unlinkSync(full);
result.removed++;
result.bytes += st.size;
} catch {
// file vanished, permission denied, or race with another process - skip silently
}
}
return result;
}
+43 -43
View File
@@ -1,12 +1,12 @@
{
"name": "@askjo/camofox-browser",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@askjo/camofox-browser",
"version": "1.5.0",
"version": "1.6.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -572,27 +572,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -830,9 +809,9 @@
}
},
"node_modules/@jest/reporters/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3366,9 +3345,9 @@
}
},
"node_modules/jest-config/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3697,9 +3676,9 @@
}
},
"node_modules/jest-runtime/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4197,20 +4176,41 @@
}
},
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimatch/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/minimatch/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -5230,9 +5230,9 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -5772,9 +5772,9 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@askjo/camofox-browser",
"version": "1.5.2",
"version": "1.6.0",
"description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
"type": "module",
"main": "server.js",
@@ -37,6 +37,8 @@
"files": [
"server.js",
"lib/",
"plugins/",
"camofox.config.json",
"plugin.ts",
"openclaw.plugin.json",
"scripts/",
@@ -54,8 +56,10 @@
"start": "node server.js",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit tests/e2e",
"test:plugins": "NODE_OPTIONS='--experimental-vm-modules' jest --forceExit plugins/",
"test:live": "RUN_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit tests/live",
"test:debug": "DEBUG_SERVER=1 NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --forceExit",
"plugin": "node scripts/plugin.js",
"version:sync": "node scripts/sync-version.js",
"version": "node scripts/sync-version.js && git add openclaw.plugin.json",
"postinstall": "npx camoufox-js fetch || true"
+37
View File
@@ -0,0 +1,37 @@
# Persistence Plugin — Agent Guide
Saves and restores per-user browser storage state (cookies + localStorage) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.
## How It Works
- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`
- `session:created` hook → imports bootstrap cookies if no persisted state exists
- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk
All hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.
## Key Files
- `index.js` — lifecycle hooks (no routes, no `child_process`)
- `persistence.test.js` — unit tests for `lib/persistence.js` helpers
- `plugin.test.js` — integration tests for plugin lifecycle hooks
## Storage Layout
```
~/.camofox/profiles/
└── <sha256(userId)>/
└── storage_state.json
```
## Configuration
Enabled by default. Override profile directory with `CAMOFOX_PROFILE_DIR` env var or `"profileDir"` in plugin config. To disable: `"persistence": { "enabled": false }` in `camofox.config.json`.
## Original Contributors
- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))
- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))
- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing
For PRs touching this plugin, tag the contributors above for review.
+48
View File
@@ -0,0 +1,48 @@
# persistence
Optional per-user browser storage state persistence for camofox-browser.
Saves and restores cookies + localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API.
## Configuration
In `camofox.config.json`:
```json
{
"plugins": {
"persistence": {
"enabled": true,
"profileDir": "/data/profiles"
}
}
}
```
Or override via environment variable:
```
CAMOFOX_PROFILE_DIR=/data/profiles
```
## How it works
- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.
- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).
- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.
- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.
## Docker
When running with Docker, mount the profile directory as a volume:
```bash
docker run -d \
-p 9377:9377 \
-v /host/profiles:/data/profiles \
camofox-browser
```
## Credits
Based on PR #62 by [company8](https://github.com/company8).
+120
View File
@@ -0,0 +1,120 @@
/**
* Persistence plugin for camofox-browser.
*
* Saves and restores per-user browser storage state (cookies + localStorage)
* across session restarts using Playwright's storageState API.
*
* Configuration (camofox.config.json):
* {
* "plugins": {
* "persistence": {
* "enabled": true,
* "profileDir": "/data/profiles"
* }
* }
* }
*
* Or via environment variables (overrides config file):
* CAMOFOX_PROFILE_DIR=/data/profiles
*
* Each userId gets a deterministic SHA256-hashed subdirectory under profileDir.
* Storage state is checkpointed on cookie import, session close, and shutdown.
* On session creation, saved state is restored into the new Playwright context
* via the session:creating hook (mutates contextOptions.storageState).
*/
import {
getUserPersistencePaths,
loadPersistedStorageState,
persistStorageState,
} from '../../lib/persistence.js';
import { importBootstrapCookies } from '../../lib/cookies.js';
export async function register(app, ctx, pluginConfig = {}) {
const { events, config, log } = ctx;
// Resolve profileDir: env var > plugin config > global config default (~/.camofox/profiles)
const profileDir = process.env.CAMOFOX_PROFILE_DIR || pluginConfig.profileDir || config.profileDir;
if (!profileDir) {
log('warn', 'persistence plugin: no profileDir configured, plugin disabled');
return;
}
const logger = {
warn: (msg, fields = {}) => log('warn', msg, fields),
};
log('info', 'persistence plugin enabled', { profileDir });
// Track active sessions for checkpoint on close
const activeSessions = new Map(); // userId -> context
/**
* Checkpoint storage state to disk for a userId.
*/
async function checkpoint(userId, context, reason) {
if (!context) return;
const result = await persistStorageState({ profileDir, userId, context, logger });
if (result.persisted) {
log('info', 'storage state persisted', { userId, reason, path: result.storageStatePath });
}
return result;
}
// --- Lifecycle hooks ---
// Before session context is created: inject storageState if we have one saved
events.on('session:creating', async ({ userId, contextOptions }) => {
const storageStatePath = await loadPersistedStorageState(profileDir, userId, logger);
if (storageStatePath) {
contextOptions.storageState = storageStatePath;
log('info', 'restoring persisted storage state', { userId, storageStatePath });
}
});
// After session is created: import bootstrap cookies if no persisted state,
// and track the context for later checkpointing
events.on('session:created', async ({ userId, context }) => {
activeSessions.set(userId, context);
// If no persisted state was restored, try bootstrap cookies
const existingState = await loadPersistedStorageState(profileDir, userId, logger);
if (!existingState) {
const result = await importBootstrapCookies({
cookiesDir: config.cookiesDir,
context,
logger,
});
if (result.imported > 0) {
log('info', 'bootstrap cookies imported', { userId, count: result.imported, source: result.source });
await checkpoint(userId, context, 'bootstrap_cookies');
}
}
});
// On cookie import: checkpoint
events.on('session:cookies:import', async ({ userId }) => {
const context = activeSessions.get(userId);
if (context) {
await checkpoint(userId, context, 'cookie_import');
}
});
// On session destroy: checkpoint then remove from tracking
events.on('session:destroyed', async ({ userId, reason }) => {
const context = activeSessions.get(userId);
if (context) {
// Context may already be closed — checkpoint will fail gracefully
await checkpoint(userId, context, reason).catch(() => {});
activeSessions.delete(userId);
}
});
// On shutdown: checkpoint all remaining sessions
events.on('server:shutdown', async () => {
for (const [userId, context] of activeSessions) {
await checkpoint(userId, context, 'shutdown').catch(() => {});
}
activeSessions.clear();
});
}
+117
View File
@@ -0,0 +1,117 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { jest } from '@jest/globals';
import {
getUserPersistencePaths,
loadPersistedStorageState,
persistStorageState,
} from '../../lib/persistence.js';
describe('profile persistence helpers', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persistence-'));
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test('getUserPersistencePaths is deterministic and stays under root', () => {
const first = getUserPersistencePaths(tmpDir, 'agent/profile:default');
const second = getUserPersistencePaths(tmpDir, 'agent/profile:default');
expect(first).toEqual(second);
expect(first.userDir.startsWith(tmpDir)).toBe(true);
expect(first.storageStatePath.startsWith(first.userDir)).toBe(true);
expect(first.metaPath.startsWith(first.userDir)).toBe(true);
expect(path.basename(first.userDir)).not.toContain('/');
expect(path.basename(first.userDir)).not.toContain(':');
});
test('loadPersistedStorageState returns undefined when no state exists', async () => {
await expect(loadPersistedStorageState(tmpDir, 'user-1')).resolves.toBeUndefined();
});
test('persistStorageState writes storage state and metadata, then load returns the storage path', async () => {
const storageState = {
cookies: [{ name: 'session', value: 'abc', domain: '.example.com', path: '/' }],
origins: [{ origin: 'https://app.example.com', localStorage: [{ name: 'foo', value: 'bar' }] }],
};
const context = {
storageState: jest.fn(async ({ path: targetPath }) => {
await fs.writeFile(targetPath, JSON.stringify(storageState, null, 2));
}),
};
const result = await persistStorageState({
profileDir: tmpDir,
userId: 'user-1',
context,
logger: { warn: jest.fn() },
});
expect(result.persisted).toBe(true);
expect(context.storageState).toHaveBeenCalledTimes(1);
const loadedPath = await loadPersistedStorageState(tmpDir, 'user-1');
expect(loadedPath).toBe(result.storageStatePath);
const meta = JSON.parse(await fs.readFile(result.metaPath, 'utf8'));
expect(meta.userId).toBe('user-1');
expect(meta.storageStatePath).toBe(result.storageStatePath);
});
test('loadPersistedStorageState ignores invalid JSON files', async () => {
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
await fs.mkdir(path.dirname(storageStatePath), { recursive: true });
await fs.writeFile(storageStatePath, '{not-json');
await expect(loadPersistedStorageState(tmpDir, 'user-2', { warn: jest.fn() })).resolves.toBeUndefined();
});
test('a failed persist leaves the previous storage-state intact and cleans up tmp files', async () => {
const originalState = {
cookies: [{ name: 'orig', value: 'v1', domain: '.example.com', path: '/' }],
};
const goodContext = {
storageState: jest.fn(async ({ path: targetPath }) => {
await fs.writeFile(targetPath, JSON.stringify(originalState, null, 2));
}),
};
const first = await persistStorageState({
profileDir: tmpDir,
userId: 'user-3',
context: goodContext,
logger: { warn: jest.fn() },
});
expect(first.persisted).toBe(true);
const failingContext = {
storageState: jest.fn(async () => {
throw new Error('simulated crash mid-write');
}),
};
const second = await persistStorageState({
profileDir: tmpDir,
userId: 'user-3',
context: failingContext,
logger: { warn: jest.fn() },
});
expect(second.persisted).toBe(false);
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-3');
const loaded = await loadPersistedStorageState(tmpDir, 'user-3');
expect(loaded).toBe(storageStatePath);
const parsed = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
expect(parsed).toEqual(originalState);
const leftovers = (await fs.readdir(userDir)).filter((name) => name.includes('.tmp-'));
expect(leftovers).toEqual([]);
});
});
+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { jest } from '@jest/globals';
import { createPluginEvents } from '../../lib/plugins.js';
import { register } from './index.js';
describe('persistence plugin', () => {
let tmpDir, events, ctx, mockApp;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-persist-plugin-'));
events = createPluginEvents();
mockApp = {};
ctx = {
events,
config: { cookiesDir: path.join(tmpDir, 'cookies') },
log: jest.fn(),
};
});
afterEach(async () => {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
});
test('skips registration when no profileDir configured', async () => {
await register(mockApp, ctx, {});
expect(ctx.log).toHaveBeenCalledWith('warn', expect.stringContaining('no profileDir'));
});
test('restores persisted state on session:creating', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
// Simulate a prior persisted state
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
const { userDir, storageStatePath } = getUserPersistencePaths(tmpDir, 'user-1');
await fs.mkdir(userDir, { recursive: true });
await fs.writeFile(storageStatePath, JSON.stringify({
cookies: [{ name: 'sid', value: 'abc', domain: '.example.com', path: '/' }],
origins: [],
}));
const contextOptions = { viewport: { width: 1280, height: 720 } };
await events.emitAsync('session:creating', { userId: 'user-1', contextOptions });
expect(contextOptions.storageState).toBe(storageStatePath);
});
test('checkpoints on session:cookies:import', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
const mockContext = {
storageState: jest.fn(async ({ path: p }) => {
await fs.writeFile(p, JSON.stringify({ cookies: [{ name: 'x', value: 'y', domain: '.test.com', path: '/' }] }));
}),
};
// Simulate session created then cookie import
await events.emitAsync('session:created', { userId: 'user-2', context: mockContext });
await events.emitAsync('session:cookies:import', { userId: 'user-2' });
expect(mockContext.storageState).toHaveBeenCalled();
// Verify file was written
const { getUserPersistencePaths } = await import('../../lib/persistence.js');
const { storageStatePath } = getUserPersistencePaths(tmpDir, 'user-2');
const saved = JSON.parse(await fs.readFile(storageStatePath, 'utf8'));
expect(saved.cookies[0].name).toBe('x');
});
test('checkpoints on session:destroyed', async () => {
await register(mockApp, ctx, { profileDir: tmpDir });
const mockContext = {
storageState: jest.fn(async ({ path: p }) => {
await fs.writeFile(p, JSON.stringify({ cookies: [], origins: [] }));
}),
};
await events.emitAsync('session:created', { userId: 'user-3', context: mockContext });
await events.emitAsync('session:destroyed', { userId: 'user-3', reason: 'test' });
expect(mockContext.storageState).toHaveBeenCalled();
});
test('env var CAMOFOX_PROFILE_DIR overrides pluginConfig', async () => {
const envDir = path.join(tmpDir, 'env-override');
const orig = process.env.CAMOFOX_PROFILE_DIR;
process.env.CAMOFOX_PROFILE_DIR = envDir;
try {
await register(mockApp, ctx, { profileDir: '/should/not/use' });
expect(ctx.log).toHaveBeenCalledWith('info', 'persistence plugin enabled', { profileDir: envDir });
} finally {
if (orig === undefined) delete process.env.CAMOFOX_PROFILE_DIR;
else process.env.CAMOFOX_PROFILE_DIR = orig;
}
});
});
+42
View File
@@ -0,0 +1,42 @@
# VNC Plugin — Agent Guide
Interactive browser access via noVNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for agent reuse.
## Endpoints
- `GET /vnc/status` — check if VNC is running (no auth)
- `GET /sessions/:userId/storage_state` — export cookies + localStorage as JSON (requires auth)
## Activation
Disabled by default. Enable with `ENABLE_VNC=1` env var or `"vnc": { "enabled": true }` in `camofox.config.json`.
## Key Files
- `index.js` — route handlers only (no `child_process`, no `process.env` reads)
- `vnc-launcher.js` — process management, config resolution from env vars (`child_process` isolated here)
- `vnc-watcher.sh` — shell script that detects Xvfb, attaches x11vnc, starts noVNC
- `vnc.test.js` — unit tests
- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)
## Scanner Compliance
`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per OpenClaw scanner rules.
## Security
- noVNC binds to `127.0.0.1` by default — set `VNC_BIND=0.0.0.0` to expose externally
- Set `VNC_PASSWORD` for password-protected access
- `VIEW_ONLY=1` disables keyboard/mouse input (observation only)
- Storage state export endpoint requires auth (API key or loopback)
## Architecture
The plugin overrides `ctx.createVirtualDisplay` to use a higher-resolution display (default 1920x1080 instead of 1x1). `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.
## Original Contributors
- [@leoneparise](https://github.com/leoneparise) — original VNC implementation + keyboard mode ([PR #65](https://github.com/jo-inc/camofox-browser/pull/65), [PR #66](https://github.com/jo-inc/camofox-browser/pull/66))
- [@pradeepe](https://github.com/pradeepe) — plugin system integration, scanner compliance refactor, security hardening
For PRs touching this plugin, tag the contributors above for review.
+165
View File
@@ -0,0 +1,165 @@
# VNC Plugin
> Originally contributed by [@leoneparise](https://github.com/leoneparise) in [PR #65](https://github.com/jo-inc/camofox-browser/pull/65). Reworked as a plugin for the camofox extension system.
Interactive browser access via VNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for reuse by your agent.
## How it works
```
Camoufox (Xvfb :99, 1920x1080)
↑
x11vnc (attaches to :99, port 5900)
↑
noVNC / websockify (port 6080)
↑
Your browser → http://localhost:6080/vnc.html
```
The plugin overrides Camoufox's default 1x1 virtual display with a human-usable resolution, then runs a watcher process that detects the Xvfb display and attaches x11vnc + noVNC. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.
## Quick start
### Docker
```bash
docker run -p 9377:9377 -p 6080:6080 \
-e ENABLE_VNC=1 \
camofox-browser
# Open http://localhost:6080/vnc.html in your browser
```
### Config file
```json
{
"plugins": {
"vnc": {
"enabled": true,
"resolution": "1920x1080",
"password": "optional-secret",
"viewOnly": false,
"novncPort": 6080
}
}
}
```
## Workflow: interactive login → agent reuse
1. **Start with VNC enabled:**
```bash
docker run -p 9377:9377 -p 6080:6080 -e ENABLE_VNC=1 camofox-browser
```
2. **Create a session and navigate to the login page:**
```bash
curl -X POST http://localhost:9377/tabs \
-H 'Content-Type: application/json' \
-d '{"userId": "my-agent", "sessionKey": "default", "url": "https://accounts.google.com"}'
```
3. **Log in visually** via http://localhost:6080/vnc.html — complete MFA, solve CAPTCHAs, etc.
4. **Export the authenticated state:**
```bash
curl http://localhost:9377/sessions/my-agent/storage_state \
-H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \
-o storage_state.json
```
5. **Reuse on future runs** — pair with the [persistence plugin](../persistence/) to automatically restore state on session creation:
```json
{
"plugins": {
"vnc": { "enabled": true },
"persistence": { "enabled": true, "profileDir": "/data/profiles" }
}
}
```
With both plugins active, the persistence plugin automatically checkpoints storage state on session close and restores it on creation. The VNC plugin's export endpoint also triggers a persistence checkpoint via the `session:storage:export` event.
## API
### GET /sessions/:userId/storage_state
Export the full Playwright storage state (cookies + localStorage origins) for a user's active browser context.
**Auth:** Same as cookie import — requires `CAMOFOX_API_KEY` Bearer token, or loopback access in non-production.
**Response:**
```json
{
"cookies": [
{
"name": "session_id",
"value": "abc123",
"domain": ".example.com",
"path": "/",
"expires": 1700000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://example.com",
"localStorage": [
{ "name": "theme", "value": "dark" }
]
}
]
}
```
**Errors:**
- `404` — No active session for the given userId
- `403` — Missing or invalid API key
- `500` — Context is dead or storageState export failed
## Configuration
| Source | Variable | Description | Default |
|--------|----------|-------------|---------|
| env | `ENABLE_VNC` | Enable the plugin (`1`) | off |
| env | `VNC_PASSWORD` | x11vnc password | none (open) |
| env | `VNC_RESOLUTION` | Xvfb screen resolution | `1920x1080` |
| env | `VIEW_ONLY` | Disable mouse/keyboard input (`1`) | off |
| env | `VNC_PORT` | x11vnc listen port | `5900` |
| env | `NOVNC_PORT` | noVNC web UI port | `6080` |
| config | `plugins.vnc.enabled` | Enable the plugin | `false` |
| config | `plugins.vnc.password` | x11vnc password | none |
| config | `plugins.vnc.resolution` | Xvfb screen resolution | `1920x1080` |
| config | `plugins.vnc.viewOnly` | View-only mode | `false` |
| config | `plugins.vnc.vncPort` | x11vnc listen port | `5900` |
| config | `plugins.vnc.novncPort` | noVNC web UI port | `6080` |
Environment variables override config file values.
## Security
⚠️ **VNC is unencrypted by default.** When running in production:
- **Set `VNC_PASSWORD`** — without it, anyone who can reach port 6080 has full browser control
- **Bind 6080 to localhost** and access via SSH tunnel: `ssh -L 6080:localhost:6080 your-server`
- **Or use a firewall** to restrict access to port 6080
- In Docker: `-p 127.0.0.1:6080:6080` binds only to localhost
## System dependencies
The plugin declares its apt dependencies in `apt.txt` — these are installed automatically during `docker build` via `scripts/install-plugin-deps.sh`:
- `x11vnc` — attaches to Xvfb display
- `novnc` + `python3-websockify` — web-based VNC client
- `net-tools` + `procps` — display detection utilities
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `vnc:watcher:started` | `{ pid }` | Watcher process spawned |
| `vnc:watcher:stopped` | `{ code, signal }` | Watcher exited |
| `vnc:storage:exported` | `{ userId, cookies, origins }` | Storage state exported via API |
| `session:storage:export` | `{ userId }` | Emitted after export (persistence plugin listens) |
+7
View File
@@ -0,0 +1,7 @@
# VNC stack: x11vnc attaches to Camoufox's Xvfb, noVNC + websockify expose it over HTTP
x11vnc
novnc
python3-websockify
# Utilities for display detection
net-tools
procps
+142
View File
@@ -0,0 +1,142 @@
/**
* VNC plugin for camofox-browser.
*
* Exposes Camoufox's virtual display via noVNC so a human can interact with
* the browser visually — log into sites, solve CAPTCHAs, approve OAuth prompts.
* After interactive login, export the storage state via the API endpoint this
* plugin registers.
*
* Architecture:
* Plugin replaces the default 1x1 Xvfb with a 1920x1080 display (via
* ctx.createVirtualDisplay factory override). vnc-watcher.sh detects the
* Xvfb process, attaches x11vnc, and noVNC (websockify) proxies it to a
* web UI on port 6080.
*
* Configuration (camofox.config.json):
* {
* "plugins": {
* "vnc": {
* "enabled": true,
* "resolution": "1920x1080",
* "password": "",
* "viewOnly": false,
* "vncPort": 5900,
* "novncPort": 6080
* }
* }
* }
*
* Or via environment variables (override config):
* ENABLE_VNC=1 Enable the plugin
* VNC_RESOLUTION=1920x1080
* VNC_PASSWORD=secret Optional password for x11vnc
* VIEW_ONLY=1 View-only mode (no mouse/keyboard input)
* VNC_PORT=5900 x11vnc listen port
* NOVNC_PORT=6080 noVNC web UI port
*
* Registers:
* GET /sessions/:userId/storage_state — export Playwright storageState as JSON
*
* Events emitted:
* vnc:watcher:started { pid }
* vnc:watcher:stopped { code, signal }
* vnc:storage:exported { userId, cookies, origins }
*/
import { resolveVncConfig, startWatcher } from './vnc-launcher.js';
import { requireAuth } from '../../lib/auth.js';
export async function register(app, ctx, pluginConfig = {}) {
const { events, config, log, sessions, VirtualDisplay, safeError } = ctx;
// Resolve all config (env vars + pluginConfig) via the launcher module
const vncConfig = resolveVncConfig(pluginConfig);
if (!vncConfig.enabled) {
log('info', 'vnc plugin: disabled (set ENABLE_VNC=1 or plugins.vnc.enabled=true)');
return;
}
// --- Override Xvfb resolution ---
const { resolution } = vncConfig;
class VncVirtualDisplay extends VirtualDisplay {
get xvfb_args() {
const args = super.xvfb_args;
const idx = args.indexOf('0');
if (idx > 0 && args[idx - 1] === '-screen') {
const patched = [...args];
patched[idx + 1] = resolution;
return patched;
}
return args;
}
}
ctx.createVirtualDisplay = () => new VncVirtualDisplay();
log('info', 'vnc plugin: overriding Xvfb resolution', { resolution });
// --- VNC watcher process ---
log('info', 'vnc plugin enabled', {
resolution,
novncPort: vncConfig.novncPort,
vncPort: vncConfig.vncPort,
viewOnly: vncConfig.viewOnly,
passwordProtected: !!vncConfig.vncPassword,
});
const watcher = startWatcher({
resolution: vncConfig.resolution,
vncPassword: vncConfig.vncPassword,
viewOnly: vncConfig.viewOnly,
vncPort: vncConfig.vncPort,
novncPort: vncConfig.novncPort,
log,
events,
});
// Clean up watcher on server shutdown
events.on('server:shutdown', () => {
if (watcher.exitCode === null) {
log('info', 'killing vnc watcher on shutdown');
watcher.kill('SIGTERM');
}
});
// --- HTTP endpoint: GET /sessions/:userId/storage_state ---
const authMiddleware = requireAuth(config);
app.get('/sessions/:userId/storage_state', authMiddleware, async (req, res) => {
try {
const userId = req.params.userId;
const session = sessions.get(String(userId));
if (!session) {
return res.status(404).json({ error: `No active session for userId="${userId}"` });
}
const state = await session.context.storageState();
log('info', 'storage_state exported', {
reqId: req.reqId,
userId: String(userId),
cookies: state.cookies?.length || 0,
origins: state.origins?.length || 0,
});
events.emit('vnc:storage:exported', {
userId: String(userId),
cookies: state.cookies?.length || 0,
origins: state.origins?.length || 0,
});
events.emit('session:storage:export', { userId: String(userId) });
res.json(state);
} catch (err) {
log('error', 'storage_state export failed', { reqId: req.reqId, error: err.message });
res.status(500).json({ error: safeError(err) });
}
});
log('info', 'vnc plugin: registered GET /sessions/:userId/storage_state');
}
+64
View File
@@ -0,0 +1,64 @@
/**
* VNC launcher — owns all child_process spawning and process.env reads.
* Isolated from route handlers for OpenClaw scanner compliance.
*/
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Resolve VNC configuration from pluginConfig + env var fallbacks.
* All process.env reads live here — callers get a plain config object.
*/
export function resolveVncConfig(pluginConfig = {}) {
const enabled = process.env.ENABLE_VNC === '1' || pluginConfig.enabled === true;
const rawResolution = process.env.VNC_RESOLUTION || pluginConfig.resolution || '1920x1080';
const resolution = rawResolution.includes('x', rawResolution.indexOf('x') + 1)
? rawResolution
: `${rawResolution}x24`;
const vncPassword = process.env.VNC_PASSWORD || pluginConfig.password || '';
const viewOnly = process.env.VIEW_ONLY === '1' || pluginConfig.viewOnly === true;
const vncPort = process.env.VNC_PORT || pluginConfig.vncPort || '5900';
const novncPort = process.env.NOVNC_PORT || pluginConfig.novncPort || '6080';
return { enabled, resolution, vncPassword, viewOnly, vncPort, novncPort };
}
/**
* Start the vnc-watcher.sh child process.
* Returns the spawned ChildProcess.
*/
export function startWatcher({ resolution, vncPassword, viewOnly, vncPort, novncPort, log, events }) {
const watcherPath = path.join(__dirname, 'vnc-watcher.sh');
const watcher = spawn('sh', [watcherPath], {
env: {
...process.env,
VNC_PASSWORD: vncPassword,
VNC_RESOLUTION: resolution,
VIEW_ONLY: viewOnly ? '1' : '0',
VNC_PORT: String(vncPort),
NOVNC_PORT: String(novncPort),
},
stdio: ['ignore', 'inherit', 'inherit'],
detached: false,
});
watcher.on('error', (err) => {
log('error', 'vnc watcher failed to start', { error: err.message });
});
watcher.on('exit', (code, signal) => {
log('warn', 'vnc watcher exited', { code, signal });
events.emit('vnc:watcher:stopped', { code, signal });
});
log('info', 'vnc watcher started', { pid: watcher.pid });
events.emit('vnc:watcher:started', { pid: watcher.pid });
return watcher;
}
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# VNC watcher: detects Camoufox's dynamically-assigned Xvfb display and attaches
# x11vnc + noVNC to it. Handles browser restarts (re-attaches on display change).
#
# Called by the VNC plugin via child_process.spawn. Not meant to run standalone.
#
# Env vars (set by the plugin):
# VNC_PASSWORD If set, x11vnc requires this password
# VIEW_ONLY "1" for view-only mode
# VNC_PORT VNC port (default: 5900)
# NOVNC_PORT noVNC websocket port (default: 6080)
set -e
VNC_PORT="${VNC_PORT:-5900}"
NOVNC_PORT="${NOVNC_PORT:-6080}"
VNC_RESOLUTION="${VNC_RESOLUTION:-1920x1080x24}"
log() { printf '[vnc-watcher] %s\n' "$*" >&2; }
CURRENT_DISPLAY=""
X11VNC_PID=""
# Prepare password file if requested
PASSFILE=""
if [ -n "${VNC_PASSWORD:-}" ]; then
mkdir -p /tmp/.vnc
x11vnc -storepasswd "$VNC_PASSWORD" /tmp/.vnc/passwd >/dev/null 2>&1
PASSFILE="/tmp/.vnc/passwd"
log "x11vnc: password protected"
else
log "x11vnc: NO password (bind $NOVNC_PORT to 127.0.0.1 on host + SSH tunnel)"
fi
# Start noVNC (websockify) — proxies to x11vnc regardless of whether it's up yet
NOVNC_DIR="/usr/share/novnc"
if [ ! -d "$NOVNC_DIR" ]; then
log "ERROR: $NOVNC_DIR not found; noVNC cannot start"
exit 1
fi
VNC_BIND="${VNC_BIND:-127.0.0.1}"
log "Starting noVNC (websockify) on $VNC_BIND:$NOVNC_PORT -> 127.0.0.1:$VNC_PORT"
websockify --web "$NOVNC_DIR" "$VNC_BIND:$NOVNC_PORT" "127.0.0.1:$VNC_PORT" >/var/log/novnc.log 2>&1 &
log "VNC watcher started — will attach x11vnc when Camoufox's Xvfb appears"
while true; do
# Find Xvfb with our patched resolution
FOUND=$(ps -eo args= 2>/dev/null | awk -v res="$VNC_RESOLUTION" '
/\/Xvfb :[0-9]+/ && index($0, res) {
for (i=1;i<=NF;i++) if ($i ~ /^:[0-9]+$/) { print $i; exit }
}
' | head -1)
if [ -n "$FOUND" ] && [ "$FOUND" != "$CURRENT_DISPLAY" ]; then
# New or changed display — (re)attach x11vnc
if [ -n "$X11VNC_PID" ] && kill -0 "$X11VNC_PID" 2>/dev/null; then
log "Camoufox display changed ($CURRENT_DISPLAY -> $FOUND), restarting x11vnc"
kill "$X11VNC_PID" 2>/dev/null || true
sleep 0.5
fi
CURRENT_DISPLAY="$FOUND"
log "Attaching x11vnc to DISPLAY=$CURRENT_DISPLAY"
X11VNC_ARGS="-display $CURRENT_DISPLAY -forever -shared -rfbport $VNC_PORT -noxdamage -quiet -bg -o /var/log/x11vnc.log"
[ "${VIEW_ONLY:-0}" = "1" ] && X11VNC_ARGS="$X11VNC_ARGS -viewonly"
if [ -n "$PASSFILE" ]; then
X11VNC_ARGS="$X11VNC_ARGS -rfbauth $PASSFILE"
else
X11VNC_ARGS="$X11VNC_ARGS -nopw"
fi
# shellcheck disable=SC2086
x11vnc $X11VNC_ARGS
sleep 1
X11VNC_PID=$(pgrep -f "x11vnc.*-display $CURRENT_DISPLAY" | head -1)
log "x11vnc running (pid=$X11VNC_PID) on DISPLAY=$CURRENT_DISPLAY"
fi
sleep 2
done
+204
View File
@@ -0,0 +1,204 @@
import { EventEmitter } from 'node:events';
import { jest } from '@jest/globals';
// Mock the launcher module — index.js no longer imports child_process directly
const mockWatcher = () => {
const proc = new EventEmitter();
proc.pid = 12345;
proc.exitCode = null;
proc.kill = jest.fn();
return proc;
};
const mockStartWatcher = jest.fn(mockWatcher);
const mockResolveVncConfig = jest.fn((pluginConfig = {}) => ({
enabled: pluginConfig.enabled || false,
resolution: pluginConfig.resolution
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
: '1920x1080x24',
vncPassword: pluginConfig.password || '',
viewOnly: pluginConfig.viewOnly || false,
vncPort: pluginConfig.vncPort || '5900',
novncPort: pluginConfig.novncPort || '6080',
}));
jest.unstable_mockModule('./vnc-launcher.js', () => ({
resolveVncConfig: mockResolveVncConfig,
startWatcher: mockStartWatcher,
}));
// Mock auth middleware
jest.unstable_mockModule('../../lib/auth.js', () => ({
requireAuth: () => (_req, _res, next) => next(),
}));
// Minimal VirtualDisplay mock (real class has side-effects that break in test)
class MockVirtualDisplay {
get xvfb_args() {
return ['-screen', '0', '1x1x24', '-ac', '-nolisten', 'tcp'];
}
}
const { register } = await import('./index.js');
describe('vnc plugin', () => {
let events, ctx, mockApp, routes;
beforeEach(() => {
events = new EventEmitter();
events.setMaxListeners(50);
routes = {};
mockApp = {
get: jest.fn((path, ...handlers) => { routes[`GET ${path}`] = handlers; }),
};
ctx = {
events,
config: {},
log: jest.fn(),
sessions: new Map(),
safeError: (err) => typeof err === 'string' ? err : (err?.message || 'Internal error'),
VirtualDisplay: MockVirtualDisplay,
createVirtualDisplay: () => new MockVirtualDisplay(),
};
mockStartWatcher.mockClear();
mockStartWatcher.mockImplementation(mockWatcher);
mockResolveVncConfig.mockClear();
mockResolveVncConfig.mockImplementation((pluginConfig = {}) => ({
enabled: pluginConfig.enabled || false,
resolution: pluginConfig.resolution
? (pluginConfig.resolution.split('x').length > 2 ? pluginConfig.resolution : `${pluginConfig.resolution}x24`)
: '1920x1080x24',
vncPassword: pluginConfig.password || '',
viewOnly: pluginConfig.viewOnly || false,
vncPort: pluginConfig.vncPort || '5900',
novncPort: pluginConfig.novncPort || '6080',
}));
});
test('does not register when disabled', async () => {
await register(mockApp, ctx, {});
expect(mockStartWatcher).not.toHaveBeenCalled();
expect(mockApp.get).not.toHaveBeenCalled();
});
test('registers when pluginConfig.enabled is true', async () => {
await register(mockApp, ctx, { enabled: true });
expect(mockStartWatcher).toHaveBeenCalled();
expect(mockApp.get).toHaveBeenCalledWith(
'/sessions/:userId/storage_state',
expect.any(Function),
expect.any(Function),
);
});
test('passes resolved config to startWatcher', async () => {
await register(mockApp, ctx, { enabled: true, password: 'secret', vncPort: 5901 });
expect(mockStartWatcher).toHaveBeenCalledWith(
expect.objectContaining({
vncPassword: 'secret',
vncPort: 5901,
log: ctx.log,
events,
}),
);
});
test('overrides createVirtualDisplay with custom resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1280x720' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1280x720x24');
});
test('appends x24 depth to WxH resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1920x1080x24');
});
test('preserves explicit depth in resolution', async () => {
await register(mockApp, ctx, { enabled: true, resolution: '1920x1080x32' });
const vd = ctx.createVirtualDisplay();
const args = vd.xvfb_args;
const screenIdx = args.indexOf('0');
expect(args[screenIdx + 1]).toBe('1920x1080x32');
});
test('storage_state endpoint returns 404 for unknown user', async () => {
await register(mockApp, ctx, { enabled: true });
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'unknown' }, reqId: 'test' };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
});
test('storage_state endpoint returns state for active session', async () => {
await register(mockApp, ctx, { enabled: true });
const mockState = { cookies: [{ name: 'sid', value: 'abc' }], origins: [] };
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => mockState) },
});
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'user-1' }, reqId: 'test' };
const res = { json: jest.fn() };
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(mockState);
});
test('storage_state endpoint uses safeError on failure', async () => {
await register(mockApp, ctx, { enabled: true });
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => { throw new Error('context destroyed'); }) },
});
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
const req = { params: { userId: 'user-1' }, reqId: 'test' };
const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
// safeError returns the message string — not the raw Error object
expect(res.json).toHaveBeenCalledWith({ error: 'context destroyed' });
});
test('emits vnc:storage:exported and session:storage:export on export', async () => {
await register(mockApp, ctx, { enabled: true });
ctx.sessions.set('user-1', {
context: { storageState: jest.fn(async () => ({ cookies: [], origins: [] })) },
});
const exported = [];
events.on('vnc:storage:exported', (e) => exported.push(e));
events.on('session:storage:export', (e) => exported.push(e));
const handler = routes['GET /sessions/:userId/storage_state'].at(-1);
await handler(
{ params: { userId: 'user-1' }, reqId: 'test' },
{ json: jest.fn() },
);
expect(exported).toHaveLength(2);
expect(exported[0]).toMatchObject({ userId: 'user-1' });
});
test('watcher is killed on server:shutdown', async () => {
await register(mockApp, ctx, { enabled: true });
const proc = mockStartWatcher.mock.results[0].value;
events.emit('server:shutdown');
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
});
});
+25
View File
@@ -0,0 +1,25 @@
# YouTube Plugin — Agent Guide
Extracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.
## Endpoint
`POST /youtube/transcript` — unauthenticated by default (set `"auth": true` in plugin config to require auth).
## Key Files
- `index.js` — route handler + browser fallback logic
- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)
- `youtube.test.js` — parser unit tests
- `apt.txt` — system deps (python3-minimal for yt-dlp)
- `post-install.sh` — downloads yt-dlp binary
## Scanner Compliance
`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per OpenClaw scanner rules.
## Maintainers
- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system
For PRs touching this plugin, tag the maintainers above for review.
+1
View File
@@ -0,0 +1 @@
python3-minimal
+206
View File
@@ -0,0 +1,206 @@
/**
* YouTube transcript plugin.
*
* Extracts video transcripts via yt-dlp (preferred) with browser fallback.
* Registers POST /youtube/transcript.
*/
import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './youtube.js';
import { classifyError } from '../../lib/request-utils.js';
export async function register(app, ctx, pluginConfig = {}) {
const { log, config, sessions, ensureBrowser, getSession,
withUserLimit, safePageClose, normalizeUserId,
validateUrl, safeError, buildProxyUrl, proxyPool,
failuresTotal } = ctx;
const NAVIGATE_TIMEOUT_MS = config.navigateTimeoutMs;
// Detect yt-dlp binary at load time
await detectYtDlp(log);
// Auth is on by default; set { "auth": false } in camofox.config.json to disable
// Auth off by default — matches pre-plugin behavior. Set { "auth": true } to require auth.
const middleware = pluginConfig.auth === true ? ctx.auth() : (_req, _res, next) => next();
app.post('/youtube/transcript', middleware, 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';
// Re-detect yt-dlp if startup detection failed (transient issue)
await ensureYtDlp(log);
const ytDlpProxyUrl = buildProxyUrl(proxyPool, config.proxy);
log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser', hasProxy: !!ytDlpProxyUrl });
let result;
if (hasYtDlp()) {
try {
result = await ytDlpTranscript(reqId, url, videoId, lang, ytDlpProxyUrl);
} catch (ytErr) {
log('warn', 'yt-dlp threw, falling back to browser', { reqId, error: ytErr.message });
result = null;
}
// If yt-dlp returned an error result (e.g. no captions) or threw, try browser
if (!result || result.status !== 'ok') {
if (result) log('warn', 'yt-dlp returned error, falling back to browser', { reqId, status: result.status, code: result.code });
result = await browserTranscript(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) {
failuresTotal.labels(classifyError(err), 'youtube_transcript').inc();
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);
// Extract caption track URLs and metadata from ytInitialPlayerResponse
const meta = await page.evaluate(() => {
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
if (!r) return { title: '', tracks: [] };
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
return {
title: r?.videoDetails?.title || '',
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
};
});
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
if (meta.tracks && meta.tracks.length > 0) {
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
if (track && track.url) {
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
try {
const captionResp = await page.evaluate(async (fetchUrl) => {
const resp = await fetch(fetchUrl);
return resp.ok ? await resp.text() : null;
}, captionUrl);
if (captionResp && captionResp.length > 0) {
let transcriptText = null;
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
if (transcriptText && transcriptText.trim()) {
return {
status: 'ok', transcript: transcriptText,
video_url: url, video_id: videoId, video_title: meta.title,
language: track.code, total_words: transcriptText.split(/\s+/).length,
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
};
}
}
} catch (fetchErr) {
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
}
}
}
// Strategy B: Play video and intercept timedtext network response
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 available for this video',
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);
// Clean up transcript session if no live pages remain
const ytKey = normalizeUserId('__yt_transcript__');
const ytSession = sessions.get(ytKey);
if (ytSession && !ytSession._closing) {
try {
const remainingPages = ytSession.context.pages();
if (remainingPages.length === 0) {
ytSession._closing = true;
ytSession.context.close().catch(() => {});
sessions.delete(ytKey);
}
} catch {
sessions.delete(ytKey);
}
}
}
});
}
}
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Install yt-dlp binary (not available via apt)
set -e
curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
chmod +x /usr/local/bin/yt-dlp
@@ -1,4 +1,4 @@
import { parseJson3, parseVtt, parseXml } from '../../lib/youtube.js';
import { parseJson3, parseVtt, parseXml } from './youtube.js';
describe('YouTube transcript parsers', () => {
test('parseJson3 extracts timestamped text', () => {
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# Install system packages declared by plugins listed in camofox.config.json.
# Each plugin can have an apt.txt (one package per line) and a post-install.sh.
# If no config file or no plugins key, installs deps for all plugins in plugins/.
set -e
CONFIG="/app/camofox.config.json"
PLUGINS_DIR="/app/plugins"
# Read plugin list from camofox.config.json, or fall back to all plugin dirs
if [ -f "$CONFIG" ] && command -v node >/dev/null 2>&1; then
PLUGIN_LIST=$(node -e "
const c = JSON.parse(require('fs').readFileSync('$CONFIG','utf-8'));
if (Array.isArray(c.plugins)) {
console.log(c.plugins.join(' '));
} else if (c.plugins && typeof c.plugins === 'object') {
console.log(Object.entries(c.plugins)
.filter(([, v]) => v && v.enabled !== false)
.map(([k]) => k)
.join(' '));
}
" 2>/dev/null || echo "")
fi
if [ -z "$PLUGIN_LIST" ]; then
# No config or no plugins key — use all plugin directories
PLUGIN_LIST=""
for d in "$PLUGINS_DIR"/*/; do
[ -d "$d" ] || continue
name=$(basename "$d")
case "$name" in _*|.*) continue ;; esac
PLUGIN_LIST="$PLUGIN_LIST $name"
done
fi
echo "[install-plugin-deps] Plugins:$PLUGIN_LIST"
# Collect apt packages
PKGS=""
for name in $PLUGIN_LIST; do
f="$PLUGINS_DIR/$name/apt.txt"
[ -f "$f" ] || continue
while IFS= read -r line; do
case "$line" in \#*|"") continue ;; esac
PKGS="$PKGS $line"
done < "$f"
done
if [ -n "$PKGS" ]; then
echo "[install-plugin-deps] Installing:$PKGS"
apt-get update && apt-get install -y $PKGS && rm -rf /var/lib/apt/lists/*
else
echo "[install-plugin-deps] No apt dependencies"
fi
# Run post-install hooks
for name in $PLUGIN_LIST; do
hook="$PLUGINS_DIR/$name/post-install.sh"
[ -x "$hook" ] || continue
echo "[install-plugin-deps] Running post-install for $name"
"$hook"
done
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env node
/**
* camofox plugin manager — install, remove, and list plugins.
*
* Usage:
* node scripts/plugin.js install <source> Install a plugin from git URL or local path
* node scripts/plugin.js remove <name> Remove a plugin and its config entry
* node scripts/plugin.js list List installed plugins and their source
*
* Sources:
* git:github.com/user/repo Git shorthand
* https://github.com/user/repo Git URL
* /absolute/path/to/plugin-dir Local directory (copied)
* ./relative/path/to/plugin-dir Local directory (copied)
*
* Plugin name is inferred from the repo/directory name. If the repo root has
* an index.js with register(), it's used directly. If it has a plugins/ subdir,
* each subdirectory is installed as a separate plugin.
*
* After install, the plugin is added to camofox.config.json plugins[] and
* npm dependencies are installed if the plugin has a package.json.
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const PLUGINS_DIR = path.join(ROOT, 'plugins');
const CONFIG_PATH = path.join(ROOT, 'camofox.config.json');
// ── Config helpers ──────────────────────────────────────────────────────────
function readConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return { id: 'camofox-browser', name: 'Camofox Browser', version: '0.0.0', plugins: [] };
}
}
function writeConfig(config) {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}
/**
* Get the set of enabled plugin names from config.
* Handles both array format ["youtube"] and object format { "youtube": { "enabled": true } }.
*/
function getEnabledPlugins(config) {
if (!config.plugins) return new Set();
if (Array.isArray(config.plugins)) return new Set(config.plugins);
if (typeof config.plugins === 'object') {
const enabled = new Set();
for (const [name, conf] of Object.entries(config.plugins)) {
if (conf === false || (typeof conf === 'object' && conf.enabled === false)) continue;
enabled.add(name);
}
return enabled;
}
return new Set();
}
function addToConfig(name) {
const config = readConfig();
if (Array.isArray(config.plugins)) {
if (!config.plugins.includes(name)) {
config.plugins.push(name);
writeConfig(config);
}
} else if (typeof config.plugins === 'object') {
if (!config.plugins[name] || config.plugins[name].enabled === false) {
config.plugins[name] = config.plugins[name] || {};
config.plugins[name].enabled = true;
writeConfig(config);
}
} else {
config.plugins = [name];
writeConfig(config);
}
}
function removeFromConfig(name) {
const config = readConfig();
if (Array.isArray(config.plugins)) {
config.plugins = config.plugins.filter(p => p !== name);
writeConfig(config);
} else if (typeof config.plugins === 'object' && config.plugins[name] !== undefined) {
delete config.plugins[name];
writeConfig(config);
}
}
// ── Source parsing ──────────────────────────────────────────────────────────
function parseSource(source) {
// Local path
if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) {
const resolved = path.resolve(source);
if (!fs.existsSync(resolved)) {
fatal(`Local path not found: ${resolved}`);
}
if (!fs.statSync(resolved).isDirectory()) {
fatal(`Source must be a directory: ${resolved}`);
}
return { type: 'local', path: resolved, name: path.basename(resolved) };
}
// Git URL — https://, ssh://, git@, git:
let gitUrl = source;
if (gitUrl.startsWith('git:')) {
gitUrl = gitUrl.slice(4);
// git:github.com/user/repo → https://github.com/user/repo
if (!gitUrl.startsWith('http') && !gitUrl.startsWith('ssh://') && !gitUrl.startsWith('git@')) {
gitUrl = `https://${gitUrl}`;
}
}
// Strip trailing .git
gitUrl = gitUrl.replace(/\.git$/, '');
// Extract name from URL
const name = gitUrl.split('/').pop().replace(/[^a-zA-Z0-9_-]/g, '');
if (!name) fatal(`Cannot infer plugin name from: ${source}`);
// Re-add .git for clone
const cloneUrl = gitUrl.endsWith('.git') ? gitUrl : `${gitUrl}.git`;
return { type: 'git', url: cloneUrl, name };
}
// ── Install ─────────────────────────────────────────────────────────────────
function isPluginDir(dir) {
const indexPath = path.join(dir, 'index.js');
if (!fs.existsSync(indexPath)) return false;
const content = fs.readFileSync(indexPath, 'utf-8');
return /\bregister\b/.test(content);
}
function installFromLocal(srcDir, name) {
const destDir = path.join(PLUGINS_DIR, name);
if (fs.existsSync(destDir)) {
fatal(`Plugin "${name}" already exists. Remove it first: node scripts/plugin.js remove ${name}`);
}
copyDirSync(srcDir, destDir);
return [name];
}
function installFromGit(url, name) {
const tmpDir = path.join(ROOT, '.tmp-plugin-clone');
try {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
console.log(`Cloning ${url}...`);
execSync(`git clone --depth 1 ${url} ${tmpDir}`, { stdio: 'pipe' });
// Case 1: Root is a plugin (has index.js with register)
if (isPluginDir(tmpDir)) {
return installFromLocal(tmpDir, name);
}
// Case 2: Has plugins/ subdir with plugin directories
const pluginsSubdir = path.join(tmpDir, 'plugins');
if (fs.existsSync(pluginsSubdir) && fs.statSync(pluginsSubdir).isDirectory()) {
const installed = [];
for (const entry of fs.readdirSync(pluginsSubdir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
const subDir = path.join(pluginsSubdir, entry.name);
if (isPluginDir(subDir)) {
installFromLocal(subDir, entry.name);
installed.push(entry.name);
}
}
if (installed.length === 0) {
fatal(`No plugins found in ${url} — expected index.js with register() at root or in plugins/*/`);
}
return installed;
}
fatal(`No plugins found in ${url} — expected index.js with register() at root or plugins/*/ subdirs`);
} finally {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
}
}
function installPluginDeps(name) {
const pluginDir = path.join(PLUGINS_DIR, name);
// npm install if package.json exists
const pkgJson = path.join(pluginDir, 'package.json');
if (fs.existsSync(pkgJson)) {
console.log(`Installing npm dependencies for ${name}...`);
execSync('npm install --omit=dev', { cwd: pluginDir, stdio: 'inherit' });
}
// Check for apt.txt / post-install.sh (just warn — can't run apt locally)
if (fs.existsSync(path.join(pluginDir, 'apt.txt'))) {
console.log(`⚠ ${name} has apt.txt — system packages need Docker build or manual install`);
}
if (fs.existsSync(path.join(pluginDir, 'post-install.sh'))) {
console.log(`⚠ ${name} has post-install.sh — run it manually or rebuild Docker image`);
}
}
// ── Remove ──────────────────────────────────────────────────────────────────
function removePlugin(name) {
const pluginDir = path.join(PLUGINS_DIR, name);
if (!fs.existsSync(pluginDir)) {
fatal(`Plugin "${name}" not found in plugins/`);
}
fs.rmSync(pluginDir, { recursive: true });
removeFromConfig(name);
console.log(`✓ Removed plugin "${name}"`);
}
// ── List ────────────────────────────────────────────────────────────────────
function listPlugins() {
const config = readConfig();
const configPlugins = getEnabledPlugins(config);
if (!fs.existsSync(PLUGINS_DIR)) {
console.log('No plugins directory.');
return;
}
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
const plugins = entries
.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
.map(e => e.name);
if (plugins.length === 0) {
console.log('No plugins installed.');
return;
}
console.log('Installed plugins:\n');
for (const name of plugins.sort()) {
const enabled = configPlugins.size === 0 || configPlugins.has(name);
const status = enabled ? '✓' : '○';
const hasTest = fs.existsSync(path.join(PLUGINS_DIR, name, `${name}.test.js`))
|| fs.readdirSync(path.join(PLUGINS_DIR, name)).some(f => f.endsWith('.test.js'));
const hasDeps = fs.existsSync(path.join(PLUGINS_DIR, name, 'apt.txt'))
|| fs.existsSync(path.join(PLUGINS_DIR, name, 'post-install.sh'));
const hasPkg = fs.existsSync(path.join(PLUGINS_DIR, name, 'package.json'));
const flags = [
hasTest ? 'tests' : null,
hasDeps ? 'sys-deps' : null,
hasPkg ? 'npm-deps' : null,
].filter(Boolean).join(', ');
console.log(` ${status} ${name}${flags ? ` (${flags})` : ''}`);
}
if (configPlugins.size > 0) {
console.log(`\n${configPlugins.size} plugin(s) enabled in camofox.config.json`);
} else {
console.log('\nNo plugins[] in config — all plugins are loaded');
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
// Skip .git, node_modules
if (entry.name === '.git' || entry.name === 'node_modules') continue;
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
function fatal(msg) {
console.error(`Error: ${msg}`);
process.exit(1);
}
// ── CLI ─────────────────────────────────────────────────────────────────────
const [,, action, ...args] = process.argv;
switch (action) {
case 'install': {
const source = args[0];
if (!source) fatal('Usage: plugin install <git-url|local-path>');
const parsed = parseSource(source);
const installed = parsed.type === 'git'
? installFromGit(parsed.url, parsed.name)
: installFromLocal(parsed.path, parsed.name);
for (const name of installed) {
addToConfig(name);
installPluginDeps(name);
}
console.log(`\n✓ Installed: ${installed.join(', ')}`);
console.log(' Restart the server to load new plugin(s).');
break;
}
case 'remove': {
const name = args[0];
if (!name) fatal('Usage: plugin remove <name>');
removePlugin(name);
break;
}
case 'list':
case 'ls': {
listPlugins();
break;
}
default:
console.log(`camofox plugin manager
Usage:
node scripts/plugin.js install <source> Install from git URL or local path
node scripts/plugin.js remove <name> Remove a plugin
node scripts/plugin.js list List installed plugins
Sources:
git:github.com/user/repo
https://github.com/user/repo
./path/to/local/plugin`);
if (action) process.exit(1);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Tests for scripts/plugin.js — plugin install, remove, list.
*/
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'plugin.js');
const PLUGINS_DIR = path.join(ROOT, 'plugins');
const CONFIG_PATH = path.join(ROOT, 'camofox.config.json');
const run = (args) => execSync(`node ${SCRIPT} ${args}`, { cwd: ROOT, encoding: 'utf-8' });
// Save/restore config around tests
let originalConfig;
beforeAll(() => { originalConfig = fs.readFileSync(CONFIG_PATH, 'utf-8'); });
afterAll(() => { fs.writeFileSync(CONFIG_PATH, originalConfig); });
// Clean up test plugins after each test
afterEach(() => {
const testDir = path.join(PLUGINS_DIR, 'test-plugin');
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
// Restore config
fs.writeFileSync(CONFIG_PATH, originalConfig);
});
describe('plugin list', () => {
test('lists youtube as enabled', () => {
const out = run('list');
expect(out).toContain('youtube');
expect(out).toContain('✓');
});
});
describe('plugin install (local)', () => {
const tmpDir = path.join(ROOT, '.tmp-test-plugin');
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'index.js'),
'export function register(app, ctx) { app.get("/test", (req, res) => res.json({})); }');
});
afterEach(() => {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin');
if (fs.existsSync(installed)) fs.rmSync(installed, { recursive: true });
});
test('copies plugin dir and updates config', () => {
const out = run(`install ${tmpDir}`);
expect(out).toContain('Installed');
// Plugin dir exists
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin');
expect(fs.existsSync(installed)).toBe(true);
expect(fs.existsSync(path.join(installed, 'index.js'))).toBe(true);
// Config updated
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
if (Array.isArray(config.plugins)) {
expect(config.plugins).toContain('.tmp-test-plugin');
} else {
expect(config.plugins['.tmp-test-plugin']).toBeDefined();
}
});
test('rejects duplicate install', () => {
run(`install ${tmpDir}`);
expect(() => run(`install ${tmpDir}`)).toThrow();
});
});
describe('plugin remove', () => {
const tmpDir = path.join(ROOT, '.tmp-test-plugin-rm');
beforeEach(() => {
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'index.js'),
'export function register(app, ctx) {}');
run(`install ${tmpDir}`);
});
afterEach(() => {
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin-rm');
if (fs.existsSync(installed)) fs.rmSync(installed, { recursive: true });
});
test('removes plugin dir and config entry', () => {
const out = run('remove .tmp-test-plugin-rm');
expect(out).toContain('Removed');
const installed = path.join(PLUGINS_DIR, '.tmp-test-plugin-rm');
expect(fs.existsSync(installed)).toBe(false);
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
expect(config.plugins).not.toContain('.tmp-test-plugin-rm');
});
test('errors on unknown plugin', () => {
expect(() => run('remove nonexistent-plugin-xyz')).toThrow();
});
});
describe('plugin help', () => {
test('shows usage with no args', () => {
const out = run('');
expect(out).toContain('Usage');
expect(out).toContain('install');
expect(out).toContain('remove');
});
});
+248 -333
View File
@@ -8,6 +8,8 @@ import { expandMacro } from './lib/macros.js';
import { loadConfig } from './lib/config.js';
import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js';
import { createFlyHelpers } from './lib/fly.js';
import { createPluginEvents, loadPlugins } from './lib/plugins.js';
import { requireAuth, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js';
import { windowSnapshot } from './lib/snapshot.js';
import {
MAX_DOWNLOAD_INLINE_BYTES,
@@ -17,15 +19,23 @@ import {
getDownloadsList,
} from './lib/downloads.js';
import { extractPageImages } from './lib/images.js';
import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
import {
initMetrics, getRegister, isMetricsEnabled,
initMetrics, getRegister, isMetricsEnabled, createMetric,
startMemoryReporter, stopMemoryReporter,
} from './lib/metrics.js';
import { actionFromReq, classifyError } from './lib/request-utils.js';
import { cleanupOrphanedTempFiles } from './lib/tmp-cleanup.js';
import { coalesceInflight } from './lib/inflight.js';
const CONFIG = loadConfig();
// --- Plugin event bus ---
const pluginEvents = createPluginEvents();
// --- Shared auth middleware ---
const authMiddleware = () => requireAuth(CONFIG);
const {
requestsTotal, requestDuration, pageLoadDuration, snapshotBytes,
activeTabsGauge, tabLockQueueDepth,
@@ -106,16 +116,9 @@ 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);
}
// timingSafeCompare and isLoopbackAddress imported from lib/auth.js
const timingSafeCompare = _timingSafeCompare;
const isLoopbackAddress = _isLoopbackAddress;
// Custom error for stale/unknown element refs — returned as 422 instead of 500
class StaleRefsError extends Error {
@@ -158,10 +161,7 @@ function validateUrl(url) {
}
}
function isLoopbackAddress(address) {
if (!address) return false;
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
}
// isLoopbackAddress — now imported from lib/auth.js (see top of file)
// Import cookies into a user's browser context (Playwright cookies format)
// POST /sessions/:userId/cookies { cookies: Cookie[] }
@@ -238,6 +238,7 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
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 });
pluginEvents.emit('session:cookies:import', { userId: String(userId), count: sanitized.length });
res.json(result);
} catch (err) {
failuresTotal.labels(classifyError(err), 'set_cookies').inc();
@@ -486,15 +487,14 @@ async function restartBrowser(reason) {
healthState.isRecovering = true;
browserRestartsTotal.labels(reason).inc();
log('error', 'restarting browser', { reason, failures: healthState.consecutiveNavFailures });
pluginEvents.emit('browser:restart', { reason });
try {
for (const [, session] of sessions) {
await session.context.close().catch(() => {});
}
sessions.clear();
await closeAllSessions(`browser_restart:${reason}`, { clearDownloads: true, clearLocks: true });
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
pluginEvents.emit('browser:closed', { reason });
browserLaunchPromise = null;
await ensureBrowser();
healthState.consecutiveNavFailures = 0;
@@ -575,7 +575,7 @@ async function launchBrowserInstance() {
try {
if (os.platform() === 'linux') {
localVirtualDisplay = new VirtualDisplay();
localVirtualDisplay = pluginCtx.createVirtualDisplay();
vdDisplay = localVirtualDisplay.get();
log('info', 'xvfb virtual display started', { display: vdDisplay, attempt });
}
@@ -608,6 +608,7 @@ async function launchBrowserInstance() {
virtual_display: vdDisplay,
});
options.proxy = normalizePlaywrightProxy(options.proxy);
await pluginEvents.emitAsync('browser:launching', { options });
candidateBrowser = await firefox.launch(options);
@@ -638,6 +639,7 @@ async function launchBrowserInstance() {
browserLaunchProxy = launchProxy;
browser = candidateBrowser;
attachBrowserCleanup(browser, localVirtualDisplay);
pluginEvents.emit('browser:launched', { browser, display: vdDisplay });
log('info', 'camoufox launched', {
attempt,
@@ -671,10 +673,7 @@ async function ensureBrowser() {
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();
await closeAllSessions('browser_disconnected', { clearDownloads: true, clearLocks: true });
// Clean up virtual display from dead browser before relaunching
if (virtualDisplay) {
virtualDisplay.kill();
@@ -698,6 +697,53 @@ function normalizeUserId(userId) {
return String(userId);
}
const sessionCreations = new Map();
function clearSessionLocks(session) {
if (!session?.tabGroups) return;
for (const [, group] of session.tabGroups) {
for (const tabId of group.keys()) {
const lock = tabLocks.get(tabId);
if (lock) {
lock.drain();
tabLocks.delete(tabId);
}
}
}
refreshTabLockQueueDepth();
}
async function closeSession(userId, session, {
reason = 'session_closed',
clearDownloads = true,
clearLocks = true,
} = {}) {
if (!session) return;
const key = normalizeUserId(userId);
if (clearDownloads) {
await clearSessionDownloads(session).catch(() => {});
}
await session.context.close().catch(() => {});
sessions.delete(key);
await pluginEvents.emitAsync('session:destroyed', { userId: key, reason });
if (clearLocks) {
clearSessionLocks(session);
}
refreshActiveTabsGauge();
}
async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) {
const openSessions = Array.from(sessions.entries());
for (const [userId, session] of openSessions) {
await closeSession(userId, session, { reason, clearDownloads, clearLocks });
}
}
async function getSession(userId) {
const key = normalizeUserId(userId);
let session = sessions.get(key);
@@ -713,48 +759,52 @@ async function getSession(userId) {
session.context.pages();
} catch (err) {
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
session.context.close().catch(() => {});
sessions.delete(key);
await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true });
session = null;
}
}
}
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 };
}
let sessionProxy = null;
if (proxyPool?.canRotateSessions) {
sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
} else if (proxyPool) {
sessionProxy = proxyPool.getNext();
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
}
const context = await b.newContext(contextOptions);
session = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
sessions.set(key, session);
log('info', 'session created', {
userId: key,
proxyMode: proxyPool?.mode || null,
proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
session = await coalesceInflight(sessionCreations, key, async () => {
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 };
}
let sessionProxy = null;
if (proxyPool?.canRotateSessions) {
sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`);
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId });
} else if (proxyPool) {
sessionProxy = proxyPool.getNext();
contextOptions.proxy = normalizePlaywrightProxy(sessionProxy);
log('info', 'session proxy assigned', { userId: key, proxy: sessionProxy.server });
}
await pluginEvents.emitAsync('session:creating', { userId: key, contextOptions });
const context = await b.newContext(contextOptions);
const created = { context, tabGroups: new Map(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null };
sessions.set(key, created);
await pluginEvents.emitAsync('session:created', { userId: key, context });
log('info', 'session created', {
userId: key,
proxyMode: proxyPool?.mode || null,
proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null,
proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null,
});
return created;
});
}
session.lastAccess = Date.now();
@@ -806,6 +856,10 @@ function handleRouteError(err, req, res, extraFields = {}) {
failuresTotal.labels(failureType, action).inc();
const userId = req.body?.userId || req.query?.userId;
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
if (tabId) {
pluginEvents.emit('tab:error', { userId, tabId, error: err });
}
if (userId && isDeadContextError(err)) {
destroySession(userId);
}
@@ -828,7 +882,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
found.tabState.consecutiveTimeouts++;
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
destroyTab(session, tabId, 'consecutive_timeouts');
destroyTab(session, tabId, 'consecutive_timeouts', userId);
}
}
}
@@ -838,7 +892,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
const session = sessions.get(normalizeUserId(userId));
if (session && tabId) {
destroyTab(session, tabId, 'lock_queue');
destroyTab(session, tabId, 'lock_queue', userId);
}
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
}
@@ -849,7 +903,7 @@ function handleRouteError(err, req, res, extraFields = {}) {
sendError(res, err, extraFields);
}
function destroyTab(session, tabId, reason) {
function destroyTab(session, tabId, reason, userId) {
const lock = tabLocks.get(tabId);
if (lock) {
lock.drain();
@@ -865,6 +919,7 @@ function destroyTab(session, tabId, reason) {
if (group.size === 0) session.tabGroups.delete(listItemId);
refreshActiveTabsGauge();
if (reason) tabsDestroyedTotal.labels(reason).inc();
pluginEvents.emit('tab:destroyed', { userId: userId || null, tabId, reason: reason || 'unknown' });
return true;
}
}
@@ -876,7 +931,7 @@ function destroyTab(session, tabId, reason) {
* Closes the old tab's page and removes it from its group.
* Returns { recycledTabId, recycledFromGroup } or null if no tab to recycle.
*/
async function recycleOldestTab(session, reqId) {
async function recycleOldestTab(session, reqId, userId) {
let oldestTab = null;
let oldestGroup = null;
let oldestGroupKey = null;
@@ -900,6 +955,7 @@ async function recycleOldestTab(session, reqId) {
if (lock) { lock.drain(); tabLocks.delete(oldestTabId); }
refreshTabLockQueueDepth();
tabsRecycledTotal.inc();
pluginEvents.emit('tab:recycled', { userId: userId || null, tabId: oldestTabId });
log('info', 'tab recycled (limit reached)', { reqId, recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey });
return { recycledTabId: oldestTabId, recycledFromGroup: oldestGroupKey };
}
@@ -909,8 +965,8 @@ function destroySession(userId) {
const session = sessions.get(key);
if (!session) return;
log('warn', 'destroying dead session', { userId: key });
session.context.close().catch(() => {});
sessions.delete(key);
closeSession(key, session, { reason: 'destroy_session', clearDownloads: true, clearLocks: true }).catch(() => {});
}
function findTab(session, tabId) {
@@ -955,8 +1011,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
const key = normalizeUserId(userId);
const oldSession = sessions.get(key);
if (oldSession) {
await oldSession.context.close().catch(() => {});
sessions.delete(key);
await closeSession(key, oldSession, { reason: 'google_rotate_context', clearDownloads: true, clearLocks: true });
}
const session = await getSession(userId);
const group = getTabGroup(session, sessionKey);
@@ -964,7 +1019,7 @@ async function rotateGoogleTab(userId, sessionKey, tabId, previousTabState, reas
const tabState = createTabState(page);
tabState.googleRetryCount = (previousTabState.googleRetryCount || 0) + 1;
tabState.lastRequestedUrl = previousTabState.lastRequestedUrl;
attachDownloadListener(tabState, tabId, log);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
group.set(tabId, tabState);
refreshActiveTabsGauge();
@@ -1453,196 +1508,6 @@ async function refreshTabRefs(tabState, options = {}) {
return refreshedRefs;
}
// --- YouTube transcript ---
// Implementation extracted to lib/youtube.js to avoid scanner false positives
// (child_process + app.post in same file triggers OpenClaw skill-scanner)
await 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';
// Re-detect yt-dlp if startup detection failed (transient issue)
await ensureYtDlp(log);
const ytDlpProxyUrl = buildProxyUrl(proxyPool, CONFIG.proxy);
log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser', hasProxy: !!ytDlpProxyUrl });
let result;
if (hasYtDlp()) {
try {
result = await ytDlpTranscript(reqId, url, videoId, lang, ytDlpProxyUrl);
} catch (ytErr) {
log('warn', 'yt-dlp threw, falling back to browser', { reqId, error: ytErr.message });
result = null;
}
// If yt-dlp returned an error result (e.g. no captions) or threw, try browser
if (!result || result.status !== 'ok') {
if (result) log('warn', 'yt-dlp returned error, falling back to browser', { reqId, status: result.status, code: result.code });
result = await browserTranscript(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) {
failuresTotal.labels(classifyError(err), 'youtube_transcript').inc();
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);
// Extract caption track URLs and metadata from ytInitialPlayerResponse
const meta = await page.evaluate(() => {
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
if (!r) return { title: '', tracks: [] };
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
return {
title: r?.videoDetails?.title || '',
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
};
});
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
// These URLs are freshly signed by YouTube and work immediately
if (meta.tracks && meta.tracks.length > 0) {
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
if (track && track.url) {
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
try {
const captionResp = await page.evaluate(async (fetchUrl) => {
const resp = await fetch(fetchUrl);
return resp.ok ? await resp.text() : null;
}, captionUrl);
if (captionResp && captionResp.length > 0) {
let transcriptText = null;
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
if (transcriptText && transcriptText.trim()) {
return {
status: 'ok', transcript: transcriptText,
video_url: url, video_id: videoId, video_title: meta.title,
language: track.code, total_words: transcriptText.split(/\s+/).length,
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
};
}
}
} catch (fetchErr) {
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
}
}
}
// Strategy B: Play video and intercept timedtext network response
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 available for this video',
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);
// Clean up transcript session if no live pages remain.
// YT transcript pages aren't tracked in tabGroups, so we must check
// actual context pages to avoid closing while concurrent requests are active.
const ytKey = normalizeUserId('__yt_transcript__');
const ytSession = sessions.get(ytKey);
if (ytSession && !ytSession._closing) {
try {
const remainingPages = ytSession.context.pages();
if (remainingPages.length === 0) {
ytSession._closing = true;
ytSession.context.close().catch(() => {});
sessions.delete(ytKey);
}
} catch {
// Context already dead — just clean up the map entry
sessions.delete(ytKey);
}
}
}
});
}
app.get('/health', (req, res) => {
if (healthState.isRecovering) {
@@ -1700,7 +1565,7 @@ app.post('/tabs', async (req, res) => {
// Recycle oldest tab when limits are reached instead of rejecting
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
const recycled = await recycleOldestTab(session, req.reqId);
const recycled = await recycleOldestTab(session, req.reqId, userId);
if (!recycled) {
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
}
@@ -1711,7 +1576,7 @@ app.post('/tabs', async (req, res) => {
const page = await session.context.newPage();
const tabId = fly.makeTabId();
const tabState = createTabState(page);
attachDownloadListener(tabState, tabId);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
group.set(tabId, tabState);
refreshActiveTabsGauge();
@@ -1723,6 +1588,7 @@ app.post('/tabs', async (req, res) => {
tabState.visitedUrls.add(url);
}
pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() });
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
return { tabId, url: page.url() };
})(), requestTimeoutMs(), 'tab create');
@@ -1755,7 +1621,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
for (const g of session.tabGroups.values()) sessionTabs += g.size;
if (getTotalTabCount() >= MAX_TABS_GLOBAL || sessionTabs >= MAX_TABS_PER_SESSION) {
// Recycle oldest tab to free a slot, then create new page
const recycled = await recycleOldestTab(session, req.reqId);
const recycled = await recycleOldestTab(session, req.reqId, userId);
if (!recycled) {
throw new Error('Maximum tabs per session reached');
}
@@ -1763,7 +1629,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
{
const page = await session.context.newPage();
tabState = createTabState(page);
attachDownloadListener(tabState, tabId, log);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
const group = getTabGroup(session, resolvedSessionKey);
group.set(tabId, tabState);
refreshActiveTabsGauge();
@@ -1822,15 +1688,14 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
const key = normalizeUserId(userId);
const oldSession = sessions.get(key);
if (oldSession) {
await oldSession.context.close().catch(() => {});
sessions.delete(key);
await closeSession(key, oldSession, { reason: 'google_blocked_context_rotate', clearDownloads: true, clearLocks: true });
}
session = await getSession(userId);
const group = getTabGroup(session, currentSessionKey);
const page = await session.context.newPage();
tabState = createTabState(page);
tabState.googleRetryCount = previousRetryCount + 1;
attachDownloadListener(tabState, tabId, log);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
group.set(tabId, tabState);
refreshActiveTabsGauge();
};
@@ -1871,6 +1736,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
})(), requestTimeoutMs(), 'navigate'));
log('info', 'navigated', { reqId: req.reqId, tabId, url: result.url });
pluginEvents.emit('tab:navigated', { userId: req.body.userId, tabId, url: result.url, prevUrl: null });
res.json(result);
} catch (err) {
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
@@ -2013,6 +1879,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
return response;
})(), requestTimeoutMs(), 'snapshot'));
pluginEvents.emit('tab:snapshot', { userId: req.query.userId, tabId: req.params.tabId, snapshot: result.snapshot });
log('info', 'snapshot', { reqId: req.reqId, tabId: req.params.tabId, url: result.url, snapshotLen: result.snapshot?.length, refsCount: result.refsCount, hasScreenshot: !!result.screenshot, truncated: result.truncated });
res.json(result);
} catch (err) {
@@ -2185,6 +2052,7 @@ app.post('/tabs/:tabId/click', async (req, res) => {
}));
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
pluginEvents.emit('tab:click', { userId: req.body.userId, tabId, ref: req.body.ref, selector: req.body.selector });
res.json(result);
} catch (err) {
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
@@ -2215,7 +2083,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
const tabId = req.params.tabId;
try {
const { userId, ref, selector, text } = req.body;
const { userId, ref, selector, text, mode = 'fill', delay = 30, submit = false, pressEnter = false } = req.body;
const session = sessions.get(normalizeUserId(userId));
const found = session && findTab(session, tabId);
if (!found) return res.status(404).json({ error: 'Tab not found' });
@@ -2223,25 +2091,50 @@ app.post('/tabs/:tabId/type', async (req, res) => {
const { tabState } = found;
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
if (!ref && !selector) {
return res.status(400).json({ error: 'ref or selector required' });
if (mode !== 'fill' && mode !== 'keyboard') {
return res.status(400).json({ error: "mode must be 'fill' or 'keyboard'" });
}
if (typeof text !== 'string') {
return res.status(400).json({ error: 'text is required' });
}
// keyboard mode: ref/selector are optional (types into current focus)
if (mode === 'fill' && !ref && !selector) {
return res.status(400).json({ error: 'ref or selector required for mode=fill' });
}
const shouldSubmit = submit || pressEnter;
await withTabLock(tabId, async () => {
// Resolve and focus the target if ref/selector provided
let locator = null;
if (ref) {
let locator = refToLocator(tabState.page, ref, tabState.refs);
locator = refToLocator(tabState.page, ref, tabState.refs);
if (!locator) {
log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
log('info', 'auto-refreshing refs before type', { ref, hadRefs: tabState.refs.size, mode });
tabState.refs = await refreshTabRefs(tabState, { reason: 'type' });
locator = refToLocator(tabState.page, ref, tabState.refs);
}
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
await locator.fill(text, { timeout: 10000 });
} else {
await tabState.page.fill(selector, text, { timeout: 10000 });
}
if (mode === 'fill') {
if (locator) {
await locator.fill(text, { timeout: 10000 });
} else {
await tabState.page.fill(selector, text, { timeout: 10000 });
}
} else {
// keyboard mode — char-by-char real key events (required for Ember/contenteditable)
if (locator) {
await locator.focus({ timeout: 10000 });
} else if (selector) {
await tabState.page.focus(selector, { timeout: 10000 });
}
await tabState.page.keyboard.type(text, { delay });
}
if (shouldSubmit) await tabState.page.keyboard.press('Enter');
});
pluginEvents.emit('tab:type', { userId: req.body.userId, tabId, text: req.body.text, ref: req.body.ref, mode: req.body.mode || 'fill' });
res.json({ ok: true });
} catch (err) {
log('error', 'type failed', { reqId: req.reqId, error: err.message });
@@ -2284,6 +2177,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
await tabState.page.keyboard.press(key);
});
pluginEvents.emit('tab:press', { userId, tabId, key });
res.json({ ok: true });
} catch (err) {
log('error', 'press failed', { reqId: req.reqId, error: err.message });
@@ -2307,6 +2201,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
await tabState.page.mouse.wheel(isVertical ? 0 : delta, isVertical ? delta : 0);
await tabState.page.waitForTimeout(300);
pluginEvents.emit('tab:scroll', { userId, tabId: req.params.tabId, direction, amount });
res.json({ ok: true });
} catch (err) {
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
@@ -2509,6 +2404,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
const { tabState } = found;
const buffer = await tabState.page.screenshot({ type: 'png', fullPage });
pluginEvents.emit('tab:screenshot', { userId, tabId: req.params.tabId, buffer });
res.set('Content-Type', 'image/png');
res.send(buffer);
} catch (err) {
@@ -2557,7 +2453,9 @@ app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, re
const { tabState } = found;
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
pluginEvents.emit('tab:evaluate', { userId, tabId: req.params.tabId, expression });
const result = await tabState.page.evaluate(expression);
pluginEvents.emit('tab:evaluated', { userId, tabId: req.params.tabId, result });
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
res.json({ ok: true, result });
} catch (err) {
@@ -2628,21 +2526,7 @@ app.delete('/sessions/:userId', async (req, res) => {
const userId = normalizeUserId(req.params.userId);
const session = sessions.get(userId);
if (session) {
await clearSessionDownloads(session);
await session.context.close();
sessions.delete(userId);
// Remove any lingering tab locks for the session
for (const [listItemId, group] of session.tabGroups) {
for (const tabId of group.keys()) {
const lock = tabLocks.get(tabId);
if (lock) {
lock.drain();
tabLocks.delete(tabId);
}
}
}
refreshTabLockQueueDepth();
refreshActiveTabsGauge();
await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true });
log('info', 'session closed', { userId });
}
if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2656,14 +2540,13 @@ app.delete('/sessions/:userId', async (req, res) => {
// Cleanup stale sessions
setInterval(() => {
const now = Date.now();
for (const [userId, session] of sessions) {
for (const [userId, session] of Array.from(sessions.entries())) {
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
session._closing = true;
const idleMs = now - session.lastAccess;
sessionsExpiredTotal.inc();
clearSessionDownloads(session).catch(() => {});
session.context.close().catch(() => {});
sessions.delete(userId);
refreshActiveTabsGauge();
pluginEvents.emit('session:expired', { userId, idleMs });
closeSession(userId, session, { reason: 'session_timeout', clearDownloads: true, clearLocks: true }).catch(() => {});
log('info', 'session expired', { userId });
}
}
@@ -2709,11 +2592,8 @@ setInterval(() => {
if (session.tabGroups.size === 0) {
session._closing = true;
log('info', 'session empty after tab reaper, closing', { userId });
clearSessionDownloads(session).catch(() => {});
session.context.close().catch(() => {});
sessions.delete(userId);
closeSession(userId, session, { reason: 'tab_reaper_empty_session', clearDownloads: true, clearLocks: true }).catch(() => {});
sessionsExpiredTotal.inc();
refreshActiveTabsGauge();
}
}
if (sessions.size === 0) scheduleBrowserIdleShutdown();
@@ -2787,7 +2667,7 @@ app.post('/tabs/open', async (req, res) => {
let totalTabs = 0;
for (const g of session.tabGroups.values()) totalTabs += g.size;
if (totalTabs >= MAX_TABS_PER_SESSION || getTotalTabCount() >= MAX_TABS_GLOBAL) {
const recycled = await recycleOldestTab(session, req.reqId);
const recycled = await recycleOldestTab(session, req.reqId, userId);
if (!recycled) {
return res.status(429).json({ error: 'Maximum tabs per session reached' });
}
@@ -2798,7 +2678,7 @@ app.post('/tabs/open', async (req, res) => {
const page = await session.context.newPage();
const tabId = fly.makeTabId();
const tabState = createTabState(page);
attachDownloadListener(tabState, tabId, log);
attachDownloadListener(tabState, tabId, log, pluginEvents, userId);
group.set(tabId, tabState);
refreshActiveTabsGauge();
@@ -2841,26 +2721,7 @@ app.post('/stop', async (req, res) => {
await browser.close().catch(() => {});
browser = null;
}
const cleanupTasks = [];
for (const session of sessions.values()) {
cleanupTasks.push(clearSessionDownloads(session));
}
await Promise.all(cleanupTasks);
for (const session of sessions.values()) {
for (const [, group] of session.tabGroups) {
for (const tabId of group.keys()) {
const lock = tabLocks.get(tabId);
if (lock) {
lock.drain();
tabLocks.delete(tabId);
}
}
}
}
tabLocks.clear();
sessions.clear();
refreshActiveTabsGauge();
refreshTabLockQueueDepth();
await closeAllSessions('admin_stop', { clearDownloads: true, clearLocks: true });
res.json({ ok: true, stopped: true, profile: 'camoufox' });
} catch (err) {
res.status(500).json({ ok: false, error: safeError(err) });
@@ -3086,28 +2947,43 @@ app.post('/act', async (req, res) => {
}
case 'type': {
const { ref, selector, text, submit } = params;
if (!ref && !selector) {
throw new Error('ref or selector required');
const { ref, selector, text, submit, mode = 'fill', delay = 30 } = params;
if (mode === 'fill' && !ref && !selector) {
throw new Error('ref or selector required for mode=fill');
}
if (typeof text !== 'string') {
throw new Error('text is required');
}
if (mode !== 'fill' && mode !== 'keyboard') {
throw new Error("mode must be 'fill' or 'keyboard'");
}
let locator = null;
if (ref) {
let locator = refToLocator(tabState.page, ref, tabState.refs);
locator = refToLocator(tabState.page, ref, tabState.refs);
if (!locator) {
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size, mode });
tabState.refs = await buildRefs(tabState.page);
locator = refToLocator(tabState.page, ref, tabState.refs);
}
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
await locator.fill(text, { timeout: 10000 });
if (submit) await tabState.page.keyboard.press('Enter');
} else {
await tabState.page.fill(selector, text, { timeout: 10000 });
if (submit) await tabState.page.keyboard.press('Enter');
}
if (mode === 'fill') {
if (locator) {
await locator.fill(text, { timeout: 10000 });
} else {
await tabState.page.fill(selector, text, { timeout: 10000 });
}
} else {
if (locator) {
await locator.focus({ timeout: 10000 });
} else if (selector) {
await tabState.page.focus(selector, { timeout: 10000 });
}
await tabState.page.keyboard.type(text, { delay });
}
if (submit) await tabState.page.keyboard.press('Enter');
return { ok: true, targetId };
}
@@ -3241,6 +3117,7 @@ setInterval(async () => {
// Crash logging
process.on('uncaughtException', (err) => {
pluginEvents.emit('browser:error', { error: err });
log('error', 'uncaughtException', { error: err.message, stack: err.stack });
process.exit(1);
});
@@ -3255,6 +3132,7 @@ async function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log('info', 'shutting down', { signal });
pluginEvents.emit('server:shutdown', { signal });
const forceTimeout = setTimeout(() => {
log('error', 'shutdown timed out, forcing exit');
@@ -3265,9 +3143,11 @@ async function gracefulShutdown(signal) {
server.close();
stopMemoryReporter();
for (const [userId, session] of sessions) {
await session.context.close().catch(() => {});
}
await closeAllSessions(`shutdown:${signal}`, {
clearDownloads: false,
clearLocks: false,
});
if (browser) await browser.close().catch(() => {});
process.exit(0);
}
@@ -3281,15 +3161,50 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Fly's auto_stop_machines=false + min_machines_running=2 handles scaling.
const PORT = CONFIG.port;
pluginEvents.emit('server:starting', { port: PORT });
// Load plugins before starting the server
const pluginCtx = {
sessions,
config: CONFIG,
log,
events: pluginEvents,
auth: authMiddleware,
ensureBrowser,
getSession,
destroySession,
closeSession,
withUserLimit,
safePageClose,
normalizeUserId,
validateUrl,
safeError,
buildProxyUrl,
proxyPool,
failuresTotal,
metricsRegistry: getRegister,
createMetric,
/** Factory for Xvfb virtual display. Plugins can replace this to customise resolution/args. */
createVirtualDisplay: () => new VirtualDisplay(),
/** The upstream VirtualDisplay class — plugins can subclass it. */
VirtualDisplay,
};
const loadedPlugins = await loadPlugins(app, pluginCtx);
const server = app.listen(PORT, async () => {
startMemoryReporter();
refreshActiveTabsGauge();
refreshTabLockQueueDepth();
pluginEvents.emit('server:started', { port: PORT, pid: process.pid, plugins: loadedPlugins });
if (FLY_MACHINE_ID) {
log('info', 'server started (fly)', { port: PORT, pid: process.pid, machineId: FLY_MACHINE_ID, nodeVersion: process.version });
} else {
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
}
const tmpCleanup = cleanupOrphanedTempFiles({ tmpDir: os.tmpdir() });
if (tmpCleanup.removed > 0) {
log('info', 'cleaned up orphaned camoufox temp files', tmpCleanup);
}
// Pre-warm browser so first request doesn't eat a 6-7s cold start
try {
const start = Date.now();
+217
View File
@@ -0,0 +1,217 @@
/**
* Tests for lib/auth.js — timingSafeCompare, isLoopbackAddress, requireAuth.
*/
import { describe, test, expect } from '@jest/globals';
import { jest } from '@jest/globals';
import { timingSafeCompare, isLoopbackAddress, requireAuth } from '../../lib/auth.js';
describe('lib/auth', () => {
describe('timingSafeCompare', () => {
test('returns true for matching strings', () => {
expect(timingSafeCompare('secret', 'secret')).toBe(true);
expect(timingSafeCompare('abc123', 'abc123')).toBe(true);
});
test('returns false for non-matching strings', () => {
expect(timingSafeCompare('secret', 'wrong')).toBe(false);
expect(timingSafeCompare('abc', 'xyz')).toBe(false);
});
test('returns false for empty strings compared to non-empty', () => {
expect(timingSafeCompare('', 'notempty')).toBe(false);
expect(timingSafeCompare('notempty', '')).toBe(false);
});
test('returns true for two empty strings', () => {
expect(timingSafeCompare('', '')).toBe(true);
});
test('returns false for different lengths', () => {
expect(timingSafeCompare('short', 'muchlongerstring')).toBe(false);
expect(timingSafeCompare('muchlongerstring', 'short')).toBe(false);
});
test('returns false for non-string inputs', () => {
expect(timingSafeCompare(null, 'test')).toBe(false);
expect(timingSafeCompare('test', null)).toBe(false);
expect(timingSafeCompare(123, 'test')).toBe(false);
expect(timingSafeCompare('test', 123)).toBe(false);
expect(timingSafeCompare(undefined, undefined)).toBe(false);
expect(timingSafeCompare(null, null)).toBe(false);
expect(timingSafeCompare({}, [])).toBe(false);
});
test('handles Unicode strings', () => {
expect(timingSafeCompare('héllo wörld', 'héllo wörld')).toBe(true);
expect(timingSafeCompare('héllo', 'hello')).toBe(false);
expect(timingSafeCompare('日本語', '日本語')).toBe(true);
expect(timingSafeCompare('日本語', '中文字')).toBe(false);
});
});
describe('isLoopbackAddress', () => {
test('returns true for 127.0.0.1', () => {
expect(isLoopbackAddress('127.0.0.1')).toBe(true);
});
test('returns true for ::1', () => {
expect(isLoopbackAddress('::1')).toBe(true);
});
test('returns true for ::ffff:127.0.0.1', () => {
expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
});
test('returns false for null', () => {
expect(isLoopbackAddress(null)).toBe(false);
});
test('returns false for undefined', () => {
expect(isLoopbackAddress(undefined)).toBe(false);
});
test('returns false for non-loopback IPs', () => {
expect(isLoopbackAddress('192.168.1.1')).toBe(false);
expect(isLoopbackAddress('10.0.0.1')).toBe(false);
expect(isLoopbackAddress('8.8.8.8')).toBe(false);
});
test('returns false for empty string', () => {
expect(isLoopbackAddress('')).toBe(false);
});
});
describe('requireAuth', () => {
function mockReq(headers = {}, remoteAddress = '127.0.0.1') {
return {
headers,
socket: { remoteAddress },
};
}
function mockRes() {
const res = {
_status: null,
_json: null,
status(code) { res._status = code; return res; },
json(body) { res._json = body; return res; },
};
return res;
}
test('returns a function (middleware)', () => {
const middleware = requireAuth({ apiKey: null, nodeEnv: 'development' });
expect(typeof middleware).toBe('function');
});
test('calls next() with valid Bearer token', () => {
const middleware = requireAuth({ apiKey: 'my-secret', nodeEnv: 'production' });
const req = mockReq({ authorization: 'Bearer my-secret' });
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res._status).toBeNull();
});
test('returns 403 with invalid Bearer token', () => {
const middleware = requireAuth({ apiKey: 'my-secret', nodeEnv: 'production' });
const req = mockReq({ authorization: 'Bearer wrong-token' });
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._json.error).toBe('Forbidden');
});
test('returns 403 with missing authorization header when apiKey set', () => {
const middleware = requireAuth({ apiKey: 'my-secret', nodeEnv: 'production' });
const req = mockReq({});
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('returns 403 with malformed authorization header', () => {
const middleware = requireAuth({ apiKey: 'my-secret', nodeEnv: 'production' });
const req = mockReq({ authorization: 'Basic my-secret' });
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('allows loopback when no apiKey and not production', () => {
const middleware = requireAuth({ apiKey: null, nodeEnv: 'development' });
const req = mockReq({}, '127.0.0.1');
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('allows loopback ::1 when no apiKey and not production', () => {
const middleware = requireAuth({ apiKey: null, nodeEnv: 'development' });
const req = mockReq({}, '::1');
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('rejects non-loopback when no apiKey and not production', () => {
const middleware = requireAuth({ apiKey: null, nodeEnv: 'development' });
const req = mockReq({}, '192.168.1.100');
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('rejects loopback in production when no apiKey', () => {
const middleware = requireAuth({ apiKey: null, nodeEnv: 'production' });
const req = mockReq({}, '127.0.0.1');
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('uses custom error message', () => {
const middleware = requireAuth(
{ apiKey: null, nodeEnv: 'production' },
{ errorMessage: 'Custom rejection' }
);
const req = mockReq({}, '127.0.0.1');
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(res._json.error).toBe('Custom rejection');
});
test('Bearer token match is case-insensitive for prefix', () => {
const middleware = requireAuth({ apiKey: 'my-secret', nodeEnv: 'production' });
const req = mockReq({ authorization: 'bearer my-secret' });
const res = mockRes();
const next = jest.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { jest } from '@jest/globals';
import { importBootstrapCookies } from '../../lib/cookies.js';
describe('importBootstrapCookies', () => {
let tmpDir;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'camofox-bootstrap-cookies-'));
});
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
test('returns zero without calling addCookies when cookies.txt is missing', async () => {
const context = { addCookies: jest.fn() };
const logger = { warn: jest.fn() };
const result = await importBootstrapCookies({ cookiesDir: tmpDir, context, logger });
expect(result.imported).toBe(0);
expect(result.source).toBe(null);
expect(context.addCookies).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
test('imports all cookies from the default cookies.txt file', async () => {
const cookieText = [
'# Netscape HTTP Cookie File',
'.example.com\tTRUE\t/\tTRUE\t1700000000\tlogged_in\tyes',
'app.example.org\tFALSE\t/\tTRUE\t1700000000\t__cflb\tabc123',
].join('\n');
await fs.writeFile(path.join(tmpDir, 'cookies.txt'), cookieText);
const context = { addCookies: jest.fn(async () => {}) };
const result = await importBootstrapCookies({ cookiesDir: tmpDir, context, logger: { warn: jest.fn() } });
expect(result.imported).toBe(2);
expect(result.source.endsWith(path.join(tmpDir, 'cookies.txt'))).toBe(true);
expect(context.addCookies).toHaveBeenCalledTimes(1);
expect(context.addCookies.mock.calls[0][0]).toEqual([
expect.objectContaining({ domain: '.example.com', name: 'logged_in', value: 'yes' }),
expect.objectContaining({ domain: 'app.example.org', name: '__cflb', value: 'abc123' }),
]);
});
test('logs and returns zero when addCookies throws', async () => {
await fs.writeFile(
path.join(tmpDir, 'cookies.txt'),
'.example.com\tTRUE\t/\tTRUE\t1700000000\tlogged_in\tyes\n'
);
const context = { addCookies: jest.fn(async () => { throw new Error('boom'); }) };
const logger = { warn: jest.fn() };
const result = await importBootstrapCookies({ cookiesDir: tmpDir, context, logger });
expect(result.imported).toBe(0);
expect(logger.warn).toHaveBeenCalled();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { coalesceInflight } from '../../lib/inflight.js';
describe('coalesceInflight', () => {
test('concurrent calls for the same key invoke the factory once', async () => {
const map = new Map();
let calls = 0;
const factory = async () => {
calls += 1;
await new Promise((r) => setTimeout(r, 10));
return { id: calls };
};
const [a, b, c] = await Promise.all([
coalesceInflight(map, 'user-1', factory),
coalesceInflight(map, 'user-1', factory),
coalesceInflight(map, 'user-1', factory),
]);
expect(calls).toBe(1);
expect(a).toBe(b);
expect(b).toBe(c);
});
test('different keys run independently', async () => {
const map = new Map();
const factoryFor = (label) => async () => {
await new Promise((r) => setTimeout(r, 5));
return label;
};
const [a, b] = await Promise.all([
coalesceInflight(map, 'user-a', factoryFor('A')),
coalesceInflight(map, 'user-b', factoryFor('B')),
]);
expect(a).toBe('A');
expect(b).toBe('B');
});
test('map entry is cleared after resolve, so a later call creates fresh', async () => {
const map = new Map();
let calls = 0;
const factory = async () => ({ id: ++calls });
const first = await coalesceInflight(map, 'user-1', factory);
expect(map.has('user-1')).toBe(false);
const second = await coalesceInflight(map, 'user-1', factory);
expect(map.has('user-1')).toBe(false);
expect(calls).toBe(2);
expect(first).not.toBe(second);
});
test('map entry is cleared after reject and the rejection propagates to all awaiters', async () => {
const map = new Map();
const factory = async () => {
await new Promise((r) => setTimeout(r, 5));
throw new Error('boom');
};
const first = coalesceInflight(map, 'user-1', factory);
const second = coalesceInflight(map, 'user-1', factory);
await expect(first).rejects.toThrow('boom');
await expect(second).rejects.toThrow('boom');
expect(map.has('user-1')).toBe(false);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* Tests for lib/plugins.js — createPluginEvents, loadPlugins, and config reading.
*/
import { describe, test, expect, jest } from '@jest/globals';
import { createPluginEvents, loadPlugins } from '../../lib/plugins.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
describe('lib/plugins', () => {
describe('createPluginEvents', () => {
test('returns an EventEmitter with high maxListeners', () => {
const events = createPluginEvents();
expect(events).toBeDefined();
expect(typeof events.on).toBe('function');
expect(typeof events.emit).toBe('function');
expect(events.getMaxListeners()).toBe(50);
});
test('basic emit/on works', () => {
const events = createPluginEvents();
const received = [];
events.on('test:event', (payload) => received.push(payload));
events.emit('test:event', { foo: 'bar' });
expect(received).toEqual([{ foo: 'bar' }]);
});
test('supports multiple listeners', () => {
const events = createPluginEvents();
const results = [];
events.on('multi', () => results.push('a'));
events.on('multi', () => results.push('b'));
events.on('multi', () => results.push('c'));
events.emit('multi');
expect(results).toEqual(['a', 'b', 'c']);
});
test('removeListener works', () => {
const events = createPluginEvents();
const results = [];
const handler = () => results.push('called');
events.on('removal', handler);
events.emit('removal');
expect(results).toEqual(['called']);
events.removeListener('removal', handler);
events.emit('removal');
expect(results).toEqual(['called']); // not called again
});
test('emitAsync awaits all listeners including async', async () => {
const events = createPluginEvents();
const results = [];
events.on('async:test', async (payload) => {
await new Promise((r) => setTimeout(r, 10));
results.push('async-' + payload.val);
});
events.on('async:test', (payload) => {
results.push('sync-' + payload.val);
});
await events.emitAsync('async:test', { val: 1 });
expect(results).toContain('async-1');
expect(results).toContain('sync-1');
expect(results.length).toBe(2);
});
test('emitAsync with no listeners resolves immediately', async () => {
const events = createPluginEvents();
await events.emitAsync('nonexistent', {});
// No error thrown
});
});
describe('loadPlugins', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camofox-plugin-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMockCtx() {
return {
log: jest.fn(),
events: createPluginEvents(),
sessions: new Map(),
config: {},
};
}
test('returns empty array when plugins directory does not exist', async () => {
// loadPlugins checks the hardcoded PLUGINS_DIR, not tmpDir.
// We test by providing a mock ctx — if no plugins/ dir exists
// relative to lib/, it would still load the real plugins.
// Instead, test via the actual project's plugin loader.
const ctx = makeMockCtx();
const app = {};
// This tests the real plugin loading — should return the project's actual plugins
const loaded = await loadPlugins(app, ctx);
expect(Array.isArray(loaded)).toBe(true);
// Each loaded plugin should be a string
for (const name of loaded) {
expect(typeof name).toBe('string');
}
});
test('loadPlugins registers plugins and logs them', async () => {
const ctx = makeMockCtx();
const app = {};
const loaded = await loadPlugins(app, ctx);
// Verify that log was called for each loaded plugin
if (loaded.length > 0) {
const pluginLoadedCalls = ctx.log.mock.calls.filter(
([level, msg]) => level === 'info' && msg === 'plugin loaded'
);
expect(pluginLoadedCalls.length).toBe(loaded.length);
}
});
});
describe('readPluginConfig (tested indirectly via loadPlugins)', () => {
// readPluginConfig is not exported, so we test its behavior
// indirectly by verifying loadPlugins respects the config.
test('project camofox.config.json exists and is valid', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(__dirname, '../../camofox.config.json');
expect(fs.existsSync(configPath)).toBe(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
expect(config).toHaveProperty('plugins');
// plugins can be array or object
const isArray = Array.isArray(config.plugins);
const isObject = typeof config.plugins === 'object' && !isArray;
expect(isArray || isObject).toBe(true);
});
test('array format plugins are string lists', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(__dirname, '../../camofox.config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
if (Array.isArray(config.plugins)) {
for (const name of config.plugins) {
expect(typeof name).toBe('string');
expect(name.length).toBeGreaterThan(0);
}
}
});
test('each configured plugin has an index.js', () => {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.join(__dirname, '../..');
const configPath = path.join(rootDir, 'camofox.config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const pluginNames = Array.isArray(config.plugins)
? config.plugins
: Object.keys(config.plugins || {});
for (const name of pluginNames) {
const indexPath = path.join(rootDir, 'plugins', name, 'index.js');
expect(fs.existsSync(indexPath)).toBe(true);
}
});
});
});
+92
View File
@@ -0,0 +1,92 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { cleanupOrphanedTempFiles } from '../../lib/tmp-cleanup.js';
describe('lib/tmp-cleanup', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camofox-tmp-cleanup-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeFile(name, { sizeBytes = 16, ageMs = 0 } = {}) {
const full = path.join(tmpDir, name);
fs.writeFileSync(full, Buffer.alloc(sizeBytes));
if (ageMs > 0) {
const past = (Date.now() - ageMs) / 1000;
fs.utimesSync(full, past, past);
}
return full;
}
test('removes orphaned .fea5*.so and .5ef7*.node files older than threshold', () => {
writeFile('.fea5abc123.so', { sizeBytes: 4300000, ageMs: 60 * 60 * 1000 });
writeFile('.5ef7deadbeef.node', { sizeBytes: 0, ageMs: 60 * 60 * 1000 });
const result = cleanupOrphanedTempFiles({ tmpDir, minAgeMs: 5 * 60 * 1000 });
expect(result.scanned).toBe(2);
expect(result.removed).toBe(2);
expect(result.bytes).toBe(4300000);
expect(fs.existsSync(path.join(tmpDir, '.fea5abc123.so'))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, '.5ef7deadbeef.node'))).toBe(false);
});
test('leaves files younger than threshold (concurrent-instance guard)', () => {
writeFile('.fea5beef01.so', { sizeBytes: 100, ageMs: 60 * 1000 });
const result = cleanupOrphanedTempFiles({ tmpDir, minAgeMs: 5 * 60 * 1000 });
expect(result.scanned).toBe(1);
expect(result.removed).toBe(0);
expect(result.skipped).toBe(1);
expect(fs.existsSync(path.join(tmpDir, '.fea5beef01.so'))).toBe(true);
});
test('leaves files that do not match the orphan patterns', () => {
writeFile('normal.so', { ageMs: 60 * 60 * 1000 });
writeFile('.fea5.so', { ageMs: 60 * 60 * 1000 });
writeFile('.fea5abc.txt', { ageMs: 60 * 60 * 1000 });
writeFile('camofox-download-abc.pdf', { ageMs: 60 * 60 * 1000 });
const result = cleanupOrphanedTempFiles({ tmpDir, minAgeMs: 5 * 60 * 1000 });
expect(result.scanned).toBe(0);
expect(result.removed).toBe(0);
expect(fs.readdirSync(tmpDir).length).toBe(4);
});
test('returns zeros when tmpDir does not exist', () => {
const missing = path.join(tmpDir, 'does-not-exist');
const result = cleanupOrphanedTempFiles({ tmpDir: missing });
expect(result).toEqual({ scanned: 0, removed: 0, bytes: 0, skipped: 0 });
});
test('uses injected now for deterministic age comparison', () => {
writeFile('.fea5abc.so', { sizeBytes: 10, ageMs: 0 });
const filePath = path.join(tmpDir, '.fea5abc.so');
const mtimeMs = fs.statSync(filePath).mtimeMs;
const fresh = cleanupOrphanedTempFiles({
tmpDir,
minAgeMs: 5 * 60 * 1000,
now: mtimeMs + 60 * 1000,
});
expect(fresh.removed).toBe(0);
expect(fresh.skipped).toBe(1);
const stale = cleanupOrphanedTempFiles({
tmpDir,
minAgeMs: 5 * 60 * 1000,
now: mtimeMs + 10 * 60 * 1000,
});
expect(stale.removed).toBe(1);
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* Tests for /type endpoint keyboard mode.
*
* Validates the type validation logic by importing and testing against
* an Express-like mock setup rather than regex-matching server.js source code.
*
* Since the /type route is deeply embedded in server.js and can't be extracted
* without an invasive refactor, we test the validation contracts by making
* HTTP-style assertions about the expected behavior patterns:
*
* 1. mode must be 'fill' or 'keyboard' (default: 'fill')
* 2. fill mode requires ref or selector
* 3. keyboard mode allows no ref/selector (types into current focus)
* 4. text is required
* 5. submit and pressEnter both trigger Enter key
*/
import { describe, test, expect } from '@jest/globals';
/**
* Extracted validation logic matching the /type endpoint in server.js.
* Kept in sync with the route — if this diverges, integration tests will catch it.
*/
function validateTypeRequest({ mode = 'fill', text, ref, selector }) {
const errors = [];
if (mode !== 'fill' && mode !== 'keyboard') {
errors.push("mode must be 'fill' or 'keyboard'");
}
if (typeof text !== 'string') {
errors.push('text is required');
}
if (mode === 'fill' && !ref && !selector) {
errors.push('ref or selector required for mode=fill');
}
return errors;
}
function shouldSubmit({ submit = false, pressEnter = false }) {
return submit || pressEnter;
}
describe('/type keyboard mode validation', () => {
describe('mode validation', () => {
test('default mode is fill', () => {
// When mode is omitted, it defaults to "fill"
const errors = validateTypeRequest({ text: 'hello', ref: 'e1' });
expect(errors).toEqual([]);
});
test('accepts fill mode', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello', ref: 'e1' });
expect(errors).toEqual([]);
});
test('accepts keyboard mode', () => {
const errors = validateTypeRequest({ mode: 'keyboard', text: 'hello' });
expect(errors).toEqual([]);
});
test('rejects invalid mode', () => {
const errors = validateTypeRequest({ mode: 'invalid', text: 'hello', ref: 'e1' });
expect(errors).toContain("mode must be 'fill' or 'keyboard'");
});
test('rejects empty string mode', () => {
const errors = validateTypeRequest({ mode: '', text: 'hello', ref: 'e1' });
expect(errors).toContain("mode must be 'fill' or 'keyboard'");
});
});
describe('text validation', () => {
test('requires text to be a string', () => {
const errors = validateTypeRequest({ mode: 'fill', ref: 'e1' });
expect(errors).toContain('text is required');
});
test('rejects number text', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 123, ref: 'e1' });
expect(errors).toContain('text is required');
});
test('accepts empty string text', () => {
const errors = validateTypeRequest({ mode: 'fill', text: '', ref: 'e1' });
expect(errors).not.toContain('text is required');
});
});
describe('fill mode ref/selector requirement', () => {
test('fill mode requires ref or selector', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello' });
expect(errors).toContain('ref or selector required for mode=fill');
});
test('fill mode accepts ref', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello', ref: 'e1' });
expect(errors).toEqual([]);
});
test('fill mode accepts selector', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello', selector: '#input' });
expect(errors).toEqual([]);
});
test('fill mode accepts both ref and selector', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello', ref: 'e1', selector: '#input' });
expect(errors).toEqual([]);
});
});
describe('keyboard mode ref/selector optionality', () => {
test('keyboard mode works without ref or selector', () => {
const errors = validateTypeRequest({ mode: 'keyboard', text: 'hello' });
expect(errors).toEqual([]);
});
test('keyboard mode also works with ref', () => {
const errors = validateTypeRequest({ mode: 'keyboard', text: 'hello', ref: 'e1' });
expect(errors).toEqual([]);
});
test('keyboard mode also works with selector', () => {
const errors = validateTypeRequest({ mode: 'keyboard', text: 'hello', selector: '#input' });
expect(errors).toEqual([]);
});
});
describe('submit / pressEnter handling', () => {
test('submit triggers enter', () => {
expect(shouldSubmit({ submit: true })).toBe(true);
});
test('pressEnter triggers enter', () => {
expect(shouldSubmit({ pressEnter: true })).toBe(true);
});
test('both submit and pressEnter triggers enter', () => {
expect(shouldSubmit({ submit: true, pressEnter: true })).toBe(true);
});
test('neither triggers no enter', () => {
expect(shouldSubmit({})).toBe(false);
expect(shouldSubmit({ submit: false, pressEnter: false })).toBe(false);
});
});
describe('/act type kind has same validation', () => {
// The /act endpoint's type case uses the same validation logic.
// These tests verify the validation matches for both endpoints.
test('act type validates mode same as /type endpoint', () => {
// Same validation: mode must be 'fill' or 'keyboard'
const errors = validateTypeRequest({ mode: 'invalid', text: 'hello', ref: 'e1' });
expect(errors).toContain("mode must be 'fill' or 'keyboard'");
});
test('act type fill mode also requires ref or selector', () => {
const errors = validateTypeRequest({ mode: 'fill', text: 'hello' });
expect(errors).toContain('ref or selector required for mode=fill');
});
test('act type keyboard mode also works without ref', () => {
const errors = validateTypeRequest({ mode: 'keyboard', text: 'hello' });
expect(errors).toEqual([]);
});
test('act type also requires text', () => {
const errors = validateTypeRequest({ mode: 'fill', ref: 'e1' });
expect(errors).toContain('text is required');
});
});
describe('keyboard mode default delay', () => {
test('default delay is 30ms', () => {
// The /type route destructures { delay = 30 }
// Verify the contract
const defaults = { delay: 30 };
expect(defaults.delay).toBe(30);
});
});
});