@phi-code-admin/camofox-browser 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +571 -0
  2. package/Dockerfile +86 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -0
  5. package/camofox.config.json +10 -0
  6. package/dist/plugin.js +616 -0
  7. package/lib/auth.js +134 -0
  8. package/lib/camoufox-executable.js +189 -0
  9. package/lib/config.js +153 -0
  10. package/lib/cookies.js +119 -0
  11. package/lib/downloads.js +168 -0
  12. package/lib/extract.js +74 -0
  13. package/lib/fly.js +54 -0
  14. package/lib/images.js +88 -0
  15. package/lib/inflight.js +16 -0
  16. package/lib/launcher.js +47 -0
  17. package/lib/macros.js +31 -0
  18. package/lib/metrics.js +184 -0
  19. package/lib/openapi.js +105 -0
  20. package/lib/persistence.js +89 -0
  21. package/lib/plugins.js +175 -0
  22. package/lib/proxy.js +277 -0
  23. package/lib/reporter.js +1102 -0
  24. package/lib/request-utils.js +59 -0
  25. package/lib/resources.js +76 -0
  26. package/lib/snapshot.js +41 -0
  27. package/lib/tmp-cleanup.js +108 -0
  28. package/lib/tracing.js +137 -0
  29. package/openclaw.plugin.json +268 -0
  30. package/package.json +148 -0
  31. package/plugin.js +616 -0
  32. package/plugin.ts +758 -0
  33. package/plugins/persistence/AGENTS.md +37 -0
  34. package/plugins/persistence/README.md +48 -0
  35. package/plugins/persistence/index.js +124 -0
  36. package/plugins/vnc/AGENTS.md +42 -0
  37. package/plugins/vnc/README.md +165 -0
  38. package/plugins/vnc/apt.txt +7 -0
  39. package/plugins/vnc/index.js +142 -0
  40. package/plugins/vnc/spawn.js +8 -0
  41. package/plugins/vnc/vnc-launcher.js +64 -0
  42. package/plugins/vnc/vnc-watcher.sh +82 -0
  43. package/plugins/youtube/AGENTS.md +25 -0
  44. package/plugins/youtube/apt.txt +1 -0
  45. package/plugins/youtube/index.js +206 -0
  46. package/plugins/youtube/post-install.sh +5 -0
  47. package/plugins/youtube/youtube.js +301 -0
  48. package/run.sh +37 -0
  49. package/scripts/exec.js +8 -0
  50. package/scripts/generate-openapi.js +24 -0
  51. package/scripts/install-plugin-deps.sh +63 -0
  52. package/scripts/plugin.js +342 -0
  53. package/scripts/postinstall.js +20 -0
  54. package/scripts/sync-version.js +25 -0
  55. package/server.js +6059 -0
  56. package/tsconfig.json +12 -0
package/plugin.ts ADDED
@@ -0,0 +1,758 @@
1
+ /**
2
+ * Camoufox Browser - OpenClaw Plugin
3
+ *
4
+ * Provides browser automation tools using the Camoufox anti-detection browser.
5
+ * Server auto-starts when plugin loads (configurable via autoStart: false).
6
+ */
7
+
8
+ import type { ChildProcess } from "child_process";
9
+ import { join, dirname, resolve } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { randomUUID } from "crypto";
12
+
13
+ import { loadConfig } from "./lib/config.js";
14
+ import { launchServer } from "./lib/launcher.js";
15
+ import { readCookieFile } from "./lib/cookies.js";
16
+
17
+ // Get plugin directory - works in both ESM and CJS contexts
18
+ const getPluginDir = (): string => {
19
+ try {
20
+ // ESM context
21
+ return dirname(fileURLToPath(import.meta.url));
22
+ } catch {
23
+ // CJS context
24
+ return __dirname;
25
+ }
26
+ };
27
+
28
+ interface PluginConfig {
29
+ url?: string;
30
+ autoStart?: boolean;
31
+ port?: number;
32
+ maxSessions?: number;
33
+ maxTabsPerSession?: number;
34
+ sessionTimeoutMs?: number;
35
+ browserIdleTimeoutMs?: number;
36
+ maxOldSpaceSize?: number;
37
+ }
38
+
39
+ interface ToolResult {
40
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
41
+ }
42
+
43
+ interface HealthCheckResult {
44
+ status: "ok" | "warn" | "error";
45
+ message?: string;
46
+ details?: Record<string, unknown>;
47
+ }
48
+
49
+ interface CliContext {
50
+ program: {
51
+ command: (name: string) => {
52
+ description: (desc: string) => CliContext["program"];
53
+ option: (flags: string, desc: string, defaultValue?: string) => CliContext["program"];
54
+ argument: (name: string, desc: string) => CliContext["program"];
55
+ action: (handler: (...args: unknown[]) => void | Promise<void>) => CliContext["program"];
56
+ command: (name: string) => CliContext["program"];
57
+ };
58
+ };
59
+ config: PluginConfig;
60
+ logger: {
61
+ info: (msg: string) => void;
62
+ error: (msg: string) => void;
63
+ };
64
+ }
65
+
66
+ interface ToolContext {
67
+ sessionKey?: string;
68
+ agentId?: string;
69
+ workspaceDir?: string;
70
+ sandboxed?: boolean;
71
+ }
72
+
73
+ type ToolDefinition = {
74
+ name: string;
75
+ description: string;
76
+ parameters: object;
77
+ execute: (id: string, params: Record<string, unknown>) => Promise<ToolResult>;
78
+ };
79
+
80
+ type ToolFactory = (ctx: ToolContext) => ToolDefinition | ToolDefinition[] | null | undefined;
81
+
82
+ interface PluginApi {
83
+ registerTool: (
84
+ tool: ToolDefinition | ToolFactory,
85
+ options?: { optional?: boolean }
86
+ ) => void;
87
+ registerCommand: (cmd: {
88
+ name: string;
89
+ description: string;
90
+ handler: (args: string[]) => Promise<void>;
91
+ }) => void;
92
+ registerCli?: (
93
+ registrar: (ctx: CliContext) => void | Promise<void>,
94
+ opts?: { commands?: string[] }
95
+ ) => void;
96
+ registerRpc?: (
97
+ name: string,
98
+ handler: (params: Record<string, unknown>) => Promise<unknown>
99
+ ) => void;
100
+ registerHealthCheck?: (
101
+ name: string,
102
+ check: () => Promise<HealthCheckResult>
103
+ ) => void;
104
+ config: Record<string, unknown>;
105
+ pluginConfig?: PluginConfig;
106
+ log: {
107
+ info: (msg: string) => void;
108
+ error: (msg: string) => void;
109
+ };
110
+ }
111
+
112
+ let serverProcess: ChildProcess | null = null;
113
+
114
+ async function startServer(
115
+ pluginDir: string,
116
+ port: number,
117
+ log: PluginApi["log"],
118
+ pluginCfg?: PluginConfig
119
+ ): Promise<ChildProcess> {
120
+ const cfg = loadConfig();
121
+ const env: Record<string, string> = { ...cfg.serverEnv };
122
+ if (pluginCfg?.maxSessions != null) env.MAX_SESSIONS = String(pluginCfg.maxSessions);
123
+ if (pluginCfg?.maxTabsPerSession != null) env.MAX_TABS_PER_SESSION = String(pluginCfg.maxTabsPerSession);
124
+ if (pluginCfg?.sessionTimeoutMs != null) env.SESSION_TIMEOUT_MS = String(pluginCfg.sessionTimeoutMs);
125
+ if (pluginCfg?.browserIdleTimeoutMs != null) env.BROWSER_IDLE_TIMEOUT_MS = String(pluginCfg.browserIdleTimeoutMs);
126
+ const proc = launchServer({ pluginDir, port, env, log, nodeArgs: pluginCfg?.maxOldSpaceSize != null ? [`--max-old-space-size=${pluginCfg.maxOldSpaceSize}`] : undefined });
127
+
128
+ proc.on("error", (err: Error) => {
129
+ log?.error?.(`Server process error: ${err.message}`);
130
+ serverProcess = null;
131
+ });
132
+
133
+ proc.on("exit", (code: number | null) => {
134
+ if (code !== 0 && code !== null) {
135
+ log?.error?.(`Server exited with code ${code}`);
136
+ }
137
+ serverProcess = null;
138
+ });
139
+
140
+ // Wait for server to be ready
141
+ const baseUrl = `http://localhost:${port}`;
142
+ for (let i = 0; i < 30; i++) {
143
+ await new Promise((r) => setTimeout(r, 500));
144
+ try {
145
+ const res = await fetch(`${baseUrl}/health`);
146
+ if (res.ok) {
147
+ log.info(`Camoufox server ready on port ${port}`);
148
+ return proc;
149
+ }
150
+ } catch {
151
+ // Server not ready yet
152
+ }
153
+ }
154
+ proc.kill();
155
+ throw new Error("Server failed to start within 15 seconds");
156
+ }
157
+
158
+ async function checkServerRunning(baseUrl: string): Promise<boolean> {
159
+ try {
160
+ const res = await fetch(`${baseUrl}/health`);
161
+ return res.ok;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ async function fetchApi(
168
+ baseUrl: string,
169
+ path: string,
170
+ options: RequestInit = {}
171
+ ): Promise<unknown> {
172
+ const url = `${baseUrl}${path}`;
173
+ const res = await fetch(url, {
174
+ ...options,
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ ...options.headers,
178
+ },
179
+ });
180
+ if (!res.ok) {
181
+ const text = await res.text();
182
+ throw new Error(`${res.status}: ${text}`);
183
+ }
184
+ return res.json();
185
+ }
186
+
187
+ function toToolResult(data: unknown): ToolResult {
188
+ return {
189
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
190
+ };
191
+ }
192
+
193
+ export default function register(api: PluginApi) {
194
+ const cfg = api.pluginConfig ?? (api.config as unknown as PluginConfig);
195
+ const port = cfg.port || 9377;
196
+ const baseUrl = cfg.url || `http://localhost:${port}`;
197
+ const autoStart = cfg.autoStart !== false; // default true
198
+ const pluginDir = getPluginDir();
199
+ const fallbackUserId = `camofox-${randomUUID()}`;
200
+
201
+ // Auto-start server if configured (default: true)
202
+ if (autoStart) {
203
+ (async () => {
204
+ const alreadyRunning = await checkServerRunning(baseUrl);
205
+ if (alreadyRunning) {
206
+ api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
207
+ } else {
208
+ try {
209
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
210
+ } catch (err) {
211
+ api.log?.error?.(`Failed to auto-start server: ${(err as Error).message}`);
212
+ }
213
+ }
214
+ })();
215
+ }
216
+
217
+ api.registerTool((ctx: ToolContext) => ({
218
+ name: "camofox_create_tab",
219
+ description:
220
+ "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.",
221
+ parameters: {
222
+ type: "object",
223
+ properties: {
224
+ url: { type: "string", description: "Initial URL to navigate to" },
225
+ },
226
+ required: ["url"],
227
+ },
228
+ async execute(_id, params) {
229
+ const sessionKey = ctx.sessionKey || "default";
230
+ const userId = ctx.agentId || fallbackUserId;
231
+ const result = await fetchApi(baseUrl, "/tabs", {
232
+ method: "POST",
233
+ body: JSON.stringify({ ...params, userId, sessionKey }),
234
+ });
235
+ return toToolResult(result);
236
+ },
237
+ }));
238
+
239
+ api.registerTool((ctx: ToolContext) => ({
240
+ name: "camofox_snapshot",
241
+ description:
242
+ "Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. " +
243
+ "Large pages are truncated with pagination links preserved at the bottom. " +
244
+ "If the response includes hasMore=true and nextOffset, call again with that offset to see more content.",
245
+ parameters: {
246
+ type: "object",
247
+ properties: {
248
+ tabId: { type: "string", description: "Tab identifier" },
249
+ offset: { type: "number", description: "Character offset for paginated snapshots. Use nextOffset from a previous truncated response." },
250
+ },
251
+ required: ["tabId"],
252
+ },
253
+ async execute(_id, params) {
254
+ const { tabId, offset } = params as { tabId: string; offset?: number };
255
+ const userId = ctx.agentId || fallbackUserId;
256
+ const qs = offset ? `&offset=${offset}` : '';
257
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}&includeScreenshot=true${qs}`) as Record<string, unknown>;
258
+ const content: ToolResult["content"] = [
259
+ { 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) },
260
+ ];
261
+ const screenshot = result.screenshot as { data?: string; mimeType?: string } | undefined;
262
+ if (screenshot?.data) {
263
+ content.push({ type: "image", data: screenshot.data, mimeType: screenshot.mimeType || "image/png" });
264
+ }
265
+ return { content };
266
+ },
267
+ }));
268
+
269
+ api.registerTool((ctx: ToolContext) => ({
270
+ name: "camofox_click",
271
+ description: "Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.",
272
+ parameters: {
273
+ type: "object",
274
+ properties: {
275
+ tabId: { type: "string", description: "Tab identifier" },
276
+ ref: { type: "string", description: "Element ref from snapshot (e.g., e1)" },
277
+ selector: { type: "string", description: "CSS selector (alternative to ref)" },
278
+ },
279
+ required: ["tabId"],
280
+ },
281
+ async execute(_id, params) {
282
+ const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
283
+ const userId = ctx.agentId || fallbackUserId;
284
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
285
+ method: "POST",
286
+ body: JSON.stringify({ ...rest, userId }),
287
+ });
288
+ return toToolResult(result);
289
+ },
290
+ }));
291
+
292
+ api.registerTool((ctx: ToolContext) => ({
293
+ name: "camofox_type",
294
+ description: "Type text into an element in a Camoufox tab.",
295
+ parameters: {
296
+ type: "object",
297
+ properties: {
298
+ tabId: { type: "string", description: "Tab identifier" },
299
+ ref: { type: "string", description: "Element ref from snapshot (e.g., e2)" },
300
+ selector: { type: "string", description: "CSS selector (alternative to ref)" },
301
+ text: { type: "string", description: "Text to type" },
302
+ pressEnter: { type: "boolean", description: "Press Enter after typing" },
303
+ },
304
+ required: ["tabId", "text"],
305
+ },
306
+ async execute(_id, params) {
307
+ const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
308
+ const userId = ctx.agentId || fallbackUserId;
309
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
310
+ method: "POST",
311
+ body: JSON.stringify({ ...rest, userId }),
312
+ });
313
+ return toToolResult(result);
314
+ },
315
+ }));
316
+
317
+ api.registerTool((ctx: ToolContext) => ({
318
+ name: "camofox_navigate",
319
+ description:
320
+ "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.",
321
+ parameters: {
322
+ type: "object",
323
+ properties: {
324
+ tabId: { type: "string", description: "Tab identifier" },
325
+ url: { type: "string", description: "URL to navigate to" },
326
+ macro: {
327
+ type: "string",
328
+ description: "Search macro (e.g., @google_search, @youtube_search)",
329
+ enum: [
330
+ "@google_search",
331
+ "@youtube_search",
332
+ "@amazon_search",
333
+ "@reddit_search",
334
+ "@wikipedia_search",
335
+ "@twitter_search",
336
+ "@yelp_search",
337
+ "@spotify_search",
338
+ "@netflix_search",
339
+ "@linkedin_search",
340
+ "@instagram_search",
341
+ "@tiktok_search",
342
+ "@twitch_search",
343
+ ],
344
+ },
345
+ query: { type: "string", description: "Search query (when using macro)" },
346
+ },
347
+ required: ["tabId"],
348
+ },
349
+ async execute(_id, params) {
350
+ const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
351
+ const userId = ctx.agentId || fallbackUserId;
352
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
353
+ method: "POST",
354
+ body: JSON.stringify({ ...rest, userId }),
355
+ });
356
+ return toToolResult(result);
357
+ },
358
+ }));
359
+
360
+ api.registerTool((ctx: ToolContext) => ({
361
+ name: "camofox_scroll",
362
+ description: "Scroll a Camoufox page.",
363
+ parameters: {
364
+ type: "object",
365
+ properties: {
366
+ tabId: { type: "string", description: "Tab identifier" },
367
+ direction: { type: "string", enum: ["up", "down", "left", "right"] },
368
+ amount: { type: "number", description: "Pixels to scroll" },
369
+ },
370
+ required: ["tabId", "direction"],
371
+ },
372
+ async execute(_id, params) {
373
+ const { tabId, ...rest } = params as { tabId: string } & Record<string, unknown>;
374
+ const userId = ctx.agentId || fallbackUserId;
375
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
376
+ method: "POST",
377
+ body: JSON.stringify({ ...rest, userId }),
378
+ });
379
+ return toToolResult(result);
380
+ },
381
+ }));
382
+
383
+ api.registerTool((ctx: ToolContext) => ({
384
+ name: "camofox_screenshot",
385
+ description: "Take a screenshot of a Camoufox page.",
386
+ parameters: {
387
+ type: "object",
388
+ properties: {
389
+ tabId: { type: "string", description: "Tab identifier" },
390
+ },
391
+ required: ["tabId"],
392
+ },
393
+ async execute(_id, params) {
394
+ const { tabId } = params as { tabId: string };
395
+ const userId = ctx.agentId || fallbackUserId;
396
+ const url = `${baseUrl}/tabs/${tabId}/screenshot?userId=${userId}`;
397
+ const res = await fetch(url);
398
+ if (!res.ok) {
399
+ const text = await res.text();
400
+ throw new Error(`${res.status}: ${text}`);
401
+ }
402
+ // Guard: if server returns JSON/text instead of image (e.g. error with 200),
403
+ // return as text to avoid crashing the client with base64-encoded JSON.
404
+ const contentType = res.headers.get('content-type') || '';
405
+ if (!contentType.startsWith('image/')) {
406
+ const text = await res.text();
407
+ return { content: [{ type: "text", text: `Screenshot failed: ${text}` }] };
408
+ }
409
+ const arrayBuffer = await res.arrayBuffer();
410
+ const base64 = Buffer.from(arrayBuffer).toString("base64");
411
+ return {
412
+ content: [
413
+ {
414
+ type: "image",
415
+ data: base64,
416
+ mimeType: contentType || "image/png",
417
+ },
418
+ ],
419
+ };
420
+ },
421
+ }));
422
+
423
+ api.registerTool((ctx: ToolContext) => ({
424
+ name: "camofox_close_tab",
425
+ description: "Close a Camoufox browser tab.",
426
+ parameters: {
427
+ type: "object",
428
+ properties: {
429
+ tabId: { type: "string", description: "Tab identifier" },
430
+ },
431
+ required: ["tabId"],
432
+ },
433
+ async execute(_id, params) {
434
+ const { tabId } = params as { tabId: string };
435
+ const userId = ctx.agentId || fallbackUserId;
436
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
437
+ method: "DELETE",
438
+ });
439
+ return toToolResult(result);
440
+ },
441
+ }));
442
+
443
+ api.registerTool((ctx: ToolContext) => ({
444
+ name: "camofox_evaluate",
445
+ description:
446
+ "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.",
447
+ parameters: {
448
+ type: "object",
449
+ properties: {
450
+ tabId: { type: "string", description: "Tab identifier" },
451
+ expression: { type: "string", description: "JavaScript expression to evaluate in the page context" },
452
+ },
453
+ required: ["tabId", "expression"],
454
+ },
455
+ async execute(_id, params) {
456
+ const { tabId, expression } = params as { tabId: string; expression: string };
457
+ const userId = ctx.agentId || fallbackUserId;
458
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/evaluate`, {
459
+ method: "POST",
460
+ body: JSON.stringify({ userId, expression }),
461
+ });
462
+ return toToolResult(result);
463
+ },
464
+ }));
465
+
466
+ api.registerTool((ctx: ToolContext) => ({
467
+ name: "camofox_list_tabs",
468
+ description: "List all open Camoufox tabs for a user.",
469
+ parameters: {
470
+ type: "object",
471
+ properties: {},
472
+ required: [],
473
+ },
474
+ async execute(_id, _params) {
475
+ const userId = ctx.agentId || fallbackUserId;
476
+ const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
477
+ return toToolResult(result);
478
+ },
479
+ }));
480
+
481
+ api.registerTool((ctx: ToolContext) => ({
482
+ name: "camofox_import_cookies",
483
+ description:
484
+ "Import cookies into the current Camoufox user session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login.",
485
+ parameters: {
486
+ type: "object",
487
+ properties: {
488
+ cookiesPath: { type: "string", description: "Path to Netscape-format cookies.txt file" },
489
+ domainSuffix: {
490
+ type: "string",
491
+ description: "Only import cookies whose domain ends with this suffix",
492
+ },
493
+ },
494
+ required: ["cookiesPath"],
495
+ },
496
+ async execute(_id, params) {
497
+ const { cookiesPath, domainSuffix } = params as {
498
+ cookiesPath: string;
499
+ domainSuffix?: string;
500
+ };
501
+
502
+ const userId = ctx.agentId || fallbackUserId;
503
+
504
+ const envCfg = loadConfig();
505
+ const cookiesDir = resolve(envCfg.cookiesDir);
506
+
507
+ const pwCookies = await readCookieFile({
508
+ cookiesDir,
509
+ cookiesPath,
510
+ domainSuffix,
511
+ });
512
+
513
+ if (!envCfg.apiKey) {
514
+ throw new Error(
515
+ "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."
516
+ );
517
+ }
518
+
519
+ const result = await fetchApi(baseUrl, `/sessions/${encodeURIComponent(userId)}/cookies`, {
520
+ method: "POST",
521
+ headers: {
522
+ Authorization: `Bearer ${envCfg.apiKey}`,
523
+ },
524
+ body: JSON.stringify({ cookies: pwCookies }),
525
+ });
526
+
527
+ return toToolResult({ imported: pwCookies.length, userId, result });
528
+ },
529
+ }));
530
+
531
+ api.registerCommand({
532
+ name: "camofox",
533
+ description: "Camoufox browser server control (status, start, stop)",
534
+ handler: async (args) => {
535
+ const subcommand = args[0] || "status";
536
+ switch (subcommand) {
537
+ case "status":
538
+ try {
539
+ const health = await fetchApi(baseUrl, "/health");
540
+ api.log?.info?.(`Camoufox server at ${baseUrl}: ${JSON.stringify(health)}`);
541
+ } catch {
542
+ api.log?.error?.(`Camoufox server at ${baseUrl}: not reachable`);
543
+ }
544
+ break;
545
+ case "start":
546
+ if (serverProcess) {
547
+ api.log?.info?.("Camoufox server already running (managed)");
548
+ return;
549
+ }
550
+ if (await checkServerRunning(baseUrl)) {
551
+ api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
552
+ return;
553
+ }
554
+ try {
555
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
556
+ } catch (err) {
557
+ api.log?.error?.(`Failed to start server: ${(err as Error).message}`);
558
+ }
559
+ break;
560
+ case "stop":
561
+ if (serverProcess) {
562
+ serverProcess.kill();
563
+ serverProcess = null;
564
+ api.log?.info?.("Stopped camofox-browser server");
565
+ } else {
566
+ api.log?.info?.("No managed server process running");
567
+ }
568
+ break;
569
+ default:
570
+ api.log?.error?.(`Unknown subcommand: ${subcommand}. Use: status, start, stop`);
571
+ }
572
+ },
573
+ });
574
+
575
+ // Register health check for openclaw doctor/status
576
+ if (api.registerHealthCheck) {
577
+ api.registerHealthCheck("camofox-browser", async () => {
578
+ try {
579
+ const health = (await fetchApi(baseUrl, "/health")) as {
580
+ status: string;
581
+ engine?: string;
582
+ activeTabs?: number;
583
+ };
584
+ return {
585
+ status: "ok",
586
+ message: `Server running (${health.engine || "camoufox"})`,
587
+ details: {
588
+ url: baseUrl,
589
+ engine: health.engine,
590
+ activeTabs: health.activeTabs,
591
+ managed: serverProcess !== null,
592
+ },
593
+ };
594
+ } catch {
595
+ return {
596
+ status: serverProcess ? "warn" : "error",
597
+ message: serverProcess
598
+ ? "Server starting..."
599
+ : `Server not reachable at ${baseUrl}`,
600
+ details: {
601
+ url: baseUrl,
602
+ managed: serverProcess !== null,
603
+ hint: "Run: openclaw camofox start",
604
+ },
605
+ };
606
+ }
607
+ });
608
+ }
609
+
610
+ // Register RPC methods for gateway integration
611
+ if (api.registerRpc) {
612
+ api.registerRpc("camofox.health", async () => {
613
+ try {
614
+ const health = await fetchApi(baseUrl, "/health");
615
+ return { status: "ok", ...health };
616
+ } catch (err) {
617
+ return { status: "error", error: (err as Error).message };
618
+ }
619
+ });
620
+
621
+ api.registerRpc("camofox.status", async () => {
622
+ const running = await checkServerRunning(baseUrl);
623
+ return {
624
+ running,
625
+ managed: serverProcess !== null,
626
+ pid: serverProcess?.pid || null,
627
+ url: baseUrl,
628
+ port,
629
+ };
630
+ });
631
+ }
632
+
633
+ // Register CLI subcommands (openclaw camofox ...)
634
+ if (api.registerCli) {
635
+ api.registerCli(
636
+ ({ program }) => {
637
+ const camofox = program
638
+ .command("camofox")
639
+ .description("Camoufox anti-detection browser automation");
640
+
641
+ camofox
642
+ .command("status")
643
+ .description("Show server status")
644
+ .action(async () => {
645
+ try {
646
+ const health = (await fetchApi(baseUrl, "/health")) as {
647
+ status: string;
648
+ engine?: string;
649
+ activeTabs?: number;
650
+ };
651
+ console.log(`Camoufox server: ${health.status}`);
652
+ console.log(` URL: ${baseUrl}`);
653
+ console.log(` Engine: ${health.engine || "camoufox"}`);
654
+ console.log(` Active tabs: ${health.activeTabs ?? 0}`);
655
+ console.log(` Managed: ${serverProcess !== null}`);
656
+ } catch {
657
+ console.log(`Camoufox server: not reachable`);
658
+ console.log(` URL: ${baseUrl}`);
659
+ console.log(` Managed: ${serverProcess !== null}`);
660
+ console.log(` Hint: Run 'openclaw camofox start' to start the server`);
661
+ }
662
+ });
663
+
664
+ camofox
665
+ .command("start")
666
+ .description("Start the camofox server")
667
+ .action(async () => {
668
+ if (serverProcess) {
669
+ console.log("Camoufox server already running (managed by plugin)");
670
+ return;
671
+ }
672
+ if (await checkServerRunning(baseUrl)) {
673
+ console.log(`Camoufox server already running at ${baseUrl}`);
674
+ return;
675
+ }
676
+ try {
677
+ console.log(`Starting camofox server on port ${port}...`);
678
+ serverProcess = await startServer(pluginDir, port, api.log, cfg);
679
+ console.log(`Camoufox server started at ${baseUrl}`);
680
+ } catch (err) {
681
+ console.error(`Failed to start server: ${(err as Error).message}`);
682
+ process.exit(1);
683
+ }
684
+ });
685
+
686
+ camofox
687
+ .command("stop")
688
+ .description("Stop the camofox server")
689
+ .action(async () => {
690
+ if (serverProcess) {
691
+ serverProcess.kill();
692
+ serverProcess = null;
693
+ console.log("Stopped camofox server");
694
+ } else {
695
+ console.log("No managed server process running");
696
+ }
697
+ });
698
+
699
+ camofox
700
+ .command("configure")
701
+ .description("Configure camofox plugin settings")
702
+ .action(async () => {
703
+ console.log("Camoufox Browser Configuration");
704
+ console.log("================================");
705
+ console.log("");
706
+ console.log("Current settings:");
707
+ console.log(` Server URL: ${baseUrl}`);
708
+ console.log(` Port: ${port}`);
709
+ console.log(` Auto-start: ${autoStart}`);
710
+ console.log("");
711
+ console.log("Plugin config (openclaw.json):");
712
+ console.log("");
713
+ console.log(" plugins:");
714
+ console.log(" entries:");
715
+ console.log(" camofox-browser:");
716
+ console.log(" enabled: true");
717
+ console.log(" config:");
718
+ console.log(" port: 9377");
719
+ console.log(" autoStart: true");
720
+ console.log("");
721
+ console.log("To use camofox as the ONLY browser tool, disable the built-in:");
722
+ console.log("");
723
+ console.log(" tools:");
724
+ console.log(' deny: ["browser"]');
725
+ console.log("");
726
+ console.log("This removes OpenClaw's built-in browser tool, leaving camofox tools.");
727
+ });
728
+
729
+ camofox
730
+ .command("tabs")
731
+ .description("List active browser tabs")
732
+ .option("--user <userId>", "Filter by user ID")
733
+ .action(async (opts: { user?: string }) => {
734
+ try {
735
+ const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs";
736
+ const tabs = (await fetchApi(baseUrl, endpoint)) as Array<{
737
+ tabId: string;
738
+ userId: string;
739
+ url: string;
740
+ title: string;
741
+ }>;
742
+ if (tabs.length === 0) {
743
+ console.log("No active tabs");
744
+ return;
745
+ }
746
+ console.log(`Active tabs (${tabs.length}):`);
747
+ for (const tab of tabs) {
748
+ console.log(` ${tab.tabId} [${tab.userId}] ${tab.title || tab.url}`);
749
+ }
750
+ } catch (err) {
751
+ console.error(`Failed to list tabs: ${(err as Error).message}`);
752
+ }
753
+ });
754
+ },
755
+ { commands: ["camofox"] }
756
+ );
757
+ }
758
+ }