@askjo/camoufox-browser 1.0.9 → 1.0.12

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