upfynai-code 3.0.3 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/client/dist/api-docs.html +838 -838
  2. package/client/dist/assets/{AppContent-Bvg0CPCO.js → AppContent-CwrTP6TW.js} +43 -43
  3. package/client/dist/assets/BrowserPanel-0TLEl-IC.js +2 -0
  4. package/client/dist/assets/{CanvasFullScreen-BdiJ35aq.js → CanvasFullScreen-D1GWQsGL.js} +1 -1
  5. package/client/dist/assets/{CanvasWorkspace-Bk9R9_e0.js → CanvasWorkspace-D7ORj358.js} +1 -1
  6. package/client/dist/assets/DashboardPanel-BV7ybUDe.js +1 -0
  7. package/client/dist/assets/FileTree-5qfhBqdE.js +1 -0
  8. package/client/dist/assets/{GitPanel-RtyZUIWS.js → GitPanel-C_xFM-N2.js} +1 -1
  9. package/client/dist/assets/{LoginModal-BWep8a6g.js → LoginModal-CImJHRjX.js} +3 -3
  10. package/client/dist/assets/{MarkdownPreview-DHmk3qzu.js → MarkdownPreview-CESjI261.js} +1 -1
  11. package/client/dist/assets/{MermaidBlock-BuBc_G-F.js → MermaidBlock-BFM21cwe.js} +2 -2
  12. package/client/dist/assets/Onboarding-B3cteLu2.js +1 -0
  13. package/client/dist/assets/SetupForm-P6dsYgHO.js +1 -0
  14. package/client/dist/assets/WorkflowsPanel-CBoN80kc.js +1 -0
  15. package/client/dist/assets/index-46kkVu2i.css +1 -0
  16. package/client/dist/assets/{index-C5ptjuTl.js → index-HaY-3pK1.js} +20 -20
  17. package/client/dist/assets/{vendor-canvas-D39yWul6.js → vendor-canvas-DvHJ_Pn2.js} +1 -1
  18. package/client/dist/assets/{vendor-codemirror-CbtmxxaB.js → vendor-codemirror-D2ALgpaX.js} +1 -1
  19. package/client/dist/assets/{vendor-icons-BaD0x9SL.js → vendor-icons-GyYE35HP.js} +178 -138
  20. package/client/dist/assets/{vendor-mermaid-CH7SGc99.js → vendor-mermaid-DucWyDEe.js} +3 -3
  21. package/client/dist/assets/{vendor-syntax-DuHI9Ok6.js → vendor-syntax-LS_Nt30I.js} +1 -1
  22. package/client/dist/clear-cache.html +85 -85
  23. package/client/dist/index.html +17 -17
  24. package/client/dist/manifest.json +3 -3
  25. package/client/dist/mcp-docs.html +108 -108
  26. package/client/dist/offline.html +84 -84
  27. package/client/dist/sw.js +82 -82
  28. package/package.json +1 -1
  29. package/server/browser.js +131 -0
  30. package/server/database/db.js +102 -6
  31. package/server/index.js +27 -28
  32. package/server/middleware/auth.js +5 -2
  33. package/server/routes/browser.js +419 -0
  34. package/server/routes/projects.js +118 -19
  35. package/server/routes/vapi-chat.js +1 -1
  36. package/server/services/browser-ai.js +154 -0
  37. package/client/dist/assets/DashboardPanel-CblJfTGi.js +0 -1
  38. package/client/dist/assets/FileTree-BDUnBheV.js +0 -1
  39. package/client/dist/assets/Onboarding-Drnlt75a.js +0 -1
  40. package/client/dist/assets/SetupForm-CtCKitZG.js +0 -1
  41. package/client/dist/assets/WorkflowsPanel-B2mIXDvD.js +0 -1
  42. package/client/dist/assets/index-BFuqS0tY.css +0 -1
package/server/index.js CHANGED
@@ -88,6 +88,7 @@ import keysRoutes from './routes/keys.js';
88
88
  import assistantRoutes from './routes/vapi-chat.js';
89
89
  import canvasRoutes from './routes/canvas.js';
90
90
  import composioRoutes from './routes/composio.js';
91
+ import browserRoutes from './routes/browser.js';
91
92
  import sessionRoutes from './routes/sessions.js';
92
93
  import { handleSandboxWebSocketCommand } from './middleware/sandboxRouter.js';
93
94
  import { createRelayMiddleware } from './middleware/relayHelpers.js';
@@ -582,13 +583,8 @@ app.locals.wss = wss;
582
583
  // Security headers — protect against common web attacks
583
584
  app.use((req, res, next) => {
584
585
  res.setHeader('X-Content-Type-Options', 'nosniff');
585
- // Allow framing from our own frontend domains (Vercel embeds Railway in an iframe)
586
- const allowedFrameOrigins = (process.env.CORS_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean);
587
- if (allowedFrameOrigins.length > 0) {
588
- res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${allowedFrameOrigins.join(' ')}`);
589
- } else {
590
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
591
- }
586
+ // No iframe embedding app is served directly via Vercel reverse proxy
587
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
592
588
  res.setHeader('X-XSS-Protection', '1; mode=block');
593
589
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
594
590
  if (process.env.NODE_ENV === 'production') {
@@ -752,6 +748,7 @@ app.use('/api/keys', authenticateToken, keysRoutes);
752
748
  app.use('/api/assistant', assistantRoutes);
753
749
  app.use('/api/canvas', authenticateToken, canvasRoutes);
754
750
  app.use('/api/composio', authenticateToken, composioRoutes);
751
+ app.use('/api/browser', authenticateToken, browserRoutes);
755
752
  app.use('/api/vapi', assistantRoutes); // Alias: VAPI dashboard webhook points to /api/vapi/webhook
756
753
 
757
754
  // Voice call history endpoints (per-user isolated)
@@ -912,7 +909,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
912
909
  if (IS_LOCAL) {
913
910
  // Self-hosted: run locally
914
911
  const { execSync } = await import('child_process');
915
- const output = execSync('npm install -g upfynai-code@latest', { encoding: 'utf8', timeout: 120000 });
912
+ const output = execSync('npm install -g @upfynai-code/app@latest', { encoding: 'utf8', timeout: 120000 });
916
913
  res.json({ output, exitCode: 0 });
917
914
  } else {
918
915
  // Platform mode: server is managed by Railway, no user action needed
@@ -1084,12 +1081,12 @@ app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
1084
1081
  // Serve public files (like api-docs.html)
1085
1082
  app.use(express.static(path.join(__dirname, '../client/public')));
1086
1083
 
1087
- // Static files served after API routes
1088
- // Add cache control: HTML files should not be cached, but assets can be cached
1089
- app.use(express.static(path.join(__dirname, '../client/dist'), {
1084
+ // Static files served after API routes at /app/ prefix
1085
+ // Assets are built with Vite base: '/app/' so all URLs are /app/assets/...
1086
+ app.use('/app', express.static(path.join(__dirname, '../client/dist'), {
1087
+ index: false, // Don't serve index.html directly — catch-all injects runtime config
1090
1088
  setHeaders: (res, filePath) => {
1091
1089
  if (filePath.endsWith('.html')) {
1092
- // Prevent HTML caching to avoid service worker issues after builds
1093
1090
  res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1094
1091
  res.setHeader('Pragma', 'no-cache');
1095
1092
  res.setHeader('Expires', '0');
@@ -3605,13 +3602,12 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
3605
3602
  }
3606
3603
  });
3607
3604
 
3608
- // Serve React app for all other routes (excluding static files and API routes)
3609
- app.get('*', (req, res) => {
3610
- // Skip API routes — they should be handled by their own routers
3611
- if (req.path.startsWith('/api/') || req.path === '/mcp' || req.path === '/relay' || req.path === '/health') {
3612
- return res.status(404).json({ error: 'Not found' });
3613
- }
3614
- // Skip requests for static assets (files with extensions)
3605
+ // Root redirect → /app/
3606
+ app.get('/', (req, res) => res.redirect('/app/'));
3607
+
3608
+ // Serve React app for /app/* routes (SPA catch-all)
3609
+ app.get('/app/*', (req, res) => {
3610
+ // Skip requests for static assets (files with extensions) — already handled by express.static
3615
3611
  if (path.extname(req.path)) {
3616
3612
  return res.status(404).send('Not found');
3617
3613
  }
@@ -3623,12 +3619,14 @@ app.get('*', (req, res) => {
3623
3619
  const decoded = jwt.verify(req.query.token, JWT_SECRET);
3624
3620
  if (decoded?.userId) {
3625
3621
  const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
3622
+ const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
3626
3623
  res.cookie('session', req.query.token, {
3627
3624
  httpOnly: true,
3628
3625
  secure: isSecure,
3629
- sameSite: isSecure ? 'none' : 'strict',
3626
+ sameSite: isSecure ? 'lax' : 'strict',
3630
3627
  maxAge: 30 * 24 * 60 * 60 * 1000,
3631
3628
  path: '/',
3629
+ ...(isSecure && cookieDomain ? { domain: cookieDomain } : {}),
3632
3630
  });
3633
3631
  }
3634
3632
  } catch (e) {
@@ -3636,27 +3634,28 @@ app.get('*', (req, res) => {
3636
3634
  }
3637
3635
  }
3638
3636
 
3639
- // Only serve index.html for HTML routes, not for static assets
3640
- // Static assets should already be handled by express.static middleware above
3641
3637
  const indexPath = path.join(__dirname, '../client/dist/index.html');
3642
3638
 
3643
- // Check if dist/index.html exists (production build available)
3644
3639
  if (fs.existsSync(indexPath)) {
3645
3640
  // Set no-cache headers for HTML to prevent service worker issues
3646
3641
  res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
3647
3642
  res.setHeader('Pragma', 'no-cache');
3648
3643
  res.setHeader('Expires', '0');
3649
3644
 
3650
- // Inject runtime config so the frontend knows the server mode
3651
- // (VITE_IS_PLATFORM is a build-time var that may not match the runtime)
3645
+ // Inject runtime config so the frontend knows the server mode and base path
3652
3646
  let html = fs.readFileSync(indexPath, 'utf8');
3653
- const runtimeConfig = JSON.stringify({ isPlatform: IS_PLATFORM, isLocal: IS_LOCAL });
3647
+ const runtimeConfig = JSON.stringify({
3648
+ isPlatform: IS_PLATFORM,
3649
+ isLocal: IS_LOCAL,
3650
+ basename: '/app',
3651
+ wsUrl: process.env.APP_WS_URL || '',
3652
+ });
3654
3653
  html = html.replace('</head>', `<script>window.__UPFYN_CONFIG__=${runtimeConfig}</script>\n</head>`);
3655
3654
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
3656
3655
  res.send(html);
3657
3656
  } else {
3658
- // In development, redirect to Vite dev server only if dist doesn't exist
3659
- res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
3657
+ // In development, redirect to Vite dev server
3658
+ res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}/app/`);
3660
3659
  }
3661
3660
  });
3662
3661
 
@@ -88,14 +88,17 @@ const generateToken = (user) => {
88
88
  // Cookie config for httpOnly session
89
89
  // Works for both self-hosted (same origin) and split deploy (Vercel proxy → Railway)
90
90
  const isSecureEnv = process.env.NODE_ENV === 'production' || !!process.env.VERCEL || !!process.env.RAILWAY_ENVIRONMENT;
91
+ const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
91
92
  const COOKIE_OPTIONS = {
92
93
  httpOnly: true,
93
94
  secure: isSecureEnv,
94
- // 'none' required for cross-origin iframe embedding (Vercel frontend Railway backend)
95
+ // 'lax' Vercel reverse-proxies to Railway (same domain from browser's perspective)
95
96
  // 'strict' used in local/dev mode where everything is same-origin
96
- sameSite: isSecureEnv ? 'none' : 'strict',
97
+ sameSite: isSecureEnv ? 'lax' : 'strict',
97
98
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
98
99
  path: '/',
100
+ // Set domain for cross-subdomain cookie sharing (e.g., '.upfyn.com')
101
+ ...(isSecureEnv && cookieDomain ? { domain: cookieDomain } : {}),
99
102
  };
100
103
 
101
104
  // Set session cookie on response
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Browser Routes — REST API for Steel browser sessions + Stagehand AI.
3
+ * Cloud-only (browser service runs on Railway).
4
+ */
5
+
6
+ import { Router } from 'express';
7
+ import { browserClient } from '../browser.js';
8
+
9
+ const router = Router();
10
+
11
+ // ── SSRF protection ─────────────────────────────────────────────────────────
12
+
13
+ function isUrlSafe(url) {
14
+ try {
15
+ const parsed = new URL(url);
16
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
17
+ // Strip brackets from IPv6 literals
18
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
19
+ // Block localhost and loopback (IPv4 + IPv6)
20
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0') return false;
21
+ if (hostname === '::1' || hostname === '0000:0000:0000:0000:0000:0000:0000:0001') return false;
22
+ if (hostname.startsWith('::ffff:127.') || hostname.startsWith('::ffff:0.')) return false;
23
+ // Block metadata endpoints (IPv4 + IPv6-mapped)
24
+ if (hostname === '169.254.169.254' || hostname === '::ffff:a9fe:a9fe') return false;
25
+ // Block RFC1918 private ranges
26
+ if (hostname.startsWith('10.') || hostname.startsWith('192.168.')) return false;
27
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
28
+ // Block IPv6 private ranges (fc00::/7, fe80::/10)
29
+ if (/^f[cd][0-9a-f]{2}:/.test(hostname)) return false; // fc00::/7 unique local
30
+ if (/^fe[89ab][0-9a-f]:/.test(hostname)) return false; // fe80::/10 link-local
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ // Allow sandbox-internal URLs (these are safe — same Railway network)
38
+ function isUrlSafeOrSandbox(url) {
39
+ if (isUrlSafe(url)) return true;
40
+ // Allow sandbox service URLs — compare origins, not string prefix
41
+ const sandboxUrl = process.env.SANDBOX_SERVICE_URL || '';
42
+ if (sandboxUrl) {
43
+ try {
44
+ const parsedUrl = new URL(url);
45
+ const parsedSandbox = new URL(sandboxUrl);
46
+ if (parsedUrl.origin === parsedSandbox.origin) return true;
47
+ } catch { /* invalid URL */ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ // ── Lazy imports for cloud-only deps ─────────────────────────────────────────
53
+
54
+ let browserAI = null;
55
+ const loadBrowserAI = async () => {
56
+ if (browserAI) return browserAI;
57
+ try {
58
+ browserAI = await import('../services/browser-ai.js');
59
+ return browserAI;
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ let browserSessionDb = null;
66
+ const loadDb = async () => {
67
+ if (browserSessionDb) return browserSessionDb;
68
+ try {
69
+ const mod = await import('../database/db.js');
70
+ browserSessionDb = mod.browserSessionDb;
71
+ return browserSessionDb;
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ // ── Status ──────────────────────────────────────────────────────────────────
78
+
79
+ router.get('/status', async (req, res) => {
80
+ const available = await browserClient.isAvailable();
81
+ const ai = await loadBrowserAI();
82
+ const aiAvailable = ai ? await ai.isAvailable() : false;
83
+
84
+ res.json({
85
+ available,
86
+ aiAvailable,
87
+ serviceUrl: available ? '(connected)' : '(not reachable)',
88
+ });
89
+ });
90
+
91
+ // ── Session Management ──────────────────────────────────────────────────────
92
+
93
+ // POST /api/browser/sessions — create a new browser session
94
+ router.post('/sessions', async (req, res) => {
95
+ const userId = req.user.id;
96
+ const db = await loadDb();
97
+
98
+ try {
99
+ // Check for existing active session
100
+ if (db) {
101
+ const existing = await db.getActive(userId);
102
+ if (existing) {
103
+ // Return existing session instead of creating new one
104
+ return res.json({
105
+ sessionId: existing.steel_session_id,
106
+ viewerUrl: existing.viewer_url,
107
+ cdpUrl: existing.cdp_url,
108
+ status: 'active',
109
+ reused: true,
110
+ });
111
+ }
112
+ }
113
+
114
+ const { blockAds, dimensions, timeout } = req.body || {};
115
+ const session = await browserClient.createSession(userId, {
116
+ blockAds,
117
+ dimensions,
118
+ timeout,
119
+ });
120
+
121
+ const viewerUrl = browserClient.getSessionViewerUrl(session.id);
122
+ const cdpUrl = browserClient.getCdpWsUrl(session.id);
123
+
124
+ // Save to DB
125
+ if (db) {
126
+ await db.create(userId, session.id, viewerUrl, cdpUrl);
127
+ }
128
+
129
+ res.json({
130
+ sessionId: session.id,
131
+ viewerUrl,
132
+ cdpUrl,
133
+ status: 'active',
134
+ });
135
+ } catch {
136
+ res.status(500).json({ error: 'Failed to create browser session' });
137
+ }
138
+ });
139
+
140
+ // GET /api/browser/sessions — list user's sessions
141
+ router.get('/sessions', async (req, res) => {
142
+ const db = await loadDb();
143
+ if (!db) return res.json({ sessions: [] });
144
+
145
+ try {
146
+ const sessions = await db.listByUser(req.user.id);
147
+ res.json({ sessions });
148
+ } catch {
149
+ res.status(500).json({ error: 'Failed to list sessions' });
150
+ }
151
+ });
152
+
153
+ // GET /api/browser/sessions/:id — get session details
154
+ router.get('/sessions/:id', async (req, res) => {
155
+ const db = await loadDb();
156
+ if (!db) return res.status(404).json({ error: 'Session not found' });
157
+
158
+ try {
159
+ const session = await db.getBySessionId(req.user.id, req.params.id);
160
+ if (!session) return res.status(404).json({ error: 'Session not found' });
161
+
162
+ await db.updateAccess(req.user.id, req.params.id);
163
+
164
+ res.json({
165
+ sessionId: session.steel_session_id,
166
+ viewerUrl: session.viewer_url,
167
+ cdpUrl: session.cdp_url,
168
+ status: session.status,
169
+ createdAt: session.created_at,
170
+ lastAccessed: session.last_accessed,
171
+ });
172
+ } catch {
173
+ res.status(500).json({ error: 'Failed to get session' });
174
+ }
175
+ });
176
+
177
+ // DELETE /api/browser/sessions/:id — release session
178
+ router.delete('/sessions/:id', async (req, res) => {
179
+ const userId = req.user.id;
180
+ const sessionId = req.params.id;
181
+
182
+ try {
183
+ // Verify ownership first
184
+ const db = await loadDb();
185
+ if (db) {
186
+ const session = await db.getBySessionId(userId, sessionId);
187
+ if (!session) return res.status(404).json({ error: 'Session not found' });
188
+ }
189
+
190
+ // Release Stagehand instance
191
+ const ai = await loadBrowserAI();
192
+ if (ai) await ai.release(sessionId);
193
+
194
+ // Release Steel session
195
+ await browserClient.releaseSession(userId, sessionId).catch(() => {});
196
+
197
+ // Deactivate in DB (user-scoped)
198
+ if (db) await db.deactivate(userId, sessionId);
199
+
200
+ res.json({ success: true });
201
+ } catch {
202
+ res.status(500).json({ error: 'Failed to close session' });
203
+ }
204
+ });
205
+
206
+ // ── Navigation ──────────────────────────────────────────────────────────────
207
+
208
+ // POST /api/browser/sessions/:id/navigate
209
+ router.post('/sessions/:id/navigate', async (req, res) => {
210
+ const { url } = req.body;
211
+ if (!url || !isUrlSafeOrSandbox(url)) {
212
+ return res.status(400).json({ error: 'Invalid or blocked URL' });
213
+ }
214
+
215
+ try {
216
+ const db = await loadDb();
217
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
218
+ if (db && !session) return res.status(404).json({ error: 'Session not found' });
219
+
220
+ if (db) await db.updateAccess(req.user.id, req.params.id);
221
+
222
+ // Use Stagehand/Playwright to navigate
223
+ const ai = await loadBrowserAI();
224
+ if (ai && await ai.isAvailable() && session?.cdp_url) {
225
+ await ai.act(req.params.id, session.cdp_url, `Navigate to ${url}`);
226
+ return res.json({ success: true, url });
227
+ }
228
+
229
+ res.json({ success: true, url, note: 'Navigation sent — viewer will update' });
230
+ } catch {
231
+ res.status(500).json({ error: 'Navigation failed' });
232
+ }
233
+ });
234
+
235
+ // POST /api/browser/sessions/:id/sandbox-preview — open sandbox dev server
236
+ router.post('/sessions/:id/sandbox-preview', async (req, res) => {
237
+ const portNum = parseInt(req.body?.port, 10);
238
+ if (!portNum || portNum < 1 || portNum > 65535) {
239
+ return res.status(400).json({ error: 'Invalid port number' });
240
+ }
241
+
242
+ try {
243
+ const db = await loadDb();
244
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
245
+ if (db && !session) return res.status(404).json({ error: 'Session not found' });
246
+
247
+ const sandboxUrl = browserClient.getSandboxPreviewUrl(req.user.id, portNum);
248
+
249
+ const ai = await loadBrowserAI();
250
+ if (ai && await ai.isAvailable() && session?.cdp_url) {
251
+ await ai.act(req.params.id, session.cdp_url, `Navigate to ${sandboxUrl}`);
252
+ }
253
+
254
+ if (db) await db.updateAccess(req.user.id, req.params.id);
255
+
256
+ res.json({ success: true, url: sandboxUrl, port: portNum });
257
+ } catch {
258
+ res.status(500).json({ error: 'Failed to open sandbox preview' });
259
+ }
260
+ });
261
+
262
+ // POST /api/browser/sessions/:id/screenshot
263
+ router.post('/sessions/:id/screenshot', async (req, res) => {
264
+ try {
265
+ // Verify ownership
266
+ const db = await loadDb();
267
+ if (db) {
268
+ const session = await db.getBySessionId(req.user.id, req.params.id);
269
+ if (!session) return res.status(404).json({ error: 'Session not found' });
270
+ }
271
+ const result = await browserClient.screenshot(req.user.id, req.params.id);
272
+ res.json(result);
273
+ } catch {
274
+ res.status(500).json({ error: 'Screenshot failed' });
275
+ }
276
+ });
277
+
278
+ // ── AI Actions ──────────────────────────────────────────────────────────────
279
+
280
+ // POST /api/browser/sessions/:id/ai/act — single AI action (chat mode)
281
+ router.post('/sessions/:id/ai/act', async (req, res) => {
282
+ const { instruction } = req.body;
283
+ if (!instruction) return res.status(400).json({ error: 'instruction is required' });
284
+
285
+ const ai = await loadBrowserAI();
286
+ if (!ai || !(await ai.isAvailable())) {
287
+ return res.status(503).json({ error: 'Browser AI not available' });
288
+ }
289
+
290
+ try {
291
+ const db = await loadDb();
292
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
293
+ if (!session?.cdp_url) return res.status(404).json({ error: 'Session not found or missing CDP URL' });
294
+
295
+ if (db) await db.updateAccess(req.user.id, req.params.id);
296
+
297
+ const result = await ai.act(req.params.id, session.cdp_url, instruction);
298
+ res.json(result);
299
+ } catch {
300
+ res.status(500).json({ error: 'AI action failed' });
301
+ }
302
+ });
303
+
304
+ // POST /api/browser/sessions/:id/ai/extract
305
+ router.post('/sessions/:id/ai/extract', async (req, res) => {
306
+ const { instruction, schema } = req.body;
307
+ if (!instruction) return res.status(400).json({ error: 'instruction is required' });
308
+
309
+ const ai = await loadBrowserAI();
310
+ if (!ai || !(await ai.isAvailable())) {
311
+ return res.status(503).json({ error: 'Browser AI not available' });
312
+ }
313
+
314
+ try {
315
+ const db = await loadDb();
316
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
317
+ if (!session?.cdp_url) return res.status(404).json({ error: 'Session not found' });
318
+
319
+ if (db) await db.updateAccess(req.user.id, req.params.id);
320
+
321
+ const result = await ai.extract(req.params.id, session.cdp_url, instruction, schema);
322
+ res.json(result);
323
+ } catch {
324
+ res.status(500).json({ error: 'AI extraction failed' });
325
+ }
326
+ });
327
+
328
+ // POST /api/browser/sessions/:id/ai/observe
329
+ router.post('/sessions/:id/ai/observe', async (req, res) => {
330
+ const { instruction } = req.body;
331
+
332
+ const ai = await loadBrowserAI();
333
+ if (!ai || !(await ai.isAvailable())) {
334
+ return res.status(503).json({ error: 'Browser AI not available' });
335
+ }
336
+
337
+ try {
338
+ const db = await loadDb();
339
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
340
+ if (!session?.cdp_url) return res.status(404).json({ error: 'Session not found' });
341
+
342
+ const result = await ai.observe(req.params.id, session.cdp_url, instruction);
343
+ res.json(result);
344
+ } catch {
345
+ res.status(500).json({ error: 'AI observation failed' });
346
+ }
347
+ });
348
+
349
+ // GET /api/browser/sessions/:id/ai/autonomous — autonomous agent with SSE streaming
350
+ router.get('/sessions/:id/ai/autonomous', async (req, res) => {
351
+ const { goal, maxSteps } = req.query;
352
+ if (!goal) {
353
+ res.status(400).json({ error: 'goal is required' });
354
+ return;
355
+ }
356
+
357
+ const ai = await loadBrowserAI();
358
+ if (!ai || !(await ai.isAvailable())) {
359
+ res.status(503).json({ error: 'Browser AI not available' });
360
+ return;
361
+ }
362
+
363
+ const db = await loadDb();
364
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
365
+ if (!session?.cdp_url) {
366
+ res.status(404).json({ error: 'Session not found' });
367
+ return;
368
+ }
369
+
370
+ // SSE setup
371
+ res.setHeader('Content-Type', 'text/event-stream');
372
+ res.setHeader('Cache-Control', 'no-cache');
373
+ res.setHeader('Connection', 'keep-alive');
374
+ res.flushHeaders();
375
+
376
+ const sendEvent = (data) => {
377
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
378
+ };
379
+
380
+ try {
381
+ if (db) await db.updateAccess(req.user.id, req.params.id);
382
+
383
+ const steps = Math.min(Math.max(parseInt(maxSteps) || 10, 1), 25);
384
+ await ai.autonomousGoal(
385
+ req.params.id,
386
+ session.cdp_url,
387
+ goal.slice(0, 500), // Truncate goal to prevent prompt injection via excessively long input
388
+ steps,
389
+ (stepData) => sendEvent(stepData)
390
+ );
391
+
392
+ sendEvent({ type: 'done' });
393
+ } catch {
394
+ sendEvent({ type: 'error', message: 'Autonomous run failed' });
395
+ }
396
+
397
+ res.end();
398
+ });
399
+
400
+ // GET /api/browser/sessions/:id/console-errors — get browser console errors
401
+ router.get('/sessions/:id/console-errors', async (req, res) => {
402
+ const ai = await loadBrowserAI();
403
+ if (!ai || !(await ai.isAvailable())) {
404
+ return res.json({ errors: [] });
405
+ }
406
+
407
+ try {
408
+ const db = await loadDb();
409
+ const session = db ? await db.getBySessionId(req.user.id, req.params.id) : null;
410
+ if (!session?.cdp_url) return res.json({ errors: [] });
411
+
412
+ const errors = await ai.getConsoleErrors(req.params.id, session.cdp_url);
413
+ res.json({ errors });
414
+ } catch {
415
+ res.json({ errors: [] });
416
+ }
417
+ });
418
+
419
+ export default router;