@askjo/camoufox-browser 1.0.8 → 1.0.11

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.
@@ -2,19 +2,23 @@
2
2
  "id": "camoufox-browser",
3
3
  "name": "Camoufox Browser",
4
4
  "description": "Anti-detection browser automation for AI agents using Camoufox (Firefox-based)",
5
- "version": "1.0.0",
5
+ "version": "1.0.11",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "properties": {
9
9
  "url": {
10
10
  "type": "string",
11
- "description": "Camoufox browser server URL",
12
- "default": "http://localhost:9377"
11
+ "description": "Camoufox browser server URL"
12
+ },
13
+ "port": {
14
+ "type": "number",
15
+ "description": "Server port (used if url not set)",
16
+ "default": 9377
13
17
  },
14
18
  "autoStart": {
15
19
  "type": "boolean",
16
20
  "description": "Auto-start the camoufox-browser server with the Gateway",
17
- "default": false
21
+ "default": true
18
22
  }
19
23
  },
20
24
  "additionalProperties": false
@@ -24,8 +28,12 @@
24
28
  "label": "Server URL",
25
29
  "placeholder": "http://localhost:9377"
26
30
  },
31
+ "port": {
32
+ "label": "Server Port",
33
+ "placeholder": "9377"
34
+ },
27
35
  "autoStart": {
28
- "label": "Auto-start server"
36
+ "label": "Auto-start server with Gateway"
29
37
  }
30
38
  }
31
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askjo/camoufox-browser",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "description": "Headless browser automation server and OpenClaw plugin for AI agents - anti-detection, element refs, and session isolation",
5
5
  "main": "server-camoufox.js",
6
6
  "license": "MIT",
package/plugin.ts CHANGED
@@ -30,6 +30,29 @@ interface ToolResult {
30
30
  content: Array<{ type: string; text: string }>;
31
31
  }
32
32
 
33
+ interface HealthCheckResult {
34
+ status: "ok" | "warn" | "error";
35
+ message?: string;
36
+ details?: Record<string, unknown>;
37
+ }
38
+
39
+ interface CliContext {
40
+ program: {
41
+ command: (name: string) => {
42
+ description: (desc: string) => CliContext["program"];
43
+ option: (flags: string, desc: string, defaultValue?: string) => CliContext["program"];
44
+ argument: (name: string, desc: string) => CliContext["program"];
45
+ action: (handler: (...args: unknown[]) => void | Promise<void>) => CliContext["program"];
46
+ command: (name: string) => CliContext["program"];
47
+ };
48
+ };
49
+ config: PluginConfig;
50
+ logger: {
51
+ info: (msg: string) => void;
52
+ error: (msg: string) => void;
53
+ };
54
+ }
55
+
33
56
  interface PluginApi {
34
57
  registerTool: (
35
58
  tool: {
@@ -45,6 +68,18 @@ interface PluginApi {
45
68
  description: string;
46
69
  handler: (args: string[]) => Promise<void>;
47
70
  }) => void;
71
+ registerCli?: (
72
+ registrar: (ctx: CliContext) => void | Promise<void>,
73
+ opts?: { commands?: string[] }
74
+ ) => void;
75
+ registerRpc?: (
76
+ name: string,
77
+ handler: (params: Record<string, unknown>) => Promise<unknown>
78
+ ) => void;
79
+ registerHealthCheck?: (
80
+ name: string,
81
+ check: () => Promise<HealthCheckResult>
82
+ ) => void;
48
83
  config: PluginConfig;
49
84
  log: {
50
85
  info: (msg: string) => void;
@@ -152,253 +187,226 @@ export default function register(api: PluginApi) {
152
187
  (async () => {
153
188
  const alreadyRunning = await checkServerRunning(baseUrl);
154
189
  if (alreadyRunning) {
155
- api.log.info(`Camoufox server already running at ${baseUrl}`);
190
+ api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
156
191
  } else {
157
192
  try {
158
193
  serverProcess = await startServer(pluginDir, port, api.log);
159
194
  } catch (err) {
160
- api.log.error(`Failed to auto-start server: ${(err as Error).message}`);
195
+ api.log?.error?.(`Failed to auto-start server: ${(err as Error).message}`);
161
196
  }
162
197
  }
163
198
  })();
164
199
  }
165
200
 
166
- api.registerTool(
167
- {
168
- name: "camoufox_create_tab",
169
- description:
170
- "Create a new browser tab. Returns tabId for subsequent operations.",
171
- parameters: {
172
- type: "object",
173
- properties: {
174
- userId: { type: "string", description: "User identifier for session isolation" },
175
- listItemId: { type: "string", description: "Conversation/task identifier for tab grouping" },
176
- url: { type: "string", description: "Initial URL to navigate to" },
177
- },
178
- required: ["userId", "url"],
179
- },
180
- async execute(_id, params) {
181
- const result = await fetchApi(baseUrl, "/tabs", {
182
- method: "POST",
183
- body: JSON.stringify(params),
184
- });
185
- return toToolResult(result);
201
+ api.registerTool({
202
+ name: "camoufox_create_tab",
203
+ description:
204
+ "PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use camoufox tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.",
205
+ parameters: {
206
+ type: "object",
207
+ properties: {
208
+ userId: { type: "string", description: "User identifier for session isolation" },
209
+ listItemId: { type: "string", description: "Conversation/task identifier for tab grouping" },
210
+ url: { type: "string", description: "Initial URL to navigate to" },
186
211
  },
212
+ required: ["userId", "url"],
187
213
  },
188
- { optional: true }
189
- );
190
-
191
- api.registerTool(
192
- {
193
- name: "camoufox_snapshot",
194
- description:
195
- "Get accessibility snapshot of a page with element refs (e1, e2, etc.) for interaction.",
196
- parameters: {
197
- type: "object",
198
- properties: {
199
- tabId: { type: "string", description: "Tab identifier" },
200
- userId: { type: "string", description: "User identifier" },
201
- },
202
- required: ["tabId", "userId"],
203
- },
204
- async execute(_id, params) {
205
- const { tabId, userId } = params as { tabId: string; userId: string };
206
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
207
- return toToolResult(result);
208
- },
214
+ async execute(_id, params) {
215
+ const result = await fetchApi(baseUrl, "/tabs", {
216
+ method: "POST",
217
+ body: JSON.stringify(params),
218
+ });
219
+ return toToolResult(result);
209
220
  },
210
- { optional: true }
211
- );
212
-
213
- api.registerTool(
214
- {
215
- name: "camoufox_click",
216
- description: "Click an element by ref (e.g., e1) or CSS selector.",
217
- parameters: {
218
- type: "object",
219
- properties: {
220
- tabId: { type: "string", description: "Tab identifier" },
221
- userId: { type: "string", description: "User identifier" },
222
- ref: { type: "string", description: "Element ref from snapshot (e.g., e1)" },
223
- selector: { type: "string", description: "CSS selector (alternative to ref)" },
224
- },
225
- required: ["tabId", "userId"],
226
- },
227
- async execute(_id, params) {
228
- const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
229
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
230
- method: "POST",
231
- body: JSON.stringify(body),
232
- });
233
- return toToolResult(result);
221
+ });
222
+
223
+ api.registerTool({
224
+ name: "camoufox_snapshot",
225
+ description:
226
+ "Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction. Use with camoufox_create_tab.",
227
+ parameters: {
228
+ type: "object",
229
+ properties: {
230
+ tabId: { type: "string", description: "Tab identifier" },
231
+ userId: { type: "string", description: "User identifier" },
234
232
  },
233
+ required: ["tabId", "userId"],
235
234
  },
236
- { optional: true }
237
- );
238
-
239
- api.registerTool(
240
- {
241
- name: "camoufox_type",
242
- description: "Type text into an element.",
243
- parameters: {
244
- type: "object",
245
- properties: {
246
- tabId: { type: "string", description: "Tab identifier" },
247
- userId: { type: "string", description: "User identifier" },
248
- ref: { type: "string", description: "Element ref from snapshot (e.g., e2)" },
249
- selector: { type: "string", description: "CSS selector (alternative to ref)" },
250
- text: { type: "string", description: "Text to type" },
251
- pressEnter: { type: "boolean", description: "Press Enter after typing" },
252
- },
253
- required: ["tabId", "userId", "text"],
254
- },
255
- async execute(_id, params) {
256
- const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
257
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
258
- method: "POST",
259
- body: JSON.stringify(body),
260
- });
261
- return toToolResult(result);
262
- },
235
+ async execute(_id, params) {
236
+ const { tabId, userId } = params as { tabId: string; userId: string };
237
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/snapshot?userId=${userId}`);
238
+ return toToolResult(result);
263
239
  },
264
- { optional: true }
265
- );
266
-
267
- api.registerTool(
268
- {
269
- name: "camoufox_navigate",
270
- description:
271
- "Navigate to a URL or use a search macro (@google_search, @youtube_search, etc.).",
272
- parameters: {
273
- type: "object",
274
- properties: {
275
- tabId: { type: "string", description: "Tab identifier" },
276
- userId: { type: "string", description: "User identifier" },
277
- url: { type: "string", description: "URL to navigate to" },
278
- macro: {
279
- type: "string",
280
- description: "Search macro (e.g., @google_search, @youtube_search)",
281
- enum: [
282
- "@google_search",
283
- "@youtube_search",
284
- "@amazon_search",
285
- "@reddit_search",
286
- "@wikipedia_search",
287
- "@twitter_search",
288
- "@yelp_search",
289
- "@spotify_search",
290
- "@netflix_search",
291
- "@linkedin_search",
292
- "@instagram_search",
293
- "@tiktok_search",
294
- "@twitch_search",
295
- ],
296
- },
297
- query: { type: "string", description: "Search query (when using macro)" },
298
- },
299
- required: ["tabId", "userId"],
300
- },
301
- async execute(_id, params) {
302
- const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
303
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
304
- method: "POST",
305
- body: JSON.stringify(body),
306
- });
307
- return toToolResult(result);
240
+ });
241
+
242
+ api.registerTool({
243
+ name: "camoufox_click",
244
+ description: "Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.",
245
+ parameters: {
246
+ type: "object",
247
+ properties: {
248
+ tabId: { type: "string", description: "Tab identifier" },
249
+ userId: { type: "string", description: "User identifier" },
250
+ ref: { type: "string", description: "Element ref from snapshot (e.g., e1)" },
251
+ selector: { type: "string", description: "CSS selector (alternative to ref)" },
308
252
  },
253
+ required: ["tabId", "userId"],
309
254
  },
310
- { optional: true }
311
- );
312
-
313
- api.registerTool(
314
- {
315
- name: "camoufox_scroll",
316
- description: "Scroll the page.",
317
- parameters: {
318
- type: "object",
319
- properties: {
320
- tabId: { type: "string", description: "Tab identifier" },
321
- userId: { type: "string", description: "User identifier" },
322
- direction: { type: "string", enum: ["up", "down", "left", "right"] },
323
- amount: { type: "number", description: "Pixels to scroll" },
324
- },
325
- required: ["tabId", "userId", "direction"],
326
- },
327
- async execute(_id, params) {
328
- const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
329
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
330
- method: "POST",
331
- body: JSON.stringify(body),
332
- });
333
- return toToolResult(result);
255
+ async execute(_id, params) {
256
+ const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
257
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/click`, {
258
+ method: "POST",
259
+ body: JSON.stringify(body),
260
+ });
261
+ return toToolResult(result);
262
+ },
263
+ });
264
+
265
+ api.registerTool({
266
+ name: "camoufox_type",
267
+ description: "Type text into an element in a Camoufox tab.",
268
+ parameters: {
269
+ type: "object",
270
+ properties: {
271
+ tabId: { type: "string", description: "Tab identifier" },
272
+ userId: { type: "string", description: "User identifier" },
273
+ ref: { type: "string", description: "Element ref from snapshot (e.g., e2)" },
274
+ selector: { type: "string", description: "CSS selector (alternative to ref)" },
275
+ text: { type: "string", description: "Text to type" },
276
+ pressEnter: { type: "boolean", description: "Press Enter after typing" },
334
277
  },
278
+ required: ["tabId", "userId", "text"],
279
+ },
280
+ async execute(_id, params) {
281
+ const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
282
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/type`, {
283
+ method: "POST",
284
+ body: JSON.stringify(body),
285
+ });
286
+ return toToolResult(result);
335
287
  },
336
- { optional: true }
337
- );
338
-
339
- api.registerTool(
340
- {
341
- name: "camoufox_screenshot",
342
- description: "Take a screenshot of the current page.",
343
- parameters: {
344
- type: "object",
345
- properties: {
346
- tabId: { type: "string", description: "Tab identifier" },
347
- userId: { type: "string", description: "User identifier" },
288
+ });
289
+
290
+ api.registerTool({
291
+ name: "camoufox_navigate",
292
+ description:
293
+ "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.",
294
+ parameters: {
295
+ type: "object",
296
+ properties: {
297
+ tabId: { type: "string", description: "Tab identifier" },
298
+ userId: { type: "string", description: "User identifier" },
299
+ url: { type: "string", description: "URL to navigate to" },
300
+ macro: {
301
+ type: "string",
302
+ description: "Search macro (e.g., @google_search, @youtube_search)",
303
+ enum: [
304
+ "@google_search",
305
+ "@youtube_search",
306
+ "@amazon_search",
307
+ "@reddit_search",
308
+ "@wikipedia_search",
309
+ "@twitter_search",
310
+ "@yelp_search",
311
+ "@spotify_search",
312
+ "@netflix_search",
313
+ "@linkedin_search",
314
+ "@instagram_search",
315
+ "@tiktok_search",
316
+ "@twitch_search",
317
+ ],
348
318
  },
349
- required: ["tabId", "userId"],
350
- },
351
- async execute(_id, params) {
352
- const { tabId, userId } = params as { tabId: string; userId: string };
353
- const result = await fetchApi(baseUrl, `/tabs/${tabId}/screenshot?userId=${userId}`);
354
- return toToolResult(result);
319
+ query: { type: "string", description: "Search query (when using macro)" },
355
320
  },
321
+ required: ["tabId", "userId"],
356
322
  },
357
- { optional: true }
358
- );
359
-
360
- api.registerTool(
361
- {
362
- name: "camoufox_close_tab",
363
- description: "Close a browser tab.",
364
- parameters: {
365
- type: "object",
366
- properties: {
367
- tabId: { type: "string", description: "Tab identifier" },
368
- userId: { type: "string", description: "User identifier" },
369
- },
370
- required: ["tabId", "userId"],
323
+ async execute(_id, params) {
324
+ const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
325
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/navigate`, {
326
+ method: "POST",
327
+ body: JSON.stringify(body),
328
+ });
329
+ return toToolResult(result);
330
+ },
331
+ });
332
+
333
+ api.registerTool({
334
+ name: "camoufox_scroll",
335
+ description: "Scroll a Camoufox page.",
336
+ parameters: {
337
+ type: "object",
338
+ properties: {
339
+ tabId: { type: "string", description: "Tab identifier" },
340
+ userId: { type: "string", description: "User identifier" },
341
+ direction: { type: "string", enum: ["up", "down", "left", "right"] },
342
+ amount: { type: "number", description: "Pixels to scroll" },
371
343
  },
372
- async execute(_id, params) {
373
- const { tabId, userId } = params as { tabId: string; userId: string };
374
- const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
375
- method: "DELETE",
376
- });
377
- return toToolResult(result);
344
+ required: ["tabId", "userId", "direction"],
345
+ },
346
+ async execute(_id, params) {
347
+ const { tabId, ...body } = params as { tabId: string } & Record<string, unknown>;
348
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/scroll`, {
349
+ method: "POST",
350
+ body: JSON.stringify(body),
351
+ });
352
+ return toToolResult(result);
353
+ },
354
+ });
355
+
356
+ api.registerTool({
357
+ name: "camoufox_screenshot",
358
+ description: "Take a screenshot of a Camoufox page.",
359
+ parameters: {
360
+ type: "object",
361
+ properties: {
362
+ tabId: { type: "string", description: "Tab identifier" },
363
+ userId: { type: "string", description: "User identifier" },
378
364
  },
365
+ required: ["tabId", "userId"],
379
366
  },
380
- { optional: true }
381
- );
382
-
383
- api.registerTool(
384
- {
385
- name: "camoufox_list_tabs",
386
- description: "List all open tabs for a user.",
387
- parameters: {
388
- type: "object",
389
- properties: {
390
- userId: { type: "string", description: "User identifier" },
391
- },
392
- required: ["userId"],
367
+ async execute(_id, params) {
368
+ const { tabId, userId } = params as { tabId: string; userId: string };
369
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}/screenshot?userId=${userId}`);
370
+ return toToolResult(result);
371
+ },
372
+ });
373
+
374
+ api.registerTool({
375
+ name: "camoufox_close_tab",
376
+ description: "Close a Camoufox browser tab.",
377
+ parameters: {
378
+ type: "object",
379
+ properties: {
380
+ tabId: { type: "string", description: "Tab identifier" },
381
+ userId: { type: "string", description: "User identifier" },
393
382
  },
394
- async execute(_id, params) {
395
- const { userId } = params as { userId: string };
396
- const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
397
- return toToolResult(result);
383
+ required: ["tabId", "userId"],
384
+ },
385
+ async execute(_id, params) {
386
+ const { tabId, userId } = params as { tabId: string; userId: string };
387
+ const result = await fetchApi(baseUrl, `/tabs/${tabId}?userId=${userId}`, {
388
+ method: "DELETE",
389
+ });
390
+ return toToolResult(result);
391
+ },
392
+ });
393
+
394
+ api.registerTool({
395
+ name: "camoufox_list_tabs",
396
+ description: "List all open Camoufox tabs for a user.",
397
+ parameters: {
398
+ type: "object",
399
+ properties: {
400
+ userId: { type: "string", description: "User identifier" },
398
401
  },
402
+ required: ["userId"],
403
+ },
404
+ async execute(_id, params) {
405
+ const { userId } = params as { userId: string };
406
+ const result = await fetchApi(baseUrl, `/tabs?userId=${userId}`);
407
+ return toToolResult(result);
399
408
  },
400
- { optional: true }
401
- );
409
+ });
402
410
 
403
411
  api.registerCommand({
404
412
  name: "camoufox",
@@ -409,38 +417,222 @@ export default function register(api: PluginApi) {
409
417
  case "status":
410
418
  try {
411
419
  const health = await fetchApi(baseUrl, "/health");
412
- api.log.info(`Camoufox server at ${baseUrl}: ${JSON.stringify(health)}`);
420
+ api.log?.info?.(`Camoufox server at ${baseUrl}: ${JSON.stringify(health)}`);
413
421
  } catch {
414
- api.log.error(`Camoufox server at ${baseUrl}: not reachable`);
422
+ api.log?.error?.(`Camoufox server at ${baseUrl}: not reachable`);
415
423
  }
416
424
  break;
417
425
  case "start":
418
426
  if (serverProcess) {
419
- api.log.info("Camoufox server already running (managed)");
427
+ api.log?.info?.("Camoufox server already running (managed)");
420
428
  return;
421
429
  }
422
430
  if (await checkServerRunning(baseUrl)) {
423
- api.log.info(`Camoufox server already running at ${baseUrl}`);
431
+ api.log?.info?.(`Camoufox server already running at ${baseUrl}`);
424
432
  return;
425
433
  }
426
434
  try {
427
435
  serverProcess = await startServer(pluginDir, port, api.log);
428
436
  } catch (err) {
429
- api.log.error(`Failed to start server: ${(err as Error).message}`);
437
+ api.log?.error?.(`Failed to start server: ${(err as Error).message}`);
430
438
  }
431
439
  break;
432
440
  case "stop":
433
441
  if (serverProcess) {
434
442
  serverProcess.kill();
435
443
  serverProcess = null;
436
- api.log.info("Stopped camoufox-browser server");
444
+ api.log?.info?.("Stopped camoufox-browser server");
437
445
  } else {
438
- api.log.info("No managed server process running");
446
+ api.log?.info?.("No managed server process running");
439
447
  }
440
448
  break;
441
449
  default:
442
- api.log.error(`Unknown subcommand: ${subcommand}. Use: status, start, stop`);
450
+ api.log?.error?.(`Unknown subcommand: ${subcommand}. Use: status, start, stop`);
443
451
  }
444
452
  },
445
453
  });
454
+
455
+ // Register health check for openclaw doctor/status
456
+ if (api.registerHealthCheck) {
457
+ api.registerHealthCheck("camoufox-browser", async () => {
458
+ try {
459
+ const health = (await fetchApi(baseUrl, "/health")) as {
460
+ status: string;
461
+ engine?: string;
462
+ activeTabs?: number;
463
+ };
464
+ return {
465
+ status: "ok",
466
+ message: `Server running (${health.engine || "camoufox"})`,
467
+ details: {
468
+ url: baseUrl,
469
+ engine: health.engine,
470
+ activeTabs: health.activeTabs,
471
+ managed: serverProcess !== null,
472
+ },
473
+ };
474
+ } catch {
475
+ return {
476
+ status: serverProcess ? "warn" : "error",
477
+ message: serverProcess
478
+ ? "Server starting..."
479
+ : `Server not reachable at ${baseUrl}`,
480
+ details: {
481
+ url: baseUrl,
482
+ managed: serverProcess !== null,
483
+ hint: "Run: openclaw camoufox start",
484
+ },
485
+ };
486
+ }
487
+ });
488
+ }
489
+
490
+ // Register RPC methods for gateway integration
491
+ if (api.registerRpc) {
492
+ api.registerRpc("camoufox.health", async () => {
493
+ try {
494
+ const health = await fetchApi(baseUrl, "/health");
495
+ return { status: "ok", ...health };
496
+ } catch (err) {
497
+ return { status: "error", error: (err as Error).message };
498
+ }
499
+ });
500
+
501
+ api.registerRpc("camoufox.status", async () => {
502
+ const running = await checkServerRunning(baseUrl);
503
+ return {
504
+ running,
505
+ managed: serverProcess !== null,
506
+ pid: serverProcess?.pid || null,
507
+ url: baseUrl,
508
+ port,
509
+ };
510
+ });
511
+ }
512
+
513
+ // Register CLI subcommands (openclaw camoufox ...)
514
+ if (api.registerCli) {
515
+ api.registerCli(
516
+ ({ program }) => {
517
+ const camoufox = program
518
+ .command("camoufox")
519
+ .description("Camoufox anti-detection browser automation");
520
+
521
+ camoufox
522
+ .command("status")
523
+ .description("Show server status")
524
+ .action(async () => {
525
+ try {
526
+ const health = (await fetchApi(baseUrl, "/health")) as {
527
+ status: string;
528
+ engine?: string;
529
+ activeTabs?: number;
530
+ };
531
+ console.log(`Camoufox server: ${health.status}`);
532
+ console.log(` URL: ${baseUrl}`);
533
+ console.log(` Engine: ${health.engine || "camoufox"}`);
534
+ console.log(` Active tabs: ${health.activeTabs ?? 0}`);
535
+ console.log(` Managed: ${serverProcess !== null}`);
536
+ } catch {
537
+ console.log(`Camoufox server: not reachable`);
538
+ console.log(` URL: ${baseUrl}`);
539
+ console.log(` Managed: ${serverProcess !== null}`);
540
+ console.log(` Hint: Run 'openclaw camoufox start' to start the server`);
541
+ }
542
+ });
543
+
544
+ camoufox
545
+ .command("start")
546
+ .description("Start the camoufox server")
547
+ .action(async () => {
548
+ if (serverProcess) {
549
+ console.log("Camoufox server already running (managed by plugin)");
550
+ return;
551
+ }
552
+ if (await checkServerRunning(baseUrl)) {
553
+ console.log(`Camoufox server already running at ${baseUrl}`);
554
+ return;
555
+ }
556
+ try {
557
+ console.log(`Starting camoufox server on port ${port}...`);
558
+ serverProcess = await startServer(pluginDir, port, api.log);
559
+ console.log(`Camoufox server started at ${baseUrl}`);
560
+ } catch (err) {
561
+ console.error(`Failed to start server: ${(err as Error).message}`);
562
+ process.exit(1);
563
+ }
564
+ });
565
+
566
+ camoufox
567
+ .command("stop")
568
+ .description("Stop the camoufox server")
569
+ .action(async () => {
570
+ if (serverProcess) {
571
+ serverProcess.kill();
572
+ serverProcess = null;
573
+ console.log("Stopped camoufox server");
574
+ } else {
575
+ console.log("No managed server process running");
576
+ }
577
+ });
578
+
579
+ camoufox
580
+ .command("configure")
581
+ .description("Configure camoufox plugin settings")
582
+ .action(async () => {
583
+ console.log("Camoufox Browser Configuration");
584
+ console.log("================================");
585
+ console.log("");
586
+ console.log("Current settings:");
587
+ console.log(` Server URL: ${baseUrl}`);
588
+ console.log(` Port: ${port}`);
589
+ console.log(` Auto-start: ${autoStart}`);
590
+ console.log("");
591
+ console.log("Plugin config (openclaw.json):");
592
+ console.log("");
593
+ console.log(" plugins:");
594
+ console.log(" entries:");
595
+ console.log(" camoufox-browser:");
596
+ console.log(" enabled: true");
597
+ console.log(" config:");
598
+ console.log(" port: 9377");
599
+ console.log(" autoStart: true");
600
+ console.log("");
601
+ console.log("To use camoufox as the ONLY browser tool, disable the built-in:");
602
+ console.log("");
603
+ console.log(" tools:");
604
+ console.log(' deny: ["browser"]');
605
+ console.log("");
606
+ console.log("This removes OpenClaw's built-in browser tool, leaving camoufox tools.");
607
+ });
608
+
609
+ camoufox
610
+ .command("tabs")
611
+ .description("List active browser tabs")
612
+ .option("--user <userId>", "Filter by user ID")
613
+ .action(async (opts: { user?: string }) => {
614
+ try {
615
+ const endpoint = opts.user ? `/tabs?userId=${opts.user}` : "/tabs";
616
+ const tabs = (await fetchApi(baseUrl, endpoint)) as Array<{
617
+ tabId: string;
618
+ userId: string;
619
+ url: string;
620
+ title: string;
621
+ }>;
622
+ if (tabs.length === 0) {
623
+ console.log("No active tabs");
624
+ return;
625
+ }
626
+ console.log(`Active tabs (${tabs.length}):`);
627
+ for (const tab of tabs) {
628
+ console.log(` ${tab.tabId} [${tab.userId}] ${tab.title || tab.url}`);
629
+ }
630
+ } catch (err) {
631
+ console.error(`Failed to list tabs: ${(err as Error).message}`);
632
+ }
633
+ });
634
+ },
635
+ { commands: ["camoufox"] }
636
+ );
637
+ }
446
638
  }
@@ -926,6 +926,344 @@ setInterval(() => {
926
926
  }
927
927
  }, 60_000);
928
928
 
929
+ // =============================================================================
930
+ // OpenClaw-compatible endpoint aliases
931
+ // These allow camoufox to be used as a profile backend for OpenClaw's browser tool
932
+ // =============================================================================
933
+
934
+ // GET / - Status (alias for GET /health)
935
+ app.get('/', async (req, res) => {
936
+ try {
937
+ const b = await ensureBrowser();
938
+ res.json({
939
+ ok: true,
940
+ enabled: true,
941
+ running: b.isConnected(),
942
+ engine: 'camoufox',
943
+ sessions: sessions.size,
944
+ browserConnected: b.isConnected()
945
+ });
946
+ } catch (err) {
947
+ res.status(500).json({ ok: false, error: err.message });
948
+ }
949
+ });
950
+
951
+ // GET /tabs - List all tabs (OpenClaw expects this)
952
+ app.get('/tabs', async (req, res) => {
953
+ try {
954
+ const userId = req.query.userId;
955
+ const session = sessions.get(normalizeUserId(userId));
956
+
957
+ if (!session) {
958
+ return res.json({ running: true, tabs: [] });
959
+ }
960
+
961
+ const tabs = [];
962
+ for (const [listItemId, group] of session.tabGroups) {
963
+ for (const [tabId, tabState] of group) {
964
+ tabs.push({
965
+ targetId: tabId,
966
+ tabId,
967
+ url: tabState.page.url(),
968
+ title: await tabState.page.title().catch(() => ''),
969
+ listItemId
970
+ });
971
+ }
972
+ }
973
+
974
+ res.json({ running: true, tabs });
975
+ } catch (err) {
976
+ console.error('List tabs error:', err);
977
+ res.status(500).json({ error: err.message });
978
+ }
979
+ });
980
+
981
+ // POST /tabs/open - Open tab (alias for POST /tabs, OpenClaw format)
982
+ app.post('/tabs/open', async (req, res) => {
983
+ try {
984
+ const { url, userId = 'openclaw', listItemId = 'default' } = req.body;
985
+ if (!url) {
986
+ return res.status(400).json({ error: 'url is required' });
987
+ }
988
+
989
+ const session = await getSession(userId);
990
+ const group = getTabGroup(session, listItemId);
991
+
992
+ const page = await session.context.newPage();
993
+ const tabId = crypto.randomUUID();
994
+ const tabState = createTabState(page);
995
+ group.set(tabId, tabState);
996
+
997
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
998
+ tabState.visitedUrls.add(url);
999
+
1000
+ console.log(`[OpenClaw] Tab ${tabId} opened: ${url}`);
1001
+ res.json({
1002
+ ok: true,
1003
+ targetId: tabId,
1004
+ tabId,
1005
+ url: page.url(),
1006
+ title: await page.title().catch(() => '')
1007
+ });
1008
+ } catch (err) {
1009
+ console.error('Open tab error:', err);
1010
+ res.status(500).json({ error: err.message });
1011
+ }
1012
+ });
1013
+
1014
+ // POST /start - Start browser (OpenClaw expects this)
1015
+ app.post('/start', async (req, res) => {
1016
+ try {
1017
+ await ensureBrowser();
1018
+ res.json({ ok: true, profile: 'camoufox' });
1019
+ } catch (err) {
1020
+ res.status(500).json({ ok: false, error: err.message });
1021
+ }
1022
+ });
1023
+
1024
+ // POST /stop - Stop browser (OpenClaw expects this)
1025
+ app.post('/stop', async (req, res) => {
1026
+ try {
1027
+ if (browser) {
1028
+ await browser.close().catch(() => {});
1029
+ browser = null;
1030
+ }
1031
+ sessions.clear();
1032
+ res.json({ ok: true, stopped: true, profile: 'camoufox' });
1033
+ } catch (err) {
1034
+ res.status(500).json({ ok: false, error: err.message });
1035
+ }
1036
+ });
1037
+
1038
+ // POST /navigate - Navigate (OpenClaw format with targetId in body)
1039
+ app.post('/navigate', async (req, res) => {
1040
+ try {
1041
+ const { targetId, url, userId = 'openclaw' } = req.body;
1042
+ if (!url) {
1043
+ return res.status(400).json({ error: 'url is required' });
1044
+ }
1045
+
1046
+ const session = sessions.get(normalizeUserId(userId));
1047
+ const found = session && findTab(session, targetId);
1048
+ if (!found) {
1049
+ return res.status(404).json({ error: 'Tab not found' });
1050
+ }
1051
+
1052
+ const { tabState } = found;
1053
+ tabState.toolCalls++;
1054
+
1055
+ const result = await withTabLock(targetId, async () => {
1056
+ await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
1057
+ tabState.visitedUrls.add(url);
1058
+ tabState.refs = await buildRefs(tabState.page);
1059
+ return { ok: true, targetId, url: tabState.page.url() };
1060
+ });
1061
+
1062
+ res.json(result);
1063
+ } catch (err) {
1064
+ console.error('Navigate error:', err);
1065
+ res.status(500).json({ error: err.message });
1066
+ }
1067
+ });
1068
+
1069
+ // GET /snapshot - Snapshot (OpenClaw format with query params)
1070
+ app.get('/snapshot', async (req, res) => {
1071
+ try {
1072
+ const { targetId, userId = 'openclaw', format = 'text' } = req.query;
1073
+
1074
+ const session = sessions.get(normalizeUserId(userId));
1075
+ const found = session && findTab(session, targetId);
1076
+ if (!found) {
1077
+ return res.status(404).json({ error: 'Tab not found' });
1078
+ }
1079
+
1080
+ const { tabState } = found;
1081
+ tabState.toolCalls++;
1082
+ tabState.refs = await buildRefs(tabState.page);
1083
+
1084
+ const ariaYaml = await getAriaSnapshot(tabState.page);
1085
+
1086
+ // Annotate YAML with ref IDs
1087
+ let annotatedYaml = ariaYaml || '';
1088
+ if (annotatedYaml && tabState.refs.size > 0) {
1089
+ const refsByKey = new Map();
1090
+ for (const [refId, el] of tabState.refs) {
1091
+ const key = `${el.role}:${el.name || ''}`;
1092
+ if (!refsByKey.has(key)) refsByKey.set(key, refId);
1093
+ }
1094
+
1095
+ const lines = annotatedYaml.split('\n');
1096
+ annotatedYaml = lines.map(line => {
1097
+ const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?/);
1098
+ if (match) {
1099
+ const [, indent, role, name] = match;
1100
+ const key = `${role}:${name || ''}`;
1101
+ const refId = refsByKey.get(key);
1102
+ if (refId) {
1103
+ return line.replace(/^(\s*-\s+\w+)/, `$1 [${refId}]`);
1104
+ }
1105
+ }
1106
+ return line;
1107
+ }).join('\n');
1108
+ }
1109
+
1110
+ res.json({
1111
+ ok: true,
1112
+ format: 'aria',
1113
+ targetId,
1114
+ url: tabState.page.url(),
1115
+ snapshot: annotatedYaml,
1116
+ refsCount: tabState.refs.size
1117
+ });
1118
+ } catch (err) {
1119
+ console.error('Snapshot error:', err);
1120
+ res.status(500).json({ error: err.message });
1121
+ }
1122
+ });
1123
+
1124
+ // POST /act - Combined action endpoint (OpenClaw format)
1125
+ // Routes to click/type/scroll/press/etc based on 'kind' parameter
1126
+ app.post('/act', async (req, res) => {
1127
+ try {
1128
+ const { kind, targetId, userId = 'openclaw', ...params } = req.body;
1129
+
1130
+ if (!kind) {
1131
+ return res.status(400).json({ error: 'kind is required' });
1132
+ }
1133
+
1134
+ const session = sessions.get(normalizeUserId(userId));
1135
+ const found = session && findTab(session, targetId);
1136
+ if (!found) {
1137
+ return res.status(404).json({ error: 'Tab not found' });
1138
+ }
1139
+
1140
+ const { tabState } = found;
1141
+ tabState.toolCalls++;
1142
+
1143
+ const result = await withTabLock(targetId, async () => {
1144
+ switch (kind) {
1145
+ case 'click': {
1146
+ const { ref, selector, doubleClick } = params;
1147
+ if (!ref && !selector) {
1148
+ throw new Error('ref or selector required');
1149
+ }
1150
+
1151
+ const doClick = async (locatorOrSelector, isLocator) => {
1152
+ const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
1153
+ const clickOpts = { timeout: 5000 };
1154
+ if (doubleClick) clickOpts.clickCount = 2;
1155
+
1156
+ try {
1157
+ await locator.click(clickOpts);
1158
+ } catch (err) {
1159
+ if (err.message.includes('intercepts pointer events')) {
1160
+ await locator.click({ ...clickOpts, force: true });
1161
+ } else {
1162
+ throw err;
1163
+ }
1164
+ }
1165
+ };
1166
+
1167
+ if (ref) {
1168
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1169
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1170
+ await doClick(locator, true);
1171
+ } else {
1172
+ await doClick(selector, false);
1173
+ }
1174
+
1175
+ await tabState.page.waitForTimeout(500);
1176
+ tabState.refs = await buildRefs(tabState.page);
1177
+ return { ok: true, targetId, url: tabState.page.url() };
1178
+ }
1179
+
1180
+ case 'type': {
1181
+ const { ref, selector, text, submit } = params;
1182
+ if (!ref && !selector) {
1183
+ throw new Error('ref or selector required');
1184
+ }
1185
+ if (typeof text !== 'string') {
1186
+ throw new Error('text is required');
1187
+ }
1188
+
1189
+ if (ref) {
1190
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1191
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1192
+ await locator.fill(text, { timeout: 10000 });
1193
+ if (submit) await tabState.page.keyboard.press('Enter');
1194
+ } else {
1195
+ await tabState.page.fill(selector, text, { timeout: 10000 });
1196
+ if (submit) await tabState.page.keyboard.press('Enter');
1197
+ }
1198
+ return { ok: true, targetId };
1199
+ }
1200
+
1201
+ case 'press': {
1202
+ const { key } = params;
1203
+ if (!key) throw new Error('key is required');
1204
+ await tabState.page.keyboard.press(key);
1205
+ return { ok: true, targetId };
1206
+ }
1207
+
1208
+ case 'scroll':
1209
+ case 'scrollIntoView': {
1210
+ const { ref, direction = 'down', amount = 500 } = params;
1211
+ if (ref) {
1212
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1213
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1214
+ await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
1215
+ } else {
1216
+ const delta = direction === 'up' ? -amount : amount;
1217
+ await tabState.page.mouse.wheel(0, delta);
1218
+ }
1219
+ await tabState.page.waitForTimeout(300);
1220
+ return { ok: true, targetId };
1221
+ }
1222
+
1223
+ case 'hover': {
1224
+ const { ref, selector } = params;
1225
+ if (!ref && !selector) throw new Error('ref or selector required');
1226
+
1227
+ if (ref) {
1228
+ const locator = refToLocator(tabState.page, ref, tabState.refs);
1229
+ if (!locator) throw new Error(`Unknown ref: ${ref}`);
1230
+ await locator.hover({ timeout: 5000 });
1231
+ } else {
1232
+ await tabState.page.locator(selector).hover({ timeout: 5000 });
1233
+ }
1234
+ return { ok: true, targetId };
1235
+ }
1236
+
1237
+ case 'wait': {
1238
+ const { timeMs, text, loadState } = params;
1239
+ if (timeMs) {
1240
+ await tabState.page.waitForTimeout(timeMs);
1241
+ } else if (text) {
1242
+ await tabState.page.waitForSelector(`text=${text}`, { timeout: 30000 });
1243
+ } else if (loadState) {
1244
+ await tabState.page.waitForLoadState(loadState, { timeout: 30000 });
1245
+ }
1246
+ return { ok: true, targetId, url: tabState.page.url() };
1247
+ }
1248
+
1249
+ case 'close': {
1250
+ await tabState.page.close();
1251
+ found.group.delete(targetId);
1252
+ return { ok: true, targetId };
1253
+ }
1254
+
1255
+ default:
1256
+ throw new Error(`Unsupported action kind: ${kind}`);
1257
+ }
1258
+ });
1259
+
1260
+ res.json(result);
1261
+ } catch (err) {
1262
+ console.error('Act error:', err);
1263
+ res.status(500).json({ error: err.message });
1264
+ }
1265
+ });
1266
+
929
1267
  // Graceful shutdown
930
1268
  process.on('SIGTERM', async () => {
931
1269
  console.log('Shutting down...');
@@ -936,7 +1274,7 @@ process.on('SIGTERM', async () => {
936
1274
  process.exit(0);
937
1275
  });
938
1276
 
939
- const PORT = process.env.PORT || 3000;
1277
+ const PORT = process.env.PORT || 9377;
940
1278
  app.listen(PORT, async () => {
941
1279
  console.log(`🦊 camoufox-browser listening on port ${PORT}`);
942
1280
  // Pre-launch browser so it's ready for first request