upfynai-code 3.0.2 → 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 +136 -136
  29. package/server/browser.js +131 -0
  30. package/server/database/db.js +108 -10
  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
@@ -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;
@@ -399,14 +399,8 @@ router.get('/clone-progress', async (req, res) => {
399
399
 
400
400
  const IS_CLOUD_ENV = !!(process.env.RAILWAY_ENVIRONMENT || process.env.VERCEL || process.env.RENDER);
401
401
 
402
- // Cloud mode: clone via relay on user's machine
402
+ // Cloud mode: clone via relay on user's machine, or sandbox if no relay
403
403
  if (IS_CLOUD_ENV) {
404
- if (!req.hasRelay || !req.hasRelay()) {
405
- sendEvent('error', { message: 'Machine not connected. Run "uc connect" on your local machine.' });
406
- res.end();
407
- return;
408
- }
409
-
410
404
  let githubToken = null;
411
405
  if (githubTokenId) {
412
406
  const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
@@ -420,13 +414,10 @@ router.get('/clone-progress', async (req, res) => {
420
414
  githubToken = newGithubToken;
421
415
  }
422
416
 
423
- sendEvent('progress', { message: 'Creating directory...' });
424
- await req.sendRelay('create-folder', { folderPath: workspacePath }, 15000);
425
-
426
417
  const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
427
418
  const repoName = normalizedUrl.split('/').pop() || 'repository';
428
- const clonePath = `${workspacePath}/${repoName}`;
429
419
 
420
+ // Build authenticated clone URL
430
421
  let cloneUrl = githubUrl;
431
422
  if (githubToken) {
432
423
  try {
@@ -439,19 +430,64 @@ router.get('/clone-progress', async (req, res) => {
439
430
  }
440
431
  }
441
432
 
442
- sendEvent('progress', { message: `Cloning into '${repoName}'...` });
433
+ // Option A: Relay connected — clone on user's machine
434
+ if (req.hasRelay && req.hasRelay()) {
435
+ const clonePath = `${workspacePath}/${repoName}`;
436
+
437
+ sendEvent('progress', { message: 'Creating directory...' });
438
+ await req.sendRelay('create-folder', { folderPath: workspacePath }, 15000);
439
+
440
+ sendEvent('progress', { message: `Cloning into '${repoName}'...` });
441
+
442
+ try {
443
+ await req.sendRelay('shell-command', {
444
+ command: `git clone "${cloneUrl}" "${clonePath}"`,
445
+ cwd: workspacePath
446
+ }, 120000);
447
+
448
+ const project = await addProjectManually(clonePath);
449
+ sendEvent('complete', { project, message: 'Repository cloned successfully' });
450
+ } catch (error) {
451
+ const sanitized = sanitizeGitError(error.message, githubToken);
452
+ sendEvent('error', { message: sanitized || 'Git clone failed' });
453
+ }
454
+
455
+ res.end();
456
+ return;
457
+ }
443
458
 
459
+ // Option B: No relay — clone into per-user sandbox
444
460
  try {
445
- await req.sendRelay('shell-command', {
446
- command: `git clone "${cloneUrl}" "${clonePath}"`,
447
- cwd: workspacePath
448
- }, 120000);
461
+ const { sandboxClient } = await import('../sandbox.js');
462
+ const sandboxAvailable = await sandboxClient.isAvailable();
463
+ if (!sandboxAvailable) {
464
+ sendEvent('error', { message: 'No machine connected and sandbox unavailable. Run "uc web connect" to connect your machine.' });
465
+ res.end();
466
+ return;
467
+ }
449
468
 
450
- const project = await addProjectManually(clonePath);
451
- sendEvent('complete', { project, message: 'Repository cloned successfully' });
469
+ const userId = req.user.id;
470
+ sendEvent('progress', { message: 'Initializing sandbox...' });
471
+ await sandboxClient.initSandbox(userId);
472
+
473
+ const sandboxPath = `/workspace/${repoName}`;
474
+ sendEvent('progress', { message: `Cloning into '${repoName}' (sandbox)...` });
475
+
476
+ await sandboxClient.exec(userId, `git clone "${cloneUrl}" "${sandboxPath}"`, { timeout: 120000 });
477
+
478
+ sendEvent('progress', { message: 'Registering project...' });
479
+
480
+ // Save project with github_origin
481
+ const { projectDb } = await import('../database/db.js');
482
+ const project = await projectDb.upsert(userId, sandboxPath, repoName, githubUrl);
483
+
484
+ sendEvent('complete', {
485
+ project: { ...project, displayName: repoName, originalPath: sandboxPath, githubOrigin: githubUrl },
486
+ message: 'Repository cloned into sandbox'
487
+ });
452
488
  } catch (error) {
453
489
  const sanitized = sanitizeGitError(error.message, githubToken);
454
- sendEvent('error', { message: sanitized || 'Git clone failed' });
490
+ sendEvent('error', { message: sanitized || 'Sandbox clone failed' });
455
491
  }
456
492
 
457
493
  res.end();
@@ -652,4 +688,67 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
652
688
  });
653
689
  }
654
690
 
691
+ /**
692
+ * Push sandbox changes back to GitHub
693
+ * POST /api/projects/:projectName/push
694
+ */
695
+ router.post('/:projectName/push', async (req, res) => {
696
+ const { branch, commitMessage } = req.body;
697
+ const userId = req.user.id;
698
+
699
+ try {
700
+ const { projectDb } = await import('../database/db.js');
701
+ const project = await projectDb.getByName(userId, req.params.projectName);
702
+
703
+ if (!project) {
704
+ return res.status(404).json({ error: 'Project not found' });
705
+ }
706
+ if (!project.github_origin) {
707
+ return res.status(400).json({ error: 'Not a GitHub project — no origin to push to' });
708
+ }
709
+
710
+ // Get user's GitHub token
711
+ const { getDatabase } = await import('../database/db.js');
712
+ const db = await getDatabase();
713
+ const cred = await db.execute({
714
+ sql: 'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 LIMIT 1',
715
+ args: [userId, 'github_token']
716
+ });
717
+
718
+ const githubToken = cred.rows[0]?.credential_value;
719
+ if (!githubToken) {
720
+ return res.status(400).json({ error: 'No GitHub token configured. Add one in Settings > AI Providers.' });
721
+ }
722
+
723
+ // Set remote URL with token for auth
724
+ const originUrl = new URL(project.github_origin.endsWith('.git') ? project.github_origin : `${project.github_origin}.git`);
725
+ originUrl.username = githubToken;
726
+ originUrl.password = 'x-oauth-basic';
727
+
728
+ const { sandboxClient } = await import('../sandbox.js');
729
+ const cwd = project.original_path;
730
+ const targetBranch = branch || 'main';
731
+ const msg = commitMessage || 'Update from Upfyn Code';
732
+
733
+ // Set remote, stage, commit, push
734
+ await sandboxClient.exec(userId, `git remote set-url origin "${originUrl.toString()}"`, { cwd });
735
+ await sandboxClient.exec(userId, 'git add -A', { cwd });
736
+
737
+ try {
738
+ await sandboxClient.exec(userId, `git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd });
739
+ } catch {
740
+ return res.json({ success: true, message: 'No changes to commit' });
741
+ }
742
+
743
+ await sandboxClient.exec(userId, `git push origin ${targetBranch}`, { cwd, timeout: 60000 });
744
+
745
+ // Clean the token from the remote URL after push
746
+ await sandboxClient.exec(userId, `git remote set-url origin "${project.github_origin}"`, { cwd });
747
+
748
+ res.json({ success: true, message: `Pushed to ${targetBranch}` });
749
+ } catch (error) {
750
+ res.status(500).json({ error: error.message || 'Push failed' });
751
+ }
752
+ });
753
+
655
754
  export default router;
@@ -488,7 +488,7 @@ async function handleTroubleshootConnection(metadata) {
488
488
  connected: false,
489
489
  message: 'Machine is NOT connected. Tell the user to open a terminal and run: uc connect',
490
490
  troubleshooting: [
491
- 'Make sure upfynai-code is installed: npm install -g upfynai-code',
491
+ 'Make sure upfynai-code is installed: npm install -g @upfynai-code/app',
492
492
  'Run: uc connect',
493
493
  'Check if their firewall is blocking WebSocket connections',
494
494
  'Try: uc doctor — to diagnose issues',