@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 +4 -4
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +13 -5
- package/package.json +1 -1
- package/plugin.ts +425 -224
- package/server-camoufox.js +350 -8
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", "
|
|
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 (
|
|
162
|
+
├── Tab Group (sessionKey: "conv1") - conversation A
|
|
163
163
|
│ ├── Tab (google.com)
|
|
164
164
|
│ └── Tab (github.com)
|
|
165
|
-
└── Tab Group (
|
|
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 `
|
|
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", "
|
|
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.
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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":
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
type: "
|
|
274
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
type: "
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
329
|
+
query: { type: "string", description: "Search query (when using macro)" },
|
|
371
330
|
},
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
}
|
package/server-camoufox.js
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
369
|
-
|
|
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,
|
|
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},
|
|
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 ||
|
|
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
|