/** * Camoufox Browser - OpenClaw Plugin * * Provides browser automation tools using the Camoufox anti-detection browser. * Server auto-starts when plugin loads (configurable via autoStart: false). */ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { randomUUID } from "crypto"; import { loadConfig } from "./lib/config.js"; import { launchServer } from "./lib/launcher.js"; import { readCookieFile } from "./lib/cookies.js"; // Get plugin directory - works in both ESM and CJS contexts const getPluginDir = () => { try { // ESM context return dirname(fileURLToPath(import.meta.url)); } catch { // CJS context return __dirname; } }; let serverProcess = null; async function startServer(pluginDir, port, log, pluginCfg) { const cfg = loadConfig(); const env = { ...cfg.serverEnv }; if (pluginCfg?.maxSessions != null) env.MAX_SESSIONS = String(pluginCfg.maxSessions); if (pluginCfg?.maxTabsPerSession != null) env.MAX_TABS_PER_SESSION = String(pluginCfg.maxTabsPerSession); if (pluginCfg?.sessionTimeoutMs != null) env.SESSION_TIMEOUT_MS = String(pluginCfg.sessionTimeoutMs); if (pluginCfg?.browserIdleTimeoutMs != null) env.BROWSER_IDLE_TIMEOUT_MS = String(pluginCfg.browserIdleTimeoutMs); const proc = launchServer({ pluginDir, port, env, log, nodeArgs: pluginCfg?.maxOldSpaceSize != null ? [`--max-old-space-size=${pluginCfg.maxOldSpaceSize}`] : undefined }); proc.on("error", (err) => { log?.error?.(`Server process error: ${err.message}`); serverProcess = null; }); proc.on("exit", (code) => { if (code !== 0 && code !== null) { log?.error?.(`Server exited with code ${code}`); } serverProcess = null; }); // Wait for server to be ready const baseUrl = `http://localhost:${port}`; for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 500)); try { const res = await fetch(`${baseUrl}/health`); if (res.ok) { log.info(`Camoufox server ready on port ${port}`); return proc; } } catch { // Server not ready yet } } proc.kill(); throw new Error("Server failed to start within 15 seconds"); } async function checkServerRunning(baseUrl) { try { const res = await fetch(`${baseUrl}/health`); return res.ok; } catch { return false; } } async function fetchApi(baseUrl, path, options = {}) { const url = `${baseUrl}${path}`; const res = await fetch(url, { ...options, headers: { "Content-Type": "application/json", ...options.headers, }, }); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status}: ${text}`); } return res.json(); } function toToolResult(data) { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; } export default function register(api) { const cfg = api.pluginConfig ?? api.config; const port = cfg.port || 9377; const baseUrl = cfg.url || `http://localhost:${port}`; const autoStart = cfg.autoStart !== false; // default true const pluginDir = getPluginDir(); const fallbackUserId = `camofox-${randomUUID()}`; // Auto-start server if configured (default: true) if (autoStart) { (async () => { const alreadyRunning = await checkServerRunning(baseUrl); if (alreadyRunning) { api.log?.info?.(`Camoufox server already running at ${baseUrl}`); } else { try { serverProcess = await startServer(pluginDir, port, api.log, cfg); } catch (err) { api.log?.error?.(`Failed to auto-start server: ${err.message}`); } } })(); } api.registerTool((ctx) => ({ name: "camofox_create_tab", description: "PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use camofox tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.", parameters: { type: "object", properties: { url: { type: "string", description: "Initial URL to navigate to" }, }, required: ["url"], }, async execute(_id, params) { const sessionKey = ctx.sessionKey || "default"; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, "/tabs", { method: "POST", body: JSON.stringify({ ...params, userId, sessionKey }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_snapshot", description: "Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. " + "Large pages are truncated with pagination links preserved at the bottom. " + "If the response includes hasMore=true and nextOffset, call again with that offset to see more content.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, offset: { type: "number", description: "Character offset for paginated snapshots. Use nextOffset from a previous truncated response." }, }, required: ["tabId"], }, async execute(_id, params) { const { tabId, offset } = params; const userId = ctx.agentId || fallbackUserId; const qs = offset ? `&offset=${offset}` : ''; const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}&includeScreenshot=true${qs}`); const content = [ { type: "text", text: JSON.stringify({ url: result.url, refsCount: result.refsCount, snapshot: result.snapshot, truncated: result.truncated, totalChars: result.totalChars, hasMore: result.hasMore, nextOffset: result.nextOffset }, null, 2) }, ]; const screenshot = result.screenshot; if (screenshot?.data) { content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType || "image/png" }); } return { content }; }, })); api.registerTool((ctx) => ({ name: "camofox_click", description: "Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, ref: { type: "string", description: "Element ref from snapshot (e.g., e1)" }, selector: { type: "string", description: "CSS selector (alternative to ref)" }, }, required: ["tabId"], }, async execute(_id, params) { const { tabId, ...rest } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, { method: "POST", body: JSON.stringify({ ...rest, userId }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_type", description: "Type text into an element in a Camoufox tab.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, ref: { type: "string", description: "Element ref from snapshot (e.g., e2)" }, selector: { type: "string", description: "CSS selector (alternative to ref)" }, text: { type: "string", description: "Text to type" }, pressEnter: { type: "boolean", description: "Press Enter after typing" }, }, required: ["tabId", "text"], }, async execute(_id, params) { const { tabId, ...rest } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, { method: "POST", body: JSON.stringify({ ...rest, userId }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_navigate", description: "Navigate a Camoufox tab to a URL or use a search macro (@google_search, @youtube_search, etc.). Preferred over Chrome for sites with bot detection.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, url: { type: "string", description: "URL to navigate to" }, macro: { type: "string", description: "Search macro (e.g., @google_search, @youtube_search)", enum: [ "@google_search", "@youtube_search", "@amazon_search", "@reddit_search", "@wikipedia_search", "@twitter_search", "@yelp_search", "@spotify_search", "@netflix_search", "@linkedin_search", "@instagram_search", "@tiktok_search", "@twitch_search", ], }, query: { type: "string", description: "Search query (when using macro)" }, }, required: ["tabId"], }, async execute(_id, params) { const { tabId, ...rest } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, { method: "POST", body: JSON.stringify({ ...rest, userId }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_scroll", description: "Scroll a Camoufox page.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, direction: { type: "string", enum: ["up", "down", "left", "right"] }, amount: { type: "number", description: "Pixels to scroll" }, }, required: ["tabId", "direction"], }, async execute(_id, params) { const { tabId, ...rest } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, { method: "POST", body: JSON.stringify({ ...rest, userId }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_screenshot", description: "Take a screenshot of a Camoufox page.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, }, required: ["tabId"], }, async execute(_id, params) { const { tabId } = params; const userId = ctx.agentId || fallbackUserId; const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`; const res = await fetch(url); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status}: ${text}`); } // Guard: if server returns JSON/text instead of image (e.g. error with 200), // return as text to avoid crashing the client with base64-encoded JSON. const contentType = res.headers.get('content-type') || ''; if (!contentType.startsWith('image/')) { const text = await res.text(); return { content: [{ type: "text", text: `Screenshot failed: ${text}` }] }; } const arrayBuffer = await res.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString("base64"); return { content: [ { type: "image", data: base64, mimeType: contentType || "image/png", }, ], }; }, })); api.registerTool((ctx) => ({ name: "camofox_close_tab", description: "Close a Camoufox browser tab.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, }, required: ["tabId"], }, async execute(_id, params) { const { tabId } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, { method: "DELETE", }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_evaluate", description: "Execute JavaScript in a Camoufox tab's page context. Returns the result of the expression. Use for injecting scripts, reading page state, or calling web app APIs.", parameters: { type: "object", properties: { tabId: { type: "string", description: "Tab identifier" }, expression: { type: "string", description: "JavaScript expression to evaluate in the page context" }, }, required: ["tabId", "expression"], }, async execute(_id, params) { const { tabId, expression } = params; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}/evaluate`, { method: "POST", body: JSON.stringify({ userId, expression }), }); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_list_tabs", description: "List all open Camoufox tabs for a user.", parameters: { type: "object", properties: {}, required: [], }, async execute(_id, _params) { const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`); return toToolResult(result); }, })); api.registerTool((ctx) => ({ name: "camofox_import_cookies", description: "Import cookies into the current Camoufox user session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login.", parameters: { type: "object", properties: { cookiesPath: { type: "string", description: "Path to Netscape-format cookies.txt file" }, domainSuffix: { type: "string", description: "Only import cookies whose domain ends with this suffix", }, }, required: ["cookiesPath"], }, async execute(_id, params) { const { cookiesPath, domainSuffix } = params; const userId = ctx.agentId || fallbackUserId; const envCfg = loadConfig(); const cookiesDir = resolve(envCfg.cookiesDir); const pwCookies = await readCookieFile({ cookiesDir, cookiesPath, domainSuffix, }); if (!envCfg.apiKey) { throw new Error("CAMOFOX_API_KEY is not set. Cookie import is disabled unless you set CAMOFOX_API_KEY for both the server and the OpenClaw plugin environment."); } const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, { method: "POST", headers: { Authorization: `Bearer ${envCfg.apiKey}`, }, body: JSON.stringify({ cookies: pwCookies }), }); return toToolResult({ imported: pwCookies.length, userId, result }); }, })); api.registerCommand({ name: "camofox", description: "Camoufox browser server control (status, start, stop)", handler: async (args) => { const subcommand = args[0] || "status"; switch (subcommand) { case "status": try { const health = await fetchApi(baseUrl, "/health"); api.log?.info?.(`Camoufox server at ${baseUrl}: ${JSON.stringify(health)}`); } catch { api.log?.error?.(`Camoufox server at ${baseUrl}: not reachable`); } break; case "start": if (serverProcess) { api.log?.info?.("Camoufox server already running (managed)"); return; } if (await checkServerRunning(baseUrl)) { api.log?.info?.(`Camoufox server already running at ${baseUrl}`); return; } try { serverProcess = await startServer(pluginDir, port, api.log, cfg); } catch (err) { api.log?.error?.(`Failed to start server: ${err.message}`); } break; case "stop": if (serverProcess) { serverProcess.kill(); serverProcess = null; api.log?.info?.("Stopped camofox-browser server"); } else { api.log?.info?.("No managed server process running"); } break; default: api.log?.error?.(`Unknown subcommand: ${subcommand}. Use: status, start, stop`); } }, }); // Register health check for openclaw doctor/status if (api.registerHealthCheck) { api.registerHealthCheck("camofox-browser", async () => { try { const health = (await fetchApi(baseUrl, "/health")); return { status: "ok", message: `Server running (${health.engine || "camoufox"})`, details: { url: baseUrl, engine: health.engine, activeTabs: health.activeTabs, managed: serverProcess !== null, }, }; } catch { return { status: serverProcess ? "warn" : "error", message: serverProcess ? "Server starting..." : `Server not reachable at ${baseUrl}`, details: { url: baseUrl, managed: serverProcess !== null, hint: "Run: openclaw camofox start", }, }; } }); } // Register RPC methods for gateway integration if (api.registerRpc) { api.registerRpc("camofox.health", async () => { try { const health = await fetchApi(baseUrl, "/health"); return { status: "ok", ...health }; } catch (err) { return { status: "error", error: err.message }; } }); api.registerRpc("camofox.status", async () => { const running = await checkServerRunning(baseUrl); return { running, managed: serverProcess !== null, pid: serverProcess?.pid || null, url: baseUrl, port, }; }); } // Register CLI subcommands (openclaw camofox ...) if (api.registerCli) { api.registerCli(({ program }) => { const camofox = program .command("camofox") .description("Camoufox anti-detection browser automation"); camofox .command("status") .description("Show server status") .action(async () => { try { const health = (await fetchApi(baseUrl, "/health")); console.log(`Camoufox server: ${health.status}`); console.log(` URL: ${baseUrl}`); console.log(` Engine: ${health.engine || "camoufox"}`); console.log(` Active tabs: ${health.activeTabs ?? 0}`); console.log(` Managed: ${serverProcess !== null}`); } catch { console.log(`Camoufox server: not reachable`); console.log(` URL: ${baseUrl}`); console.log(` Managed: ${serverProcess !== null}`); console.log(` Hint: Run 'openclaw camofox start' to start the server`); } }); camofox .command("start") .description("Start the camofox server") .action(async () => { if (serverProcess) { console.log("Camoufox server already running (managed by plugin)"); return; } if (await checkServerRunning(baseUrl)) { console.log(`Camoufox server already running at ${baseUrl}`); return; } try { console.log(`Starting camofox server on port ${port}...`); serverProcess = await startServer(pluginDir, port, api.log, cfg); console.log(`Camoufox server started at ${baseUrl}`); } catch (err) { console.error(`Failed to start server: ${err.message}`); process.exit(1); } }); camofox .command("stop") .description("Stop the camofox server") .action(async () => { if (serverProcess) { serverProcess.kill(); serverProcess = null; console.log("Stopped camofox server"); } else { console.log("No managed server process running"); } }); camofox .command("configure") .description("Configure camofox plugin settings") .action(async () => { console.log("Camoufox Browser Configuration"); console.log("================================"); console.log(""); console.log("Current settings:"); console.log(` Server URL: ${baseUrl}`); console.log(` Port: ${port}`); console.log(` Auto-start: ${autoStart}`); console.log(""); console.log("Plugin config (openclaw.json):"); console.log(""); console.log(" plugins:"); console.log(" entries:"); console.log(" camofox-browser:"); console.log(" enabled: true"); console.log(" config:"); console.log(" port: 9377"); console.log(" autoStart: true"); console.log(""); console.log("To use camofox as the ONLY browser tool, disable the built-in:"); console.log(""); console.log(" tools:"); console.log(' deny: ["browser"]'); console.log(""); console.log("This removes OpenClaw's built-in browser tool, leaving camofox tools."); }); camofox .command("tabs") .description("List active browser tabs") .option("--user ", "Filter by user ID") .action(async (opts) => { try { const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs"; const tabs = (await fetchApi(baseUrl, endpoint)); if (tabs.length === 0) { console.log("No active tabs"); return; } console.log(`Active tabs (${tabs.length}):`); for (const tab of tabs) { console.log(` ${tab.tabId} [${tab.userId}] ${tab.title || tab.url}`); } } catch (err) { console.error(`Failed to list tabs: ${err.message}`); } }); }, { commands: ["camofox"] }); } }