/** * 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 type { ChildProcess } from "child_process"; import { join, 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 = (): string => { try { // ESM context return dirname(fileURLToPath(import.meta.url)); } catch { // CJS context return __dirname; } }; interface PluginConfig { url?: string; autoStart?: boolean; port?: number; maxSessions?: number; maxTabsPerSession?: number; sessionTimeoutMs?: number; browserIdleTimeoutMs?: number; maxOldSpaceSize?: number; } interface ToolResult { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; } interface HealthCheckResult { status: "ok" | "warn" | "error"; message?: string; details?: Record; } interface CliContext { program: { command: (name: string) => { description: (desc: string) => CliContext["program"]; option: (flags: string, desc: string, defaultValue?: string) => CliContext["program"]; argument: (name: string, desc: string) => CliContext["program"]; action: (handler: (...args: unknown[]) => void | Promise) => CliContext["program"]; command: (name: string) => CliContext["program"]; }; }; config: PluginConfig; logger: { info: (msg: string) => void; error: (msg: string) => void; }; } interface ToolContext { sessionKey?: string; agentId?: string; workspaceDir?: string; sandboxed?: boolean; } type ToolDefinition = { name: string; description: string; parameters: object; execute: (id: string, params: Record) => Promise; }; type ToolFactory = (ctx: ToolContext) => ToolDefinition | ToolDefinition[] | null | undefined; interface PluginApi { registerTool: ( tool: ToolDefinition | ToolFactory, options?: { optional?: boolean } ) => void; registerCommand: (cmd: { name: string; description: string; handler: (args: string[]) => Promise; }) => void; registerCli?: ( registrar: (ctx: CliContext) => void | Promise, opts?: { commands?: string[] } ) => void; registerRpc?: ( name: string, handler: (params: Record) => Promise ) => void; registerHealthCheck?: ( name: string, check: () => Promise ) => void; config: Record; pluginConfig?: PluginConfig; log: { info: (msg: string) => void; error: (msg: string) => void; }; } let serverProcess: ChildProcess | null = null; async function startServer( pluginDir: string, port: number, log: PluginApi["log"], pluginCfg?: PluginConfig ): Promise { const cfg = loadConfig(); const env: Record = { ...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: Error) => { log?.error?.(`Server process error: ${err.message}`); serverProcess = null; }); proc.on("exit", (code: number | null) => { 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: string): Promise { try { const res = await fetch(`${baseUrl}/health`); return res.ok; } catch { return false; } } async function fetchApi( baseUrl: string, path: string, options: RequestInit = {} ): Promise { 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: unknown): ToolResult { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; } export default function register(api: PluginApi) { const cfg = api.pluginConfig ?? (api.config as unknown as PluginConfig); 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 as Error).message}`); } } })(); } api.registerTool((ctx: ToolContext) => ({ 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: ToolContext) => ({ 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 as { tabId: string; offset?: number }; const userId = ctx.agentId || fallbackUserId; const qs = offset ? `&offset=${offset}` : ''; const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}&includeScreenshot=true${qs}`) as Record; const content: ToolResult["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 as { data?: string; mimeType?: string } | undefined; if (screenshot?.data) { content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType || "image/png" }); } return { content }; }, })); api.registerTool((ctx: ToolContext) => ({ 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 as { tabId: string } & Record; 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: ToolContext) => ({ 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 as { tabId: string } & Record; 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: ToolContext) => ({ 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 as { tabId: string } & Record; 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: ToolContext) => ({ 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 as { tabId: string } & Record; 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: ToolContext) => ({ 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 as { tabId: string }; 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: ToolContext) => ({ 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 as { tabId: string }; const userId = ctx.agentId || fallbackUserId; const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, { method: "DELETE", }); return toToolResult(result); }, })); api.registerTool((ctx: ToolContext) => ({ 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 as { tabId: string; expression: string }; 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: ToolContext) => ({ 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: ToolContext) => ({ 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 as { cookiesPath: string; domainSuffix?: string; }; 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 as Error).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")) as { status: string; engine?: string; activeTabs?: number; }; 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 as Error).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")) as { status: string; engine?: string; activeTabs?: number; }; 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 as Error).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: { user?: string }) => { try { const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs"; const tabs = (await fetchApi(baseUrl, endpoint)) as Array<{ tabId: string; userId: string; url: string; title: string; }>; 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 as Error).message}`); } }); }, { commands: ["camofox"] } ); } }