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.
- package/client/dist/api-docs.html +838 -838
- package/client/dist/assets/{AppContent-Bvg0CPCO.js → AppContent-CwrTP6TW.js} +43 -43
- package/client/dist/assets/BrowserPanel-0TLEl-IC.js +2 -0
- package/client/dist/assets/{CanvasFullScreen-BdiJ35aq.js → CanvasFullScreen-D1GWQsGL.js} +1 -1
- package/client/dist/assets/{CanvasWorkspace-Bk9R9_e0.js → CanvasWorkspace-D7ORj358.js} +1 -1
- package/client/dist/assets/DashboardPanel-BV7ybUDe.js +1 -0
- package/client/dist/assets/FileTree-5qfhBqdE.js +1 -0
- package/client/dist/assets/{GitPanel-RtyZUIWS.js → GitPanel-C_xFM-N2.js} +1 -1
- package/client/dist/assets/{LoginModal-BWep8a6g.js → LoginModal-CImJHRjX.js} +3 -3
- package/client/dist/assets/{MarkdownPreview-DHmk3qzu.js → MarkdownPreview-CESjI261.js} +1 -1
- package/client/dist/assets/{MermaidBlock-BuBc_G-F.js → MermaidBlock-BFM21cwe.js} +2 -2
- package/client/dist/assets/Onboarding-B3cteLu2.js +1 -0
- package/client/dist/assets/SetupForm-P6dsYgHO.js +1 -0
- package/client/dist/assets/WorkflowsPanel-CBoN80kc.js +1 -0
- package/client/dist/assets/index-46kkVu2i.css +1 -0
- package/client/dist/assets/{index-C5ptjuTl.js → index-HaY-3pK1.js} +20 -20
- package/client/dist/assets/{vendor-canvas-D39yWul6.js → vendor-canvas-DvHJ_Pn2.js} +1 -1
- package/client/dist/assets/{vendor-codemirror-CbtmxxaB.js → vendor-codemirror-D2ALgpaX.js} +1 -1
- package/client/dist/assets/{vendor-icons-BaD0x9SL.js → vendor-icons-GyYE35HP.js} +178 -138
- package/client/dist/assets/{vendor-mermaid-CH7SGc99.js → vendor-mermaid-DucWyDEe.js} +3 -3
- package/client/dist/assets/{vendor-syntax-DuHI9Ok6.js → vendor-syntax-LS_Nt30I.js} +1 -1
- package/client/dist/clear-cache.html +85 -85
- package/client/dist/index.html +17 -17
- package/client/dist/manifest.json +3 -3
- package/client/dist/mcp-docs.html +108 -108
- package/client/dist/offline.html +84 -84
- package/client/dist/sw.js +82 -82
- package/package.json +1 -1
- package/server/browser.js +131 -0
- package/server/database/db.js +102 -6
- package/server/index.js +27 -28
- package/server/middleware/auth.js +5 -2
- package/server/routes/browser.js +419 -0
- package/server/routes/projects.js +118 -19
- package/server/routes/vapi-chat.js +1 -1
- package/server/services/browser-ai.js +154 -0
- package/client/dist/assets/DashboardPanel-CblJfTGi.js +0 -1
- package/client/dist/assets/FileTree-BDUnBheV.js +0 -1
- package/client/dist/assets/Onboarding-Drnlt75a.js +0 -1
- package/client/dist/assets/SetupForm-CtCKitZG.js +0 -1
- package/client/dist/assets/WorkflowsPanel-B2mIXDvD.js +0 -1
- 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
|
-
//
|
|
586
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
3609
|
-
app.get('
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
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 ? '
|
|
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({
|
|
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
|
|
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
|
-
// '
|
|
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 ? '
|
|
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;
|