@browsercash/chase 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +14 -0
- package/.dockerignore +34 -0
- package/README.md +256 -0
- package/api-1 (3).json +831 -0
- package/dist/browser-cash.js +128 -0
- package/dist/claude-runner.js +285 -0
- package/dist/cli-install.js +104 -0
- package/dist/cli.js +503 -0
- package/dist/codegen/bash-generator.js +104 -0
- package/dist/config.js +112 -0
- package/dist/errors/error-classifier.js +351 -0
- package/dist/hooks/capture-hook.js +57 -0
- package/dist/index.js +180 -0
- package/dist/iterative-tester.js +407 -0
- package/dist/logger/command-log.js +38 -0
- package/dist/prompts/agentic-prompt.js +78 -0
- package/dist/prompts/fix-prompt.js +477 -0
- package/dist/prompts/helpers.js +214 -0
- package/dist/prompts/system-prompt.js +282 -0
- package/dist/script-runner.js +429 -0
- package/dist/server.js +1934 -0
- package/dist/types/iteration-history.js +139 -0
- package/openapi.json +1131 -0
- package/package.json +44 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as crypto from 'crypto';
|
|
8
|
+
import { Storage } from '@google-cloud/storage';
|
|
9
|
+
import { loadConfig } from './config.js';
|
|
10
|
+
import { runClaudeForScriptGeneration } from './claude-runner.js';
|
|
11
|
+
import { runIterativeTest } from './iterative-tester.js';
|
|
12
|
+
import { writeScript } from './codegen/bash-generator.js';
|
|
13
|
+
import { getSystemPrompt } from './prompts/system-prompt.js';
|
|
14
|
+
import { getAgenticPrompt } from './prompts/agentic-prompt.js';
|
|
15
|
+
import { createAndWaitForSession, stopBrowserSession, } from './browser-cash.js';
|
|
16
|
+
// Google Cloud Storage setup
|
|
17
|
+
const storage = new Storage();
|
|
18
|
+
const BUCKET_NAME = process.env.GCS_BUCKET || 'claude-gen-scripts';
|
|
19
|
+
/**
|
|
20
|
+
* Hash an API key to create a user namespace (ownerId).
|
|
21
|
+
* We use a truncated SHA-256 hash to avoid storing the raw key.
|
|
22
|
+
*/
|
|
23
|
+
function hashApiKey(apiKey) {
|
|
24
|
+
return crypto.createHash('sha256').update(apiKey).digest('hex').substring(0, 16);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Upload a script to GCS and return its metadata
|
|
28
|
+
*/
|
|
29
|
+
async function uploadScript(scriptContent, task, iterations, success, ownerId) {
|
|
30
|
+
const id = `script-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
|
|
31
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
32
|
+
// Upload the script
|
|
33
|
+
const scriptFile = bucket.file(`scripts/${id}.sh`);
|
|
34
|
+
await scriptFile.save(scriptContent, {
|
|
35
|
+
contentType: 'application/x-sh',
|
|
36
|
+
metadata: {
|
|
37
|
+
task,
|
|
38
|
+
ownerId,
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
iterations: iterations.toString(),
|
|
41
|
+
success: success.toString(),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
// Save metadata separately for easy listing
|
|
45
|
+
const metadata = {
|
|
46
|
+
id,
|
|
47
|
+
ownerId,
|
|
48
|
+
task,
|
|
49
|
+
createdAt: new Date().toISOString(),
|
|
50
|
+
iterations,
|
|
51
|
+
success,
|
|
52
|
+
scriptSize: scriptContent.length,
|
|
53
|
+
};
|
|
54
|
+
const metaFile = bucket.file(`metadata/${id}.json`);
|
|
55
|
+
await metaFile.save(JSON.stringify(metadata, null, 2), {
|
|
56
|
+
contentType: 'application/json',
|
|
57
|
+
});
|
|
58
|
+
return metadata;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get a script from GCS, verifying ownership
|
|
62
|
+
*/
|
|
63
|
+
async function getScript(id, ownerId) {
|
|
64
|
+
try {
|
|
65
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
66
|
+
const [metaContent] = await bucket.file(`metadata/${id}.json`).download();
|
|
67
|
+
const metadata = JSON.parse(metaContent.toString());
|
|
68
|
+
// Verify ownership
|
|
69
|
+
if (metadata.ownerId !== ownerId) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const [scriptContent] = await bucket.file(`scripts/${id}.sh`).download();
|
|
73
|
+
return {
|
|
74
|
+
content: scriptContent.toString(),
|
|
75
|
+
metadata,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* List scripts from GCS filtered by ownerId
|
|
84
|
+
*/
|
|
85
|
+
async function listScripts(ownerId, limit = 50) {
|
|
86
|
+
try {
|
|
87
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
88
|
+
// Get more files than limit to account for filtering
|
|
89
|
+
const [files] = await bucket.getFiles({ prefix: 'metadata/', maxResults: limit * 3 });
|
|
90
|
+
const scripts = [];
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
try {
|
|
93
|
+
const [content] = await file.download();
|
|
94
|
+
const metadata = JSON.parse(content.toString());
|
|
95
|
+
// Only include scripts owned by this user
|
|
96
|
+
if (metadata.ownerId === ownerId) {
|
|
97
|
+
scripts.push(metadata);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Skip invalid files
|
|
102
|
+
}
|
|
103
|
+
// Stop if we have enough
|
|
104
|
+
if (scripts.length >= limit)
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// Sort by createdAt descending
|
|
108
|
+
scripts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
109
|
+
return scripts.slice(0, limit);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Generate a unique task ID
|
|
117
|
+
*/
|
|
118
|
+
function generateTaskId() {
|
|
119
|
+
const timestamp = Date.now().toString(36);
|
|
120
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
121
|
+
return `task-${timestamp}-${random}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Save a task record to GCS
|
|
125
|
+
*/
|
|
126
|
+
async function saveTask(task) {
|
|
127
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
128
|
+
const file = bucket.file(`tasks/${task.taskId}.json`);
|
|
129
|
+
await file.save(JSON.stringify(task, null, 2), {
|
|
130
|
+
contentType: 'application/json',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Get a task record from GCS, verifying ownership
|
|
135
|
+
*/
|
|
136
|
+
async function getTask(taskId, ownerId) {
|
|
137
|
+
try {
|
|
138
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
139
|
+
const [content] = await bucket.file(`tasks/${taskId}.json`).download();
|
|
140
|
+
const task = JSON.parse(content.toString());
|
|
141
|
+
// Verify ownership
|
|
142
|
+
if (task.ownerId !== ownerId) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return task;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* List recent tasks from GCS filtered by ownerId
|
|
153
|
+
*/
|
|
154
|
+
async function listTasks(ownerId, limit = 50) {
|
|
155
|
+
try {
|
|
156
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
157
|
+
// Get more files than limit to account for filtering
|
|
158
|
+
const [files] = await bucket.getFiles({ prefix: 'tasks/', maxResults: limit * 3 });
|
|
159
|
+
const tasks = [];
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
try {
|
|
162
|
+
const [content] = await file.download();
|
|
163
|
+
const task = JSON.parse(content.toString());
|
|
164
|
+
// Only include tasks owned by this user
|
|
165
|
+
if (task.ownerId === ownerId) {
|
|
166
|
+
tasks.push(task);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Skip invalid files
|
|
171
|
+
}
|
|
172
|
+
// Stop if we have enough
|
|
173
|
+
if (tasks.length >= limit)
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
// Sort by createdAt descending
|
|
177
|
+
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
178
|
+
return tasks.slice(0, limit);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Create Fastify instance
|
|
185
|
+
const server = Fastify({
|
|
186
|
+
logger: true,
|
|
187
|
+
});
|
|
188
|
+
// Health check endpoint
|
|
189
|
+
server.get('/health', async (_request, _reply) => {
|
|
190
|
+
return {
|
|
191
|
+
status: 'ok',
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
version: '1.0.0',
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
// ============================================
|
|
197
|
+
// Task Status Endpoints
|
|
198
|
+
// ============================================
|
|
199
|
+
// Get a specific task's status and result
|
|
200
|
+
server.get('/tasks/:taskId', async (request, reply) => {
|
|
201
|
+
const { taskId } = request.params;
|
|
202
|
+
const apiKey = request.headers['x-api-key'] || request.query.apiKey;
|
|
203
|
+
if (!apiKey) {
|
|
204
|
+
reply.code(401);
|
|
205
|
+
return { error: 'API key required (x-api-key header or apiKey query param)' };
|
|
206
|
+
}
|
|
207
|
+
const ownerId = hashApiKey(apiKey);
|
|
208
|
+
try {
|
|
209
|
+
const task = await getTask(taskId, ownerId);
|
|
210
|
+
if (!task) {
|
|
211
|
+
reply.code(404);
|
|
212
|
+
return { error: 'Task not found' };
|
|
213
|
+
}
|
|
214
|
+
return task;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
218
|
+
reply.code(500);
|
|
219
|
+
return { error: message };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// List recent tasks
|
|
223
|
+
server.get('/tasks', async (request, reply) => {
|
|
224
|
+
const apiKey = request.headers['x-api-key'] || request.query.apiKey;
|
|
225
|
+
if (!apiKey) {
|
|
226
|
+
reply.code(401);
|
|
227
|
+
return { error: 'API key required (x-api-key header or apiKey query param)' };
|
|
228
|
+
}
|
|
229
|
+
const ownerId = hashApiKey(apiKey);
|
|
230
|
+
try {
|
|
231
|
+
const tasks = await listTasks(ownerId);
|
|
232
|
+
return { tasks };
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
236
|
+
reply.code(500);
|
|
237
|
+
return { error: message };
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
/**
|
|
241
|
+
* Run a single agent-browser command and return stdout/stderr
|
|
242
|
+
*/
|
|
243
|
+
function runAgentBrowserCommand(cdpUrl, command, args, timeoutMs = 30000) {
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
const cmd = `agent-browser --cdp "${cdpUrl}" ${command} ${args}`;
|
|
246
|
+
const proc = spawn('bash', ['-c', cmd], {
|
|
247
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
248
|
+
});
|
|
249
|
+
let stdout = '';
|
|
250
|
+
let stderr = '';
|
|
251
|
+
proc.stdout?.on('data', (data) => {
|
|
252
|
+
stdout += data.toString();
|
|
253
|
+
});
|
|
254
|
+
proc.stderr?.on('data', (data) => {
|
|
255
|
+
stderr += data.toString();
|
|
256
|
+
});
|
|
257
|
+
const timeout = setTimeout(() => {
|
|
258
|
+
proc.kill();
|
|
259
|
+
resolve({ success: false, stdout, stderr: 'Timeout', code: null });
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
proc.on('close', (code) => {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
resolve({ success: code === 0, stdout, stderr, code });
|
|
264
|
+
});
|
|
265
|
+
proc.on('error', (err) => {
|
|
266
|
+
clearTimeout(timeout);
|
|
267
|
+
resolve({ success: false, stdout, stderr: err.message, code: null });
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
server.post('/test-cdp', {
|
|
272
|
+
schema: {
|
|
273
|
+
body: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
required: ['cdpUrl'],
|
|
276
|
+
properties: {
|
|
277
|
+
cdpUrl: { type: 'string', minLength: 1 },
|
|
278
|
+
testNavigation: { type: 'boolean', default: true },
|
|
279
|
+
testUrl: { type: 'string', default: 'https://example.com' },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}, async (request, reply) => {
|
|
284
|
+
const { cdpUrl, testNavigation = true, testUrl = 'https://example.com' } = request.body;
|
|
285
|
+
const startTime = Date.now();
|
|
286
|
+
request.log.info({ cdpUrl: cdpUrl.substring(0, 50) + '...' }, 'Testing CDP connectivity');
|
|
287
|
+
const diagnostics = {
|
|
288
|
+
cdpConnected: false,
|
|
289
|
+
browserResponsive: false,
|
|
290
|
+
canNavigate: false,
|
|
291
|
+
dnsWorking: false,
|
|
292
|
+
};
|
|
293
|
+
const timing = {
|
|
294
|
+
connectionMs: 0,
|
|
295
|
+
commandMs: 0,
|
|
296
|
+
totalMs: 0,
|
|
297
|
+
};
|
|
298
|
+
// Test 1: Basic CDP connectivity - get current page info
|
|
299
|
+
const connectStart = Date.now();
|
|
300
|
+
const infoResult = await runAgentBrowserCommand(cdpUrl, 'eval', '"JSON.stringify({title: document.title, url: location.href, userAgent: navigator.userAgent})"', 15000);
|
|
301
|
+
timing.connectionMs = Date.now() - connectStart;
|
|
302
|
+
if (infoResult.success && infoResult.stdout) {
|
|
303
|
+
diagnostics.cdpConnected = true;
|
|
304
|
+
diagnostics.browserResponsive = true;
|
|
305
|
+
try {
|
|
306
|
+
let parsed = infoResult.stdout.trim();
|
|
307
|
+
if (parsed.startsWith('"') && parsed.endsWith('"')) {
|
|
308
|
+
parsed = JSON.parse(parsed);
|
|
309
|
+
}
|
|
310
|
+
const browserInfo = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
|
311
|
+
diagnostics.browserInfo = {
|
|
312
|
+
userAgent: browserInfo.userAgent,
|
|
313
|
+
currentUrl: browserInfo.url,
|
|
314
|
+
currentTitle: browserInfo.title,
|
|
315
|
+
};
|
|
316
|
+
// Check if current URL indicates an error page
|
|
317
|
+
if (browserInfo.url?.includes('chrome-error://')) {
|
|
318
|
+
diagnostics.dnsWorking = false;
|
|
319
|
+
diagnostics.canNavigate = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
diagnostics.browserInfo = { currentTitle: infoResult.stdout.trim() };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
return {
|
|
328
|
+
success: false,
|
|
329
|
+
connected: false,
|
|
330
|
+
error: infoResult.stderr || `Failed to connect: exit code ${infoResult.code}`,
|
|
331
|
+
diagnostics,
|
|
332
|
+
timing: { ...timing, totalMs: Date.now() - startTime },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Test 2: Navigation test (if requested)
|
|
336
|
+
if (testNavigation) {
|
|
337
|
+
const navStart = Date.now();
|
|
338
|
+
// First, navigate to the test URL
|
|
339
|
+
const navResult = await runAgentBrowserCommand(cdpUrl, 'open', `"${testUrl}"`, 20000);
|
|
340
|
+
if (navResult.success) {
|
|
341
|
+
// Wait a moment for the page to load
|
|
342
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
343
|
+
// Check what URL we ended up at
|
|
344
|
+
const checkResult = await runAgentBrowserCommand(cdpUrl, 'eval', `"JSON.stringify({url: location.href, title: document.title, body: document.body?.innerText?.substring(0, 200) || ''})"`, 10000);
|
|
345
|
+
timing.navigationMs = Date.now() - navStart;
|
|
346
|
+
if (checkResult.success && checkResult.stdout) {
|
|
347
|
+
try {
|
|
348
|
+
let parsed = checkResult.stdout.trim();
|
|
349
|
+
if (parsed.startsWith('"') && parsed.endsWith('"')) {
|
|
350
|
+
parsed = JSON.parse(parsed);
|
|
351
|
+
}
|
|
352
|
+
const navInfo = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
|
353
|
+
diagnostics.navigationUrl = navInfo.url;
|
|
354
|
+
// Check for common error patterns
|
|
355
|
+
const url = navInfo.url || '';
|
|
356
|
+
const body = navInfo.body || '';
|
|
357
|
+
if (url.includes('chrome-error://') || body.includes('ERR_NAME_NOT_RESOLVED')) {
|
|
358
|
+
diagnostics.canNavigate = false;
|
|
359
|
+
diagnostics.dnsWorking = false;
|
|
360
|
+
diagnostics.navigationError = 'DNS resolution failed (ERR_NAME_NOT_RESOLVED) - browser cannot resolve domain names';
|
|
361
|
+
}
|
|
362
|
+
else if (body.includes('ERR_CONNECTION_REFUSED')) {
|
|
363
|
+
diagnostics.canNavigate = false;
|
|
364
|
+
diagnostics.dnsWorking = true;
|
|
365
|
+
diagnostics.navigationError = 'Connection refused - target server not reachable';
|
|
366
|
+
}
|
|
367
|
+
else if (body.includes('ERR_CONNECTION_TIMED_OUT')) {
|
|
368
|
+
diagnostics.canNavigate = false;
|
|
369
|
+
diagnostics.dnsWorking = true;
|
|
370
|
+
diagnostics.navigationError = 'Connection timed out - network issues';
|
|
371
|
+
}
|
|
372
|
+
else if (body.includes('ERR_')) {
|
|
373
|
+
diagnostics.canNavigate = false;
|
|
374
|
+
const errMatch = body.match(/ERR_[A-Z_]+/);
|
|
375
|
+
diagnostics.navigationError = errMatch ? errMatch[0] : 'Unknown navigation error';
|
|
376
|
+
}
|
|
377
|
+
else if (url.startsWith('http') && !url.includes('chrome-error')) {
|
|
378
|
+
diagnostics.canNavigate = true;
|
|
379
|
+
diagnostics.dnsWorking = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
diagnostics.navigationError = 'Failed to parse navigation result';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
diagnostics.navigationError = checkResult.stderr || 'Navigation check failed';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
timing.navigationMs = Date.now() - navStart;
|
|
392
|
+
diagnostics.navigationError = navResult.stderr || 'Navigation command failed';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
timing.commandMs = Date.now() - startTime - timing.connectionMs;
|
|
396
|
+
timing.totalMs = Date.now() - startTime;
|
|
397
|
+
// Overall success check
|
|
398
|
+
const overallSuccess = diagnostics.cdpConnected &&
|
|
399
|
+
(!testNavigation || diagnostics.canNavigate);
|
|
400
|
+
return {
|
|
401
|
+
success: overallSuccess,
|
|
402
|
+
connected: diagnostics.cdpConnected,
|
|
403
|
+
pageTitle: diagnostics.browserInfo?.currentTitle,
|
|
404
|
+
currentUrl: diagnostics.browserInfo?.currentUrl,
|
|
405
|
+
error: overallSuccess ? undefined : (diagnostics.navigationError || 'CDP test failed'),
|
|
406
|
+
diagnostics,
|
|
407
|
+
timing,
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
// Generate script endpoint (non-streaming)
|
|
411
|
+
server.post('/generate', {
|
|
412
|
+
schema: {
|
|
413
|
+
body: {
|
|
414
|
+
type: 'object',
|
|
415
|
+
required: ['task'],
|
|
416
|
+
properties: {
|
|
417
|
+
task: { type: 'string', minLength: 1 },
|
|
418
|
+
browserCashApiKey: { type: 'string', minLength: 1 },
|
|
419
|
+
cdpUrl: { type: 'string', minLength: 1 },
|
|
420
|
+
browserOptions: {
|
|
421
|
+
type: 'object',
|
|
422
|
+
properties: {
|
|
423
|
+
country: { type: 'string', minLength: 2, maxLength: 2 },
|
|
424
|
+
type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
|
|
425
|
+
proxyUrl: { type: 'string' },
|
|
426
|
+
windowSize: { type: 'string' },
|
|
427
|
+
adblock: { type: 'boolean' },
|
|
428
|
+
captchaSolver: { type: 'boolean' },
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
skipTest: { type: 'boolean', default: false },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
}, async (request, reply) => {
|
|
436
|
+
const { task, browserCashApiKey, cdpUrl, browserOptions, skipTest } = request.body;
|
|
437
|
+
let browserSession = null;
|
|
438
|
+
let effectiveCdpUrl;
|
|
439
|
+
// Validate that either browserCashApiKey or cdpUrl is provided
|
|
440
|
+
if (!browserCashApiKey && !cdpUrl) {
|
|
441
|
+
reply.code(400);
|
|
442
|
+
return {
|
|
443
|
+
success: false,
|
|
444
|
+
error: 'Either browserCashApiKey or cdpUrl is required',
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
if (cdpUrl) {
|
|
449
|
+
// Use direct CDP URL
|
|
450
|
+
effectiveCdpUrl = cdpUrl;
|
|
451
|
+
}
|
|
452
|
+
else if (browserCashApiKey) {
|
|
453
|
+
// Create browser session via Browser.cash
|
|
454
|
+
const sessionResult = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
|
|
455
|
+
browserSession = { sessionId: sessionResult.session.sessionId, cdpUrl: sessionResult.cdpUrl };
|
|
456
|
+
effectiveCdpUrl = sessionResult.cdpUrl;
|
|
457
|
+
}
|
|
458
|
+
// Load configuration with effective CDP URL
|
|
459
|
+
const config = loadConfig({
|
|
460
|
+
cdpUrl: effectiveCdpUrl,
|
|
461
|
+
taskDescription: task,
|
|
462
|
+
});
|
|
463
|
+
request.log.info({ task, skipTest }, 'Starting script generation');
|
|
464
|
+
// Generate script using Claude
|
|
465
|
+
const result = await runClaudeForScriptGeneration(task, config);
|
|
466
|
+
if (!result.success || !result.script) {
|
|
467
|
+
request.log.error({ error: result.error }, 'Failed to generate script');
|
|
468
|
+
// Clean up browser session on error (only if we created one)
|
|
469
|
+
if (browserSession && browserCashApiKey) {
|
|
470
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
reply.code(400);
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
error: result.error || 'Failed to generate script - no valid script in Claude output',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
request.log.info('Script generated successfully');
|
|
479
|
+
// Optionally run iterative testing
|
|
480
|
+
if (!skipTest) {
|
|
481
|
+
request.log.info('Starting iterative testing');
|
|
482
|
+
const testResult = await runIterativeTest(result.script, task, config);
|
|
483
|
+
if (testResult.skippedDueToStaleCdp) {
|
|
484
|
+
request.log.warn('Testing skipped due to stale CDP connection');
|
|
485
|
+
// Clean up browser session (only if we created one)
|
|
486
|
+
if (browserSession && browserCashApiKey) {
|
|
487
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
success: false,
|
|
491
|
+
script: testResult.scriptContent,
|
|
492
|
+
iterations: testResult.iterations,
|
|
493
|
+
scriptPath: testResult.finalScriptPath,
|
|
494
|
+
skippedDueToStaleCdp: true,
|
|
495
|
+
error: 'CDP connection unavailable - script generated but not tested',
|
|
496
|
+
browserSessionId: browserSession?.sessionId,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
// Clean up browser session (only if we created one)
|
|
500
|
+
if (browserSession && browserCashApiKey) {
|
|
501
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
success: testResult.success,
|
|
505
|
+
script: testResult.scriptContent,
|
|
506
|
+
iterations: testResult.iterations,
|
|
507
|
+
scriptPath: testResult.finalScriptPath,
|
|
508
|
+
error: testResult.success ? undefined : testResult.lastError,
|
|
509
|
+
browserSessionId: browserSession?.sessionId,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// Skip testing - just write and return the script
|
|
513
|
+
const scriptPath = writeScript(result.script, {
|
|
514
|
+
cdpUrl: config.cdpUrl,
|
|
515
|
+
outputDir: config.outputDir,
|
|
516
|
+
});
|
|
517
|
+
// Clean up browser session (only if we created one)
|
|
518
|
+
if (browserSession && browserCashApiKey) {
|
|
519
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
success: true,
|
|
523
|
+
script: result.script,
|
|
524
|
+
scriptPath,
|
|
525
|
+
browserSessionId: browserSession?.sessionId,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
// Clean up browser session on error (only if we created one)
|
|
530
|
+
if (browserSession && browserCashApiKey) {
|
|
531
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId).catch(() => { });
|
|
532
|
+
}
|
|
533
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
534
|
+
request.log.error({ error: message }, 'Request failed');
|
|
535
|
+
reply.code(500);
|
|
536
|
+
return {
|
|
537
|
+
success: false,
|
|
538
|
+
error: message,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
// SSE Streaming endpoint for real-time logs
|
|
543
|
+
server.post('/generate/stream', {
|
|
544
|
+
schema: {
|
|
545
|
+
body: {
|
|
546
|
+
type: 'object',
|
|
547
|
+
required: ['task'],
|
|
548
|
+
properties: {
|
|
549
|
+
task: { type: 'string', minLength: 1 },
|
|
550
|
+
browserCashApiKey: { type: 'string', minLength: 1 },
|
|
551
|
+
cdpUrl: { type: 'string', minLength: 1 },
|
|
552
|
+
browserOptions: {
|
|
553
|
+
type: 'object',
|
|
554
|
+
properties: {
|
|
555
|
+
country: { type: 'string', minLength: 2, maxLength: 2 },
|
|
556
|
+
type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
|
|
557
|
+
proxyUrl: { type: 'string' },
|
|
558
|
+
windowSize: { type: 'string' },
|
|
559
|
+
adblock: { type: 'boolean' },
|
|
560
|
+
captchaSolver: { type: 'boolean' },
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
skipTest: { type: 'boolean', default: false },
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
}, async (request, reply) => {
|
|
568
|
+
const { task, browserCashApiKey, cdpUrl, browserOptions, skipTest } = request.body;
|
|
569
|
+
// Validate that either browserCashApiKey or cdpUrl is provided
|
|
570
|
+
if (!browserCashApiKey && !cdpUrl) {
|
|
571
|
+
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
|
572
|
+
reply.raw.end(JSON.stringify({ error: 'Either browserCashApiKey or cdpUrl is required' }));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// Hash API key to create owner ID (use a placeholder for direct CDP URL users)
|
|
576
|
+
const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
|
|
577
|
+
// Create task record for persistent storage
|
|
578
|
+
const taskId = generateTaskId();
|
|
579
|
+
const taskRecord = {
|
|
580
|
+
taskId,
|
|
581
|
+
ownerId,
|
|
582
|
+
type: 'generate',
|
|
583
|
+
status: 'pending',
|
|
584
|
+
task,
|
|
585
|
+
createdAt: new Date().toISOString(),
|
|
586
|
+
updatedAt: new Date().toISOString(),
|
|
587
|
+
};
|
|
588
|
+
// Set SSE headers
|
|
589
|
+
reply.raw.writeHead(200, {
|
|
590
|
+
'Content-Type': 'text/event-stream',
|
|
591
|
+
'Cache-Control': 'no-cache',
|
|
592
|
+
'Connection': 'keep-alive',
|
|
593
|
+
'Access-Control-Allow-Origin': '*',
|
|
594
|
+
});
|
|
595
|
+
const sendEvent = (type, data) => {
|
|
596
|
+
const event = {
|
|
597
|
+
type,
|
|
598
|
+
data,
|
|
599
|
+
timestamp: new Date().toISOString(),
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Client may have disconnected, ignore write errors
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
// Browser session manager for cleanup
|
|
609
|
+
let browserSession = null;
|
|
610
|
+
let effectiveCdpUrl;
|
|
611
|
+
try {
|
|
612
|
+
// Save initial task record
|
|
613
|
+
await saveTask(taskRecord);
|
|
614
|
+
// Set up browser - either direct CDP URL or create Browser.cash session
|
|
615
|
+
if (cdpUrl) {
|
|
616
|
+
// Use direct CDP URL
|
|
617
|
+
effectiveCdpUrl = cdpUrl;
|
|
618
|
+
sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
|
|
619
|
+
}
|
|
620
|
+
else if (browserCashApiKey) {
|
|
621
|
+
// Create browser session via Browser.cash
|
|
622
|
+
sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
|
|
623
|
+
try {
|
|
624
|
+
const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
|
|
625
|
+
effectiveCdpUrl = result.cdpUrl;
|
|
626
|
+
browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
|
|
627
|
+
taskRecord.browserSessionId = result.session.sessionId;
|
|
628
|
+
sendEvent('log', {
|
|
629
|
+
message: `Browser session created: ${result.session.sessionId}`,
|
|
630
|
+
level: 'info',
|
|
631
|
+
browserSessionId: result.session.sessionId,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
|
636
|
+
taskRecord.status = 'error';
|
|
637
|
+
taskRecord.error = errorMsg;
|
|
638
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
639
|
+
await saveTask(taskRecord);
|
|
640
|
+
sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
|
|
641
|
+
reply.raw.end();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Update task to running
|
|
646
|
+
taskRecord.status = 'running';
|
|
647
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
648
|
+
await saveTask(taskRecord);
|
|
649
|
+
// Load configuration
|
|
650
|
+
const config = loadConfig({
|
|
651
|
+
cdpUrl: effectiveCdpUrl,
|
|
652
|
+
taskDescription: task,
|
|
653
|
+
});
|
|
654
|
+
sendEvent('start', {
|
|
655
|
+
taskId,
|
|
656
|
+
task,
|
|
657
|
+
model: config.model,
|
|
658
|
+
skipTest,
|
|
659
|
+
browserSessionId: browserSession?.sessionId,
|
|
660
|
+
});
|
|
661
|
+
// Run streaming script generation
|
|
662
|
+
const result = await runClaudeStreamingGeneration(task, config, sendEvent);
|
|
663
|
+
if (!result.success || !result.script) {
|
|
664
|
+
const errorMsg = result.error || 'Failed to generate script';
|
|
665
|
+
taskRecord.status = 'error';
|
|
666
|
+
taskRecord.error = errorMsg;
|
|
667
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
668
|
+
await saveTask(taskRecord);
|
|
669
|
+
sendEvent('error', { message: errorMsg, phase: 'generation', taskId });
|
|
670
|
+
reply.raw.end();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
sendEvent('script_extracted', {
|
|
674
|
+
scriptPreview: result.script.substring(0, 500) + '...',
|
|
675
|
+
scriptLength: result.script.length
|
|
676
|
+
});
|
|
677
|
+
// Run iterative testing if not skipped
|
|
678
|
+
if (!skipTest) {
|
|
679
|
+
const testResult = await runStreamingIterativeTest(result.script, task, config, sendEvent);
|
|
680
|
+
// Save to GCS if successful
|
|
681
|
+
let savedScript = null;
|
|
682
|
+
if (testResult.success) {
|
|
683
|
+
try {
|
|
684
|
+
savedScript = await uploadScript(testResult.scriptContent, task, testResult.iterations, testResult.success, ownerId);
|
|
685
|
+
sendEvent('script_saved', { scriptId: savedScript.id, metadata: savedScript });
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
sendEvent('log', {
|
|
689
|
+
message: `Failed to save script to storage: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
690
|
+
level: 'warn'
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Update task record with final result
|
|
695
|
+
taskRecord.status = testResult.success ? 'completed' : 'error';
|
|
696
|
+
taskRecord.script = testResult.scriptContent;
|
|
697
|
+
taskRecord.iterations = testResult.iterations;
|
|
698
|
+
taskRecord.scriptId = savedScript?.id;
|
|
699
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
700
|
+
if (!testResult.success) {
|
|
701
|
+
taskRecord.error = 'Script testing failed';
|
|
702
|
+
}
|
|
703
|
+
await saveTask(taskRecord);
|
|
704
|
+
sendEvent('complete', {
|
|
705
|
+
taskId,
|
|
706
|
+
success: testResult.success,
|
|
707
|
+
script: testResult.scriptContent,
|
|
708
|
+
iterations: testResult.iterations,
|
|
709
|
+
skippedDueToStaleCdp: testResult.skippedDueToStaleCdp,
|
|
710
|
+
scriptId: savedScript?.id,
|
|
711
|
+
browserSessionId: browserSession?.sessionId,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
// Skip testing - just return the script
|
|
716
|
+
const scriptPath = writeScript(result.script, {
|
|
717
|
+
cdpUrl: config.cdpUrl,
|
|
718
|
+
outputDir: config.outputDir,
|
|
719
|
+
});
|
|
720
|
+
// Save to GCS
|
|
721
|
+
let savedScript = null;
|
|
722
|
+
try {
|
|
723
|
+
savedScript = await uploadScript(result.script, task, 0, true, ownerId);
|
|
724
|
+
sendEvent('script_saved', { scriptId: savedScript.id, metadata: savedScript });
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
sendEvent('log', {
|
|
728
|
+
message: `Failed to save script to storage: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
729
|
+
level: 'warn'
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
// Update task record with final result
|
|
733
|
+
taskRecord.status = 'completed';
|
|
734
|
+
taskRecord.script = result.script;
|
|
735
|
+
taskRecord.iterations = 0;
|
|
736
|
+
taskRecord.scriptId = savedScript?.id;
|
|
737
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
738
|
+
await saveTask(taskRecord);
|
|
739
|
+
sendEvent('complete', {
|
|
740
|
+
taskId,
|
|
741
|
+
success: true,
|
|
742
|
+
script: result.script,
|
|
743
|
+
scriptPath,
|
|
744
|
+
scriptId: savedScript?.id,
|
|
745
|
+
browserSessionId: browserSession?.sessionId,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
751
|
+
taskRecord.status = 'error';
|
|
752
|
+
taskRecord.error = message;
|
|
753
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
754
|
+
await saveTask(taskRecord);
|
|
755
|
+
sendEvent('error', { message, phase: 'unknown', taskId });
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
// Clean up browser session (only if we created one)
|
|
759
|
+
if (browserSession && browserCashApiKey) {
|
|
760
|
+
try {
|
|
761
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
|
|
762
|
+
sendEvent('log', { message: 'Browser session stopped', level: 'info' });
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
// Ignore cleanup errors
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
reply.raw.end();
|
|
770
|
+
});
|
|
771
|
+
// SSE Streaming endpoint for agentic automation (direct execution, no script generation)
|
|
772
|
+
server.post('/automate/stream', {
|
|
773
|
+
schema: {
|
|
774
|
+
body: {
|
|
775
|
+
type: 'object',
|
|
776
|
+
required: ['task'],
|
|
777
|
+
properties: {
|
|
778
|
+
task: { type: 'string', minLength: 1 },
|
|
779
|
+
browserCashApiKey: { type: 'string', minLength: 1 },
|
|
780
|
+
cdpUrl: { type: 'string', minLength: 1 },
|
|
781
|
+
browserOptions: {
|
|
782
|
+
type: 'object',
|
|
783
|
+
properties: {
|
|
784
|
+
country: { type: 'string', minLength: 2, maxLength: 2 },
|
|
785
|
+
type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
|
|
786
|
+
proxyUrl: { type: 'string' },
|
|
787
|
+
windowSize: { type: 'string' },
|
|
788
|
+
adblock: { type: 'boolean' },
|
|
789
|
+
captchaSolver: { type: 'boolean' },
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
}, async (request, reply) => {
|
|
796
|
+
const { task, browserCashApiKey, cdpUrl, browserOptions } = request.body;
|
|
797
|
+
// Validate that either browserCashApiKey or cdpUrl is provided
|
|
798
|
+
if (!browserCashApiKey && !cdpUrl) {
|
|
799
|
+
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
|
800
|
+
reply.raw.end(JSON.stringify({ error: 'Either browserCashApiKey or cdpUrl is required' }));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
// Hash API key to create owner ID (use a placeholder for direct CDP URL users)
|
|
804
|
+
const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
|
|
805
|
+
// Create task record for persistent storage
|
|
806
|
+
const taskId = generateTaskId();
|
|
807
|
+
const taskRecord = {
|
|
808
|
+
taskId,
|
|
809
|
+
ownerId,
|
|
810
|
+
type: 'automate',
|
|
811
|
+
status: 'pending',
|
|
812
|
+
task,
|
|
813
|
+
createdAt: new Date().toISOString(),
|
|
814
|
+
updatedAt: new Date().toISOString(),
|
|
815
|
+
};
|
|
816
|
+
// Set SSE headers
|
|
817
|
+
reply.raw.writeHead(200, {
|
|
818
|
+
'Content-Type': 'text/event-stream',
|
|
819
|
+
'Cache-Control': 'no-cache',
|
|
820
|
+
'Connection': 'keep-alive',
|
|
821
|
+
'Access-Control-Allow-Origin': '*',
|
|
822
|
+
});
|
|
823
|
+
const sendEvent = (type, data) => {
|
|
824
|
+
const event = {
|
|
825
|
+
type,
|
|
826
|
+
data,
|
|
827
|
+
timestamp: new Date().toISOString(),
|
|
828
|
+
};
|
|
829
|
+
try {
|
|
830
|
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// Client may have disconnected, ignore write errors
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
// Browser session manager for cleanup
|
|
837
|
+
let browserSession = null;
|
|
838
|
+
let effectiveCdpUrl;
|
|
839
|
+
try {
|
|
840
|
+
// Save initial task record
|
|
841
|
+
await saveTask(taskRecord);
|
|
842
|
+
// Set up browser - either direct CDP URL or create Browser.cash session
|
|
843
|
+
if (cdpUrl) {
|
|
844
|
+
// Use direct CDP URL
|
|
845
|
+
effectiveCdpUrl = cdpUrl;
|
|
846
|
+
sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
|
|
847
|
+
}
|
|
848
|
+
else if (browserCashApiKey) {
|
|
849
|
+
// Create browser session via Browser.cash
|
|
850
|
+
sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
|
|
851
|
+
try {
|
|
852
|
+
const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
|
|
853
|
+
effectiveCdpUrl = result.cdpUrl;
|
|
854
|
+
browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
|
|
855
|
+
taskRecord.browserSessionId = result.session.sessionId;
|
|
856
|
+
sendEvent('log', {
|
|
857
|
+
message: `Browser session created: ${result.session.sessionId}`,
|
|
858
|
+
level: 'info',
|
|
859
|
+
browserSessionId: result.session.sessionId,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
|
864
|
+
taskRecord.status = 'error';
|
|
865
|
+
taskRecord.error = errorMsg;
|
|
866
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
867
|
+
await saveTask(taskRecord);
|
|
868
|
+
sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
|
|
869
|
+
reply.raw.end();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// Update task to running
|
|
874
|
+
taskRecord.status = 'running';
|
|
875
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
876
|
+
await saveTask(taskRecord);
|
|
877
|
+
// Load configuration
|
|
878
|
+
const config = loadConfig({
|
|
879
|
+
cdpUrl: effectiveCdpUrl,
|
|
880
|
+
taskDescription: task,
|
|
881
|
+
});
|
|
882
|
+
sendEvent('start', {
|
|
883
|
+
taskId,
|
|
884
|
+
task,
|
|
885
|
+
mode: 'agentic',
|
|
886
|
+
model: config.model,
|
|
887
|
+
browserSessionId: browserSession?.sessionId,
|
|
888
|
+
});
|
|
889
|
+
// Run agentic automation
|
|
890
|
+
const result = await runAgenticAutomation(task, config, sendEvent);
|
|
891
|
+
// Update task record with final result
|
|
892
|
+
taskRecord.status = result.success ? 'completed' : 'error';
|
|
893
|
+
taskRecord.result = result.result;
|
|
894
|
+
taskRecord.summary = result.summary;
|
|
895
|
+
taskRecord.error = result.error;
|
|
896
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
897
|
+
await saveTask(taskRecord);
|
|
898
|
+
sendEvent('complete', {
|
|
899
|
+
taskId,
|
|
900
|
+
success: result.success,
|
|
901
|
+
result: result.result,
|
|
902
|
+
summary: result.summary,
|
|
903
|
+
error: result.error,
|
|
904
|
+
browserSessionId: browserSession?.sessionId,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
909
|
+
taskRecord.status = 'error';
|
|
910
|
+
taskRecord.error = message;
|
|
911
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
912
|
+
await saveTask(taskRecord);
|
|
913
|
+
sendEvent('error', { message, phase: 'unknown', taskId });
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
// Clean up browser session (only if we created one)
|
|
917
|
+
if (browserSession && browserCashApiKey) {
|
|
918
|
+
try {
|
|
919
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
|
|
920
|
+
sendEvent('log', { message: 'Browser session stopped', level: 'info' });
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
// Ignore cleanup errors
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
reply.raw.end();
|
|
928
|
+
});
|
|
929
|
+
// ============================================
|
|
930
|
+
// Script Storage & Execution Endpoints
|
|
931
|
+
// ============================================
|
|
932
|
+
// List all stored scripts
|
|
933
|
+
server.get('/scripts', async (request, reply) => {
|
|
934
|
+
const apiKey = request.headers['x-api-key'] || request.query.apiKey;
|
|
935
|
+
if (!apiKey) {
|
|
936
|
+
reply.code(401);
|
|
937
|
+
return { error: 'API key required (x-api-key header or apiKey query param)' };
|
|
938
|
+
}
|
|
939
|
+
const ownerId = hashApiKey(apiKey);
|
|
940
|
+
try {
|
|
941
|
+
const scripts = await listScripts(ownerId);
|
|
942
|
+
return { scripts };
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
946
|
+
reply.code(500);
|
|
947
|
+
return { error: message };
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
// Get a specific script
|
|
951
|
+
server.get('/scripts/:id', async (request, reply) => {
|
|
952
|
+
const { id } = request.params;
|
|
953
|
+
const apiKey = request.headers['x-api-key'] || request.query.apiKey;
|
|
954
|
+
if (!apiKey) {
|
|
955
|
+
reply.code(401);
|
|
956
|
+
return { error: 'API key required (x-api-key header or apiKey query param)' };
|
|
957
|
+
}
|
|
958
|
+
const ownerId = hashApiKey(apiKey);
|
|
959
|
+
try {
|
|
960
|
+
const result = await getScript(id, ownerId);
|
|
961
|
+
if (!result) {
|
|
962
|
+
reply.code(404);
|
|
963
|
+
return { error: 'Script not found' };
|
|
964
|
+
}
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
969
|
+
reply.code(500);
|
|
970
|
+
return { error: message };
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
server.post('/scripts/:id/run', {
|
|
974
|
+
schema: {
|
|
975
|
+
body: {
|
|
976
|
+
type: 'object',
|
|
977
|
+
properties: {
|
|
978
|
+
browserCashApiKey: { type: 'string', minLength: 1 },
|
|
979
|
+
cdpUrl: { type: 'string', minLength: 1 },
|
|
980
|
+
browserOptions: {
|
|
981
|
+
type: 'object',
|
|
982
|
+
properties: {
|
|
983
|
+
country: { type: 'string', minLength: 2, maxLength: 2 },
|
|
984
|
+
type: { type: 'string', enum: ['consumer_distributed', 'hosted', 'testing'] },
|
|
985
|
+
proxyUrl: { type: 'string' },
|
|
986
|
+
windowSize: { type: 'string' },
|
|
987
|
+
adblock: { type: 'boolean' },
|
|
988
|
+
captchaSolver: { type: 'boolean' },
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
}, async (request, reply) => {
|
|
995
|
+
const { id } = request.params;
|
|
996
|
+
const { browserCashApiKey, cdpUrl, browserOptions } = request.body;
|
|
997
|
+
// Validate that either browserCashApiKey or cdpUrl is provided
|
|
998
|
+
if (!browserCashApiKey && !cdpUrl) {
|
|
999
|
+
reply.code(400);
|
|
1000
|
+
return { error: 'Either browserCashApiKey or cdpUrl is required' };
|
|
1001
|
+
}
|
|
1002
|
+
// Hash API key to create owner ID (use a placeholder for direct CDP URL users)
|
|
1003
|
+
const ownerId = browserCashApiKey ? hashApiKey(browserCashApiKey) : hashApiKey(cdpUrl);
|
|
1004
|
+
// Get the script (verifies ownership)
|
|
1005
|
+
const script = await getScript(id, ownerId);
|
|
1006
|
+
if (!script) {
|
|
1007
|
+
reply.code(404);
|
|
1008
|
+
return { error: 'Script not found' };
|
|
1009
|
+
}
|
|
1010
|
+
// Create task record for persistent storage
|
|
1011
|
+
const taskId = generateTaskId();
|
|
1012
|
+
const taskRecord = {
|
|
1013
|
+
taskId,
|
|
1014
|
+
ownerId,
|
|
1015
|
+
type: 'run_script',
|
|
1016
|
+
status: 'pending',
|
|
1017
|
+
task: script.metadata.task,
|
|
1018
|
+
scriptId: id,
|
|
1019
|
+
createdAt: new Date().toISOString(),
|
|
1020
|
+
updatedAt: new Date().toISOString(),
|
|
1021
|
+
};
|
|
1022
|
+
// Set SSE headers
|
|
1023
|
+
reply.raw.writeHead(200, {
|
|
1024
|
+
'Content-Type': 'text/event-stream',
|
|
1025
|
+
'Cache-Control': 'no-cache',
|
|
1026
|
+
'Connection': 'keep-alive',
|
|
1027
|
+
'Access-Control-Allow-Origin': '*',
|
|
1028
|
+
});
|
|
1029
|
+
const sendEvent = (type, data) => {
|
|
1030
|
+
const event = {
|
|
1031
|
+
type,
|
|
1032
|
+
data,
|
|
1033
|
+
timestamp: new Date().toISOString(),
|
|
1034
|
+
};
|
|
1035
|
+
try {
|
|
1036
|
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
// Client may have disconnected, ignore write errors
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
// Browser session manager for cleanup
|
|
1043
|
+
let browserSession = null;
|
|
1044
|
+
let effectiveCdpUrl;
|
|
1045
|
+
// Collect output for task record
|
|
1046
|
+
let collectedOutput = '';
|
|
1047
|
+
// Save initial task record
|
|
1048
|
+
await saveTask(taskRecord);
|
|
1049
|
+
// Set up browser - either direct CDP URL or create Browser.cash session
|
|
1050
|
+
if (cdpUrl) {
|
|
1051
|
+
// Use direct CDP URL
|
|
1052
|
+
effectiveCdpUrl = cdpUrl;
|
|
1053
|
+
sendEvent('log', { message: 'Using direct CDP URL', level: 'info' });
|
|
1054
|
+
}
|
|
1055
|
+
else if (browserCashApiKey) {
|
|
1056
|
+
// Create browser session via Browser.cash
|
|
1057
|
+
sendEvent('log', { message: 'Creating Browser.cash session...', level: 'info' });
|
|
1058
|
+
try {
|
|
1059
|
+
const result = await createAndWaitForSession(browserCashApiKey, browserOptions || {});
|
|
1060
|
+
effectiveCdpUrl = result.cdpUrl;
|
|
1061
|
+
browserSession = { sessionId: result.session.sessionId, cdpUrl: result.cdpUrl };
|
|
1062
|
+
taskRecord.browserSessionId = result.session.sessionId;
|
|
1063
|
+
sendEvent('log', {
|
|
1064
|
+
message: `Browser session created: ${result.session.sessionId}`,
|
|
1065
|
+
level: 'info',
|
|
1066
|
+
browserSessionId: result.session.sessionId,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
catch (err) {
|
|
1070
|
+
const errorMsg = `Failed to create browser session: ${err instanceof Error ? err.message : 'Unknown error'}`;
|
|
1071
|
+
taskRecord.status = 'error';
|
|
1072
|
+
taskRecord.error = errorMsg;
|
|
1073
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
1074
|
+
await saveTask(taskRecord);
|
|
1075
|
+
sendEvent('error', { message: errorMsg, phase: 'browser_setup', taskId });
|
|
1076
|
+
reply.raw.end();
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Update task to running
|
|
1081
|
+
taskRecord.status = 'running';
|
|
1082
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
1083
|
+
await saveTask(taskRecord);
|
|
1084
|
+
sendEvent('start', {
|
|
1085
|
+
taskId,
|
|
1086
|
+
scriptId: id,
|
|
1087
|
+
task: script.metadata.task,
|
|
1088
|
+
scriptSize: script.metadata.scriptSize,
|
|
1089
|
+
browserSessionId: browserSession?.sessionId,
|
|
1090
|
+
});
|
|
1091
|
+
// Write script to temp file
|
|
1092
|
+
const tempScript = `/tmp/run-${id}-${Date.now()}.sh`;
|
|
1093
|
+
fs.writeFileSync(tempScript, script.content);
|
|
1094
|
+
fs.chmodSync(tempScript, '755');
|
|
1095
|
+
try {
|
|
1096
|
+
// Execute the script with CDP_URL set
|
|
1097
|
+
const proc = spawn('bash', [tempScript], {
|
|
1098
|
+
env: {
|
|
1099
|
+
...process.env,
|
|
1100
|
+
CDP_URL: effectiveCdpUrl,
|
|
1101
|
+
},
|
|
1102
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1103
|
+
});
|
|
1104
|
+
proc.stdout?.on('data', (data) => {
|
|
1105
|
+
const text = data.toString();
|
|
1106
|
+
collectedOutput += text;
|
|
1107
|
+
sendEvent('output', { stream: 'stdout', text });
|
|
1108
|
+
});
|
|
1109
|
+
proc.stderr?.on('data', (data) => {
|
|
1110
|
+
const text = data.toString();
|
|
1111
|
+
collectedOutput += `[stderr] ${text}`;
|
|
1112
|
+
sendEvent('output', { stream: 'stderr', text });
|
|
1113
|
+
});
|
|
1114
|
+
proc.on('close', async (code) => {
|
|
1115
|
+
// Clean up temp file
|
|
1116
|
+
try {
|
|
1117
|
+
fs.unlinkSync(tempScript);
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
// Ignore
|
|
1121
|
+
}
|
|
1122
|
+
// Clean up browser session (only if we created one)
|
|
1123
|
+
if (browserSession && browserCashApiKey) {
|
|
1124
|
+
try {
|
|
1125
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
|
|
1126
|
+
sendEvent('log', { message: 'Browser session stopped', level: 'info' });
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// Ignore cleanup errors
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
// Update task record with final result
|
|
1133
|
+
taskRecord.status = code === 0 ? 'completed' : 'error';
|
|
1134
|
+
taskRecord.exitCode = code ?? undefined;
|
|
1135
|
+
taskRecord.output = collectedOutput;
|
|
1136
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
1137
|
+
if (code !== 0) {
|
|
1138
|
+
taskRecord.error = `Script exited with code ${code}`;
|
|
1139
|
+
}
|
|
1140
|
+
await saveTask(taskRecord);
|
|
1141
|
+
sendEvent('complete', {
|
|
1142
|
+
taskId,
|
|
1143
|
+
exitCode: code,
|
|
1144
|
+
success: code === 0,
|
|
1145
|
+
browserSessionId: browserSession?.sessionId,
|
|
1146
|
+
});
|
|
1147
|
+
reply.raw.end();
|
|
1148
|
+
});
|
|
1149
|
+
proc.on('error', async (err) => {
|
|
1150
|
+
try {
|
|
1151
|
+
fs.unlinkSync(tempScript);
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
// Ignore
|
|
1155
|
+
}
|
|
1156
|
+
// Clean up browser session (only if we created one)
|
|
1157
|
+
if (browserSession && browserCashApiKey) {
|
|
1158
|
+
try {
|
|
1159
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
|
|
1160
|
+
}
|
|
1161
|
+
catch {
|
|
1162
|
+
// Ignore cleanup errors
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
// Update task record with error
|
|
1166
|
+
taskRecord.status = 'error';
|
|
1167
|
+
taskRecord.error = err.message;
|
|
1168
|
+
taskRecord.output = collectedOutput;
|
|
1169
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
1170
|
+
await saveTask(taskRecord);
|
|
1171
|
+
sendEvent('error', { message: err.message, taskId });
|
|
1172
|
+
reply.raw.end();
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
// Clean up browser session (only if we created one)
|
|
1177
|
+
if (browserSession && browserCashApiKey) {
|
|
1178
|
+
try {
|
|
1179
|
+
await stopBrowserSession(browserCashApiKey, browserSession.sessionId);
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
// Ignore cleanup errors
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
1186
|
+
taskRecord.status = 'error';
|
|
1187
|
+
taskRecord.error = message;
|
|
1188
|
+
taskRecord.updatedAt = new Date().toISOString();
|
|
1189
|
+
await saveTask(taskRecord);
|
|
1190
|
+
sendEvent('error', { message, taskId });
|
|
1191
|
+
reply.raw.end();
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
/**
|
|
1195
|
+
* Generate a unique session ID
|
|
1196
|
+
*/
|
|
1197
|
+
function generateSessionId() {
|
|
1198
|
+
const timestamp = Date.now().toString(36);
|
|
1199
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
1200
|
+
return `session-${timestamp}-${random}`;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Streaming version of Claude script generation
|
|
1204
|
+
*/
|
|
1205
|
+
async function runClaudeStreamingGeneration(taskPrompt, config, sendEvent) {
|
|
1206
|
+
const sessionId = generateSessionId();
|
|
1207
|
+
// Ensure sessions directory exists
|
|
1208
|
+
if (!fs.existsSync(config.sessionsDir)) {
|
|
1209
|
+
fs.mkdirSync(config.sessionsDir, { recursive: true });
|
|
1210
|
+
}
|
|
1211
|
+
// Get the system prompt and combine with task
|
|
1212
|
+
const systemPrompt = getSystemPrompt(config.cdpUrl);
|
|
1213
|
+
const fullPrompt = `${systemPrompt}\n\n## Your Task\n\n${taskPrompt}`;
|
|
1214
|
+
// Write full prompt to a file
|
|
1215
|
+
const promptFile = path.join(config.sessionsDir, `${sessionId}-prompt.txt`);
|
|
1216
|
+
fs.writeFileSync(promptFile, fullPrompt);
|
|
1217
|
+
sendEvent('log', { message: `Starting Claude session: ${sessionId}`, level: 'info' });
|
|
1218
|
+
return new Promise((resolve) => {
|
|
1219
|
+
let output = '';
|
|
1220
|
+
const env = {
|
|
1221
|
+
...process.env,
|
|
1222
|
+
CDP_URL: config.cdpUrl,
|
|
1223
|
+
};
|
|
1224
|
+
const shellCmd = `cat "${promptFile}" | claude -p --model ${config.model} --max-turns ${config.maxTurns} --allowedTools "Bash" --output-format stream-json --verbose`;
|
|
1225
|
+
const claude = spawn('bash', ['-c', shellCmd], {
|
|
1226
|
+
env,
|
|
1227
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1228
|
+
detached: false,
|
|
1229
|
+
});
|
|
1230
|
+
claude.stdout?.on('data', (data) => {
|
|
1231
|
+
const text = data.toString();
|
|
1232
|
+
output += text;
|
|
1233
|
+
// Parse and stream each line
|
|
1234
|
+
const lines = text.split('\n');
|
|
1235
|
+
for (const line of lines) {
|
|
1236
|
+
if (!line.trim())
|
|
1237
|
+
continue;
|
|
1238
|
+
try {
|
|
1239
|
+
const json = JSON.parse(line);
|
|
1240
|
+
// Stream different event types
|
|
1241
|
+
if (json.type === 'assistant' && json.message?.content) {
|
|
1242
|
+
for (const block of json.message.content) {
|
|
1243
|
+
if (block.type === 'text') {
|
|
1244
|
+
sendEvent('claude_output', { type: 'text', content: block.text });
|
|
1245
|
+
}
|
|
1246
|
+
else if (block.type === 'tool_use') {
|
|
1247
|
+
sendEvent('claude_output', { type: 'tool_use', name: block.name, input: block.input });
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
else if (json.type === 'tool_result') {
|
|
1252
|
+
sendEvent('claude_output', { type: 'tool_result', content: json.content?.substring(0, 500) });
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
// Non-JSON output, log as raw
|
|
1257
|
+
if (line.trim()) {
|
|
1258
|
+
sendEvent('log', { message: line, level: 'debug' });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
claude.stderr?.on('data', (data) => {
|
|
1264
|
+
const text = data.toString();
|
|
1265
|
+
sendEvent('log', { message: text, level: 'warn' });
|
|
1266
|
+
});
|
|
1267
|
+
claude.on('close', (code) => {
|
|
1268
|
+
// Clean up temp files
|
|
1269
|
+
try {
|
|
1270
|
+
if (fs.existsSync(promptFile)) {
|
|
1271
|
+
fs.unlinkSync(promptFile);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
// Ignore cleanup errors
|
|
1276
|
+
}
|
|
1277
|
+
// Extract the script from Claude's output
|
|
1278
|
+
const script = extractScriptFromOutput(output);
|
|
1279
|
+
if (script) {
|
|
1280
|
+
sendEvent('log', { message: 'Successfully extracted script from Claude output', level: 'info' });
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
sendEvent('log', { message: 'Could not extract script from Claude output', level: 'warn' });
|
|
1284
|
+
}
|
|
1285
|
+
resolve({
|
|
1286
|
+
success: code === 0 && script !== null,
|
|
1287
|
+
script,
|
|
1288
|
+
error: code !== 0 ? `Exit code: ${code}` : undefined,
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
claude.on('error', (err) => {
|
|
1292
|
+
try {
|
|
1293
|
+
if (fs.existsSync(promptFile)) {
|
|
1294
|
+
fs.unlinkSync(promptFile);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
catch {
|
|
1298
|
+
// Ignore
|
|
1299
|
+
}
|
|
1300
|
+
resolve({
|
|
1301
|
+
success: false,
|
|
1302
|
+
script: null,
|
|
1303
|
+
error: err.message,
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Streaming version of iterative testing
|
|
1310
|
+
*/
|
|
1311
|
+
async function runStreamingIterativeTest(scriptContent, originalTask, config, sendEvent) {
|
|
1312
|
+
const maxIterations = config.maxFixIterations;
|
|
1313
|
+
sendEvent('log', { message: `Starting iterative testing (max ${maxIterations} attempts)`, level: 'info' });
|
|
1314
|
+
// For now, delegate to the existing implementation
|
|
1315
|
+
// In a full implementation, we'd refactor iterative-tester.ts to support streaming
|
|
1316
|
+
const result = await runIterativeTest(scriptContent, originalTask, config);
|
|
1317
|
+
// Send iteration results
|
|
1318
|
+
for (let i = 1; i <= result.iterations; i++) {
|
|
1319
|
+
sendEvent('iteration_result', {
|
|
1320
|
+
iteration: i,
|
|
1321
|
+
maxIterations,
|
|
1322
|
+
success: i === result.iterations ? result.success : false,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
return {
|
|
1326
|
+
success: result.success,
|
|
1327
|
+
iterations: result.iterations,
|
|
1328
|
+
scriptContent: result.scriptContent,
|
|
1329
|
+
skippedDueToStaleCdp: result.skippedDueToStaleCdp,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Run agentic automation - Claude performs the task directly and returns results.
|
|
1334
|
+
* Unlike script generation mode, this does not create a reusable script.
|
|
1335
|
+
*/
|
|
1336
|
+
async function runAgenticAutomation(taskPrompt, config, sendEvent) {
|
|
1337
|
+
const sessionId = generateSessionId();
|
|
1338
|
+
// Ensure sessions directory exists
|
|
1339
|
+
if (!fs.existsSync(config.sessionsDir)) {
|
|
1340
|
+
fs.mkdirSync(config.sessionsDir, { recursive: true });
|
|
1341
|
+
}
|
|
1342
|
+
// Get the agentic prompt and combine with task
|
|
1343
|
+
const systemPrompt = getAgenticPrompt(config.cdpUrl);
|
|
1344
|
+
const fullPrompt = `${systemPrompt}\n\n## Your Task\n\n${taskPrompt}`;
|
|
1345
|
+
// Write full prompt to a file
|
|
1346
|
+
const promptFile = path.join(config.sessionsDir, `${sessionId}-agentic-prompt.txt`);
|
|
1347
|
+
fs.writeFileSync(promptFile, fullPrompt);
|
|
1348
|
+
sendEvent('log', { message: `Starting agentic session: ${sessionId}`, level: 'info' });
|
|
1349
|
+
return new Promise((resolve) => {
|
|
1350
|
+
let output = '';
|
|
1351
|
+
const env = {
|
|
1352
|
+
...process.env,
|
|
1353
|
+
CDP_URL: config.cdpUrl,
|
|
1354
|
+
};
|
|
1355
|
+
const shellCmd = `cat "${promptFile}" | claude -p --model ${config.model} --max-turns ${config.maxTurns} --allowedTools "Bash" --output-format stream-json --verbose`;
|
|
1356
|
+
const claude = spawn('bash', ['-c', shellCmd], {
|
|
1357
|
+
env,
|
|
1358
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1359
|
+
detached: false,
|
|
1360
|
+
});
|
|
1361
|
+
claude.stdout?.on('data', (data) => {
|
|
1362
|
+
const text = data.toString();
|
|
1363
|
+
output += text;
|
|
1364
|
+
// Parse and stream each line
|
|
1365
|
+
const lines = text.split('\n');
|
|
1366
|
+
for (const line of lines) {
|
|
1367
|
+
if (!line.trim())
|
|
1368
|
+
continue;
|
|
1369
|
+
try {
|
|
1370
|
+
const json = JSON.parse(line);
|
|
1371
|
+
// Stream different event types
|
|
1372
|
+
if (json.type === 'assistant' && json.message?.content) {
|
|
1373
|
+
for (const block of json.message.content) {
|
|
1374
|
+
if (block.type === 'text') {
|
|
1375
|
+
sendEvent('claude_output', { type: 'text', content: block.text });
|
|
1376
|
+
}
|
|
1377
|
+
else if (block.type === 'tool_use') {
|
|
1378
|
+
sendEvent('claude_output', { type: 'tool_use', name: block.name, input: block.input });
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else if (json.type === 'tool_result') {
|
|
1383
|
+
sendEvent('claude_output', { type: 'tool_result', content: json.content?.substring(0, 500) });
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
// Non-JSON output, log as raw
|
|
1388
|
+
if (line.trim()) {
|
|
1389
|
+
sendEvent('log', { message: line, level: 'debug' });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
claude.stderr?.on('data', (data) => {
|
|
1395
|
+
const text = data.toString();
|
|
1396
|
+
sendEvent('log', { message: text, level: 'warn' });
|
|
1397
|
+
});
|
|
1398
|
+
claude.on('close', (code) => {
|
|
1399
|
+
// Clean up temp files
|
|
1400
|
+
try {
|
|
1401
|
+
if (fs.existsSync(promptFile)) {
|
|
1402
|
+
fs.unlinkSync(promptFile);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
// Ignore cleanup errors
|
|
1407
|
+
}
|
|
1408
|
+
// Extract the JSON result from Claude's output
|
|
1409
|
+
const result = extractAgenticResult(output);
|
|
1410
|
+
if (result) {
|
|
1411
|
+
sendEvent('log', { message: 'Successfully extracted result from Claude output', level: 'info' });
|
|
1412
|
+
resolve({
|
|
1413
|
+
success: result.success,
|
|
1414
|
+
result: result.data,
|
|
1415
|
+
summary: result.summary,
|
|
1416
|
+
error: result.success ? undefined : result.error,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
sendEvent('log', { message: 'Could not extract structured result from Claude output', level: 'warn' });
|
|
1421
|
+
resolve({
|
|
1422
|
+
success: false,
|
|
1423
|
+
result: null,
|
|
1424
|
+
error: code !== 0 ? `Exit code: ${code}` : 'Failed to extract result from output',
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
claude.on('error', (err) => {
|
|
1429
|
+
try {
|
|
1430
|
+
if (fs.existsSync(promptFile)) {
|
|
1431
|
+
fs.unlinkSync(promptFile);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
// Ignore
|
|
1436
|
+
}
|
|
1437
|
+
resolve({
|
|
1438
|
+
success: false,
|
|
1439
|
+
result: null,
|
|
1440
|
+
error: err.message,
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Extract the final JSON result from agentic automation output.
|
|
1447
|
+
* Looks for the structured JSON output format in Claude's response.
|
|
1448
|
+
*/
|
|
1449
|
+
function extractAgenticResult(output) {
|
|
1450
|
+
const textContent = extractTextFromStreamJson(output);
|
|
1451
|
+
// Look for JSON code blocks with our expected format
|
|
1452
|
+
const jsonBlockPattern = /```json\s*([\s\S]*?)```/g;
|
|
1453
|
+
let lastValidResult = null;
|
|
1454
|
+
let match;
|
|
1455
|
+
while ((match = jsonBlockPattern.exec(textContent)) !== null) {
|
|
1456
|
+
try {
|
|
1457
|
+
const jsonStr = match[1].trim();
|
|
1458
|
+
const parsed = JSON.parse(jsonStr);
|
|
1459
|
+
// Check if this looks like our expected output format
|
|
1460
|
+
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
|
1461
|
+
lastValidResult = {
|
|
1462
|
+
success: parsed.success === true,
|
|
1463
|
+
data: parsed.data,
|
|
1464
|
+
summary: parsed.summary,
|
|
1465
|
+
error: parsed.error,
|
|
1466
|
+
attempted: parsed.attempted,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
catch {
|
|
1471
|
+
// Not valid JSON, continue looking
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
// If we found a structured result, return it
|
|
1475
|
+
if (lastValidResult) {
|
|
1476
|
+
return lastValidResult;
|
|
1477
|
+
}
|
|
1478
|
+
// Fallback: try to find any JSON that looks like extracted data
|
|
1479
|
+
// This handles cases where Claude outputs data directly without our wrapper format
|
|
1480
|
+
const anyJsonPattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
1481
|
+
while ((match = anyJsonPattern.exec(textContent)) !== null) {
|
|
1482
|
+
try {
|
|
1483
|
+
const jsonStr = match[1].trim();
|
|
1484
|
+
// Skip if it doesn't look like JSON
|
|
1485
|
+
if (!jsonStr.startsWith('{') && !jsonStr.startsWith('['))
|
|
1486
|
+
continue;
|
|
1487
|
+
const parsed = JSON.parse(jsonStr);
|
|
1488
|
+
// If it's an array or object with data, wrap it in our format
|
|
1489
|
+
if (Array.isArray(parsed) || (typeof parsed === 'object' && parsed !== null)) {
|
|
1490
|
+
return {
|
|
1491
|
+
success: true,
|
|
1492
|
+
data: parsed,
|
|
1493
|
+
summary: `Extracted ${Array.isArray(parsed) ? parsed.length + ' items' : 'data'}`,
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
catch {
|
|
1498
|
+
// Not valid JSON, continue looking
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Extract text content from stream-json format output
|
|
1505
|
+
*/
|
|
1506
|
+
function extractTextFromStreamJson(output) {
|
|
1507
|
+
const lines = output.split('\n');
|
|
1508
|
+
const textParts = [];
|
|
1509
|
+
for (const line of lines) {
|
|
1510
|
+
if (!line.trim().startsWith('{'))
|
|
1511
|
+
continue;
|
|
1512
|
+
try {
|
|
1513
|
+
const json = JSON.parse(line);
|
|
1514
|
+
if (json.type === 'assistant' && json.message?.content) {
|
|
1515
|
+
for (const block of json.message.content) {
|
|
1516
|
+
if (block.type === 'text') {
|
|
1517
|
+
textParts.push(block.text);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
if (json.type === 'result' && json.result) {
|
|
1522
|
+
textParts.push(json.result);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
catch {
|
|
1526
|
+
textParts.push(line);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return textParts.join('\n');
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Extract a bash script from Claude's output.
|
|
1533
|
+
*/
|
|
1534
|
+
function extractScriptFromOutput(output) {
|
|
1535
|
+
const textContent = extractTextFromStreamJson(output);
|
|
1536
|
+
const codeBlockPatterns = [
|
|
1537
|
+
new RegExp('```bash\\n([\\s\\S]*?)```', 'g'),
|
|
1538
|
+
new RegExp('```sh\\n([\\s\\S]*?)```', 'g'),
|
|
1539
|
+
new RegExp('```shell\\n([\\s\\S]*?)```', 'g'),
|
|
1540
|
+
new RegExp('```\\n(#!/bin/bash[\\s\\S]*?)```', 'g'),
|
|
1541
|
+
];
|
|
1542
|
+
let bestScript = null;
|
|
1543
|
+
let bestScore = 0;
|
|
1544
|
+
for (const pattern of codeBlockPatterns) {
|
|
1545
|
+
let match;
|
|
1546
|
+
while ((match = pattern.exec(textContent)) !== null) {
|
|
1547
|
+
const script = match[1].trim();
|
|
1548
|
+
const score = scoreScript(script);
|
|
1549
|
+
if (score > bestScore) {
|
|
1550
|
+
bestScore = score;
|
|
1551
|
+
bestScript = script;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (bestScript) {
|
|
1556
|
+
if (!bestScript.startsWith('#!/')) {
|
|
1557
|
+
bestScript = '#!/bin/bash\nset -e\n\nCDP="${CDP_URL:?Required}"\n\n' + bestScript;
|
|
1558
|
+
}
|
|
1559
|
+
return bestScript;
|
|
1560
|
+
}
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Score a script based on how complete it looks
|
|
1565
|
+
*/
|
|
1566
|
+
function scoreScript(script) {
|
|
1567
|
+
let score = 0;
|
|
1568
|
+
if (!script.includes('agent-browser'))
|
|
1569
|
+
return 0;
|
|
1570
|
+
if (script.includes('#!/bin/bash'))
|
|
1571
|
+
score += 10;
|
|
1572
|
+
if (script.includes('CDP=') || script.includes('$CDP'))
|
|
1573
|
+
score += 10;
|
|
1574
|
+
if (script.includes('open "http'))
|
|
1575
|
+
score += 20;
|
|
1576
|
+
if (script.includes('eval "'))
|
|
1577
|
+
score += 20;
|
|
1578
|
+
if (script.includes('FINAL RESULTS') || script.includes('echo "$'))
|
|
1579
|
+
score += 10;
|
|
1580
|
+
const snapshotCount = (script.match(/snapshot/g) || []).length;
|
|
1581
|
+
score -= snapshotCount * 5;
|
|
1582
|
+
const refCount = (script.match(/@e\d+/g) || []).length;
|
|
1583
|
+
score -= refCount * 10;
|
|
1584
|
+
const openCount = (script.match(/\bopen\s+"/g) || []).length;
|
|
1585
|
+
const evalCount = (script.match(/\beval\s+"/g) || []).length;
|
|
1586
|
+
score += openCount * 5 + evalCount * 10;
|
|
1587
|
+
return score;
|
|
1588
|
+
}
|
|
1589
|
+
// ============================================
|
|
1590
|
+
// MCP HTTP Transport Endpoint
|
|
1591
|
+
// ============================================
|
|
1592
|
+
// MCP tool definitions
|
|
1593
|
+
const mcpTools = [
|
|
1594
|
+
{
|
|
1595
|
+
name: 'browser_automate',
|
|
1596
|
+
description: 'Perform one-off browser automation task. Claude will navigate to websites, interact with elements, and extract data directly. Returns results immediately (no script generated).',
|
|
1597
|
+
inputSchema: {
|
|
1598
|
+
type: 'object',
|
|
1599
|
+
properties: {
|
|
1600
|
+
task: { type: 'string', description: 'Description of the automation task' },
|
|
1601
|
+
browserOptions: {
|
|
1602
|
+
type: 'object',
|
|
1603
|
+
description: 'Browser session options',
|
|
1604
|
+
properties: {
|
|
1605
|
+
country: { type: 'string', description: '2-letter ISO country code' },
|
|
1606
|
+
adblock: { type: 'boolean', description: 'Enable ad-blocking' },
|
|
1607
|
+
captchaSolver: { type: 'boolean', description: 'Enable CAPTCHA solving' },
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
waitForCompletion: { type: 'boolean', description: 'Wait for results (default: true for HTTP MCP)' },
|
|
1611
|
+
maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 120)' },
|
|
1612
|
+
},
|
|
1613
|
+
required: ['task'],
|
|
1614
|
+
},
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
name: 'generate_script',
|
|
1618
|
+
description: 'Generate a reusable browser automation script that can be run multiple times.',
|
|
1619
|
+
inputSchema: {
|
|
1620
|
+
type: 'object',
|
|
1621
|
+
properties: {
|
|
1622
|
+
task: { type: 'string', description: 'Description of what the script should do' },
|
|
1623
|
+
browserOptions: { type: 'object', description: 'Browser session options' },
|
|
1624
|
+
skipTest: { type: 'boolean', description: 'Skip iterative testing (default: false)' },
|
|
1625
|
+
waitForCompletion: { type: 'boolean', description: 'Wait for script generation (default: true)' },
|
|
1626
|
+
maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 300)' },
|
|
1627
|
+
},
|
|
1628
|
+
required: ['task'],
|
|
1629
|
+
},
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
name: 'list_scripts',
|
|
1633
|
+
description: 'List all stored automation scripts.',
|
|
1634
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
name: 'get_script',
|
|
1638
|
+
description: 'Get details of a specific script including content.',
|
|
1639
|
+
inputSchema: {
|
|
1640
|
+
type: 'object',
|
|
1641
|
+
properties: {
|
|
1642
|
+
scriptId: { type: 'string', description: 'The script ID' },
|
|
1643
|
+
},
|
|
1644
|
+
required: ['scriptId'],
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
name: 'run_script',
|
|
1649
|
+
description: 'Execute a stored automation script.',
|
|
1650
|
+
inputSchema: {
|
|
1651
|
+
type: 'object',
|
|
1652
|
+
properties: {
|
|
1653
|
+
scriptId: { type: 'string', description: 'The script ID to run' },
|
|
1654
|
+
browserOptions: { type: 'object', description: 'Browser session options' },
|
|
1655
|
+
waitForCompletion: { type: 'boolean', description: 'Wait for execution (default: true)' },
|
|
1656
|
+
maxWaitSeconds: { type: 'number', description: 'Max wait time (default: 120)' },
|
|
1657
|
+
},
|
|
1658
|
+
required: ['scriptId'],
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
name: 'get_task',
|
|
1663
|
+
description: 'Get the status and result of a task by ID.',
|
|
1664
|
+
inputSchema: {
|
|
1665
|
+
type: 'object',
|
|
1666
|
+
properties: {
|
|
1667
|
+
taskId: { type: 'string', description: 'The task ID' },
|
|
1668
|
+
},
|
|
1669
|
+
required: ['taskId'],
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
name: 'list_tasks',
|
|
1674
|
+
description: 'List recent tasks.',
|
|
1675
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
1676
|
+
},
|
|
1677
|
+
];
|
|
1678
|
+
// Helper to consume SSE stream and wait for completion
|
|
1679
|
+
async function consumeMcpStream(response, apiKey, endpoint, body, maxWaitMs) {
|
|
1680
|
+
const apiUrl = `http://localhost:${process.env.PORT || 3000}${endpoint}`;
|
|
1681
|
+
return new Promise(async (resolve) => {
|
|
1682
|
+
try {
|
|
1683
|
+
const fetchResponse = await fetch(apiUrl, {
|
|
1684
|
+
method: 'POST',
|
|
1685
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1686
|
+
body: JSON.stringify({ ...body, browserCashApiKey: apiKey }),
|
|
1687
|
+
});
|
|
1688
|
+
if (!fetchResponse.ok) {
|
|
1689
|
+
const error = await fetchResponse.json().catch(() => ({ error: 'Request failed' }));
|
|
1690
|
+
resolve({ success: false, data: null, error: error.error || 'Request failed' });
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const reader = fetchResponse.body?.getReader();
|
|
1694
|
+
if (!reader) {
|
|
1695
|
+
resolve({ success: false, data: null, error: 'No response body' });
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const decoder = new TextDecoder();
|
|
1699
|
+
let buffer = '';
|
|
1700
|
+
const timeout = setTimeout(() => {
|
|
1701
|
+
reader.cancel();
|
|
1702
|
+
resolve({ success: false, data: null, error: 'Timeout' });
|
|
1703
|
+
}, maxWaitMs);
|
|
1704
|
+
while (true) {
|
|
1705
|
+
const { value, done } = await reader.read();
|
|
1706
|
+
if (done)
|
|
1707
|
+
break;
|
|
1708
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1709
|
+
const lines = buffer.split('\n');
|
|
1710
|
+
buffer = lines.pop() || '';
|
|
1711
|
+
for (const line of lines) {
|
|
1712
|
+
if (line.startsWith('data: ')) {
|
|
1713
|
+
try {
|
|
1714
|
+
const event = JSON.parse(line.slice(6));
|
|
1715
|
+
if (event.type === 'complete') {
|
|
1716
|
+
clearTimeout(timeout);
|
|
1717
|
+
resolve({ success: event.data?.success ?? true, data: event.data });
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if (event.type === 'error') {
|
|
1721
|
+
clearTimeout(timeout);
|
|
1722
|
+
resolve({ success: false, data: null, error: event.data?.message || 'Error' });
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
// Ignore parse errors
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
clearTimeout(timeout);
|
|
1733
|
+
resolve({ success: false, data: null, error: 'Stream ended without completion' });
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
resolve({ success: false, data: null, error: err instanceof Error ? err.message : 'Unknown error' });
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
// MCP HTTP endpoint
|
|
1741
|
+
server.post('/mcp', async (request, reply) => {
|
|
1742
|
+
const apiKey = request.headers['x-api-key'];
|
|
1743
|
+
if (!apiKey) {
|
|
1744
|
+
return reply.code(401).send({
|
|
1745
|
+
jsonrpc: '2.0',
|
|
1746
|
+
id: request.body?.id || null,
|
|
1747
|
+
error: { code: -32001, message: 'x-api-key header required' },
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
const { jsonrpc, id, method, params } = request.body;
|
|
1751
|
+
if (jsonrpc !== '2.0') {
|
|
1752
|
+
return reply.code(400).send({
|
|
1753
|
+
jsonrpc: '2.0',
|
|
1754
|
+
id,
|
|
1755
|
+
error: { code: -32600, message: 'Invalid JSON-RPC version' },
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
const ownerId = hashApiKey(apiKey);
|
|
1759
|
+
// Handle MCP methods
|
|
1760
|
+
switch (method) {
|
|
1761
|
+
case 'initialize':
|
|
1762
|
+
return {
|
|
1763
|
+
jsonrpc: '2.0',
|
|
1764
|
+
id,
|
|
1765
|
+
result: {
|
|
1766
|
+
protocolVersion: '2024-11-05',
|
|
1767
|
+
serverInfo: { name: 'claude-gen', version: '1.0.0' },
|
|
1768
|
+
capabilities: { tools: {} },
|
|
1769
|
+
},
|
|
1770
|
+
};
|
|
1771
|
+
case 'tools/list':
|
|
1772
|
+
return {
|
|
1773
|
+
jsonrpc: '2.0',
|
|
1774
|
+
id,
|
|
1775
|
+
result: { tools: mcpTools },
|
|
1776
|
+
};
|
|
1777
|
+
case 'tools/call': {
|
|
1778
|
+
const { name, arguments: args = {} } = (params || {});
|
|
1779
|
+
const maxWait = (args.maxWaitSeconds || 120) * 1000;
|
|
1780
|
+
try {
|
|
1781
|
+
let result;
|
|
1782
|
+
switch (name) {
|
|
1783
|
+
case 'browser_automate':
|
|
1784
|
+
result = await consumeMcpStream(reply, apiKey, '/automate/stream', {
|
|
1785
|
+
task: args.task,
|
|
1786
|
+
browserOptions: args.browserOptions,
|
|
1787
|
+
}, maxWait);
|
|
1788
|
+
break;
|
|
1789
|
+
case 'generate_script':
|
|
1790
|
+
result = await consumeMcpStream(reply, apiKey, '/generate/stream', {
|
|
1791
|
+
task: args.task,
|
|
1792
|
+
browserOptions: args.browserOptions,
|
|
1793
|
+
skipTest: args.skipTest,
|
|
1794
|
+
}, (args.maxWaitSeconds || 300) * 1000);
|
|
1795
|
+
break;
|
|
1796
|
+
case 'run_script':
|
|
1797
|
+
result = await consumeMcpStream(reply, apiKey, `/scripts/${args.scriptId}/run`, {
|
|
1798
|
+
browserOptions: args.browserOptions,
|
|
1799
|
+
}, maxWait);
|
|
1800
|
+
break;
|
|
1801
|
+
case 'list_scripts': {
|
|
1802
|
+
const scripts = await listScripts(ownerId);
|
|
1803
|
+
result = { success: true, data: { scripts } };
|
|
1804
|
+
break;
|
|
1805
|
+
}
|
|
1806
|
+
case 'get_script': {
|
|
1807
|
+
const script = await getScript(args.scriptId, ownerId);
|
|
1808
|
+
if (!script) {
|
|
1809
|
+
result = { success: false, data: null, error: 'Script not found' };
|
|
1810
|
+
}
|
|
1811
|
+
else {
|
|
1812
|
+
result = { success: true, data: script };
|
|
1813
|
+
}
|
|
1814
|
+
break;
|
|
1815
|
+
}
|
|
1816
|
+
case 'get_task': {
|
|
1817
|
+
const task = await getTask(args.taskId, ownerId);
|
|
1818
|
+
if (!task) {
|
|
1819
|
+
result = { success: false, data: null, error: 'Task not found' };
|
|
1820
|
+
}
|
|
1821
|
+
else {
|
|
1822
|
+
result = { success: true, data: task };
|
|
1823
|
+
}
|
|
1824
|
+
break;
|
|
1825
|
+
}
|
|
1826
|
+
case 'list_tasks': {
|
|
1827
|
+
const tasks = await listTasks(ownerId);
|
|
1828
|
+
result = { success: true, data: { tasks } };
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
default:
|
|
1832
|
+
return {
|
|
1833
|
+
jsonrpc: '2.0',
|
|
1834
|
+
id,
|
|
1835
|
+
error: { code: -32601, message: `Unknown tool: ${name}` },
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
return {
|
|
1839
|
+
jsonrpc: '2.0',
|
|
1840
|
+
id,
|
|
1841
|
+
result: {
|
|
1842
|
+
content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }],
|
|
1843
|
+
},
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
catch (err) {
|
|
1847
|
+
return {
|
|
1848
|
+
jsonrpc: '2.0',
|
|
1849
|
+
id,
|
|
1850
|
+
error: { code: -32000, message: err instanceof Error ? err.message : 'Unknown error' },
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
default:
|
|
1855
|
+
return {
|
|
1856
|
+
jsonrpc: '2.0',
|
|
1857
|
+
id,
|
|
1858
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
// Start server
|
|
1863
|
+
async function start() {
|
|
1864
|
+
const port = parseInt(process.env.PORT || '3000', 10);
|
|
1865
|
+
const host = process.env.HOST || '0.0.0.0';
|
|
1866
|
+
try {
|
|
1867
|
+
await server.listen({ port, host });
|
|
1868
|
+
console.log(`
|
|
1869
|
+
===========================================
|
|
1870
|
+
Claude-Gen HTTP API Server
|
|
1871
|
+
===========================================
|
|
1872
|
+
|
|
1873
|
+
Server running at http://${host}:${port}
|
|
1874
|
+
|
|
1875
|
+
Endpoints:
|
|
1876
|
+
GET /health - Health check
|
|
1877
|
+
POST /mcp - MCP HTTP transport (for Claude Desktop/Cursor)
|
|
1878
|
+
POST /test-cdp - Test CDP connectivity
|
|
1879
|
+
POST /generate - Generate browser automation script (JSON response)
|
|
1880
|
+
POST /generate/stream - Generate with real-time SSE streaming
|
|
1881
|
+
POST /automate/stream - Agentic mode: perform task and return results directly
|
|
1882
|
+
|
|
1883
|
+
GET /scripts - List all stored scripts
|
|
1884
|
+
GET /scripts/:id - Get a specific script
|
|
1885
|
+
POST /scripts/:id/run - Run a stored script with SSE streaming
|
|
1886
|
+
|
|
1887
|
+
GET /tasks - List recent tasks (for result retrieval)
|
|
1888
|
+
GET /tasks/:taskId - Get task status and result (if client disconnects)
|
|
1889
|
+
|
|
1890
|
+
MCP Integration (Claude Code):
|
|
1891
|
+
claude mcp add --transport http claude-gen https://claude-gen-api-264851422957.us-central1.run.app/mcp -H "x-api-key: YOUR_API_KEY"
|
|
1892
|
+
|
|
1893
|
+
Browser Authentication (choose one):
|
|
1894
|
+
- cdpUrl: Direct WebSocket CDP URL
|
|
1895
|
+
- browserCashApiKey: Browser.cash API key (creates session automatically)
|
|
1896
|
+
|
|
1897
|
+
Example requests:
|
|
1898
|
+
|
|
1899
|
+
# Generate with Browser.cash API key (recommended)
|
|
1900
|
+
curl -X POST http://localhost:${port}/generate/stream \\
|
|
1901
|
+
-H "Content-Type: application/json" \\
|
|
1902
|
+
-d '{
|
|
1903
|
+
"task": "Go to example.com and get the title",
|
|
1904
|
+
"browserCashApiKey": "your-api-key",
|
|
1905
|
+
"browserOptions": {"country": "US", "adblock": true}
|
|
1906
|
+
}'
|
|
1907
|
+
|
|
1908
|
+
# Generate with direct CDP URL (legacy)
|
|
1909
|
+
curl -X POST http://localhost:${port}/generate/stream \\
|
|
1910
|
+
-H "Content-Type: application/json" \\
|
|
1911
|
+
-d '{"task": "Go to example.com and get the title", "cdpUrl": "wss://..."}'
|
|
1912
|
+
|
|
1913
|
+
# Run stored script with Browser.cash
|
|
1914
|
+
curl -X POST http://localhost:${port}/scripts/script-abc123/run \\
|
|
1915
|
+
-H "Content-Type: application/json" \\
|
|
1916
|
+
-d '{"browserCashApiKey": "your-api-key"}'
|
|
1917
|
+
|
|
1918
|
+
Environment variables:
|
|
1919
|
+
PORT - Server port (default: 3000)
|
|
1920
|
+
HOST - Server host (default: 0.0.0.0)
|
|
1921
|
+
GCS_BUCKET - GCS bucket for script storage (default: claude-gen-scripts)
|
|
1922
|
+
BROWSER_CASH_API_URL - Browser.cash API URL (default: https://api.browser.cash)
|
|
1923
|
+
ANTHROPIC_API_KEY - Required for Claude API calls
|
|
1924
|
+
MODEL - Claude model (default: claude-opus-4-5-20251101)
|
|
1925
|
+
MAX_TURNS - Max Claude turns (default: 15)
|
|
1926
|
+
MAX_FIX_ITERATIONS - Max fix attempts (default: 5)
|
|
1927
|
+
`);
|
|
1928
|
+
}
|
|
1929
|
+
catch (err) {
|
|
1930
|
+
server.log.error(err);
|
|
1931
|
+
process.exit(1);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
start();
|