{"owner":"jo-inc","repo":"camofox-browser","hasSkills":true,"totalSkillsCount":8,"totalTokensCount":10378,"categories":["root-instruction","anthropic-skill","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md","plugins/persistence/AGENTS.md","plugins/vnc/AGENTS.md","plugins/vnc/plugin.json","openclaw.plugin.json","plugins/youtube/AGENTS.md","plugins/vnc/README.md","plugins/persistence/README.md"],"skills":{"AGENTS.md":"# camofox-browser Agent Guide\n\nHeadless browser automation server for AI agents. Run locally or deploy to any cloud provider.\n\n## Quick Start for Agents\n\n```bash\n# Install and start\nnpm install && npm start\n# Server runs on http://localhost:9377\n```\n\n## Core Workflow\n\n1. **Create a tab** -> Get `tabId`\n2. **Navigate** -> Go to URL or use search macro\n3. **Get snapshot** -> Receive page content with element refs (`e1`, `e2`, etc.)\n4. **Interact** -> Click/type using refs\n5. **Repeat** steps 3-4 as needed\n\n## API Reference\n\n### Create Tab\n```bash\nPOST /tabs\n{\"userId\": \"agent1\", \"sessionKey\": \"task1\", \"url\": \"https://example.com\"}\n```\nReturns: `{\"tabId\": \"abc123\", \"url\": \"...\", \"title\": \"...\"}`\n\n### Navigate\n```bash\nPOST /tabs/:tabId/navigate\n{\"userId\": \"agent1\", \"url\": \"https://google.com\"}\n# Or use macro:\n{\"userId\": \"agent1\", \"macro\": \"@google_search\", \"query\": \"weather today\"}\n```\n\n### Get Snapshot\n```bash\nGET /tabs/:tabId/snapshot?userId=agent1\n```\nReturns accessibility tree with refs:\n```\n[heading] Example Domain\n[paragraph] This domain is for use in examples.\n[link e1] More information...\n```\n\n### Click Element\n```bash\nPOST /tabs/:tabId/click\n{\"userId\": \"agent1\", \"ref\": \"e1\"}\n# Or CSS selector:\n{\"userId\": \"agent1\", \"selector\": \"button.submit\"}\n```\n\n### Type Text\n```bash\nPOST /tabs/:tabId/type\n{\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"hello world\"}\n# Add enter: {\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"search query\", \"pressEnter\": true}\n```\n\n### Scroll\n```bash\nPOST /tabs/:tabId/scroll\n{\"userId\": \"agent1\", \"direction\": \"down\", \"amount\": 500}\n```\n\n### Navigation\n```bash\nPOST /tabs/:tabId/back     {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/forward  {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/refresh  {\"userId\": \"agent1\"}\n```\n\n### Get Links\n```bash\nGET /tabs/:tabId/links?userId=agent1&limit=50\n```\n\n### Close Tab\n```bash\nDELETE /tabs/:tabId?userId=agent1\n```\n\n## Search Macros\n\nUse these instead of constructing URLs:\n\n| Macro | Site |\n|-------|------|\n| `@google_search` | Google |\n| `@youtube_search` | YouTube |\n| `@amazon_search` | Amazon |\n| `@reddit_search` | Reddit |\n| `@wikipedia_search` | Wikipedia |\n| `@twitter_search` | Twitter/X |\n| `@yelp_search` | Yelp |\n| `@linkedin_search` | LinkedIn |\n\n## Element Refs\n\nRefs like `e1`, `e2` are stable identifiers for page elements:\n\n1. Call `/snapshot` to get current refs\n2. Use ref in `/click` or `/type`\n3. Refs reset on navigation - get new snapshot after\n\n## Session Management\n\n- `userId` isolates cookies/storage between users\n- `sessionKey` groups tabs by conversation/task (legacy: `listItemId` also accepted)\n- Sessions timeout after 30 minutes of inactivity\n- Delete all user data: `DELETE /sessions/:userId`\n\n## Running Engines\n\n### Camoufox (Default)\n```bash\nnpm start\n# Or: ./run.sh\n```\nFirefox-based with anti-detection. Bypasses Google captcha.\n\n## Testing\n\n```bash\nnpm test                          # All tests (unit + e2e + plugin)\nnpm run test:plugins              # All plugin tests\nnpm run test:e2e                  # E2E tests\nnpm run test:live                 # Live Google tests\nnpm run test:debug                # With server output\nnpx jest plugins/youtube          # Single plugin's tests\n```\n\n## Docker\n\n```bash\ndocker build -t camofox-browser .\ndocker run -p 9377:9377 camofox-browser\n```\n\n## Key Files\n\n- `server.js` - Camoufox engine (routes + browser logic only -- NO `process.env` or `child_process`)\n- `lib/openapi.js` - OpenAPI spec generation via swagger-jsdoc + docs route setup\n- `lib/config.js` - All `process.env` reads centralized here\n- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)\n- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)\n- `lib/cookies.js` - Cookie file I/O\n- `lib/metrics.js` - Prometheus metrics (lazy-loaded, off by default -- set `PROMETHEUS_ENABLED=1`)\n- `lib/request-utils.js` - HTTP request classification helpers (`actionFromReq`, `classifyError`)\n- `lib/snapshot.js` - Accessibility tree snapshot\n- `lib/macros.js` - Search macro URL expansion\n- `lib/plugins.js` - Plugin loader and event bus\n- `lib/auth.js` - Shared auth middleware (API key / loopback)\n- `camofox.config.json` - Plugin configuration (which plugins to load)\n- `plugins/` - Plugin directory (loaded per camofox.config.json)\n- `plugins/youtube/` - Default plugin: YouTube transcript extraction\n- `scripts/install-plugin-deps.sh` - Installs plugin deps (apt.txt + post-install.sh)\n- `plugins/vnc/index.js` - VNC plugin routes (no `child_process` -- spawning isolated in `vnc-launcher.js`)\n- `plugins/vnc/vnc-launcher.js` - VNC process management (`child_process` isolated here)\n- `plugins/persistence/index.js` - Session persistence lifecycle hooks\n- `lib/persistence.js` - Atomic storage state read/write\n- `lib/inflight.js` - Inflight request coalescing\n- `lib/tmp-cleanup.js` - Orphaned temp file cleanup\n- `lib/reporter.js` - Crash/hang reporter with anonymization + GitHub App auth (see README \"Crash Reporter\" for setup)\n- `Dockerfile` - Production container with default plugin deps pre-installed\n\n## OpenAPI Spec (REQUIRED for route changes)\n\nThe API spec is auto-generated from `@openapi` JSDoc comments in `server.js` via [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc). It's served at `GET /openapi.json` (machine-readable) and `GET /docs` ([swagger-stripey](https://github.com/skyfallsin/swagger-stripey) three-panel UI).\n\n**When adding, modifying, or removing a route, you MUST update the `@openapi` JSDoc block above it.**\n\nEvery route handler in `server.js` has a JSDoc comment block directly above it like:\n\n```js\n/**\n * @openapi\n * /tabs/{tabId}/click:\n *   post:\n *     tags: [Interaction]\n *     summary: Click an element\n *     parameters:\n *       - name: tabId\n *         in: path\n *         required: true\n *         schema:\n *           type: string\n *     requestBody:\n *       required: true\n *       content:\n *         application/json:\n *           schema:\n *             type: object\n *             required: [userId]\n *             properties:\n *               userId:\n *                 type: string\n *               ref:\n *                 type: string\n *     responses:\n *       200:\n *         description: Click result.\n *         content:\n *           application/json:\n *             schema:\n *               type: object\n *       404:\n *         description: Tab not found.\n *         content:\n *           application/json:\n *             schema:\n *               $ref: '#/components/schemas/Error'\n */\napp.post('/tabs/:tabId/click', async (req, res) => {\n```\n\n**Rules:**\n- New routes: add a `@openapi` JSDoc block immediately above the `app.get/post/delete(...)` call\n- Path params use `{tabId}` syntax (not `:tabId`) in the JSDoc YAML\n- Tag must be one of: `System`, `Tabs`, `Navigation`, `Interaction`, `Content`, `Sessions`, `Browser`, `Legacy`\n- Every operation must have `tags`, `summary`, and `responses`\n- Include `requestBody` for POST/PUT/DELETE routes that accept JSON\n- Include `parameters` for path params and required query params\n- Mark backward-compat endpoints with `deprecated: true`\n- Removing a route: delete the `@openapi` block along with the handler\n- **After any route change, run `npm run generate-openapi`** to regenerate the committed `openapi.json`. The test suite will fail if it's stale.\n- Run `npx jest tests/unit/openapi.test.js` to verify coverage -- the test fails if any route is missing from the spec, if a stale route exists, or if `openapi.json` is out of date\n- Reusable schemas go in `components.schemas` in `lib/openapi.js` (the `swaggerDefinition`); reference them via `$ref: '#/components/schemas/Name'`\n\n## Telemetry\n\n**No credentials are embedded in this package.** `lib/reporter.js` is a stateless HTTP client that sends anonymized crash/hang telemetry to a Cloudflare Worker endpoint (`camofox-telemetry.askjo.workers.dev`). The endpoint holds the GitHub App credentials as environment secrets -- see `workers/crash-reporter/index.ts`. The source is in-repo and auditable.\n\n- **Architecture**: `lib/reporter.js` (client, no secrets, no `fs`) -> POST -> Cloudflare Worker endpoint -> GitHub Issues\n- **`lib/reporter.js`** has ZERO credentials, ZERO private keys, ZERO `fs` imports. It only does `fetch()` to the telemetry endpoint.\n- **`lib/resources.js`** handles `fs`-based resource snapshots (reading /proc on Linux) -- separated from reporter.js so no file-read + network-send pattern exists in any single file. No `child_process` import.\n- **Anonymization** is in `lib/reporter.js` L28-290 -- text scrubbing (`anonymize()`), URL anonymization (`createUrlAnonymizer()`), and tab health tracking (`createTabHealthTracker()`)\n- **Public domain list** (~120 entries) determines which domains are shown verbatim vs HMAC-hashed\n- **Tests**: `tests/unit/crashRelay.test.js` (telemetry client), `tests/unit/crashRelayWorker.test.js` (worker contract), `tests/unit/noSecrets.test.js` (asserts no key material in shipped files)\n- Self-hosted endpoint: see README \"Self-hosted telemetry endpoint\" section\n- Disable with `CAMOFOX_CRASH_REPORT_ENABLED=false`\n\n## Code Separation Conventions\n\nThe codebase separates concerns across files for clarity and auditability:\n\n- **Configuration**: `process.env` reads live in `lib/config.js`, which exports a plain config object. No other file reads environment variables directly.\n- **Subprocess management**: `child_process` usage lives in dedicated launcher modules (`lib/launcher.js`, `plugins/youtube/youtube.js`, `plugins/vnc/vnc-launcher.js`), not in route handlers.\n- **Route handlers**: `server.js` defines Express routes but delegates env/config reads and subprocess spawning to the modules above.\n- **Metrics**: `lib/metrics.js` lazy-loads prom-client. `lib/request-utils.js` handles HTTP method classification.\n\nWhen adding features that need env vars or subprocesses, put that code in a `lib/` module and import the result into `server.js`.\n\n## Plugin System\n\nPlugins extend camofox-browser with new endpoints, background processes, and lifecycle hooks. The server auto-loads all plugins from `plugins/<name>/index.js` on startup.\n\n### Creating a Plugin\n\n```\nplugins/\n  my-plugin/\n    index.js        Required -- exports register(app, ctx)\n    apt.txt         Optional -- system packages (one per line)\n    post-install.sh Optional -- executable hook for binary downloads\n    *.test.js       Optional -- Jest tests (auto-discovered)\n```\n\n```js\n// plugins/my-plugin/index.js\n\nexport function register(app, ctx) {\n  const { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession,\n          withUserLimit, safePageClose, normalizeUserId, validateUrl, safeError,\n          buildProxyUrl, proxyPool, failuresTotal } = ctx;\n\n  // Register Express routes (auth() enforces API key or loopback)\n  app.get('/my-endpoint', auth(), async (req, res) => {\n    const session = sessions.get(req.params.userId);\n    res.json({ ok: true });\n  });\n\n  // Listen to lifecycle events\n  events.on('browser:launched', ({ browser, display }) => {\n    log('info', 'browser is up', { display });\n  });\n\n  events.on('session:created', ({ userId, context }) => {\n    log('info', 'new session', { userId });\n  });\n\n  events.on('tab:navigated', ({ userId, tabId, url }) => {\n    log('info', 'navigation', { userId, tabId, url });\n  });\n}\n```\n\n### Plugin Context (`ctx`)\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` |\n| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |\n| `log` | `function` | `log(level, msg, fields)` -- structured JSON logging |\n| `events` | `EventEmitter` | Plugin event bus (29 events -- see below) |\n| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |\n| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |\n| `getSession` | `async function` | `getSession(userId)` -- get or create a session |\n| `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close |\n| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit |\n| `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard |\n| `normalizeUserId` | `function` | `normalizeUserId(id)` -- coerce to string for map keys |\n| `validateUrl` | `function` | `validateUrl(url)` -- returns error string or null |\n| `safeError` | `function` | `safeError(err)` -- sanitize error for client response |\n| `buildProxyUrl` | `function` | `buildProxyUrl(pool, proxyConfig)` -- get proxy URL for external requests |\n| `proxyPool` | `object\\|null` | Proxy pool instance (null if no proxy configured) |\n| `failuresTotal` | `Counter` | Prometheus counter: `failuresTotal.labels(type, action).inc()` |\n| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |\n| `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null |\n\n### Events (29)\n\n28 emitted by core, 1 (`session:storage:export`) emitted by plugins.\n\n#### Browser Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `browser:launching` | `{ options }` | (ok) Modify launch options in-place |\n| `browser:launched` | `{ browser, display }` | |\n| `browser:restart` | `{ reason }` | |\n| `browser:closed` | `{ reason }` | |\n| `browser:error` | `{ error }` | |\n\n#### Session Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `session:creating` | `{ userId, contextOptions }` | (ok) Modify context options in-place |\n| `session:created` | `{ userId, context }` | |\n| `session:destroyed` | `{ userId, reason }` | |\n| `session:expired` | `{ userId, idleMs }` | |\n\n#### Tab Lifecycle\n| Event | Payload |\n|-------|---------|\n| `tab:created` | `{ userId, tabId, page, url }` |\n| `tab:navigated` | `{ userId, tabId, url, prevUrl }` |\n| `tab:destroyed` | `{ userId, tabId, reason }` |\n| `tab:recycled` | `{ userId, tabId }` |\n| `tab:error` | `{ userId, tabId, error }` |\n\n#### Content\n| Event | Payload |\n|-------|---------|\n| `tab:snapshot` | `{ userId, tabId, snapshot }` |\n| `tab:screenshot` | `{ userId, tabId, buffer }` |\n| `tab:evaluate` | `{ userId, tabId, expression }` |\n| `tab:evaluated` | `{ userId, tabId, result }` |\n\n#### Input\n| Event | Payload |\n|-------|---------|\n| `tab:click` | `{ userId, tabId, ref, selector }` |\n| `tab:type` | `{ userId, tabId, text, ref, mode }` |\n| `tab:scroll` | `{ userId, tabId, direction, amount }` |\n| `tab:press` | `{ userId, tabId, key }` |\n\n#### Downloads\n| Event | Payload |\n|-------|---------|\n| `tab:download:start` | `{ userId, tabId, filename, url }` |\n| `tab:download:complete` | `{ userId, tabId, filename, path, size }` |\n\n#### Cookies / Auth\n| Event | Payload |\n|-------|---------|\n| `session:cookies:import` | `{ userId, count }` |\n| `session:storage:export` | `{ userId, storageState }` |\n\n#### Server\n| Event | Payload |\n|-------|---------|\n| `server:starting` | `{ port }` |\n| `server:started` | `{ port, pid }` |\n| `server:shutdown` | `{ signal }` |\n\n### Mutating Hooks\n\n`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.\n\nOther events use regular `events.emit()` (fire-and-forget).\n\nModify payload objects in-place:\n\n```js\n// Change Xvfb resolution (e.g., for VNC plugin)\nevents.on('browser:launching', ({ options }) => {\n  options.virtual_display_resolution = '1920x1080x24';\n});\n\n// Inject saved auth state into new sessions\nevents.on('session:creating', ({ userId, contextOptions }) => {\n  const saved = loadStorageState(userId);\n  if (saved) contextOptions.storageState = saved;\n});\n```\n\n### System Packages (`apt.txt`) and Post-Install Hooks\n\nPlugins that need system packages list them one per line in `apt.txt`:\n\n```\n# plugins/vnc/apt.txt\nx11vnc\nnovnc\npython3-websockify\n```\n\nFor binary downloads or setup not available via apt, add an executable `post-install.sh`:\n\n```bash\n# plugins/youtube/post-install.sh\n#!/bin/sh\nset -e\ncurl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp\nchmod +x /usr/local/bin/yt-dlp\n```\n\nBoth are run by `scripts/install-plugin-deps.sh` during Docker build.\n\n### Configuration (`camofox.config.json`)\n\n`camofox.config.json` controls which plugins are loaded at runtime and during Docker build:\n\n```json\n{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"version\": \"1.5.2\",\n  \"plugins\": [\"youtube\"]\n}\n```\n\n- **`plugins`** -- array of plugin directory names to load. Only these are loaded at startup and have deps installed during build.\n- If the file is missing or has no `plugins` key, **all** plugins in `plugins/` are loaded (backward-compatible).\n- This is camofox's own config. `openclaw.plugin.json` is separate -- it tells the OpenClaw Gateway how to configure camofox as an external service.\n\n### Installing Plugins\n\nUse the plugin manager to install third-party plugins from git or local paths:\n\n```bash\n# Install from git\nnode scripts/plugin.js install https://github.com/user/camofox-screenshot-plugin\nnode scripts/plugin.js install git:github.com/user/my-plugin\n\n# Install from local directory\nnode scripts/plugin.js install ./path/to/my-plugin\n\n# List installed plugins\nnode scripts/plugin.js list\n\n# Remove a plugin\nnode scripts/plugin.js remove my-plugin\n```\n\nThe 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.\n\nPlugin sources can be:\n- **Git repos** where the root has `index.js` with `register()` (installed as one plugin)\n- **Git repos** with a `plugins/` subdirectory (each subdirectory installed as a separate plugin)\n- **Local directories** with `index.js` and `register()`\n\n### Default Plugins\n\nThree plugins ship by default:\n\n- **youtube** -- YouTube transcript extraction (enabled by default)\n- **persistence** -- Per-user session state persistence to `~/.camofox/profiles/` (enabled by default)\n- **vnc** -- Interactive browser login via noVNC (disabled by default, requires `ENABLE_VNC=1`)\n\nThe `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.\n\nThe `with-plugins` Dockerfile stage is for rebuilding after adding third-party plugins:\n\n```bash\ndocker build --target with-plugins -t camofox-browser .\n```\n\nThe `with-plugins` stage re-runs `install-plugin-deps.sh` to pick up any new plugins added to `plugins/`.\n\n### Code Separation Rules\n\nPlugins follow the same separation conventions as core (see \"Code Separation Conventions\" above):\n- **No `process.env` in plugin files that also have route handlers** -- read config from `ctx.config`\n- **No `child_process` in plugin files that also have route handlers** -- spawn from a separate `lib/` module\n\n### Custom Metrics\n\nPlugins create Prometheus metrics via `ctx.createMetric()`. Returns a no-op stub when Prometheus is disabled -- no null checks needed.\n\n```js\n// In register(app, ctx):\nconst transcriptsTotal = await ctx.createMetric('counter', {\n  name: 'camofox_youtube_transcripts_total',\n  help: 'YouTube transcripts extracted',\n  labelNames: ['method'],\n});\n\n// Use anywhere -- works whether Prometheus is enabled or not\ntranscriptsTotal.labels('yt-dlp').inc();\n```\n\nSupported 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`.\n\nFor advanced use, `ctx.metricsRegistry()` returns the raw prom-client `Registry` (or `null` when disabled).\n\n### Example: YouTube Transcript Plugin\n\nThe 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.\n\n```\nplugins/\n  youtube/\n    index.js        # register(app, ctx) -- route handler + browser fallback\n    youtube.js      # yt-dlp process management + transcript parsing\n    youtube.test.js # parser unit tests\n    apt.txt         # python3-minimal (yt-dlp runtime dep)\n    post-install.sh # downloads yt-dlp binary\n```\n\n```js\n// plugins/youtube/index.js (simplified)\nimport { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript } from './youtube.js';\nimport { classifyError } from '../../lib/request-utils.js';\n\nexport async function register(app, ctx) {\n  const { log, config, sessions, ensureBrowser, getSession,\n          withUserLimit, safePageClose, normalizeUserId,\n          validateUrl, safeError, buildProxyUrl, proxyPool,\n          failuresTotal } = ctx;\n\n  await detectYtDlp(log);\n\n  app.post('/youtube/transcript', ctx.auth(), async (req, res) => {\n    // ... validate URL, extract videoId, try yt-dlp then browser fallback\n  });\n\n  async function browserTranscript(reqId, url, videoId, lang) {\n    return await withUserLimit('__yt_transcript__', async () => {\n      await ensureBrowser();\n      const session = await getSession('__yt_transcript__');\n      const page = await session.context.newPage();\n      // ... intercept captions, parse transcript\n      await safePageClose(page);\n    });\n  }\n}\n```\n\nKey patterns:\n- **Auth**: `ctx.auth()` middleware on the route\n- **Logging**: `ctx.log('info', ...)` -- never `console.log`\n- **Browser access**: `ctx.ensureBrowser()` + `ctx.getSession()` for browser-backed features\n- **Concurrency**: `ctx.withUserLimit()` to respect per-user limits\n- **Metrics**: `ctx.failuresTotal.labels(...)` for core counters, `ctx.createMetric()` for custom\n- **Code separation**: `child_process` in `youtube.js`, route handler in `index.js` -- separate files\n- **System deps**: `apt.txt` lists packages installed via `scripts/install-plugin-deps.sh`\n","plugins/persistence/AGENTS.md":"# Persistence Plugin — Agent Guide\n\nSaves and restores per-user browser storage state (cookies + localStorage, with optional IndexedDB) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.\n\n## How It Works\n\n- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`\n- `session:created` hook → imports bootstrap cookies if no persisted state exists\n- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk\n- `DELETE /sessions/:userId/storage_state` → closes the live context without checkpointing and removes persisted state\n\nAll hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.\n\n## Key Files\n\n- `index.js` — lifecycle hooks (no routes, no `child_process`)\n- `persistence.test.js` — unit tests for `lib/persistence.js` helpers\n- `plugin.test.js` — integration tests for plugin lifecycle hooks\n\n## Storage Layout\n\n```\n~/.camofox/profiles/\n└── <sha256(userId)>/\n    └── storage_state.json\n```\n\n## Configuration\n\nEnabled 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`.\n\nIndexedDB persistence is opt-in (`\"indexedDB\": true` in the persistence plugin config). It captures all serializable IndexedDB records, not only authentication data, and may make snapshots significantly larger and checkpoints slower.\n\n## Original Contributors\n\n- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))\n- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing\n\nFor PRs touching this plugin, tag the contributors above for review.\n","plugins/vnc/AGENTS.md":"# VNC Plugin — Agent Guide\n\nInteractive browser access via noVNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for agent reuse.\n\n## Endpoints\n\n- `GET /vnc/status` — check if VNC is running (no auth)\n- `GET /sessions/:userId/storage_state` — export cookies + localStorage as JSON (requires auth)\n\n## Activation\n\nDisabled by default. Enable with `ENABLE_VNC=1` env var or `\"vnc\": { \"enabled\": true }` in `camofox.config.json`.\n\n## Key Files\n\n- `index.js` — route handlers only (no `child_process`, no `process.env` reads)\n- `vnc-launcher.js` — process management, config resolution from env vars (`child_process` isolated here)\n- `vnc-watcher.sh` — shell script that detects Xvfb, attaches x11vnc, starts noVNC\n- `vnc.test.js` — unit tests\n- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)\n\n## Code Separation\n\n`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per project conventions.\n\n## Security\n\n- noVNC binds to `127.0.0.1` by default — set `VNC_BIND=0.0.0.0` to expose externally; this does not change native VNC's loopback bind\n- Native VNC requires separate `VNC_RFB_BIND` to expose externally, and refuses non-loopback binds without `VNC_PASSWORD`\n- Set `VNC_PASSWORD` for password-protected access; exposing noVNC without it remains unsafe\n- `VIEW_ONLY=1` disables keyboard/mouse input (observation only)\n- Storage state export endpoint requires auth (API key or loopback)\n\n## Architecture\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.\n\n## Original Contributors\n\n- [@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))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, code separation refactor, security hardening\n\nFor PRs touching this plugin, tag the contributors above for review.\n","plugins/vnc/plugin.json":"{\n  \"enableEnvVar\": \"ENABLE_VNC\"\n}\n","openclaw.plugin.json":"{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"description\": \"Anti-detection browser automation for AI agents using Camoufox (Firefox-based)\",\n  \"version\": \"1.18.1\",\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"url\": {\n        \"type\": \"string\",\n        \"description\": \"Camoufox browser server URL\"\n      },\n      \"port\": {\n        \"type\": \"number\",\n        \"description\": \"Server port (used if url not set)\",\n        \"default\": 9377\n      },\n      \"autoStart\": {\n        \"type\": \"boolean\",\n        \"description\": \"Auto-start the camofox-browser server with the Gateway\",\n        \"default\": true\n      },\n      \"maxSessions\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum concurrent browser sessions (server default: 50)\",\n        \"default\": 5\n      },\n      \"maxTabsPerSession\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum tabs per session (server default: 10)\",\n        \"default\": 3\n      },\n      \"sessionTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Session inactivity timeout in milliseconds (server default: 1800000)\",\n        \"default\": 600000\n      },\n      \"browserIdleTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Kill browser after this many ms with no sessions (0 = never)\",\n        \"default\": 300000\n      },\n      \"maxOldSpaceSize\": {\n        \"type\": \"number\",\n        \"description\": \"Node.js V8 heap limit in MB\",\n        \"default\": 128\n      },\n      \"apiKey\": {\n        \"type\": \"string\",\n        \"description\": \"Secret key for cookie import endpoint. Cookie import is disabled if unset. Set via CAMOFOX_API_KEY env var.\"\n      },\n      \"accessKey\": {\n        \"type\": \"string\",\n        \"description\": \"If set, all routes (except /health) require Authorization: Bearer <key>. Set via CAMOFOX_ACCESS_KEY env var.\"\n      },\n      \"crashReportEnabled\": {\n        \"type\": \"boolean\",\n        \"description\": \"Enable anonymized crash/hang telemetry. Set false to disable all outbound telemetry.\",\n        \"default\": true\n      },\n      \"crashReportUrl\": {\n        \"type\": \"string\",\n        \"description\": \"Telemetry endpoint URL. Override to point to a self-hosted endpoint. Set via CAMOFOX_CRASH_REPORT_URL env var.\",\n        \"default\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n      }\n    },\n    \"additionalProperties\": false\n  },\n  \"uiHints\": {\n    \"url\": {\n      \"label\": \"Server URL\",\n      \"placeholder\": \"http://localhost:9377\"\n    },\n    \"port\": {\n      \"label\": \"Server Port\",\n      \"placeholder\": \"9377\"\n    },\n    \"autoStart\": {\n      \"label\": \"Auto-start server with Gateway\"\n    },\n    \"maxSessions\": {\n      \"label\": \"Max Sessions\",\n      \"placeholder\": \"5\"\n    },\n    \"maxTabsPerSession\": {\n      \"label\": \"Max Tabs per Session\",\n      \"placeholder\": \"3\"\n    },\n    \"sessionTimeoutMs\": {\n      \"label\": \"Session Timeout (ms)\",\n      \"placeholder\": \"600000\"\n    },\n    \"browserIdleTimeoutMs\": {\n      \"label\": \"Browser Idle Timeout (ms)\",\n      \"placeholder\": \"300000\"\n    },\n    \"maxOldSpaceSize\": {\n      \"label\": \"Node Heap Limit (MB)\",\n      \"placeholder\": \"128\"\n    },\n    \"apiKey\": {\n      \"label\": \"Cookie Import API Key (CAMOFOX_API_KEY)\",\n      \"placeholder\": \"Leave empty to disable cookie import\"\n    },\n    \"accessKey\": {\n      \"label\": \"Global Access Key (CAMOFOX_ACCESS_KEY)\",\n      \"placeholder\": \"Leave empty for localhost-only access\"\n    },\n    \"crashReportEnabled\": {\n      \"label\": \"Enable Telemetry\"\n    },\n    \"crashReportUrl\": {\n      \"label\": \"Telemetry Endpoint URL\",\n      \"placeholder\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n    }\n  },\n  \"contracts\": {\n    \"tools\": [\n      \"camofox_create_tab\",\n      \"camofox_snapshot\",\n      \"camofox_click\",\n      \"camofox_type\",\n      \"camofox_navigate\",\n      \"camofox_scroll\",\n      \"camofox_screenshot\",\n      \"camofox_close_tab\",\n      \"camofox_evaluate\",\n      \"camofox_list_tabs\",\n      \"camofox_import_cookies\"\n    ]\n  }\n}\n","plugins/youtube/AGENTS.md":"# YouTube Plugin — Agent Guide\n\nExtracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.\n\n## Endpoint\n\n`POST /youtube/transcript` — unauthenticated by default (set `\"auth\": true` in plugin config to require auth).\n\n## Key Files\n\n- `index.js` — route handler + browser fallback logic\n- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)\n- `youtube.test.js` — parser unit tests\n- `apt.txt` — system deps (python3-minimal for yt-dlp)\n- `post-install.sh` — downloads yt-dlp binary\n\n## Code Separation\n\n`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per project conventions.\n\n## Maintainers\n\n- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system\n\nFor PRs touching this plugin, tag the maintainers above for review.\n","plugins/vnc/README.md":"# VNC Plugin\n\n> 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.\n\nInteractive browser access via VNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for reuse by your agent.\n\n## How it works\n\n```\nCamoufox (Xvfb :99, 1920x1080)\n    ↑\nx11vnc (attaches to :99, port 5900)\n    ↑\nnoVNC / websockify (port 6080)\n    ↑\nYour browser → http://localhost:6080/vnc.html\n```\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.\n\n## Quick start\n\n### Docker\n\n```bash\ndocker run -p 9377:9377 -p 127.0.0.1:6080:6080 \\\n  -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 \\\n  camofox-browser\n\n# Open http://localhost:6080/vnc.html in your browser\n```\n\n### Config file\n\n```json\n{\n  \"plugins\": {\n    \"vnc\": {\n      \"enabled\": true,\n      \"resolution\": \"1920x1080\",\n      \"password\": \"optional-secret\",\n      \"viewOnly\": false,\n      \"novncPort\": 6080\n    }\n  }\n}\n```\n\n## Workflow: interactive login → agent reuse\n\n1. **Start with VNC enabled:**\n   ```bash\n   docker run -p 9377:9377 -p 127.0.0.1:6080:6080 -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 camofox-browser\n   ```\n\n2. **Create a session and navigate to the login page:**\n   ```bash\n   curl -X POST http://localhost:9377/tabs \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"userId\": \"my-agent\", \"sessionKey\": \"default\", \"url\": \"https://accounts.google.com\"}'\n   ```\n\n3. **Log in visually** via http://localhost:6080/vnc.html — complete MFA, solve CAPTCHAs, etc.\n\n4. **Export the authenticated state:**\n   ```bash\n   curl http://localhost:9377/sessions/my-agent/storage_state \\\n     -H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \\\n     -o storage_state.json\n   ```\n\n5. **Reuse on future runs** — pair with the [persistence plugin](../persistence/) to automatically restore state on session creation:\n   ```json\n   {\n     \"plugins\": {\n       \"vnc\": { \"enabled\": true },\n       \"persistence\": { \"enabled\": true, \"profileDir\": \"/data/profiles\" }\n     }\n   }\n   ```\n   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.\n\n## API\n\n### GET /vnc/status\n\nReport whether the VNC watcher and its x11vnc backend are running. This endpoint does not require authentication and exposes no password or session data.\n\n```json\n{\n  \"enabled\": true,\n  \"running\": true,\n  \"watcherRunning\": true,\n  \"display\": \":0\",\n  \"vncPort\": 5900,\n  \"novncPort\": 6080,\n  \"path\": \"/vnc.html\"\n}\n```\n\n`running` becomes false while no owned X display is attached or while x11vnc is being restarted. `watcherRunning` distinguishes that state from the watcher process itself having exited.\n\n### GET /sessions/:userId/storage_state\n\nExport the full Playwright storage state (cookies + localStorage origins) for a user's active browser context.\n\n**Auth:** Same as cookie import — requires `CAMOFOX_API_KEY` Bearer token, or loopback access in non-production.\n\n**Response:**\n```json\n{\n  \"cookies\": [\n    {\n      \"name\": \"session_id\",\n      \"value\": \"abc123\",\n      \"domain\": \".example.com\",\n      \"path\": \"/\",\n      \"expires\": 1700000000,\n      \"httpOnly\": true,\n      \"secure\": true,\n      \"sameSite\": \"Lax\"\n    }\n  ],\n  \"origins\": [\n    {\n      \"origin\": \"https://example.com\",\n      \"localStorage\": [\n        { \"name\": \"theme\", \"value\": \"dark\" }\n      ]\n    }\n  ]\n}\n```\n\n**Errors:**\n- `404` — No active session for the given userId\n- `403` — Missing or invalid API key\n- `500` — Context is dead or storageState export failed\n\n## Configuration\n\n| Source | Variable | Description | Default |\n|--------|----------|-------------|---------|\n| env | `ENABLE_VNC` | Enable the plugin (`1`) | off |\n| env | `VNC_PASSWORD` | x11vnc password | none (open) |\n| env | `VNC_BIND` | noVNC listener address; use `0.0.0.0` in a container with host-local port publishing | `127.0.0.1` |\n| env | `VNC_RFB_BIND` | Native VNC listener address; non-loopback requires `VNC_PASSWORD` | `127.0.0.1` |\n| env | `VNC_RESOLUTION` | Xvfb screen resolution | `1920x1080` |\n| env | `VIEW_ONLY` | Disable mouse/keyboard input (`1`) | off |\n| env | `VNC_PORT` | x11vnc listen port | `5900` |\n| env | `NOVNC_PORT` | noVNC web UI port | `6080` |\n| config | `plugins.vnc.enabled` | Enable the plugin | `false` |\n| config | `plugins.vnc.password` | x11vnc password | none |\n| config | `plugins.vnc.resolution` | Xvfb screen resolution | `1920x1080` |\n| config | `plugins.vnc.viewOnly` | View-only mode | `false` |\n| config | `plugins.vnc.vncPort` | x11vnc listen port | `5900` |\n| config | `plugins.vnc.novncPort` | noVNC web UI port | `6080` |\n\nEnvironment variables override config file values.\n\n## Security\n\n⚠️ **VNC is unencrypted by default.** When running in production:\n\n- **Set `VNC_PASSWORD`** — without it, anyone who can reach port 6080 has full browser control\n- **Bind 6080 to localhost** and access via SSH tunnel: `ssh -L 6080:localhost:6080 your-server`\n- **Or use a firewall** to restrict access to port 6080\n- In Docker: set `VNC_BIND=0.0.0.0` for container forwarding and `-p 127.0.0.1:6080:6080` to bind the host port only to localhost. To expose native VNC separately, set `VNC_RFB_BIND=0.0.0.0` **and** `VNC_PASSWORD`, then publish port 5900 only to trusted hosts.\n\n## System dependencies\n\nThe plugin declares its apt dependencies in `apt.txt` — these are installed automatically during `docker build` via `scripts/install-plugin-deps.sh`:\n\n- `x11vnc` — attaches to Xvfb display\n- `novnc` + `python3-websockify` — web-based VNC client\n- `net-tools` + `procps` — display detection utilities\n\n## Events\n\n| Event | Payload | Description |\n|-------|---------|-------------|\n| `vnc:watcher:started` | `{ pid }` | Watcher process spawned |\n| `vnc:watcher:stopped` | `{ code, signal }` | Watcher exited |\n| `vnc:storage:exported` | `{ userId, cookies, origins }` | Storage state exported via API |\n| `session:storage:export` | `{ userId, storageState }` | Awaited after export; the persistence plugin writes the same snapshot to disk without serializing the browser again |\n","plugins/persistence/README.md":"# persistence\n\nOptional per-user browser storage state persistence for camofox-browser.\n\nSaves and restores cookies and localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API. IndexedDB persistence is available as an opt-in.\n\n## Configuration\n\nIn `camofox.config.json`:\n\n```json\n{\n  \"plugins\": {\n    \"persistence\": {\n      \"enabled\": true,\n      \"profileDir\": \"/data/profiles\",\n      \"indexedDB\": true\n    }\n  }\n}\n```\n\nThe profile directory can also be set via environment variable:\n\n```\nCAMOFOX_PROFILE_DIR=/data/profiles\n```\n\n- `indexedDB` (default: `false`) — set to `true` to capture IndexedDB through `storageState()`. This can preserve logins stored there, including Firebase Auth and other SSO flows. It captures all serializable IndexedDB records—not only authentication data—and may make snapshots significantly larger and checkpoints slower.\n\n## How it works\n\n- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.\n- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).\n- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.\n- **Session reset**: `DELETE /sessions/:userId/storage_state` closes the live context without checkpointing it, waits for in-flight writes, and deletes persisted state so the next session starts fresh.\n- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.\n\n## Docker\n\nWhen running with Docker, mount the profile directory as a volume:\n\n```bash\ndocker run -d \\\n  -p 9377:9377 \\\n  -v /host/profiles:/data/profiles \\\n  camofox-browser\n```\n\n## Credits\n\nBased on PR #62 by [company8](https://github.com/company8).\n"},"files":{"AGENTS.md":"# camofox-browser Agent Guide\n\nHeadless browser automation server for AI agents. Run locally or deploy to any cloud provider.\n\n## Quick Start for Agents\n\n```bash\n# Install and start\nnpm install && npm start\n# Server runs on http://localhost:9377\n```\n\n## Core Workflow\n\n1. **Create a tab** -> Get `tabId`\n2. **Navigate** -> Go to URL or use search macro\n3. **Get snapshot** -> Receive page content with element refs (`e1`, `e2`, etc.)\n4. **Interact** -> Click/type using refs\n5. **Repeat** steps 3-4 as needed\n\n## API Reference\n\n### Create Tab\n```bash\nPOST /tabs\n{\"userId\": \"agent1\", \"sessionKey\": \"task1\", \"url\": \"https://example.com\"}\n```\nReturns: `{\"tabId\": \"abc123\", \"url\": \"...\", \"title\": \"...\"}`\n\n### Navigate\n```bash\nPOST /tabs/:tabId/navigate\n{\"userId\": \"agent1\", \"url\": \"https://google.com\"}\n# Or use macro:\n{\"userId\": \"agent1\", \"macro\": \"@google_search\", \"query\": \"weather today\"}\n```\n\n### Get Snapshot\n```bash\nGET /tabs/:tabId/snapshot?userId=agent1\n```\nReturns accessibility tree with refs:\n```\n[heading] Example Domain\n[paragraph] This domain is for use in examples.\n[link e1] More information...\n```\n\n### Click Element\n```bash\nPOST /tabs/:tabId/click\n{\"userId\": \"agent1\", \"ref\": \"e1\"}\n# Or CSS selector:\n{\"userId\": \"agent1\", \"selector\": \"button.submit\"}\n```\n\n### Type Text\n```bash\nPOST /tabs/:tabId/type\n{\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"hello world\"}\n# Add enter: {\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"search query\", \"pressEnter\": true}\n```\n\n### Scroll\n```bash\nPOST /tabs/:tabId/scroll\n{\"userId\": \"agent1\", \"direction\": \"down\", \"amount\": 500}\n```\n\n### Navigation\n```bash\nPOST /tabs/:tabId/back     {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/forward  {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/refresh  {\"userId\": \"agent1\"}\n```\n\n### Get Links\n```bash\nGET /tabs/:tabId/links?userId=agent1&limit=50\n```\n\n### Close Tab\n```bash\nDELETE /tabs/:tabId?userId=agent1\n```\n\n## Search Macros\n\nUse these instead of constructing URLs:\n\n| Macro | Site |\n|-------|------|\n| `@google_search` | Google |\n| `@youtube_search` | YouTube |\n| `@amazon_search` | Amazon |\n| `@reddit_search` | Reddit |\n| `@wikipedia_search` | Wikipedia |\n| `@twitter_search` | Twitter/X |\n| `@yelp_search` | Yelp |\n| `@linkedin_search` | LinkedIn |\n\n## Element Refs\n\nRefs like `e1`, `e2` are stable identifiers for page elements:\n\n1. Call `/snapshot` to get current refs\n2. Use ref in `/click` or `/type`\n3. Refs reset on navigation - get new snapshot after\n\n## Session Management\n\n- `userId` isolates cookies/storage between users\n- `sessionKey` groups tabs by conversation/task (legacy: `listItemId` also accepted)\n- Sessions timeout after 30 minutes of inactivity\n- Delete all user data: `DELETE /sessions/:userId`\n\n## Running Engines\n\n### Camoufox (Default)\n```bash\nnpm start\n# Or: ./run.sh\n```\nFirefox-based with anti-detection. Bypasses Google captcha.\n\n## Testing\n\n```bash\nnpm test                          # All tests (unit + e2e + plugin)\nnpm run test:plugins              # All plugin tests\nnpm run test:e2e                  # E2E tests\nnpm run test:live                 # Live Google tests\nnpm run test:debug                # With server output\nnpx jest plugins/youtube          # Single plugin's tests\n```\n\n## Docker\n\n```bash\ndocker build -t camofox-browser .\ndocker run -p 9377:9377 camofox-browser\n```\n\n## Key Files\n\n- `server.js` - Camoufox engine (routes + browser logic only -- NO `process.env` or `child_process`)\n- `lib/openapi.js` - OpenAPI spec generation via swagger-jsdoc + docs route setup\n- `lib/config.js` - All `process.env` reads centralized here\n- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)\n- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)\n- `lib/cookies.js` - Cookie file I/O\n- `lib/metrics.js` - Prometheus metrics (lazy-loaded, off by default -- set `PROMETHEUS_ENABLED=1`)\n- `lib/request-utils.js` - HTTP request classification helpers (`actionFromReq`, `classifyError`)\n- `lib/snapshot.js` - Accessibility tree snapshot\n- `lib/macros.js` - Search macro URL expansion\n- `lib/plugins.js` - Plugin loader and event bus\n- `lib/auth.js` - Shared auth middleware (API key / loopback)\n- `camofox.config.json` - Plugin configuration (which plugins to load)\n- `plugins/` - Plugin directory (loaded per camofox.config.json)\n- `plugins/youtube/` - Default plugin: YouTube transcript extraction\n- `scripts/install-plugin-deps.sh` - Installs plugin deps (apt.txt + post-install.sh)\n- `plugins/vnc/index.js` - VNC plugin routes (no `child_process` -- spawning isolated in `vnc-launcher.js`)\n- `plugins/vnc/vnc-launcher.js` - VNC process management (`child_process` isolated here)\n- `plugins/persistence/index.js` - Session persistence lifecycle hooks\n- `lib/persistence.js` - Atomic storage state read/write\n- `lib/inflight.js` - Inflight request coalescing\n- `lib/tmp-cleanup.js` - Orphaned temp file cleanup\n- `lib/reporter.js` - Crash/hang reporter with anonymization + GitHub App auth (see README \"Crash Reporter\" for setup)\n- `Dockerfile` - Production container with default plugin deps pre-installed\n\n## OpenAPI Spec (REQUIRED for route changes)\n\nThe API spec is auto-generated from `@openapi` JSDoc comments in `server.js` via [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc). It's served at `GET /openapi.json` (machine-readable) and `GET /docs` ([swagger-stripey](https://github.com/skyfallsin/swagger-stripey) three-panel UI).\n\n**When adding, modifying, or removing a route, you MUST update the `@openapi` JSDoc block above it.**\n\nEvery route handler in `server.js` has a JSDoc comment block directly above it like:\n\n```js\n/**\n * @openapi\n * /tabs/{tabId}/click:\n *   post:\n *     tags: [Interaction]\n *     summary: Click an element\n *     parameters:\n *       - name: tabId\n *         in: path\n *         required: true\n *         schema:\n *           type: string\n *     requestBody:\n *       required: true\n *       content:\n *         application/json:\n *           schema:\n *             type: object\n *             required: [userId]\n *             properties:\n *               userId:\n *                 type: string\n *               ref:\n *                 type: string\n *     responses:\n *       200:\n *         description: Click result.\n *         content:\n *           application/json:\n *             schema:\n *               type: object\n *       404:\n *         description: Tab not found.\n *         content:\n *           application/json:\n *             schema:\n *               $ref: '#/components/schemas/Error'\n */\napp.post('/tabs/:tabId/click', async (req, res) => {\n```\n\n**Rules:**\n- New routes: add a `@openapi` JSDoc block immediately above the `app.get/post/delete(...)` call\n- Path params use `{tabId}` syntax (not `:tabId`) in the JSDoc YAML\n- Tag must be one of: `System`, `Tabs`, `Navigation`, `Interaction`, `Content`, `Sessions`, `Browser`, `Legacy`\n- Every operation must have `tags`, `summary`, and `responses`\n- Include `requestBody` for POST/PUT/DELETE routes that accept JSON\n- Include `parameters` for path params and required query params\n- Mark backward-compat endpoints with `deprecated: true`\n- Removing a route: delete the `@openapi` block along with the handler\n- **After any route change, run `npm run generate-openapi`** to regenerate the committed `openapi.json`. The test suite will fail if it's stale.\n- Run `npx jest tests/unit/openapi.test.js` to verify coverage -- the test fails if any route is missing from the spec, if a stale route exists, or if `openapi.json` is out of date\n- Reusable schemas go in `components.schemas` in `lib/openapi.js` (the `swaggerDefinition`); reference them via `$ref: '#/components/schemas/Name'`\n\n## Telemetry\n\n**No credentials are embedded in this package.** `lib/reporter.js` is a stateless HTTP client that sends anonymized crash/hang telemetry to a Cloudflare Worker endpoint (`camofox-telemetry.askjo.workers.dev`). The endpoint holds the GitHub App credentials as environment secrets -- see `workers/crash-reporter/index.ts`. The source is in-repo and auditable.\n\n- **Architecture**: `lib/reporter.js` (client, no secrets, no `fs`) -> POST -> Cloudflare Worker endpoint -> GitHub Issues\n- **`lib/reporter.js`** has ZERO credentials, ZERO private keys, ZERO `fs` imports. It only does `fetch()` to the telemetry endpoint.\n- **`lib/resources.js`** handles `fs`-based resource snapshots (reading /proc on Linux) -- separated from reporter.js so no file-read + network-send pattern exists in any single file. No `child_process` import.\n- **Anonymization** is in `lib/reporter.js` L28-290 -- text scrubbing (`anonymize()`), URL anonymization (`createUrlAnonymizer()`), and tab health tracking (`createTabHealthTracker()`)\n- **Public domain list** (~120 entries) determines which domains are shown verbatim vs HMAC-hashed\n- **Tests**: `tests/unit/crashRelay.test.js` (telemetry client), `tests/unit/crashRelayWorker.test.js` (worker contract), `tests/unit/noSecrets.test.js` (asserts no key material in shipped files)\n- Self-hosted endpoint: see README \"Self-hosted telemetry endpoint\" section\n- Disable with `CAMOFOX_CRASH_REPORT_ENABLED=false`\n\n## Code Separation Conventions\n\nThe codebase separates concerns across files for clarity and auditability:\n\n- **Configuration**: `process.env` reads live in `lib/config.js`, which exports a plain config object. No other file reads environment variables directly.\n- **Subprocess management**: `child_process` usage lives in dedicated launcher modules (`lib/launcher.js`, `plugins/youtube/youtube.js`, `plugins/vnc/vnc-launcher.js`), not in route handlers.\n- **Route handlers**: `server.js` defines Express routes but delegates env/config reads and subprocess spawning to the modules above.\n- **Metrics**: `lib/metrics.js` lazy-loads prom-client. `lib/request-utils.js` handles HTTP method classification.\n\nWhen adding features that need env vars or subprocesses, put that code in a `lib/` module and import the result into `server.js`.\n\n## Plugin System\n\nPlugins extend camofox-browser with new endpoints, background processes, and lifecycle hooks. The server auto-loads all plugins from `plugins/<name>/index.js` on startup.\n\n### Creating a Plugin\n\n```\nplugins/\n  my-plugin/\n    index.js        Required -- exports register(app, ctx)\n    apt.txt         Optional -- system packages (one per line)\n    post-install.sh Optional -- executable hook for binary downloads\n    *.test.js       Optional -- Jest tests (auto-discovered)\n```\n\n```js\n// plugins/my-plugin/index.js\n\nexport function register(app, ctx) {\n  const { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession,\n          withUserLimit, safePageClose, normalizeUserId, validateUrl, safeError,\n          buildProxyUrl, proxyPool, failuresTotal } = ctx;\n\n  // Register Express routes (auth() enforces API key or loopback)\n  app.get('/my-endpoint', auth(), async (req, res) => {\n    const session = sessions.get(req.params.userId);\n    res.json({ ok: true });\n  });\n\n  // Listen to lifecycle events\n  events.on('browser:launched', ({ browser, display }) => {\n    log('info', 'browser is up', { display });\n  });\n\n  events.on('session:created', ({ userId, context }) => {\n    log('info', 'new session', { userId });\n  });\n\n  events.on('tab:navigated', ({ userId, tabId, url }) => {\n    log('info', 'navigation', { userId, tabId, url });\n  });\n}\n```\n\n### Plugin Context (`ctx`)\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` |\n| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |\n| `log` | `function` | `log(level, msg, fields)` -- structured JSON logging |\n| `events` | `EventEmitter` | Plugin event bus (29 events -- see below) |\n| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |\n| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |\n| `getSession` | `async function` | `getSession(userId)` -- get or create a session |\n| `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close |\n| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit |\n| `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard |\n| `normalizeUserId` | `function` | `normalizeUserId(id)` -- coerce to string for map keys |\n| `validateUrl` | `function` | `validateUrl(url)` -- returns error string or null |\n| `safeError` | `function` | `safeError(err)` -- sanitize error for client response |\n| `buildProxyUrl` | `function` | `buildProxyUrl(pool, proxyConfig)` -- get proxy URL for external requests |\n| `proxyPool` | `object\\|null` | Proxy pool instance (null if no proxy configured) |\n| `failuresTotal` | `Counter` | Prometheus counter: `failuresTotal.labels(type, action).inc()` |\n| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |\n| `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null |\n\n### Events (29)\n\n28 emitted by core, 1 (`session:storage:export`) emitted by plugins.\n\n#### Browser Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `browser:launching` | `{ options }` | (ok) Modify launch options in-place |\n| `browser:launched` | `{ browser, display }` | |\n| `browser:restart` | `{ reason }` | |\n| `browser:closed` | `{ reason }` | |\n| `browser:error` | `{ error }` | |\n\n#### Session Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `session:creating` | `{ userId, contextOptions }` | (ok) Modify context options in-place |\n| `session:created` | `{ userId, context }` | |\n| `session:destroyed` | `{ userId, reason }` | |\n| `session:expired` | `{ userId, idleMs }` | |\n\n#### Tab Lifecycle\n| Event | Payload |\n|-------|---------|\n| `tab:created` | `{ userId, tabId, page, url }` |\n| `tab:navigated` | `{ userId, tabId, url, prevUrl }` |\n| `tab:destroyed` | `{ userId, tabId, reason }` |\n| `tab:recycled` | `{ userId, tabId }` |\n| `tab:error` | `{ userId, tabId, error }` |\n\n#### Content\n| Event | Payload |\n|-------|---------|\n| `tab:snapshot` | `{ userId, tabId, snapshot }` |\n| `tab:screenshot` | `{ userId, tabId, buffer }` |\n| `tab:evaluate` | `{ userId, tabId, expression }` |\n| `tab:evaluated` | `{ userId, tabId, result }` |\n\n#### Input\n| Event | Payload |\n|-------|---------|\n| `tab:click` | `{ userId, tabId, ref, selector }` |\n| `tab:type` | `{ userId, tabId, text, ref, mode }` |\n| `tab:scroll` | `{ userId, tabId, direction, amount }` |\n| `tab:press` | `{ userId, tabId, key }` |\n\n#### Downloads\n| Event | Payload |\n|-------|---------|\n| `tab:download:start` | `{ userId, tabId, filename, url }` |\n| `tab:download:complete` | `{ userId, tabId, filename, path, size }` |\n\n#### Cookies / Auth\n| Event | Payload |\n|-------|---------|\n| `session:cookies:import` | `{ userId, count }` |\n| `session:storage:export` | `{ userId, storageState }` |\n\n#### Server\n| Event | Payload |\n|-------|---------|\n| `server:starting` | `{ port }` |\n| `server:started` | `{ port, pid }` |\n| `server:shutdown` | `{ signal }` |\n\n### Mutating Hooks\n\n`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.\n\nOther events use regular `events.emit()` (fire-and-forget).\n\nModify payload objects in-place:\n\n```js\n// Change Xvfb resolution (e.g., for VNC plugin)\nevents.on('browser:launching', ({ options }) => {\n  options.virtual_display_resolution = '1920x1080x24';\n});\n\n// Inject saved auth state into new sessions\nevents.on('session:creating', ({ userId, contextOptions }) => {\n  const saved = loadStorageState(userId);\n  if (saved) contextOptions.storageState = saved;\n});\n```\n\n### System Packages (`apt.txt`) and Post-Install Hooks\n\nPlugins that need system packages list them one per line in `apt.txt`:\n\n```\n# plugins/vnc/apt.txt\nx11vnc\nnovnc\npython3-websockify\n```\n\nFor binary downloads or setup not available via apt, add an executable `post-install.sh`:\n\n```bash\n# plugins/youtube/post-install.sh\n#!/bin/sh\nset -e\ncurl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp\nchmod +x /usr/local/bin/yt-dlp\n```\n\nBoth are run by `scripts/install-plugin-deps.sh` during Docker build.\n\n### Configuration (`camofox.config.json`)\n\n`camofox.config.json` controls which plugins are loaded at runtime and during Docker build:\n\n```json\n{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"version\": \"1.5.2\",\n  \"plugins\": [\"youtube\"]\n}\n```\n\n- **`plugins`** -- array of plugin directory names to load. Only these are loaded at startup and have deps installed during build.\n- If the file is missing or has no `plugins` key, **all** plugins in `plugins/` are loaded (backward-compatible).\n- This is camofox's own config. `openclaw.plugin.json` is separate -- it tells the OpenClaw Gateway how to configure camofox as an external service.\n\n### Installing Plugins\n\nUse the plugin manager to install third-party plugins from git or local paths:\n\n```bash\n# Install from git\nnode scripts/plugin.js install https://github.com/user/camofox-screenshot-plugin\nnode scripts/plugin.js install git:github.com/user/my-plugin\n\n# Install from local directory\nnode scripts/plugin.js install ./path/to/my-plugin\n\n# List installed plugins\nnode scripts/plugin.js list\n\n# Remove a plugin\nnode scripts/plugin.js remove my-plugin\n```\n\nThe 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.\n\nPlugin sources can be:\n- **Git repos** where the root has `index.js` with `register()` (installed as one plugin)\n- **Git repos** with a `plugins/` subdirectory (each subdirectory installed as a separate plugin)\n- **Local directories** with `index.js` and `register()`\n\n### Default Plugins\n\nThree plugins ship by default:\n\n- **youtube** -- YouTube transcript extraction (enabled by default)\n- **persistence** -- Per-user session state persistence to `~/.camofox/profiles/` (enabled by default)\n- **vnc** -- Interactive browser login via noVNC (disabled by default, requires `ENABLE_VNC=1`)\n\nThe `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.\n\nThe `with-plugins` Dockerfile stage is for rebuilding after adding third-party plugins:\n\n```bash\ndocker build --target with-plugins -t camofox-browser .\n```\n\nThe `with-plugins` stage re-runs `install-plugin-deps.sh` to pick up any new plugins added to `plugins/`.\n\n### Code Separation Rules\n\nPlugins follow the same separation conventions as core (see \"Code Separation Conventions\" above):\n- **No `process.env` in plugin files that also have route handlers** -- read config from `ctx.config`\n- **No `child_process` in plugin files that also have route handlers** -- spawn from a separate `lib/` module\n\n### Custom Metrics\n\nPlugins create Prometheus metrics via `ctx.createMetric()`. Returns a no-op stub when Prometheus is disabled -- no null checks needed.\n\n```js\n// In register(app, ctx):\nconst transcriptsTotal = await ctx.createMetric('counter', {\n  name: 'camofox_youtube_transcripts_total',\n  help: 'YouTube transcripts extracted',\n  labelNames: ['method'],\n});\n\n// Use anywhere -- works whether Prometheus is enabled or not\ntranscriptsTotal.labels('yt-dlp').inc();\n```\n\nSupported 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`.\n\nFor advanced use, `ctx.metricsRegistry()` returns the raw prom-client `Registry` (or `null` when disabled).\n\n### Example: YouTube Transcript Plugin\n\nThe 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.\n\n```\nplugins/\n  youtube/\n    index.js        # register(app, ctx) -- route handler + browser fallback\n    youtube.js      # yt-dlp process management + transcript parsing\n    youtube.test.js # parser unit tests\n    apt.txt         # python3-minimal (yt-dlp runtime dep)\n    post-install.sh # downloads yt-dlp binary\n```\n\n```js\n// plugins/youtube/index.js (simplified)\nimport { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript } from './youtube.js';\nimport { classifyError } from '../../lib/request-utils.js';\n\nexport async function register(app, ctx) {\n  const { log, config, sessions, ensureBrowser, getSession,\n          withUserLimit, safePageClose, normalizeUserId,\n          validateUrl, safeError, buildProxyUrl, proxyPool,\n          failuresTotal } = ctx;\n\n  await detectYtDlp(log);\n\n  app.post('/youtube/transcript', ctx.auth(), async (req, res) => {\n    // ... validate URL, extract videoId, try yt-dlp then browser fallback\n  });\n\n  async function browserTranscript(reqId, url, videoId, lang) {\n    return await withUserLimit('__yt_transcript__', async () => {\n      await ensureBrowser();\n      const session = await getSession('__yt_transcript__');\n      const page = await session.context.newPage();\n      // ... intercept captions, parse transcript\n      await safePageClose(page);\n    });\n  }\n}\n```\n\nKey patterns:\n- **Auth**: `ctx.auth()` middleware on the route\n- **Logging**: `ctx.log('info', ...)` -- never `console.log`\n- **Browser access**: `ctx.ensureBrowser()` + `ctx.getSession()` for browser-backed features\n- **Concurrency**: `ctx.withUserLimit()` to respect per-user limits\n- **Metrics**: `ctx.failuresTotal.labels(...)` for core counters, `ctx.createMetric()` for custom\n- **Code separation**: `child_process` in `youtube.js`, route handler in `index.js` -- separate files\n- **System deps**: `apt.txt` lists packages installed via `scripts/install-plugin-deps.sh`\n","plugins/persistence/AGENTS.md":"# Persistence Plugin — Agent Guide\n\nSaves and restores per-user browser storage state (cookies + localStorage, with optional IndexedDB) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.\n\n## How It Works\n\n- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`\n- `session:created` hook → imports bootstrap cookies if no persisted state exists\n- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk\n- `DELETE /sessions/:userId/storage_state` → closes the live context without checkpointing and removes persisted state\n\nAll hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.\n\n## Key Files\n\n- `index.js` — lifecycle hooks (no routes, no `child_process`)\n- `persistence.test.js` — unit tests for `lib/persistence.js` helpers\n- `plugin.test.js` — integration tests for plugin lifecycle hooks\n\n## Storage Layout\n\n```\n~/.camofox/profiles/\n└── <sha256(userId)>/\n    └── storage_state.json\n```\n\n## Configuration\n\nEnabled 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`.\n\nIndexedDB persistence is opt-in (`\"indexedDB\": true` in the persistence plugin config). It captures all serializable IndexedDB records, not only authentication data, and may make snapshots significantly larger and checkpoints slower.\n\n## Original Contributors\n\n- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))\n- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing\n\nFor PRs touching this plugin, tag the contributors above for review.\n","plugins/vnc/AGENTS.md":"# VNC Plugin — Agent Guide\n\nInteractive browser access via noVNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for agent reuse.\n\n## Endpoints\n\n- `GET /vnc/status` — check if VNC is running (no auth)\n- `GET /sessions/:userId/storage_state` — export cookies + localStorage as JSON (requires auth)\n\n## Activation\n\nDisabled by default. Enable with `ENABLE_VNC=1` env var or `\"vnc\": { \"enabled\": true }` in `camofox.config.json`.\n\n## Key Files\n\n- `index.js` — route handlers only (no `child_process`, no `process.env` reads)\n- `vnc-launcher.js` — process management, config resolution from env vars (`child_process` isolated here)\n- `vnc-watcher.sh` — shell script that detects Xvfb, attaches x11vnc, starts noVNC\n- `vnc.test.js` — unit tests\n- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)\n\n## Code Separation\n\n`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per project conventions.\n\n## Security\n\n- noVNC binds to `127.0.0.1` by default — set `VNC_BIND=0.0.0.0` to expose externally; this does not change native VNC's loopback bind\n- Native VNC requires separate `VNC_RFB_BIND` to expose externally, and refuses non-loopback binds without `VNC_PASSWORD`\n- Set `VNC_PASSWORD` for password-protected access; exposing noVNC without it remains unsafe\n- `VIEW_ONLY=1` disables keyboard/mouse input (observation only)\n- Storage state export endpoint requires auth (API key or loopback)\n\n## Architecture\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.\n\n## Original Contributors\n\n- [@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))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, code separation refactor, security hardening\n\nFor PRs touching this plugin, tag the contributors above for review.\n","plugins/vnc/plugin.json":"{\n  \"enableEnvVar\": \"ENABLE_VNC\"\n}\n","openclaw.plugin.json":"{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"description\": \"Anti-detection browser automation for AI agents using Camoufox (Firefox-based)\",\n  \"version\": \"1.18.1\",\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"url\": {\n        \"type\": \"string\",\n        \"description\": \"Camoufox browser server URL\"\n      },\n      \"port\": {\n        \"type\": \"number\",\n        \"description\": \"Server port (used if url not set)\",\n        \"default\": 9377\n      },\n      \"autoStart\": {\n        \"type\": \"boolean\",\n        \"description\": \"Auto-start the camofox-browser server with the Gateway\",\n        \"default\": true\n      },\n      \"maxSessions\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum concurrent browser sessions (server default: 50)\",\n        \"default\": 5\n      },\n      \"maxTabsPerSession\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum tabs per session (server default: 10)\",\n        \"default\": 3\n      },\n      \"sessionTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Session inactivity timeout in milliseconds (server default: 1800000)\",\n        \"default\": 600000\n      },\n      \"browserIdleTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Kill browser after this many ms with no sessions (0 = never)\",\n        \"default\": 300000\n      },\n      \"maxOldSpaceSize\": {\n        \"type\": \"number\",\n        \"description\": \"Node.js V8 heap limit in MB\",\n        \"default\": 128\n      },\n      \"apiKey\": {\n        \"type\": \"string\",\n        \"description\": \"Secret key for cookie import endpoint. Cookie import is disabled if unset. Set via CAMOFOX_API_KEY env var.\"\n      },\n      \"accessKey\": {\n        \"type\": \"string\",\n        \"description\": \"If set, all routes (except /health) require Authorization: Bearer <key>. Set via CAMOFOX_ACCESS_KEY env var.\"\n      },\n      \"crashReportEnabled\": {\n        \"type\": \"boolean\",\n        \"description\": \"Enable anonymized crash/hang telemetry. Set false to disable all outbound telemetry.\",\n        \"default\": true\n      },\n      \"crashReportUrl\": {\n        \"type\": \"string\",\n        \"description\": \"Telemetry endpoint URL. Override to point to a self-hosted endpoint. Set via CAMOFOX_CRASH_REPORT_URL env var.\",\n        \"default\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n      }\n    },\n    \"additionalProperties\": false\n  },\n  \"uiHints\": {\n    \"url\": {\n      \"label\": \"Server URL\",\n      \"placeholder\": \"http://localhost:9377\"\n    },\n    \"port\": {\n      \"label\": \"Server Port\",\n      \"placeholder\": \"9377\"\n    },\n    \"autoStart\": {\n      \"label\": \"Auto-start server with Gateway\"\n    },\n    \"maxSessions\": {\n      \"label\": \"Max Sessions\",\n      \"placeholder\": \"5\"\n    },\n    \"maxTabsPerSession\": {\n      \"label\": \"Max Tabs per Session\",\n      \"placeholder\": \"3\"\n    },\n    \"sessionTimeoutMs\": {\n      \"label\": \"Session Timeout (ms)\",\n      \"placeholder\": \"600000\"\n    },\n    \"browserIdleTimeoutMs\": {\n      \"label\": \"Browser Idle Timeout (ms)\",\n      \"placeholder\": \"300000\"\n    },\n    \"maxOldSpaceSize\": {\n      \"label\": \"Node Heap Limit (MB)\",\n      \"placeholder\": \"128\"\n    },\n    \"apiKey\": {\n      \"label\": \"Cookie Import API Key (CAMOFOX_API_KEY)\",\n      \"placeholder\": \"Leave empty to disable cookie import\"\n    },\n    \"accessKey\": {\n      \"label\": \"Global Access Key (CAMOFOX_ACCESS_KEY)\",\n      \"placeholder\": \"Leave empty for localhost-only access\"\n    },\n    \"crashReportEnabled\": {\n      \"label\": \"Enable Telemetry\"\n    },\n    \"crashReportUrl\": {\n      \"label\": \"Telemetry Endpoint URL\",\n      \"placeholder\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n    }\n  },\n  \"contracts\": {\n    \"tools\": [\n      \"camofox_create_tab\",\n      \"camofox_snapshot\",\n      \"camofox_click\",\n      \"camofox_type\",\n      \"camofox_navigate\",\n      \"camofox_scroll\",\n      \"camofox_screenshot\",\n      \"camofox_close_tab\",\n      \"camofox_evaluate\",\n      \"camofox_list_tabs\",\n      \"camofox_import_cookies\"\n    ]\n  }\n}\n","plugins/youtube/AGENTS.md":"# YouTube Plugin — Agent Guide\n\nExtracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.\n\n## Endpoint\n\n`POST /youtube/transcript` — unauthenticated by default (set `\"auth\": true` in plugin config to require auth).\n\n## Key Files\n\n- `index.js` — route handler + browser fallback logic\n- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)\n- `youtube.test.js` — parser unit tests\n- `apt.txt` — system deps (python3-minimal for yt-dlp)\n- `post-install.sh` — downloads yt-dlp binary\n\n## Code Separation\n\n`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per project conventions.\n\n## Maintainers\n\n- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system\n\nFor PRs touching this plugin, tag the maintainers above for review.\n","plugins/vnc/README.md":"# VNC Plugin\n\n> 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.\n\nInteractive browser access via VNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for reuse by your agent.\n\n## How it works\n\n```\nCamoufox (Xvfb :99, 1920x1080)\n    ↑\nx11vnc (attaches to :99, port 5900)\n    ↑\nnoVNC / websockify (port 6080)\n    ↑\nYour browser → http://localhost:6080/vnc.html\n```\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.\n\n## Quick start\n\n### Docker\n\n```bash\ndocker run -p 9377:9377 -p 127.0.0.1:6080:6080 \\\n  -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 \\\n  camofox-browser\n\n# Open http://localhost:6080/vnc.html in your browser\n```\n\n### Config file\n\n```json\n{\n  \"plugins\": {\n    \"vnc\": {\n      \"enabled\": true,\n      \"resolution\": \"1920x1080\",\n      \"password\": \"optional-secret\",\n      \"viewOnly\": false,\n      \"novncPort\": 6080\n    }\n  }\n}\n```\n\n## Workflow: interactive login → agent reuse\n\n1. **Start with VNC enabled:**\n   ```bash\n   docker run -p 9377:9377 -p 127.0.0.1:6080:6080 -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 camofox-browser\n   ```\n\n2. **Create a session and navigate to the login page:**\n   ```bash\n   curl -X POST http://localhost:9377/tabs \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"userId\": \"my-agent\", \"sessionKey\": \"default\", \"url\": \"https://accounts.google.com\"}'\n   ```\n\n3. **Log in visually** via http://localhost:6080/vnc.html — complete MFA, solve CAPTCHAs, etc.\n\n4. **Export the authenticated state:**\n   ```bash\n   curl http://localhost:9377/sessions/my-agent/storage_state \\\n     -H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \\\n     -o storage_state.json\n   ```\n\n5. **Reuse on future runs** — pair with the [persistence plugin](../persistence/) to automatically restore state on session creation:\n   ```json\n   {\n     \"plugins\": {\n       \"vnc\": { \"enabled\": true },\n       \"persistence\": { \"enabled\": true, \"profileDir\": \"/data/profiles\" }\n     }\n   }\n   ```\n   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.\n\n## API\n\n### GET /vnc/status\n\nReport whether the VNC watcher and its x11vnc backend are running. This endpoint does not require authentication and exposes no password or session data.\n\n```json\n{\n  \"enabled\": true,\n  \"running\": true,\n  \"watcherRunning\": true,\n  \"display\": \":0\",\n  \"vncPort\": 5900,\n  \"novncPort\": 6080,\n  \"path\": \"/vnc.html\"\n}\n```\n\n`running` becomes false while no owned X display is attached or while x11vnc is being restarted. `watcherRunning` distinguishes that state from the watcher process itself having exited.\n\n### GET /sessions/:userId/storage_state\n\nExport the full Playwright storage state (cookies + localStorage origins) for a user's active browser context.\n\n**Auth:** Same as cookie import — requires `CAMOFOX_API_KEY` Bearer token, or loopback access in non-production.\n\n**Response:**\n```json\n{\n  \"cookies\": [\n    {\n      \"name\": \"session_id\",\n      \"value\": \"abc123\",\n      \"domain\": \".example.com\",\n      \"path\": \"/\",\n      \"expires\": 1700000000,\n      \"httpOnly\": true,\n      \"secure\": true,\n      \"sameSite\": \"Lax\"\n    }\n  ],\n  \"origins\": [\n    {\n      \"origin\": \"https://example.com\",\n      \"localStorage\": [\n        { \"name\": \"theme\", \"value\": \"dark\" }\n      ]\n    }\n  ]\n}\n```\n\n**Errors:**\n- `404` — No active session for the given userId\n- `403` — Missing or invalid API key\n- `500` — Context is dead or storageState export failed\n\n## Configuration\n\n| Source | Variable | Description | Default |\n|--------|----------|-------------|---------|\n| env | `ENABLE_VNC` | Enable the plugin (`1`) | off |\n| env | `VNC_PASSWORD` | x11vnc password | none (open) |\n| env | `VNC_BIND` | noVNC listener address; use `0.0.0.0` in a container with host-local port publishing | `127.0.0.1` |\n| env | `VNC_RFB_BIND` | Native VNC listener address; non-loopback requires `VNC_PASSWORD` | `127.0.0.1` |\n| env | `VNC_RESOLUTION` | Xvfb screen resolution | `1920x1080` |\n| env | `VIEW_ONLY` | Disable mouse/keyboard input (`1`) | off |\n| env | `VNC_PORT` | x11vnc listen port | `5900` |\n| env | `NOVNC_PORT` | noVNC web UI port | `6080` |\n| config | `plugins.vnc.enabled` | Enable the plugin | `false` |\n| config | `plugins.vnc.password` | x11vnc password | none |\n| config | `plugins.vnc.resolution` | Xvfb screen resolution | `1920x1080` |\n| config | `plugins.vnc.viewOnly` | View-only mode | `false` |\n| config | `plugins.vnc.vncPort` | x11vnc listen port | `5900` |\n| config | `plugins.vnc.novncPort` | noVNC web UI port | `6080` |\n\nEnvironment variables override config file values.\n\n## Security\n\n⚠️ **VNC is unencrypted by default.** When running in production:\n\n- **Set `VNC_PASSWORD`** — without it, anyone who can reach port 6080 has full browser control\n- **Bind 6080 to localhost** and access via SSH tunnel: `ssh -L 6080:localhost:6080 your-server`\n- **Or use a firewall** to restrict access to port 6080\n- In Docker: set `VNC_BIND=0.0.0.0` for container forwarding and `-p 127.0.0.1:6080:6080` to bind the host port only to localhost. To expose native VNC separately, set `VNC_RFB_BIND=0.0.0.0` **and** `VNC_PASSWORD`, then publish port 5900 only to trusted hosts.\n\n## System dependencies\n\nThe plugin declares its apt dependencies in `apt.txt` — these are installed automatically during `docker build` via `scripts/install-plugin-deps.sh`:\n\n- `x11vnc` — attaches to Xvfb display\n- `novnc` + `python3-websockify` — web-based VNC client\n- `net-tools` + `procps` — display detection utilities\n\n## Events\n\n| Event | Payload | Description |\n|-------|---------|-------------|\n| `vnc:watcher:started` | `{ pid }` | Watcher process spawned |\n| `vnc:watcher:stopped` | `{ code, signal }` | Watcher exited |\n| `vnc:storage:exported` | `{ userId, cookies, origins }` | Storage state exported via API |\n| `session:storage:export` | `{ userId, storageState }` | Awaited after export; the persistence plugin writes the same snapshot to disk without serializing the browser again |\n","plugins/persistence/README.md":"# persistence\n\nOptional per-user browser storage state persistence for camofox-browser.\n\nSaves and restores cookies and localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API. IndexedDB persistence is available as an opt-in.\n\n## Configuration\n\nIn `camofox.config.json`:\n\n```json\n{\n  \"plugins\": {\n    \"persistence\": {\n      \"enabled\": true,\n      \"profileDir\": \"/data/profiles\",\n      \"indexedDB\": true\n    }\n  }\n}\n```\n\nThe profile directory can also be set via environment variable:\n\n```\nCAMOFOX_PROFILE_DIR=/data/profiles\n```\n\n- `indexedDB` (default: `false`) — set to `true` to capture IndexedDB through `storageState()`. This can preserve logins stored there, including Firebase Auth and other SSO flows. It captures all serializable IndexedDB records—not only authentication data—and may make snapshots significantly larger and checkpoints slower.\n\n## How it works\n\n- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.\n- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).\n- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.\n- **Session reset**: `DELETE /sessions/:userId/storage_state` closes the live context without checkpointing it, waits for in-flight writes, and deletes persisted state so the next session starts fresh.\n- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.\n\n## Docker\n\nWhen running with Docker, mount the profile directory as a volume:\n\n```bash\ndocker run -d \\\n  -p 9377:9377 \\\n  -v /host/profiles:/data/profiles \\\n  camofox-browser\n```\n\n## Credits\n\nBased on PR #62 by [company8](https://github.com/company8).\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# camofox-browser Agent Guide\n\nHeadless browser automation server for AI agents. Run locally or deploy to any cloud provider.\n\n## Quick Start for Agents\n\n```bash\n# Install and start\nnpm install && npm start\n# Server runs on http://localhost:9377\n```\n\n## Core Workflow\n\n1. **Create a tab** -> Get `tabId`\n2. **Navigate** -> Go to URL or use search macro\n3. **Get snapshot** -> Receive page content with element refs (`e1`, `e2`, etc.)\n4. **Interact** -> Click/type using refs\n5. **Repeat** steps 3-4 as needed\n\n## API Reference\n\n### Create Tab\n```bash\nPOST /tabs\n{\"userId\": \"agent1\", \"sessionKey\": \"task1\", \"url\": \"https://example.com\"}\n```\nReturns: `{\"tabId\": \"abc123\", \"url\": \"...\", \"title\": \"...\"}`\n\n### Navigate\n```bash\nPOST /tabs/:tabId/navigate\n{\"userId\": \"agent1\", \"url\": \"https://google.com\"}\n# Or use macro:\n{\"userId\": \"agent1\", \"macro\": \"@google_search\", \"query\": \"weather today\"}\n```\n\n### Get Snapshot\n```bash\nGET /tabs/:tabId/snapshot?userId=agent1\n```\nReturns accessibility tree with refs:\n```\n[heading] Example Domain\n[paragraph] This domain is for use in examples.\n[link e1] More information...\n```\n\n### Click Element\n```bash\nPOST /tabs/:tabId/click\n{\"userId\": \"agent1\", \"ref\": \"e1\"}\n# Or CSS selector:\n{\"userId\": \"agent1\", \"selector\": \"button.submit\"}\n```\n\n### Type Text\n```bash\nPOST /tabs/:tabId/type\n{\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"hello world\"}\n# Add enter: {\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"search query\", \"pressEnter\": true}\n```\n\n### Scroll\n```bash\nPOST /tabs/:tabId/scroll\n{\"userId\": \"agent1\", \"direction\": \"down\", \"amount\": 500}\n```\n\n### Navigation\n```bash\nPOST /tabs/:tabId/back     {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/forward  {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/refresh  {\"userId\": \"agent1\"}\n```\n\n### Get Links\n```bash\nGET /tabs/:tabId/links?userId=agent1&limit=50\n```\n\n### Close Tab\n```bash\nDELETE /tabs/:tabId?userId=agent1\n```\n\n## Search Macros\n\nUse these instead of constructing URLs:\n\n| Macro | Site |\n|-------|------|\n| `@google_search` | Google |\n| `@youtube_search` | YouTube |\n| `@amazon_search` | Amazon |\n| `@reddit_search` | Reddit |\n| `@wikipedia_search` | Wikipedia |\n| `@twitter_search` | Twitter/X |\n| `@yelp_search` | Yelp |\n| `@linkedin_search` | LinkedIn |\n\n## Element Refs\n\nRefs like `e1`, `e2` are stable identifiers for page elements:\n\n1. Call `/snapshot` to get current refs\n2. Use ref in `/click` or `/type`\n3. Refs reset on navigation - get new snapshot after\n\n## Session Management\n\n- `userId` isolates cookies/storage between users\n- `sessionKey` groups tabs by conversation/task (legacy: `listItemId` also accepted)\n- Sessions timeout after 30 minutes of inactivity\n- Delete all user data: `DELETE /sessions/:userId`\n\n## Running Engines\n\n### Camoufox (Default)\n```bash\nnpm start\n# Or: ./run.sh\n```\nFirefox-based with anti-detection. Bypasses Google captcha.\n\n## Testing\n\n```bash\nnpm test                          # All tests (unit + e2e + plugin)\nnpm run test:plugins              # All plugin tests\nnpm run test:e2e                  # E2E tests\nnpm run test:live                 # Live Google tests\nnpm run test:debug                # With server output\nnpx jest plugins/youtube          # Single plugin's tests\n```\n\n## Docker\n\n```bash\ndocker build -t camofox-browser .\ndocker run -p 9377:9377 camofox-browser\n```\n\n## Key Files\n\n- `server.js` - Camoufox engine (routes + browser logic only -- NO `process.env` or `child_process`)\n- `lib/openapi.js` - OpenAPI spec generation via swagger-jsdoc + docs route setup\n- `lib/config.js` - All `process.env` reads centralized here\n- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)\n- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)\n- `lib/cookies.js` - Cookie file I/O\n- `lib/metrics.js` - Prometheus metrics (lazy-loaded, off by default -- set `PROMETHEUS_ENABLED=1`)\n- `lib/request-utils.js` - HTTP request classification helpers (`actionFromReq`, `classifyError`)\n- `lib/snapshot.js` - Accessibility tree snapshot\n- `lib/macros.js` - Search macro URL expansion\n- `lib/plugins.js` - Plugin loader and event bus\n- `lib/auth.js` - Shared auth middleware (API key / loopback)\n- `camofox.config.json` - Plugin configuration (which plugins to load)\n- `plugins/` - Plugin directory (loaded per camofox.config.json)\n- `plugins/youtube/` - Default plugin: YouTube transcript extraction\n- `scripts/install-plugin-deps.sh` - Installs plugin deps (apt.txt + post-install.sh)\n- `plugins/vnc/index.js` - VNC plugin routes (no `child_process` -- spawning isolated in `vnc-launcher.js`)\n- `plugins/vnc/vnc-launcher.js` - VNC process management (`child_process` isolated here)\n- `plugins/persistence/index.js` - Session persistence lifecycle hooks\n- `lib/persistence.js` - Atomic storage state read/write\n- `lib/inflight.js` - Inflight request coalescing\n- `lib/tmp-cleanup.js` - Orphaned temp file cleanup\n- `lib/reporter.js` - Crash/hang reporter with anonymization + GitHub App auth (see README \"Crash Reporter\" for setup)\n- `Dockerfile` - Production container with default plugin deps pre-installed\n\n## OpenAPI Spec (REQUIRED for route changes)\n\nThe API spec is auto-generated from `@openapi` JSDoc comments in `server.js` via [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc). It's served at `GET /openapi.json` (machine-readable) and `GET /docs` ([swagger-stripey](https://github.com/skyfallsin/swagger-stripey) three-panel UI).\n\n**When adding, modifying, or removing a route, you MUST update the `@openapi` JSDoc block above it.**\n\nEvery route handler in `server.js` has a JSDoc comment block directly above it like:\n\n```js\n/**\n * @openapi\n * /tabs/{tabId}/click:\n *   post:\n *     tags: [Interaction]\n *     summary: Click an element\n *     parameters:\n *       - name: tabId\n *         in: path\n *         required: true\n *         schema:\n *           type: string\n *     requestBody:\n *       required: true\n *       content:\n *         application/json:\n *           schema:\n *             type: object\n *             required: [userId]\n *             properties:\n *               userId:\n *                 type: string\n *               ref:\n *                 type: string\n *     responses:\n *       200:\n *         description: Click result.\n *         content:\n *           application/json:\n *             schema:\n *               type: object\n *       404:\n *         description: Tab not found.\n *         content:\n *           application/json:\n *             schema:\n *               $ref: '#/components/schemas/Error'\n */\napp.post('/tabs/:tabId/click', async (req, res) => {\n```\n\n**Rules:**\n- New routes: add a `@openapi` JSDoc block immediately above the `app.get/post/delete(...)` call\n- Path params use `{tabId}` syntax (not `:tabId`) in the JSDoc YAML\n- Tag must be one of: `System`, `Tabs`, `Navigation`, `Interaction`, `Content`, `Sessions`, `Browser`, `Legacy`\n- Every operation must have `tags`, `summary`, and `responses`\n- Include `requestBody` for POST/PUT/DELETE routes that accept JSON\n- Include `parameters` for path params and required query params\n- Mark backward-compat endpoints with `deprecated: true`\n- Removing a route: delete the `@openapi` block along with the handler\n- **After any route change, run `npm run generate-openapi`** to regenerate the committed `openapi.json`. The test suite will fail if it's stale.\n- Run `npx jest tests/unit/openapi.test.js` to verify coverage -- the test fails if any route is missing from the spec, if a stale route exists, or if `openapi.json` is out of date\n- Reusable schemas go in `components.schemas` in `lib/openapi.js` (the `swaggerDefinition`); reference them via `$ref: '#/components/schemas/Name'`\n\n## Telemetry\n\n**No credentials are embedded in this package.** `lib/reporter.js` is a stateless HTTP client that sends anonymized crash/hang telemetry to a Cloudflare Worker endpoint (`camofox-telemetry.askjo.workers.dev`). The endpoint holds the GitHub App credentials as environment secrets -- see `workers/crash-reporter/index.ts`. The source is in-repo and auditable.\n\n- **Architecture**: `lib/reporter.js` (client, no secrets, no `fs`) -> POST -> Cloudflare Worker endpoint -> GitHub Issues\n- **`lib/reporter.js`** has ZERO credentials, ZERO private keys, ZERO `fs` imports. It only does `fetch()` to the telemetry endpoint.\n- **`lib/resources.js`** handles `fs`-based resource snapshots (reading /proc on Linux) -- separated from reporter.js so no file-read + network-send pattern exists in any single file. No `child_process` import.\n- **Anonymization** is in `lib/reporter.js` L28-290 -- text scrubbing (`anonymize()`), URL anonymization (`createUrlAnonymizer()`), and tab health tracking (`createTabHealthTracker()`)\n- **Public domain list** (~120 entries) determines which domains are shown verbatim vs HMAC-hashed\n- **Tests**: `tests/unit/crashRelay.test.js` (telemetry client), `tests/unit/crashRelayWorker.test.js` (worker contract), `tests/unit/noSecrets.test.js` (asserts no key material in shipped files)\n- Self-hosted endpoint: see README \"Self-hosted telemetry endpoint\" section\n- Disable with `CAMOFOX_CRASH_REPORT_ENABLED=false`\n\n## Code Separation Conventions\n\nThe codebase separates concerns across files for clarity and auditability:\n\n- **Configuration**: `process.env` reads live in `lib/config.js`, which exports a plain config object. No other file reads environment variables directly.\n- **Subprocess management**: `child_process` usage lives in dedicated launcher modules (`lib/launcher.js`, `plugins/youtube/youtube.js`, `plugins/vnc/vnc-launcher.js`), not in route handlers.\n- **Route handlers**: `server.js` defines Express routes but delegates env/config reads and subprocess spawning to the modules above.\n- **Metrics**: `lib/metrics.js` lazy-loads prom-client. `lib/request-utils.js` handles HTTP method classification.\n\nWhen adding features that need env vars or subprocesses, put that code in a `lib/` module and import the result into `server.js`.\n\n## Plugin System\n\nPlugins extend camofox-browser with new endpoints, background processes, and lifecycle hooks. The server auto-loads all plugins from `plugins/<name>/index.js` on startup.\n\n### Creating a Plugin\n\n```\nplugins/\n  my-plugin/\n    index.js        Required -- exports register(app, ctx)\n    apt.txt         Optional -- system packages (one per line)\n    post-install.sh Optional -- executable hook for binary downloads\n    *.test.js       Optional -- Jest tests (auto-discovered)\n```\n\n```js\n// plugins/my-plugin/index.js\n\nexport function register(app, ctx) {\n  const { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession,\n          withUserLimit, safePageClose, normalizeUserId, validateUrl, safeError,\n          buildProxyUrl, proxyPool, failuresTotal } = ctx;\n\n  // Register Express routes (auth() enforces API key or loopback)\n  app.get('/my-endpoint', auth(), async (req, res) => {\n    const session = sessions.get(req.params.userId);\n    res.json({ ok: true });\n  });\n\n  // Listen to lifecycle events\n  events.on('browser:launched', ({ browser, display }) => {\n    log('info', 'browser is up', { display });\n  });\n\n  events.on('session:created', ({ userId, context }) => {\n    log('info', 'new session', { userId });\n  });\n\n  events.on('tab:navigated', ({ userId, tabId, url }) => {\n    log('info', 'navigation', { userId, tabId, url });\n  });\n}\n```\n\n### Plugin Context (`ctx`)\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` |\n| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |\n| `log` | `function` | `log(level, msg, fields)` -- structured JSON logging |\n| `events` | `EventEmitter` | Plugin event bus (29 events -- see below) |\n| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |\n| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |\n| `getSession` | `async function` | `getSession(userId)` -- get or create a session |\n| `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close |\n| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit |\n| `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard |\n| `normalizeUserId` | `function` | `normalizeUserId(id)` -- coerce to string for map keys |\n| `validateUrl` | `function` | `validateUrl(url)` -- returns error string or null |\n| `safeError` | `function` | `safeError(err)` -- sanitize error for client response |\n| `buildProxyUrl` | `function` | `buildProxyUrl(pool, proxyConfig)` -- get proxy URL for external requests |\n| `proxyPool` | `object\\|null` | Proxy pool instance (null if no proxy configured) |\n| `failuresTotal` | `Counter` | Prometheus counter: `failuresTotal.labels(type, action).inc()` |\n| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |\n| `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null |\n\n### Events (29)\n\n28 emitted by core, 1 (`session:storage:export`) emitted by plugins.\n\n#### Browser Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `browser:launching` | `{ options }` | (ok) Modify launch options in-place |\n| `browser:launched` | `{ browser, display }` | |\n| `browser:restart` | `{ reason }` | |\n| `browser:closed` | `{ reason }` | |\n| `browser:error` | `{ error }` | |\n\n#### Session Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `session:creating` | `{ userId, contextOptions }` | (ok) Modify context options in-place |\n| `session:created` | `{ userId, context }` | |\n| `session:destroyed` | `{ userId, reason }` | |\n| `session:expired` | `{ userId, idleMs }` | |\n\n#### Tab Lifecycle\n| Event | Payload |\n|-------|---------|\n| `tab:created` | `{ userId, tabId, page, url }` |\n| `tab:navigated` | `{ userId, tabId, url, prevUrl }` |\n| `tab:destroyed` | `{ userId, tabId, reason }` |\n| `tab:recycled` | `{ userId, tabId }` |\n| `tab:error` | `{ userId, tabId, error }` |\n\n#### Content\n| Event | Payload |\n|-------|---------|\n| `tab:snapshot` | `{ userId, tabId, snapshot }` |\n| `tab:screenshot` | `{ userId, tabId, buffer }` |\n| `tab:evaluate` | `{ userId, tabId, expression }` |\n| `tab:evaluated` | `{ userId, tabId, result }` |\n\n#### Input\n| Event | Payload |\n|-------|---------|\n| `tab:click` | `{ userId, tabId, ref, selector }` |\n| `tab:type` | `{ userId, tabId, text, ref, mode }` |\n| `tab:scroll` | `{ userId, tabId, direction, amount }` |\n| `tab:press` | `{ userId, tabId, key }` |\n\n#### Downloads\n| Event | Payload |\n|-------|---------|\n| `tab:download:start` | `{ userId, tabId, filename, url }` |\n| `tab:download:complete` | `{ userId, tabId, filename, path, size }` |\n\n#### Cookies / Auth\n| Event | Payload |\n|-------|---------|\n| `session:cookies:import` | `{ userId, count }` |\n| `session:storage:export` | `{ userId, storageState }` |\n\n#### Server\n| Event | Payload |\n|-------|---------|\n| `server:starting` | `{ port }` |\n| `server:started` | `{ port, pid }` |\n| `server:shutdown` | `{ signal }` |\n\n### Mutating Hooks\n\n`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.\n\nOther events use regular `events.emit()` (fire-and-forget).\n\nModify payload objects in-place:\n\n```js\n// Change Xvfb resolution (e.g., for VNC plugin)\nevents.on('browser:launching', ({ options }) => {\n  options.virtual_display_resolution = '1920x1080x24';\n});\n\n// Inject saved auth state into new sessions\nevents.on('session:creating', ({ userId, contextOptions }) => {\n  const saved = loadStorageState(userId);\n  if (saved) contextOptions.storageState = saved;\n});\n```\n\n### System Packages (`apt.txt`) and Post-Install Hooks\n\nPlugins that need system packages list them one per line in `apt.txt`:\n\n```\n# plugins/vnc/apt.txt\nx11vnc\nnovnc\npython3-websockify\n```\n\nFor binary downloads or setup not available via apt, add an executable `post-install.sh`:\n\n```bash\n# plugins/youtube/post-install.sh\n#!/bin/sh\nset -e\ncurl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp\nchmod +x /usr/local/bin/yt-dlp\n```\n\nBoth are run by `scripts/install-plugin-deps.sh` during Docker build.\n\n### Configuration (`camofox.config.json`)\n\n`camofox.config.json` controls which plugins are loaded at runtime and during Docker build:\n\n```json\n{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"version\": \"1.5.2\",\n  \"plugins\": [\"youtube\"]\n}\n```\n\n- **`plugins`** -- array of plugin directory names to load. Only these are loaded at startup and have deps installed during build.\n- If the file is missing or has no `plugins` key, **all** plugins in `plugins/` are loaded (backward-compatible).\n- This is camofox's own config. `openclaw.plugin.json` is separate -- it tells the OpenClaw Gateway how to configure camofox as an external service.\n\n### Installing Plugins\n\nUse the plugin manager to install third-party plugins from git or local paths:\n\n```bash\n# Install from git\nnode scripts/plugin.js install https://github.com/user/camofox-screenshot-plugin\nnode scripts/plugin.js install git:github.com/user/my-plugin\n\n# Install from local directory\nnode scripts/plugin.js install ./path/to/my-plugin\n\n# List installed plugins\nnode scripts/plugin.js list\n\n# Remove a plugin\nnode scripts/plugin.js remove my-plugin\n```\n\nThe 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.\n\nPlugin sources can be:\n- **Git repos** where the root has `index.js` with `register()` (installed as one plugin)\n- **Git repos** with a `plugins/` subdirectory (each subdirectory installed as a separate plugin)\n- **Local directories** with `index.js` and `register()`\n\n### Default Plugins\n\nThree plugins ship by default:\n\n- **youtube** -- YouTube transcript extraction (enabled by default)\n- **persistence** -- Per-user session state persistence to `~/.camofox/profiles/` (enabled by default)\n- **vnc** -- Interactive browser login via noVNC (disabled by default, requires `ENABLE_VNC=1`)\n\nThe `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.\n\nThe `with-plugins` Dockerfile stage is for rebuilding after adding third-party plugins:\n\n```bash\ndocker build --target with-plugins -t camofox-browser .\n```\n\nThe `with-plugins` stage re-runs `install-plugin-deps.sh` to pick up any new plugins added to `plugins/`.\n\n### Code Separation Rules\n\nPlugins follow the same separation conventions as core (see \"Code Separation Conventions\" above):\n- **No `process.env` in plugin files that also have route handlers** -- read config from `ctx.config`\n- **No `child_process` in plugin files that also have route handlers** -- spawn from a separate `lib/` module\n\n### Custom Metrics\n\nPlugins create Prometheus metrics via `ctx.createMetric()`. Returns a no-op stub when Prometheus is disabled -- no null checks needed.\n\n```js\n// In register(app, ctx):\nconst transcriptsTotal = await ctx.createMetric('counter', {\n  name: 'camofox_youtube_transcripts_total',\n  help: 'YouTube transcripts extracted',\n  labelNames: ['method'],\n});\n\n// Use anywhere -- works whether Prometheus is enabled or not\ntranscriptsTotal.labels('yt-dlp').inc();\n```\n\nSupported 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`.\n\nFor advanced use, `ctx.metricsRegistry()` returns the raw prom-client `Registry` (or `null` when disabled).\n\n### Example: YouTube Transcript Plugin\n\nThe 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.\n\n```\nplugins/\n  youtube/\n    index.js        # register(app, ctx) -- route handler + browser fallback\n    youtube.js      # yt-dlp process management + transcript parsing\n    youtube.test.js # parser unit tests\n    apt.txt         # python3-minimal (yt-dlp runtime dep)\n    post-install.sh # downloads yt-dlp binary\n```\n\n```js\n// plugins/youtube/index.js (simplified)\nimport { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript } from './youtube.js';\nimport { classifyError } from '../../lib/request-utils.js';\n\nexport async function register(app, ctx) {\n  const { log, config, sessions, ensureBrowser, getSession,\n          withUserLimit, safePageClose, normalizeUserId,\n          validateUrl, safeError, buildProxyUrl, proxyPool,\n          failuresTotal } = ctx;\n\n  await detectYtDlp(log);\n\n  app.post('/youtube/transcript', ctx.auth(), async (req, res) => {\n    // ... validate URL, extract videoId, try yt-dlp then browser fallback\n  });\n\n  async function browserTranscript(reqId, url, videoId, lang) {\n    return await withUserLimit('__yt_transcript__', async () => {\n      await ensureBrowser();\n      const session = await getSession('__yt_transcript__');\n      const page = await session.context.newPage();\n      // ... intercept captions, parse transcript\n      await safePageClose(page);\n    });\n  }\n}\n```\n\nKey patterns:\n- **Auth**: `ctx.auth()` middleware on the route\n- **Logging**: `ctx.log('info', ...)` -- never `console.log`\n- **Browser access**: `ctx.ensureBrowser()` + `ctx.getSession()` for browser-backed features\n- **Concurrency**: `ctx.withUserLimit()` to respect per-user limits\n- **Metrics**: `ctx.failuresTotal.labels(...)` for core counters, `ctx.createMetric()` for custom\n- **Code separation**: `child_process` in `youtube.js`, route handler in `index.js` -- separate files\n- **System deps**: `apt.txt` lists packages installed via `scripts/install-plugin-deps.sh`\n","isInternal":false,"tokens":5729,"sizeBytes":22420},{"name":"AGENTS.md","path":"plugins/persistence/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/persistence/AGENTS.md","title":"Persistence Skill","category":"anthropic-skill","format":"markdown","content":"# Persistence Plugin — Agent Guide\n\nSaves and restores per-user browser storage state (cookies + localStorage, with optional IndexedDB) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.\n\n## How It Works\n\n- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`\n- `session:created` hook → imports bootstrap cookies if no persisted state exists\n- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk\n- `DELETE /sessions/:userId/storage_state` → closes the live context without checkpointing and removes persisted state\n\nAll hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.\n\n## Key Files\n\n- `index.js` — lifecycle hooks (no routes, no `child_process`)\n- `persistence.test.js` — unit tests for `lib/persistence.js` helpers\n- `plugin.test.js` — integration tests for plugin lifecycle hooks\n\n## Storage Layout\n\n```\n~/.camofox/profiles/\n└── <sha256(userId)>/\n    └── storage_state.json\n```\n\n## Configuration\n\nEnabled 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`.\n\nIndexedDB persistence is opt-in (`\"indexedDB\": true` in the persistence plugin config). It captures all serializable IndexedDB records, not only authentication data, and may make snapshots significantly larger and checkpoints slower.\n\n## Original Contributors\n\n- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))\n- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing\n\nFor PRs touching this plugin, tag the contributors above for review.\n","isInternal":false,"tokens":482,"sizeBytes":2066},{"name":"AGENTS.md","path":"plugins/vnc/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/vnc/AGENTS.md","title":"Vnc Skill","category":"anthropic-skill","format":"markdown","content":"# VNC Plugin — Agent Guide\n\nInteractive browser access via noVNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for agent reuse.\n\n## Endpoints\n\n- `GET /vnc/status` — check if VNC is running (no auth)\n- `GET /sessions/:userId/storage_state` — export cookies + localStorage as JSON (requires auth)\n\n## Activation\n\nDisabled by default. Enable with `ENABLE_VNC=1` env var or `\"vnc\": { \"enabled\": true }` in `camofox.config.json`.\n\n## Key Files\n\n- `index.js` — route handlers only (no `child_process`, no `process.env` reads)\n- `vnc-launcher.js` — process management, config resolution from env vars (`child_process` isolated here)\n- `vnc-watcher.sh` — shell script that detects Xvfb, attaches x11vnc, starts noVNC\n- `vnc.test.js` — unit tests\n- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)\n\n## Code Separation\n\n`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per project conventions.\n\n## Security\n\n- noVNC binds to `127.0.0.1` by default — set `VNC_BIND=0.0.0.0` to expose externally; this does not change native VNC's loopback bind\n- Native VNC requires separate `VNC_RFB_BIND` to expose externally, and refuses non-loopback binds without `VNC_PASSWORD`\n- Set `VNC_PASSWORD` for password-protected access; exposing noVNC without it remains unsafe\n- `VIEW_ONLY=1` disables keyboard/mouse input (observation only)\n- Storage state export endpoint requires auth (API key or loopback)\n\n## Architecture\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.\n\n## Original Contributors\n\n- [@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))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, code separation refactor, security hardening\n\nFor PRs touching this plugin, tag the contributors above for review.\n","isInternal":false,"tokens":605,"sizeBytes":2312},{"name":"AGENTS.md","path":"plugins/youtube/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/youtube/AGENTS.md","title":"Youtube Skill","category":"anthropic-skill","format":"markdown","content":"# YouTube Plugin — Agent Guide\n\nExtracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.\n\n## Endpoint\n\n`POST /youtube/transcript` — unauthenticated by default (set `\"auth\": true` in plugin config to require auth).\n\n## Key Files\n\n- `index.js` — route handler + browser fallback logic\n- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)\n- `youtube.test.js` — parser unit tests\n- `apt.txt` — system deps (python3-minimal for yt-dlp)\n- `post-install.sh` — downloads yt-dlp binary\n\n## Code Separation\n\n`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per project conventions.\n\n## Maintainers\n\n- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system\n\nFor PRs touching this plugin, tag the maintainers above for review.\n","isInternal":false,"tokens":206,"sizeBytes":867},{"name":"openclaw.plugin.json","path":"openclaw.plugin.json","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/openclaw.plugin.json","title":"Camofox Browser","category":"plugin-manifest","format":"json","content":"{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"description\": \"Anti-detection browser automation for AI agents using Camoufox (Firefox-based)\",\n  \"version\": \"1.18.1\",\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"url\": {\n        \"type\": \"string\",\n        \"description\": \"Camoufox browser server URL\"\n      },\n      \"port\": {\n        \"type\": \"number\",\n        \"description\": \"Server port (used if url not set)\",\n        \"default\": 9377\n      },\n      \"autoStart\": {\n        \"type\": \"boolean\",\n        \"description\": \"Auto-start the camofox-browser server with the Gateway\",\n        \"default\": true\n      },\n      \"maxSessions\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum concurrent browser sessions (server default: 50)\",\n        \"default\": 5\n      },\n      \"maxTabsPerSession\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum tabs per session (server default: 10)\",\n        \"default\": 3\n      },\n      \"sessionTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Session inactivity timeout in milliseconds (server default: 1800000)\",\n        \"default\": 600000\n      },\n      \"browserIdleTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Kill browser after this many ms with no sessions (0 = never)\",\n        \"default\": 300000\n      },\n      \"maxOldSpaceSize\": {\n        \"type\": \"number\",\n        \"description\": \"Node.js V8 heap limit in MB\",\n        \"default\": 128\n      },\n      \"apiKey\": {\n        \"type\": \"string\",\n        \"description\": \"Secret key for cookie import endpoint. Cookie import is disabled if unset. Set via CAMOFOX_API_KEY env var.\"\n      },\n      \"accessKey\": {\n        \"type\": \"string\",\n        \"description\": \"If set, all routes (except /health) require Authorization: Bearer <key>. Set via CAMOFOX_ACCESS_KEY env var.\"\n      },\n      \"crashReportEnabled\": {\n        \"type\": \"boolean\",\n        \"description\": \"Enable anonymized crash/hang telemetry. Set false to disable all outbound telemetry.\",\n        \"default\": true\n      },\n      \"crashReportUrl\": {\n        \"type\": \"string\",\n        \"description\": \"Telemetry endpoint URL. Override to point to a self-hosted endpoint. Set via CAMOFOX_CRASH_REPORT_URL env var.\",\n        \"default\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n      }\n    },\n    \"additionalProperties\": false\n  },\n  \"uiHints\": {\n    \"url\": {\n      \"label\": \"Server URL\",\n      \"placeholder\": \"http://localhost:9377\"\n    },\n    \"port\": {\n      \"label\": \"Server Port\",\n      \"placeholder\": \"9377\"\n    },\n    \"autoStart\": {\n      \"label\": \"Auto-start server with Gateway\"\n    },\n    \"maxSessions\": {\n      \"label\": \"Max Sessions\",\n      \"placeholder\": \"5\"\n    },\n    \"maxTabsPerSession\": {\n      \"label\": \"Max Tabs per Session\",\n      \"placeholder\": \"3\"\n    },\n    \"sessionTimeoutMs\": {\n      \"label\": \"Session Timeout (ms)\",\n      \"placeholder\": \"600000\"\n    },\n    \"browserIdleTimeoutMs\": {\n      \"label\": \"Browser Idle Timeout (ms)\",\n      \"placeholder\": \"300000\"\n    },\n    \"maxOldSpaceSize\": {\n      \"label\": \"Node Heap Limit (MB)\",\n      \"placeholder\": \"128\"\n    },\n    \"apiKey\": {\n      \"label\": \"Cookie Import API Key (CAMOFOX_API_KEY)\",\n      \"placeholder\": \"Leave empty to disable cookie import\"\n    },\n    \"accessKey\": {\n      \"label\": \"Global Access Key (CAMOFOX_ACCESS_KEY)\",\n      \"placeholder\": \"Leave empty for localhost-only access\"\n    },\n    \"crashReportEnabled\": {\n      \"label\": \"Enable Telemetry\"\n    },\n    \"crashReportUrl\": {\n      \"label\": \"Telemetry Endpoint URL\",\n      \"placeholder\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n    }\n  },\n  \"contracts\": {\n    \"tools\": [\n      \"camofox_create_tab\",\n      \"camofox_snapshot\",\n      \"camofox_click\",\n      \"camofox_type\",\n      \"camofox_navigate\",\n      \"camofox_scroll\",\n      \"camofox_screenshot\",\n      \"camofox_close_tab\",\n      \"camofox_evaluate\",\n      \"camofox_list_tabs\",\n      \"camofox_import_cookies\"\n    ]\n  }\n}\n","frontmatter":{"id":"camofox-browser","name":"Camofox Browser","description":"Anti-detection browser automation for AI agents using Camoufox (Firefox-based)","version":"1.18.1","configSchema":{"type":"object","properties":{"url":{"type":"string","description":"Camoufox browser server URL"},"port":{"type":"number","description":"Server port (used if url not set)","default":9377},"autoStart":{"type":"boolean","description":"Auto-start the camofox-browser server with the Gateway","default":true},"maxSessions":{"type":"number","description":"Maximum concurrent browser sessions (server default: 50)","default":5},"maxTabsPerSession":{"type":"number","description":"Maximum tabs per session (server default: 10)","default":3},"sessionTimeoutMs":{"type":"number","description":"Session inactivity timeout in milliseconds (server default: 1800000)","default":600000},"browserIdleTimeoutMs":{"type":"number","description":"Kill browser after this many ms with no sessions (0 = never)","default":300000},"maxOldSpaceSize":{"type":"number","description":"Node.js V8 heap limit in MB","default":128},"apiKey":{"type":"string","description":"Secret key for cookie import endpoint. Cookie import is disabled if unset. Set via CAMOFOX_API_KEY env var."},"accessKey":{"type":"string","description":"If set, all routes (except /health) require Authorization: Bearer <key>. Set via CAMOFOX_ACCESS_KEY env var."},"crashReportEnabled":{"type":"boolean","description":"Enable anonymized crash/hang telemetry. Set false to disable all outbound telemetry.","default":true},"crashReportUrl":{"type":"string","description":"Telemetry endpoint URL. Override to point to a self-hosted endpoint. Set via CAMOFOX_CRASH_REPORT_URL env var.","default":"https://camofox-telemetry.askjo.workers.dev/report"}},"additionalProperties":false},"uiHints":{"url":{"label":"Server URL","placeholder":"http://localhost:9377"},"port":{"label":"Server Port","placeholder":"9377"},"autoStart":{"label":"Auto-start server with Gateway"},"maxSessions":{"label":"Max Sessions","placeholder":"5"},"maxTabsPerSession":{"label":"Max Tabs per Session","placeholder":"3"},"sessionTimeoutMs":{"label":"Session Timeout (ms)","placeholder":"600000"},"browserIdleTimeoutMs":{"label":"Browser Idle Timeout (ms)","placeholder":"300000"},"maxOldSpaceSize":{"label":"Node Heap Limit (MB)","placeholder":"128"},"apiKey":{"label":"Cookie Import API Key (CAMOFOX_API_KEY)","placeholder":"Leave empty to disable cookie import"},"accessKey":{"label":"Global Access Key (CAMOFOX_ACCESS_KEY)","placeholder":"Leave empty for localhost-only access"},"crashReportEnabled":{"label":"Enable Telemetry"},"crashReportUrl":{"label":"Telemetry Endpoint URL","placeholder":"https://camofox-telemetry.askjo.workers.dev/report"}},"contracts":{"tools":["camofox_create_tab","camofox_snapshot","camofox_click","camofox_type","camofox_navigate","camofox_scroll","camofox_screenshot","camofox_close_tab","camofox_evaluate","camofox_list_tabs","camofox_import_cookies"]}},"isInternal":false,"tokens":1027,"sizeBytes":3947},{"name":"README.md","path":"plugins/persistence/README.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/persistence/README.md","title":"persistence Documentation","category":"plugin-manifest","format":"markdown","content":"# persistence\n\nOptional per-user browser storage state persistence for camofox-browser.\n\nSaves and restores cookies and localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API. IndexedDB persistence is available as an opt-in.\n\n## Configuration\n\nIn `camofox.config.json`:\n\n```json\n{\n  \"plugins\": {\n    \"persistence\": {\n      \"enabled\": true,\n      \"profileDir\": \"/data/profiles\",\n      \"indexedDB\": true\n    }\n  }\n}\n```\n\nThe profile directory can also be set via environment variable:\n\n```\nCAMOFOX_PROFILE_DIR=/data/profiles\n```\n\n- `indexedDB` (default: `false`) — set to `true` to capture IndexedDB through `storageState()`. This can preserve logins stored there, including Firebase Auth and other SSO flows. It captures all serializable IndexedDB records—not only authentication data—and may make snapshots significantly larger and checkpoints slower.\n\n## How it works\n\n- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.\n- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).\n- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.\n- **Session reset**: `DELETE /sessions/:userId/storage_state` closes the live context without checkpointing it, waits for in-flight writes, and deletes persisted state so the next session starts fresh.\n- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.\n\n## Docker\n\nWhen running with Docker, mount the profile directory as a volume:\n\n```bash\ndocker run -d \\\n  -p 9377:9377 \\\n  -v /host/profiles:/data/profiles \\\n  camofox-browser\n```\n\n## Credits\n\nBased on PR #62 by [company8](https://github.com/company8).\n","isInternal":false,"tokens":438,"sizeBytes":1904},{"name":"plugin.json","path":"plugins/vnc/plugin.json","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/vnc/plugin.json","title":"vnc Plugin Manifest","category":"plugin-manifest","format":"json","content":"{\n  \"enableEnvVar\": \"ENABLE_VNC\"\n}\n","frontmatter":{"enableEnvVar":"ENABLE_VNC"},"isInternal":false,"tokens":13,"sizeBytes":35},{"name":"README.md","path":"plugins/vnc/README.md","rawUrl":"https://raw.githubusercontent.com/jo-inc/camofox-browser/HEAD/plugins/vnc/README.md","title":"vnc Documentation","category":"plugin-manifest","format":"markdown","content":"# VNC Plugin\n\n> 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.\n\nInteractive browser access via VNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for reuse by your agent.\n\n## How it works\n\n```\nCamoufox (Xvfb :99, 1920x1080)\n    ↑\nx11vnc (attaches to :99, port 5900)\n    ↑\nnoVNC / websockify (port 6080)\n    ↑\nYour browser → http://localhost:6080/vnc.html\n```\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.\n\n## Quick start\n\n### Docker\n\n```bash\ndocker run -p 9377:9377 -p 127.0.0.1:6080:6080 \\\n  -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 \\\n  camofox-browser\n\n# Open http://localhost:6080/vnc.html in your browser\n```\n\n### Config file\n\n```json\n{\n  \"plugins\": {\n    \"vnc\": {\n      \"enabled\": true,\n      \"resolution\": \"1920x1080\",\n      \"password\": \"optional-secret\",\n      \"viewOnly\": false,\n      \"novncPort\": 6080\n    }\n  }\n}\n```\n\n## Workflow: interactive login → agent reuse\n\n1. **Start with VNC enabled:**\n   ```bash\n   docker run -p 9377:9377 -p 127.0.0.1:6080:6080 -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 camofox-browser\n   ```\n\n2. **Create a session and navigate to the login page:**\n   ```bash\n   curl -X POST http://localhost:9377/tabs \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"userId\": \"my-agent\", \"sessionKey\": \"default\", \"url\": \"https://accounts.google.com\"}'\n   ```\n\n3. **Log in visually** via http://localhost:6080/vnc.html — complete MFA, solve CAPTCHAs, etc.\n\n4. **Export the authenticated state:**\n   ```bash\n   curl http://localhost:9377/sessions/my-agent/storage_state \\\n     -H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \\\n     -o storage_state.json\n   ```\n\n5. **Reuse on future runs** — pair with the [persistence plugin](../persistence/) to automatically restore state on session creation:\n   ```json\n   {\n     \"plugins\": {\n       \"vnc\": { \"enabled\": true },\n       \"persistence\": { \"enabled\": true, \"profileDir\": \"/data/profiles\" }\n     }\n   }\n   ```\n   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.\n\n## API\n\n### GET /vnc/status\n\nReport whether the VNC watcher and its x11vnc backend are running. This endpoint does not require authentication and exposes no password or session data.\n\n```json\n{\n  \"enabled\": true,\n  \"running\": true,\n  \"watcherRunning\": true,\n  \"display\": \":0\",\n  \"vncPort\": 5900,\n  \"novncPort\": 6080,\n  \"path\": \"/vnc.html\"\n}\n```\n\n`running` becomes false while no owned X display is attached or while x11vnc is being restarted. `watcherRunning` distinguishes that state from the watcher process itself having exited.\n\n### GET /sessions/:userId/storage_state\n\nExport the full Playwright storage state (cookies + localStorage origins) for a user's active browser context.\n\n**Auth:** Same as cookie import — requires `CAMOFOX_API_KEY` Bearer token, or loopback access in non-production.\n\n**Response:**\n```json\n{\n  \"cookies\": [\n    {\n      \"name\": \"session_id\",\n      \"value\": \"abc123\",\n      \"domain\": \".example.com\",\n      \"path\": \"/\",\n      \"expires\": 1700000000,\n      \"httpOnly\": true,\n      \"secure\": true,\n      \"sameSite\": \"Lax\"\n    }\n  ],\n  \"origins\": [\n    {\n      \"origin\": \"https://example.com\",\n      \"localStorage\": [\n        { \"name\": \"theme\", \"value\": \"dark\" }\n      ]\n    }\n  ]\n}\n```\n\n**Errors:**\n- `404` — No active session for the given userId\n- `403` — Missing or invalid API key\n- `500` — Context is dead or storageState export failed\n\n## Configuration\n\n| Source | Variable | Description | Default |\n|--------|----------|-------------|---------|\n| env | `ENABLE_VNC` | Enable the plugin (`1`) | off |\n| env | `VNC_PASSWORD` | x11vnc password | none (open) |\n| env | `VNC_BIND` | noVNC listener address; use `0.0.0.0` in a container with host-local port publishing | `127.0.0.1` |\n| env | `VNC_RFB_BIND` | Native VNC listener address; non-loopback requires `VNC_PASSWORD` | `127.0.0.1` |\n| env | `VNC_RESOLUTION` | Xvfb screen resolution | `1920x1080` |\n| env | `VIEW_ONLY` | Disable mouse/keyboard input (`1`) | off |\n| env | `VNC_PORT` | x11vnc listen port | `5900` |\n| env | `NOVNC_PORT` | noVNC web UI port | `6080` |\n| config | `plugins.vnc.enabled` | Enable the plugin | `false` |\n| config | `plugins.vnc.password` | x11vnc password | none |\n| config | `plugins.vnc.resolution` | Xvfb screen resolution | `1920x1080` |\n| config | `plugins.vnc.viewOnly` | View-only mode | `false` |\n| config | `plugins.vnc.vncPort` | x11vnc listen port | `5900` |\n| config | `plugins.vnc.novncPort` | noVNC web UI port | `6080` |\n\nEnvironment variables override config file values.\n\n## Security\n\n⚠️ **VNC is unencrypted by default.** When running in production:\n\n- **Set `VNC_PASSWORD`** — without it, anyone who can reach port 6080 has full browser control\n- **Bind 6080 to localhost** and access via SSH tunnel: `ssh -L 6080:localhost:6080 your-server`\n- **Or use a firewall** to restrict access to port 6080\n- In Docker: set `VNC_BIND=0.0.0.0` for container forwarding and `-p 127.0.0.1:6080:6080` to bind the host port only to localhost. To expose native VNC separately, set `VNC_RFB_BIND=0.0.0.0` **and** `VNC_PASSWORD`, then publish port 5900 only to trusted hosts.\n\n## System dependencies\n\nThe plugin declares its apt dependencies in `apt.txt` — these are installed automatically during `docker build` via `scripts/install-plugin-deps.sh`:\n\n- `x11vnc` — attaches to Xvfb display\n- `novnc` + `python3-websockify` — web-based VNC client\n- `net-tools` + `procps` — display detection utilities\n\n## Events\n\n| Event | Payload | Description |\n|-------|---------|-------------|\n| `vnc:watcher:started` | `{ pid }` | Watcher process spawned |\n| `vnc:watcher:stopped` | `{ code, signal }` | Watcher exited |\n| `vnc:storage:exported` | `{ userId, cookies, origins }` | Storage state exported via API |\n| `session:storage:export` | `{ userId, storageState }` | Awaited after export; the persistence plugin writes the same snapshot to disk without serializing the browser again |\n","isInternal":false,"tokens":1878,"sizeBytes":6571}],"systemPromptSnippet":"<yakaai_skills repo=\"jo-inc/camofox-browser\">\n<!-- File: AGENTS.md (Tokens: ~5729 | Category: root-instruction) -->\n# camofox-browser Agent Guide\n\nHeadless browser automation server for AI agents. Run locally or deploy to any cloud provider.\n\n## Quick Start for Agents\n\n```bash\n# Install and start\nnpm install && npm start\n# Server runs on http://localhost:9377\n```\n\n## Core Workflow\n\n1. **Create a tab** -> Get `tabId`\n2. **Navigate** -> Go to URL or use search macro\n3. **Get snapshot** -> Receive page content with element refs (`e1`, `e2`, etc.)\n4. **Interact** -> Click/type using refs\n5. **Repeat** steps 3-4 as needed\n\n## API Reference\n\n### Create Tab\n```bash\nPOST /tabs\n{\"userId\": \"agent1\", \"sessionKey\": \"task1\", \"url\": \"https://example.com\"}\n```\nReturns: `{\"tabId\": \"abc123\", \"url\": \"...\", \"title\": \"...\"}`\n\n### Navigate\n```bash\nPOST /tabs/:tabId/navigate\n{\"userId\": \"agent1\", \"url\": \"https://google.com\"}\n# Or use macro:\n{\"userId\": \"agent1\", \"macro\": \"@google_search\", \"query\": \"weather today\"}\n```\n\n### Get Snapshot\n```bash\nGET /tabs/:tabId/snapshot?userId=agent1\n```\nReturns accessibility tree with refs:\n```\n[heading] Example Domain\n[paragraph] This domain is for use in examples.\n[link e1] More information...\n```\n\n### Click Element\n```bash\nPOST /tabs/:tabId/click\n{\"userId\": \"agent1\", \"ref\": \"e1\"}\n# Or CSS selector:\n{\"userId\": \"agent1\", \"selector\": \"button.submit\"}\n```\n\n### Type Text\n```bash\nPOST /tabs/:tabId/type\n{\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"hello world\"}\n# Add enter: {\"userId\": \"agent1\", \"ref\": \"e2\", \"text\": \"search query\", \"pressEnter\": true}\n```\n\n### Scroll\n```bash\nPOST /tabs/:tabId/scroll\n{\"userId\": \"agent1\", \"direction\": \"down\", \"amount\": 500}\n```\n\n### Navigation\n```bash\nPOST /tabs/:tabId/back     {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/forward  {\"userId\": \"agent1\"}\nPOST /tabs/:tabId/refresh  {\"userId\": \"agent1\"}\n```\n\n### Get Links\n```bash\nGET /tabs/:tabId/links?userId=agent1&limit=50\n```\n\n### Close Tab\n```bash\nDELETE /tabs/:tabId?userId=agent1\n```\n\n## Search Macros\n\nUse these instead of constructing URLs:\n\n| Macro | Site |\n|-------|------|\n| `@google_search` | Google |\n| `@youtube_search` | YouTube |\n| `@amazon_search` | Amazon |\n| `@reddit_search` | Reddit |\n| `@wikipedia_search` | Wikipedia |\n| `@twitter_search` | Twitter/X |\n| `@yelp_search` | Yelp |\n| `@linkedin_search` | LinkedIn |\n\n## Element Refs\n\nRefs like `e1`, `e2` are stable identifiers for page elements:\n\n1. Call `/snapshot` to get current refs\n2. Use ref in `/click` or `/type`\n3. Refs reset on navigation - get new snapshot after\n\n## Session Management\n\n- `userId` isolates cookies/storage between users\n- `sessionKey` groups tabs by conversation/task (legacy: `listItemId` also accepted)\n- Sessions timeout after 30 minutes of inactivity\n- Delete all user data: `DELETE /sessions/:userId`\n\n## Running Engines\n\n### Camoufox (Default)\n```bash\nnpm start\n# Or: ./run.sh\n```\nFirefox-based with anti-detection. Bypasses Google captcha.\n\n## Testing\n\n```bash\nnpm test                          # All tests (unit + e2e + plugin)\nnpm run test:plugins              # All plugin tests\nnpm run test:e2e                  # E2E tests\nnpm run test:live                 # Live Google tests\nnpm run test:debug                # With server output\nnpx jest plugins/youtube          # Single plugin's tests\n```\n\n## Docker\n\n```bash\ndocker build -t camofox-browser .\ndocker run -p 9377:9377 camofox-browser\n```\n\n## Key Files\n\n- `server.js` - Camoufox engine (routes + browser logic only -- NO `process.env` or `child_process`)\n- `lib/openapi.js` - OpenAPI spec generation via swagger-jsdoc + docs route setup\n- `lib/config.js` - All `process.env` reads centralized here\n- `plugins/youtube/youtube.js` - YouTube transcript extraction via yt-dlp (`child_process` isolated here)\n- `lib/launcher.js` - Subprocess spawning (`child_process` isolated here)\n- `lib/cookies.js` - Cookie file I/O\n- `lib/metrics.js` - Prometheus metrics (lazy-loaded, off by default -- set `PROMETHEUS_ENABLED=1`)\n- `lib/request-utils.js` - HTTP request classification helpers (`actionFromReq`, `classifyError`)\n- `lib/snapshot.js` - Accessibility tree snapshot\n- `lib/macros.js` - Search macro URL expansion\n- `lib/plugins.js` - Plugin loader and event bus\n- `lib/auth.js` - Shared auth middleware (API key / loopback)\n- `camofox.config.json` - Plugin configuration (which plugins to load)\n- `plugins/` - Plugin directory (loaded per camofox.config.json)\n- `plugins/youtube/` - Default plugin: YouTube transcript extraction\n- `scripts/install-plugin-deps.sh` - Installs plugin deps (apt.txt + post-install.sh)\n- `plugins/vnc/index.js` - VNC plugin routes (no `child_process` -- spawning isolated in `vnc-launcher.js`)\n- `plugins/vnc/vnc-launcher.js` - VNC process management (`child_process` isolated here)\n- `plugins/persistence/index.js` - Session persistence lifecycle hooks\n- `lib/persistence.js` - Atomic storage state read/write\n- `lib/inflight.js` - Inflight request coalescing\n- `lib/tmp-cleanup.js` - Orphaned temp file cleanup\n- `lib/reporter.js` - Crash/hang reporter with anonymization + GitHub App auth (see README \"Crash Reporter\" for setup)\n- `Dockerfile` - Production container with default plugin deps pre-installed\n\n## OpenAPI Spec (REQUIRED for route changes)\n\nThe API spec is auto-generated from `@openapi` JSDoc comments in `server.js` via [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc). It's served at `GET /openapi.json` (machine-readable) and `GET /docs` ([swagger-stripey](https://github.com/skyfallsin/swagger-stripey) three-panel UI).\n\n**When adding, modifying, or removing a route, you MUST update the `@openapi` JSDoc block above it.**\n\nEvery route handler in `server.js` has a JSDoc comment block directly above it like:\n\n```js\n/**\n * @openapi\n * /tabs/{tabId}/click:\n *   post:\n *     tags: [Interaction]\n *     summary: Click an element\n *     parameters:\n *       - name: tabId\n *         in: path\n *         required: true\n *         schema:\n *           type: string\n *     requestBody:\n *       required: true\n *       content:\n *         application/json:\n *           schema:\n *             type: object\n *             required: [userId]\n *             properties:\n *               userId:\n *                 type: string\n *               ref:\n *                 type: string\n *     responses:\n *       200:\n *         description: Click result.\n *         content:\n *           application/json:\n *             schema:\n *               type: object\n *       404:\n *         description: Tab not found.\n *         content:\n *           application/json:\n *             schema:\n *               $ref: '#/components/schemas/Error'\n */\napp.post('/tabs/:tabId/click', async (req, res) => {\n```\n\n**Rules:**\n- New routes: add a `@openapi` JSDoc block immediately above the `app.get/post/delete(...)` call\n- Path params use `{tabId}` syntax (not `:tabId`) in the JSDoc YAML\n- Tag must be one of: `System`, `Tabs`, `Navigation`, `Interaction`, `Content`, `Sessions`, `Browser`, `Legacy`\n- Every operation must have `tags`, `summary`, and `responses`\n- Include `requestBody` for POST/PUT/DELETE routes that accept JSON\n- Include `parameters` for path params and required query params\n- Mark backward-compat endpoints with `deprecated: true`\n- Removing a route: delete the `@openapi` block along with the handler\n- **After any route change, run `npm run generate-openapi`** to regenerate the committed `openapi.json`. The test suite will fail if it's stale.\n- Run `npx jest tests/unit/openapi.test.js` to verify coverage -- the test fails if any route is missing from the spec, if a stale route exists, or if `openapi.json` is out of date\n- Reusable schemas go in `components.schemas` in `lib/openapi.js` (the `swaggerDefinition`); reference them via `$ref: '#/components/schemas/Name'`\n\n## Telemetry\n\n**No credentials are embedded in this package.** `lib/reporter.js` is a stateless HTTP client that sends anonymized crash/hang telemetry to a Cloudflare Worker endpoint (`camofox-telemetry.askjo.workers.dev`). The endpoint holds the GitHub App credentials as environment secrets -- see `workers/crash-reporter/index.ts`. The source is in-repo and auditable.\n\n- **Architecture**: `lib/reporter.js` (client, no secrets, no `fs`) -> POST -> Cloudflare Worker endpoint -> GitHub Issues\n- **`lib/reporter.js`** has ZERO credentials, ZERO private keys, ZERO `fs` imports. It only does `fetch()` to the telemetry endpoint.\n- **`lib/resources.js`** handles `fs`-based resource snapshots (reading /proc on Linux) -- separated from reporter.js so no file-read + network-send pattern exists in any single file. No `child_process` import.\n- **Anonymization** is in `lib/reporter.js` L28-290 -- text scrubbing (`anonymize()`), URL anonymization (`createUrlAnonymizer()`), and tab health tracking (`createTabHealthTracker()`)\n- **Public domain list** (~120 entries) determines which domains are shown verbatim vs HMAC-hashed\n- **Tests**: `tests/unit/crashRelay.test.js` (telemetry client), `tests/unit/crashRelayWorker.test.js` (worker contract), `tests/unit/noSecrets.test.js` (asserts no key material in shipped files)\n- Self-hosted endpoint: see README \"Self-hosted telemetry endpoint\" section\n- Disable with `CAMOFOX_CRASH_REPORT_ENABLED=false`\n\n## Code Separation Conventions\n\nThe codebase separates concerns across files for clarity and auditability:\n\n- **Configuration**: `process.env` reads live in `lib/config.js`, which exports a plain config object. No other file reads environment variables directly.\n- **Subprocess management**: `child_process` usage lives in dedicated launcher modules (`lib/launcher.js`, `plugins/youtube/youtube.js`, `plugins/vnc/vnc-launcher.js`), not in route handlers.\n- **Route handlers**: `server.js` defines Express routes but delegates env/config reads and subprocess spawning to the modules above.\n- **Metrics**: `lib/metrics.js` lazy-loads prom-client. `lib/request-utils.js` handles HTTP method classification.\n\nWhen adding features that need env vars or subprocesses, put that code in a `lib/` module and import the result into `server.js`.\n\n## Plugin System\n\nPlugins extend camofox-browser with new endpoints, background processes, and lifecycle hooks. The server auto-loads all plugins from `plugins/<name>/index.js` on startup.\n\n### Creating a Plugin\n\n```\nplugins/\n  my-plugin/\n    index.js        Required -- exports register(app, ctx)\n    apt.txt         Optional -- system packages (one per line)\n    post-install.sh Optional -- executable hook for binary downloads\n    *.test.js       Optional -- Jest tests (auto-discovered)\n```\n\n```js\n// plugins/my-plugin/index.js\n\nexport function register(app, ctx) {\n  const { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession,\n          withUserLimit, safePageClose, normalizeUserId, validateUrl, safeError,\n          buildProxyUrl, proxyPool, failuresTotal } = ctx;\n\n  // Register Express routes (auth() enforces API key or loopback)\n  app.get('/my-endpoint', auth(), async (req, res) => {\n    const session = sessions.get(req.params.userId);\n    res.json({ ok: true });\n  });\n\n  // Listen to lifecycle events\n  events.on('browser:launched', ({ browser, display }) => {\n    log('info', 'browser is up', { display });\n  });\n\n  events.on('session:created', ({ userId, context }) => {\n    log('info', 'new session', { userId });\n  });\n\n  events.on('tab:navigated', ({ userId, tabId, url }) => {\n    log('info', 'navigation', { userId, tabId, url });\n  });\n}\n```\n\n### Plugin Context (`ctx`)\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` |\n| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |\n| `log` | `function` | `log(level, msg, fields)` -- structured JSON logging |\n| `events` | `EventEmitter` | Plugin event bus (29 events -- see below) |\n| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |\n| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |\n| `getSession` | `async function` | `getSession(userId)` -- get or create a session |\n| `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close |\n| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit |\n| `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard |\n| `normalizeUserId` | `function` | `normalizeUserId(id)` -- coerce to string for map keys |\n| `validateUrl` | `function` | `validateUrl(url)` -- returns error string or null |\n| `safeError` | `function` | `safeError(err)` -- sanitize error for client response |\n| `buildProxyUrl` | `function` | `buildProxyUrl(pool, proxyConfig)` -- get proxy URL for external requests |\n| `proxyPool` | `object\\|null` | Proxy pool instance (null if no proxy configured) |\n| `failuresTotal` | `Counter` | Prometheus counter: `failuresTotal.labels(type, action).inc()` |\n| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |\n| `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null |\n\n### Events (29)\n\n28 emitted by core, 1 (`session:storage:export`) emitted by plugins.\n\n#### Browser Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `browser:launching` | `{ options }` | (ok) Modify launch options in-place |\n| `browser:launched` | `{ browser, display }` | |\n| `browser:restart` | `{ reason }` | |\n| `browser:closed` | `{ reason }` | |\n| `browser:error` | `{ error }` | |\n\n#### Session Lifecycle\n| Event | Payload | Mutating? |\n|-------|---------|-----------|\n| `session:creating` | `{ userId, contextOptions }` | (ok) Modify context options in-place |\n| `session:created` | `{ userId, context }` | |\n| `session:destroyed` | `{ userId, reason }` | |\n| `session:expired` | `{ userId, idleMs }` | |\n\n#### Tab Lifecycle\n| Event | Payload |\n|-------|---------|\n| `tab:created` | `{ userId, tabId, page, url }` |\n| `tab:navigated` | `{ userId, tabId, url, prevUrl }` |\n| `tab:destroyed` | `{ userId, tabId, reason }` |\n| `tab:recycled` | `{ userId, tabId }` |\n| `tab:error` | `{ userId, tabId, error }` |\n\n#### Content\n| Event | Payload |\n|-------|---------|\n| `tab:snapshot` | `{ userId, tabId, snapshot }` |\n| `tab:screenshot` | `{ userId, tabId, buffer }` |\n| `tab:evaluate` | `{ userId, tabId, expression }` |\n| `tab:evaluated` | `{ userId, tabId, result }` |\n\n#### Input\n| Event | Payload |\n|-------|---------|\n| `tab:click` | `{ userId, tabId, ref, selector }` |\n| `tab:type` | `{ userId, tabId, text, ref, mode }` |\n| `tab:scroll` | `{ userId, tabId, direction, amount }` |\n| `tab:press` | `{ userId, tabId, key }` |\n\n#### Downloads\n| Event | Payload |\n|-------|---------|\n| `tab:download:start` | `{ userId, tabId, filename, url }` |\n| `tab:download:complete` | `{ userId, tabId, filename, path, size }` |\n\n#### Cookies / Auth\n| Event | Payload |\n|-------|---------|\n| `session:cookies:import` | `{ userId, count }` |\n| `session:storage:export` | `{ userId, storageState }` |\n\n#### Server\n| Event | Payload |\n|-------|---------|\n| `server:starting` | `{ port }` |\n| `server:started` | `{ port, pid }` |\n| `server:shutdown` | `{ signal }` |\n\n### Mutating Hooks\n\n`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.\n\nOther events use regular `events.emit()` (fire-and-forget).\n\nModify payload objects in-place:\n\n```js\n// Change Xvfb resolution (e.g., for VNC plugin)\nevents.on('browser:launching', ({ options }) => {\n  options.virtual_display_resolution = '1920x1080x24';\n});\n\n// Inject saved auth state into new sessions\nevents.on('session:creating', ({ userId, contextOptions }) => {\n  const saved = loadStorageState(userId);\n  if (saved) contextOptions.storageState = saved;\n});\n```\n\n### System Packages (`apt.txt`) and Post-Install Hooks\n\nPlugins that need system packages list them one per line in `apt.txt`:\n\n```\n# plugins/vnc/apt.txt\nx11vnc\nnovnc\npython3-websockify\n```\n\nFor binary downloads or setup not available via apt, add an executable `post-install.sh`:\n\n```bash\n# plugins/youtube/post-install.sh\n#!/bin/sh\nset -e\ncurl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp\nchmod +x /usr/local/bin/yt-dlp\n```\n\nBoth are run by `scripts/install-plugin-deps.sh` during Docker build.\n\n### Configuration (`camofox.config.json`)\n\n`camofox.config.json` controls which plugins are loaded at runtime and during Docker build:\n\n```json\n{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"version\": \"1.5.2\",\n  \"plugins\": [\"youtube\"]\n}\n```\n\n- **`plugins`** -- array of plugin directory names to load. Only these are loaded at startup and have deps installed during build.\n- If the file is missing or has no `plugins` key, **all** plugins in `plugins/` are loaded (backward-compatible).\n- This is camofox's own config. `openclaw.plugin.json` is separate -- it tells the OpenClaw Gateway how to configure camofox as an external service.\n\n### Installing Plugins\n\nUse the plugin manager to install third-party plugins from git or local paths:\n\n```bash\n# Install from git\nnode scripts/plugin.js install https://github.com/user/camofox-screenshot-plugin\nnode scripts/plugin.js install git:github.com/user/my-plugin\n\n# Install from local directory\nnode scripts/plugin.js install ./path/to/my-plugin\n\n# List installed plugins\nnode scripts/plugin.js list\n\n# Remove a plugin\nnode scripts/plugin.js remove my-plugin\n```\n\nThe 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.\n\nPlugin sources can be:\n- **Git repos** where the root has `index.js` with `register()` (installed as one plugin)\n- **Git repos** with a `plugins/` subdirectory (each subdirectory installed as a separate plugin)\n- **Local directories** with `index.js` and `register()`\n\n### Default Plugins\n\nThree plugins ship by default:\n\n- **youtube** -- YouTube transcript extraction (enabled by default)\n- **persistence** -- Per-user session state persistence to `~/.camofox/profiles/` (enabled by default)\n- **vnc** -- Interactive browser login via noVNC (disabled by default, requires `ENABLE_VNC=1`)\n\nThe `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.\n\nThe `with-plugins` Dockerfile stage is for rebuilding after adding third-party plugins:\n\n```bash\ndocker build --target with-plugins -t camofox-browser .\n```\n\nThe `with-plugins` stage re-runs `install-plugin-deps.sh` to pick up any new plugins added to `plugins/`.\n\n### Code Separation Rules\n\nPlugins follow the same separation conventions as core (see \"Code Separation Conventions\" above):\n- **No `process.env` in plugin files that also have route handlers** -- read config from `ctx.config`\n- **No `child_process` in plugin files that also have route handlers** -- spawn from a separate `lib/` module\n\n### Custom Metrics\n\nPlugins create Prometheus metrics via `ctx.createMetric()`. Returns a no-op stub when Prometheus is disabled -- no null checks needed.\n\n```js\n// In register(app, ctx):\nconst transcriptsTotal = await ctx.createMetric('counter', {\n  name: 'camofox_youtube_transcripts_total',\n  help: 'YouTube transcripts extracted',\n  labelNames: ['method'],\n});\n\n// Use anywhere -- works whether Prometheus is enabled or not\ntranscriptsTotal.labels('yt-dlp').inc();\n```\n\nSupported 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`.\n\nFor advanced use, `ctx.metricsRegistry()` returns the raw prom-client `Registry` (or `null` when disabled).\n\n### Example: YouTube Transcript Plugin\n\nThe 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.\n\n```\nplugins/\n  youtube/\n    index.js        # register(app, ctx) -- route handler + browser fallback\n    youtube.js      # yt-dlp process management + transcript parsing\n    youtube.test.js # parser unit tests\n    apt.txt         # python3-minimal (yt-dlp runtime dep)\n    post-install.sh # downloads yt-dlp binary\n```\n\n```js\n// plugins/youtube/index.js (simplified)\nimport { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript } from './youtube.js';\nimport { classifyError } from '../../lib/request-utils.js';\n\nexport async function register(app, ctx) {\n  const { log, config, sessions, ensureBrowser, getSession,\n          withUserLimit, safePageClose, normalizeUserId,\n          validateUrl, safeError, buildProxyUrl, proxyPool,\n          failuresTotal } = ctx;\n\n  await detectYtDlp(log);\n\n  app.post('/youtube/transcript', ctx.auth(), async (req, res) => {\n    // ... validate URL, extract videoId, try yt-dlp then browser fallback\n  });\n\n  async function browserTranscript(reqId, url, videoId, lang) {\n    return await withUserLimit('__yt_transcript__', async () => {\n      await ensureBrowser();\n      const session = await getSession('__yt_transcript__');\n      const page = await session.context.newPage();\n      // ... intercept captions, parse transcript\n      await safePageClose(page);\n    });\n  }\n}\n```\n\nKey patterns:\n- **Auth**: `ctx.auth()` middleware on the route\n- **Logging**: `ctx.log('info', ...)` -- never `console.log`\n- **Browser access**: `ctx.ensureBrowser()` + `ctx.getSession()` for browser-backed features\n- **Concurrency**: `ctx.withUserLimit()` to respect per-user limits\n- **Metrics**: `ctx.failuresTotal.labels(...)` for core counters, `ctx.createMetric()` for custom\n- **Code separation**: `child_process` in `youtube.js`, route handler in `index.js` -- separate files\n- **System deps**: `apt.txt` lists packages installed via `scripts/install-plugin-deps.sh`\n\n<!-- File: plugins/persistence/AGENTS.md (Tokens: ~482 | Category: anthropic-skill) -->\n# Persistence Plugin — Agent Guide\n\nSaves and restores per-user browser storage state (cookies + localStorage, with optional IndexedDB) across session restarts using Playwright's `storageState` API. Enabled by default — profiles persist to `~/.camofox/profiles/`.\n\n## How It Works\n\n- `session:creating` hook → loads saved `storage_state.json` into `contextOptions.storageState`\n- `session:created` hook → imports bootstrap cookies if no persisted state exists\n- `session:cookies:import` / `session:destroyed` / `server:shutdown` → checkpoints state to disk\n- `DELETE /sessions/:userId/storage_state` → closes the live context without checkpointing and removes persisted state\n\nAll hooks are async and awaited via `emitAsync()` — storage state is guaranteed loaded before the context is created.\n\n## Key Files\n\n- `index.js` — lifecycle hooks (no routes, no `child_process`)\n- `persistence.test.js` — unit tests for `lib/persistence.js` helpers\n- `plugin.test.js` — integration tests for plugin lifecycle hooks\n\n## Storage Layout\n\n```\n~/.camofox/profiles/\n└── <sha256(userId)>/\n    └── storage_state.json\n```\n\n## Configuration\n\nEnabled 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`.\n\nIndexedDB persistence is opt-in (`\"indexedDB\": true` in the persistence plugin config). It captures all serializable IndexedDB records, not only authentication data, and may make snapshots significantly larger and checkpoints slower.\n\n## Original Contributors\n\n- [@company8](https://github.com/company8) — original persistence concept ([PR #62](https://github.com/jo-inc/camofox-browser/pull/62))\n- [@eddieoz](https://github.com/eddieoz) — cookie auto-load on startup ([PR #55](https://github.com/jo-inc/camofox-browser/pull/55))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, atomic writes, inflight coalescing\n\nFor PRs touching this plugin, tag the contributors above for review.\n\n<!-- File: plugins/vnc/AGENTS.md (Tokens: ~605 | Category: anthropic-skill) -->\n# VNC Plugin — Agent Guide\n\nInteractive browser access via noVNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for agent reuse.\n\n## Endpoints\n\n- `GET /vnc/status` — check if VNC is running (no auth)\n- `GET /sessions/:userId/storage_state` — export cookies + localStorage as JSON (requires auth)\n\n## Activation\n\nDisabled by default. Enable with `ENABLE_VNC=1` env var or `\"vnc\": { \"enabled\": true }` in `camofox.config.json`.\n\n## Key Files\n\n- `index.js` — route handlers only (no `child_process`, no `process.env` reads)\n- `vnc-launcher.js` — process management, config resolution from env vars (`child_process` isolated here)\n- `vnc-watcher.sh` — shell script that detects Xvfb, attaches x11vnc, starts noVNC\n- `vnc.test.js` — unit tests\n- `apt.txt` — system deps (x11vnc, novnc, websockify, etc.)\n\n## Code Separation\n\n`child_process` is in `vnc-launcher.js`, route handlers are in `index.js`, env var reads are in `vnc-launcher.js` — separate files per project conventions.\n\n## Security\n\n- noVNC binds to `127.0.0.1` by default — set `VNC_BIND=0.0.0.0` to expose externally; this does not change native VNC's loopback bind\n- Native VNC requires separate `VNC_RFB_BIND` to expose externally, and refuses non-loopback binds without `VNC_PASSWORD`\n- Set `VNC_PASSWORD` for password-protected access; exposing noVNC without it remains unsafe\n- `VIEW_ONLY=1` disables keyboard/mouse input (observation only)\n- Storage state export endpoint requires auth (API key or loopback)\n\n## Architecture\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a higher-resolution display (default 1920x1080 instead of 1x1). A second plugin cannot silently replace that provider. `vnc-watcher.sh` polls for the Xvfb process, then attaches x11vnc + noVNC on top.\n\n## Original Contributors\n\n- [@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))\n- [@pradeepe](https://github.com/pradeepe) — plugin system integration, code separation refactor, security hardening\n\nFor PRs touching this plugin, tag the contributors above for review.\n\n<!-- File: plugins/youtube/AGENTS.md (Tokens: ~206 | Category: anthropic-skill) -->\n# YouTube Plugin — Agent Guide\n\nExtracts video transcripts via yt-dlp (preferred) with Playwright browser fallback.\n\n## Endpoint\n\n`POST /youtube/transcript` — unauthenticated by default (set `\"auth\": true` in plugin config to require auth).\n\n## Key Files\n\n- `index.js` — route handler + browser fallback logic\n- `youtube.js` — yt-dlp process management + transcript parsing (`child_process` isolated here)\n- `youtube.test.js` — parser unit tests\n- `apt.txt` — system deps (python3-minimal for yt-dlp)\n- `post-install.sh` — downloads yt-dlp binary\n\n## Code Separation\n\n`child_process` is in `youtube.js`, route handlers are in `index.js` — separate files per project conventions.\n\n## Maintainers\n\n- [@pradeepe](https://github.com/pradeepe) — extracted from core into plugin system\n\nFor PRs touching this plugin, tag the maintainers above for review.\n\n<!-- File: openclaw.plugin.json (Tokens: ~1027 | Category: plugin-manifest) -->\n{\n  \"id\": \"camofox-browser\",\n  \"name\": \"Camofox Browser\",\n  \"description\": \"Anti-detection browser automation for AI agents using Camoufox (Firefox-based)\",\n  \"version\": \"1.18.1\",\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"url\": {\n        \"type\": \"string\",\n        \"description\": \"Camoufox browser server URL\"\n      },\n      \"port\": {\n        \"type\": \"number\",\n        \"description\": \"Server port (used if url not set)\",\n        \"default\": 9377\n      },\n      \"autoStart\": {\n        \"type\": \"boolean\",\n        \"description\": \"Auto-start the camofox-browser server with the Gateway\",\n        \"default\": true\n      },\n      \"maxSessions\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum concurrent browser sessions (server default: 50)\",\n        \"default\": 5\n      },\n      \"maxTabsPerSession\": {\n        \"type\": \"number\",\n        \"description\": \"Maximum tabs per session (server default: 10)\",\n        \"default\": 3\n      },\n      \"sessionTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Session inactivity timeout in milliseconds (server default: 1800000)\",\n        \"default\": 600000\n      },\n      \"browserIdleTimeoutMs\": {\n        \"type\": \"number\",\n        \"description\": \"Kill browser after this many ms with no sessions (0 = never)\",\n        \"default\": 300000\n      },\n      \"maxOldSpaceSize\": {\n        \"type\": \"number\",\n        \"description\": \"Node.js V8 heap limit in MB\",\n        \"default\": 128\n      },\n      \"apiKey\": {\n        \"type\": \"string\",\n        \"description\": \"Secret key for cookie import endpoint. Cookie import is disabled if unset. Set via CAMOFOX_API_KEY env var.\"\n      },\n      \"accessKey\": {\n        \"type\": \"string\",\n        \"description\": \"If set, all routes (except /health) require Authorization: Bearer <key>. Set via CAMOFOX_ACCESS_KEY env var.\"\n      },\n      \"crashReportEnabled\": {\n        \"type\": \"boolean\",\n        \"description\": \"Enable anonymized crash/hang telemetry. Set false to disable all outbound telemetry.\",\n        \"default\": true\n      },\n      \"crashReportUrl\": {\n        \"type\": \"string\",\n        \"description\": \"Telemetry endpoint URL. Override to point to a self-hosted endpoint. Set via CAMOFOX_CRASH_REPORT_URL env var.\",\n        \"default\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n      }\n    },\n    \"additionalProperties\": false\n  },\n  \"uiHints\": {\n    \"url\": {\n      \"label\": \"Server URL\",\n      \"placeholder\": \"http://localhost:9377\"\n    },\n    \"port\": {\n      \"label\": \"Server Port\",\n      \"placeholder\": \"9377\"\n    },\n    \"autoStart\": {\n      \"label\": \"Auto-start server with Gateway\"\n    },\n    \"maxSessions\": {\n      \"label\": \"Max Sessions\",\n      \"placeholder\": \"5\"\n    },\n    \"maxTabsPerSession\": {\n      \"label\": \"Max Tabs per Session\",\n      \"placeholder\": \"3\"\n    },\n    \"sessionTimeoutMs\": {\n      \"label\": \"Session Timeout (ms)\",\n      \"placeholder\": \"600000\"\n    },\n    \"browserIdleTimeoutMs\": {\n      \"label\": \"Browser Idle Timeout (ms)\",\n      \"placeholder\": \"300000\"\n    },\n    \"maxOldSpaceSize\": {\n      \"label\": \"Node Heap Limit (MB)\",\n      \"placeholder\": \"128\"\n    },\n    \"apiKey\": {\n      \"label\": \"Cookie Import API Key (CAMOFOX_API_KEY)\",\n      \"placeholder\": \"Leave empty to disable cookie import\"\n    },\n    \"accessKey\": {\n      \"label\": \"Global Access Key (CAMOFOX_ACCESS_KEY)\",\n      \"placeholder\": \"Leave empty for localhost-only access\"\n    },\n    \"crashReportEnabled\": {\n      \"label\": \"Enable Telemetry\"\n    },\n    \"crashReportUrl\": {\n      \"label\": \"Telemetry Endpoint URL\",\n      \"placeholder\": \"https://camofox-telemetry.askjo.workers.dev/report\"\n    }\n  },\n  \"contracts\": {\n    \"tools\": [\n      \"camofox_create_tab\",\n      \"camofox_snapshot\",\n      \"camofox_click\",\n      \"camofox_type\",\n      \"camofox_navigate\",\n      \"camofox_scroll\",\n      \"camofox_screenshot\",\n      \"camofox_close_tab\",\n      \"camofox_evaluate\",\n      \"camofox_list_tabs\",\n      \"camofox_import_cookies\"\n    ]\n  }\n}\n\n<!-- File: plugins/persistence/README.md (Tokens: ~438 | Category: plugin-manifest) -->\n# persistence\n\nOptional per-user browser storage state persistence for camofox-browser.\n\nSaves and restores cookies and localStorage across session restarts, container deploys, and idle timeouts using Playwright's `storageState` API. IndexedDB persistence is available as an opt-in.\n\n## Configuration\n\nIn `camofox.config.json`:\n\n```json\n{\n  \"plugins\": {\n    \"persistence\": {\n      \"enabled\": true,\n      \"profileDir\": \"/data/profiles\",\n      \"indexedDB\": true\n    }\n  }\n}\n```\n\nThe profile directory can also be set via environment variable:\n\n```\nCAMOFOX_PROFILE_DIR=/data/profiles\n```\n\n- `indexedDB` (default: `false`) — set to `true` to capture IndexedDB through `storageState()`. This can preserve logins stored there, including Firebase Auth and other SSO flows. It captures all serializable IndexedDB records—not only authentication data—and may make snapshots significantly larger and checkpoints slower.\n\n## How it works\n\n- **Session create**: If a persisted `storageState` exists for the `userId`, it's restored into the new Playwright context.\n- **First run**: If no persisted state exists, bootstrap cookies from `CAMOFOX_COOKIES_DIR/cookies.txt` are imported (if present).\n- **Cookie import / session close / shutdown**: Storage state is checkpointed to disk via atomic tmp-write + rename.\n- **Session reset**: `DELETE /sessions/:userId/storage_state` closes the live context without checkpointing it, waits for in-flight writes, and deletes persisted state so the next session starts fresh.\n- **User isolation**: Each `userId` maps to a deterministic SHA256-hashed subdirectory under `profileDir`, so arbitrary userIds are path-safe.\n\n## Docker\n\nWhen running with Docker, mount the profile directory as a volume:\n\n```bash\ndocker run -d \\\n  -p 9377:9377 \\\n  -v /host/profiles:/data/profiles \\\n  camofox-browser\n```\n\n## Credits\n\nBased on PR #62 by [company8](https://github.com/company8).\n\n<!-- File: plugins/vnc/plugin.json (Tokens: ~13 | Category: plugin-manifest) -->\n{\n  \"enableEnvVar\": \"ENABLE_VNC\"\n}\n\n<!-- File: plugins/vnc/README.md (Tokens: ~1878 | Category: plugin-manifest) -->\n# VNC Plugin\n\n> 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.\n\nInteractive browser access via VNC. Log into sites visually, solve CAPTCHAs, approve OAuth prompts — then export the authenticated storage state for reuse by your agent.\n\n## How it works\n\n```\nCamoufox (Xvfb :99, 1920x1080)\n    ↑\nx11vnc (attaches to :99, port 5900)\n    ↑\nnoVNC / websockify (port 6080)\n    ↑\nYour browser → http://localhost:6080/vnc.html\n```\n\nThe plugin registers the `virtualDisplay` capability for its own `plugins.vnc` settings, selecting a human-usable resolution instead of the default 1x1 display. A second enabled display plugin cannot silently replace that provider. The watcher handles browser restarts automatically — when Camoufox relaunches on a new display, x11vnc reattaches.\n\n## Quick start\n\n### Docker\n\n```bash\ndocker run -p 9377:9377 -p 127.0.0.1:6080:6080 \\\n  -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 \\\n  camofox-browser\n\n# Open http://localhost:6080/vnc.html in your browser\n```\n\n### Config file\n\n```json\n{\n  \"plugins\": {\n    \"vnc\": {\n      \"enabled\": true,\n      \"resolution\": \"1920x1080\",\n      \"password\": \"optional-secret\",\n      \"viewOnly\": false,\n      \"novncPort\": 6080\n    }\n  }\n}\n```\n\n## Workflow: interactive login → agent reuse\n\n1. **Start with VNC enabled:**\n   ```bash\n   docker run -p 9377:9377 -p 127.0.0.1:6080:6080 -e ENABLE_VNC=1 -e VNC_BIND=0.0.0.0 camofox-browser\n   ```\n\n2. **Create a session and navigate to the login page:**\n   ```bash\n   curl -X POST http://localhost:9377/tabs \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"userId\": \"my-agent\", \"sessionKey\": \"default\", \"url\": \"https://accounts.google.com\"}'\n   ```\n\n3. **Log in visually** via http://localhost:6080/vnc.html — complete MFA, solve CAPTCHAs, etc.\n\n4. **Export the authenticated state:**\n   ```bash\n   curl http://localhost:9377/sessions/my-agent/storage_state \\\n     -H 'Authorization: Bearer YOUR_CAMOFOX_API_KEY' \\\n     -o storage_state.json\n   ```\n\n5. **Reuse on future runs** — pair with the [persistence plugin](../persistence/) to automatically restore state on session creation:\n   ```json\n   {\n     \"plugins\": {\n       \"vnc\": { \"enabled\": true },\n       \"persistence\": { \"enabled\": true, \"profileDir\": \"/data/profiles\" }\n     }\n   }\n   ```\n   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.\n\n## API\n\n### GET /vnc/status\n\nReport whether the VNC watcher and its x11vnc backend are running. This endpoint does not require authentication and exposes no password or session data.\n\n```json\n{\n  \"enabled\": true,\n  \"running\": true,\n  \"watcherRunning\": true,\n  \"display\": \":0\",\n  \"vncPort\": 5900,\n  \"novncPort\": 6080,\n  \"path\": \"/vnc.html\"\n}\n```\n\n`running` becomes false while no owned X display is attached or while x11vnc is being restarted. `watcherRunning` distinguishes that state from the watcher process itself having exited.\n\n### GET /sessions/:userId/storage_state\n\nExport the full Playwright storage state (cookies + localStorage origins) for a user's active browser context.\n\n**Auth:** Same as cookie import — requires `CAMOFOX_API_KEY` Bearer token, or loopback access in non-production.\n\n**Response:**\n```json\n{\n  \"cookies\": [\n    {\n      \"name\": \"session_id\",\n      \"value\": \"abc123\",\n      \"domain\": \".example.com\",\n      \"path\": \"/\",\n      \"expires\": 1700000000,\n      \"httpOnly\": true,\n      \"secure\": true,\n      \"sameSite\": \"Lax\"\n    }\n  ],\n  \"origins\": [\n    {\n      \"origin\": \"https://example.com\",\n      \"localStorage\": [\n        { \"name\": \"theme\", \"value\": \"dark\" }\n      ]\n    }\n  ]\n}\n```\n\n**Errors:**\n- `404` — No active session for the given userId\n- `403` — Missing or invalid API key\n- `500` — Context is dead or storageState export failed\n\n## Configuration\n\n| Source | Variable | Description | Default |\n|--------|----------|-------------|---------|\n| env | `ENABLE_VNC` | Enable the plugin (`1`) | off |\n| env | `VNC_PASSWORD` | x11vnc password | none (open) |\n| env | `VNC_BIND` | noVNC listener address; use `0.0.0.0` in a container with host-local port publishing | `127.0.0.1` |\n| env | `VNC_RFB_BIND` | Native VNC listener address; non-loopback requires `VNC_PASSWORD` | `127.0.0.1` |\n| env | `VNC_RESOLUTION` | Xvfb screen resolution | `1920x1080` |\n| env | `VIEW_ONLY` | Disable mouse/keyboard input (`1`) | off |\n| env | `VNC_PORT` | x11vnc listen port | `5900` |\n| env | `NOVNC_PORT` | noVNC web UI port | `6080` |\n| config | `plugins.vnc.enabled` | Enable the plugin | `false` |\n| config | `plugins.vnc.password` | x11vnc password | none |\n| config | `plugins.vnc.resolution` | Xvfb screen resolution | `1920x1080` |\n| config | `plugins.vnc.viewOnly` | View-only mode | `false` |\n| config | `plugins.vnc.vncPort` | x11vnc listen port | `5900` |\n| config | `plugins.vnc.novncPort` | noVNC web UI port | `6080` |\n\nEnvironment variables override config file values.\n\n## Security\n\n⚠️ **VNC is unencrypted by default.** When running in production:\n\n- **Set `VNC_PASSWORD`** — without it, anyone who can reach port 6080 has full browser control\n- **Bind 6080 to localhost** and access via SSH tunnel: `ssh -L 6080:localhost:6080 your-server`\n- **Or use a firewall** to restrict access to port 6080\n- In Docker: set `VNC_BIND=0.0.0.0` for container forwarding and `-p 127.0.0.1:6080:6080` to bind the host port only to localhost. To expose native VNC separately, set `VNC_RFB_BIND=0.0.0.0` **and** `VNC_PASSWORD`, then publish port 5900 only to trusted hosts.\n\n## System dependencies\n\nThe plugin declares its apt dependencies in `apt.txt` — these are installed automatically during `docker build` via `scripts/install-plugin-deps.sh`:\n\n- `x11vnc` — attaches to Xvfb display\n- `novnc` + `python3-websockify` — web-based VNC client\n- `net-tools` + `procps` — display detection utilities\n\n## Events\n\n| Event | Payload | Description |\n|-------|---------|-------------|\n| `vnc:watcher:started` | `{ pid }` | Watcher process spawned |\n| `vnc:watcher:stopped` | `{ code, signal }` | Watcher exited |\n| `vnc:storage:exported` | `{ userId, cookies, origins }` | Storage state exported via API |\n| `session:storage:export` | `{ userId, storageState }` | Awaited after export; the persistence plugin writes the same snapshot to disk without serializing the browser again |\n</yakaai_skills>"}