upfynai-code 3.0.3 → 3.1.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.
Files changed (243) hide show
  1. package/README.md +66 -91
  2. package/bin/cli.js +191 -0
  3. package/{client/dist → dist/client}/api-docs.html +838 -838
  4. package/{client/dist/assets/AppContent-Bvg0CPCO.js → dist/client/assets/AppContent-BofJquUs.js} +43 -43
  5. package/dist/client/assets/BrowserPanel-CSvD4jOX.js +2 -0
  6. package/dist/client/assets/CanvasFullScreen-onRfarpc.js +1 -0
  7. package/dist/client/assets/CanvasWorkspace-DvGKdL-k.js +259 -0
  8. package/dist/client/assets/DashboardPanel-DqAHbXDO.js +1 -0
  9. package/dist/client/assets/FileTree-BE0h-9M9.js +1 -0
  10. package/{client/dist/assets/GitPanel-RtyZUIWS.js → dist/client/assets/GitPanel-DdeJ0bp5.js} +2 -2
  11. package/{client/dist/assets/LoginModal-BWep8a6g.js → dist/client/assets/LoginModal-BP0pCTrH.js} +3 -3
  12. package/{client/dist/assets/MarkdownPreview-DHmk3qzu.js → dist/client/assets/MarkdownPreview-CESjI261.js} +1 -1
  13. package/dist/client/assets/MermaidBlock-D0rfEhrT.js +2 -0
  14. package/dist/client/assets/Onboarding-B2zQy-_6.js +1 -0
  15. package/dist/client/assets/SetupForm-Be7-WBe-.js +1 -0
  16. package/dist/client/assets/WorkflowsPanel-CusLbVJ6.js +1 -0
  17. package/{client/dist/assets/index-C5ptjuTl.js → dist/client/assets/index-BQy15irW.js} +25 -25
  18. package/dist/client/assets/index-CS0fDqEC.js +1 -0
  19. package/dist/client/assets/index-DYLSCCCp.css +1 -0
  20. package/dist/client/assets/vendor-canvas-QWTduIvM.js +23 -0
  21. package/{client/dist/assets/vendor-codemirror-CbtmxxaB.js → dist/client/assets/vendor-codemirror-D2ALgpaX.js} +1 -1
  22. package/{client/dist/assets/vendor-icons-BaD0x9SL.js → dist/client/assets/vendor-icons-kix3Gb31.js} +178 -138
  23. package/{client/dist/assets/vendor-mermaid-CH7SGc99.js → dist/client/assets/vendor-mermaid-CS3J4_Bz.js} +329 -326
  24. package/{client/dist/assets/vendor-syntax-DuHI9Ok6.js → dist/client/assets/vendor-syntax-LS_Nt30I.js} +1 -1
  25. package/{client/dist → dist/client}/clear-cache.html +85 -85
  26. package/dist/client/favicon.png +0 -0
  27. package/dist/client/favicon.svg +15 -0
  28. package/{client/dist → dist/client}/index.html +17 -17
  29. package/{client/dist → dist/client}/manifest.json +15 -15
  30. package/{client/dist → dist/client}/mcp-docs.html +108 -108
  31. package/{client/dist → dist/client}/offline.html +84 -84
  32. package/{client/dist → dist/client}/sw.js +82 -82
  33. package/package.json +55 -104
  34. package/scripts/postinstall.js +9 -0
  35. package/scripts/prepublish.js +77 -0
  36. package/src/animation.js +228 -0
  37. package/src/auth.js +142 -0
  38. package/src/config.js +40 -0
  39. package/src/connect.js +416 -0
  40. package/src/launch.js +81 -0
  41. package/src/mcp.js +57 -0
  42. package/src/permissions.js +140 -0
  43. package/src/persistent-shell.js +261 -0
  44. package/src/server.js +54 -0
  45. package/client/dist/assets/CanvasFullScreen-BdiJ35aq.js +0 -1
  46. package/client/dist/assets/CanvasWorkspace-Bk9R9_e0.js +0 -163
  47. package/client/dist/assets/DashboardPanel-CblJfTGi.js +0 -1
  48. package/client/dist/assets/FileTree-BDUnBheV.js +0 -1
  49. package/client/dist/assets/MermaidBlock-BuBc_G-F.js +0 -2
  50. package/client/dist/assets/Onboarding-Drnlt75a.js +0 -1
  51. package/client/dist/assets/SetupForm-CtCKitZG.js +0 -1
  52. package/client/dist/assets/WorkflowsPanel-B2mIXDvD.js +0 -1
  53. package/client/dist/assets/index-BFuqS0tY.css +0 -1
  54. package/client/dist/assets/vendor-canvas-D39yWul6.js +0 -49
  55. package/client/dist/favicon.png +0 -0
  56. package/client/dist/favicon.svg +0 -5
  57. package/commands/upfynai-connect.md +0 -59
  58. package/commands/upfynai-disconnect.md +0 -31
  59. package/commands/upfynai-doctor.md +0 -99
  60. package/commands/upfynai-export.md +0 -49
  61. package/commands/upfynai-local.md +0 -82
  62. package/commands/upfynai-status.md +0 -75
  63. package/commands/upfynai-stop.md +0 -49
  64. package/commands/upfynai-uninstall.md +0 -58
  65. package/commands/upfynai.md +0 -69
  66. package/scripts/build-client.js +0 -17
  67. package/scripts/fix-node-pty.js +0 -67
  68. package/scripts/install-commands.js +0 -78
  69. package/server/agent-loop.js +0 -242
  70. package/server/auto-compact.js +0 -99
  71. package/server/claude-sdk.js +0 -797
  72. package/server/cli-ui.js +0 -798
  73. package/server/cli.js +0 -751
  74. package/server/constants/config.js +0 -31
  75. package/server/cursor-cli.js +0 -270
  76. package/server/database/auth.db +0 -0
  77. package/server/database/db.js +0 -1451
  78. package/server/database/init.sql +0 -70
  79. package/server/index.js +0 -3814
  80. package/server/load-env.js +0 -26
  81. package/server/mcp-server.js +0 -621
  82. package/server/middleware/auth.js +0 -181
  83. package/server/middleware/relayHelpers.js +0 -44
  84. package/server/middleware/sandboxRouter.js +0 -174
  85. package/server/openai-codex.js +0 -403
  86. package/server/openrouter.js +0 -137
  87. package/server/projects.js +0 -1807
  88. package/server/provider-factory.js +0 -174
  89. package/server/relay-client.js +0 -390
  90. package/server/routes/agent.js +0 -1234
  91. package/server/routes/auth.js +0 -559
  92. package/server/routes/canvas.js +0 -53
  93. package/server/routes/cli-auth.js +0 -263
  94. package/server/routes/codex.js +0 -396
  95. package/server/routes/commands.js +0 -707
  96. package/server/routes/composio.js +0 -176
  97. package/server/routes/cursor.js +0 -770
  98. package/server/routes/dashboard.js +0 -295
  99. package/server/routes/git.js +0 -1208
  100. package/server/routes/keys.js +0 -34
  101. package/server/routes/mcp-utils.js +0 -48
  102. package/server/routes/mcp.js +0 -661
  103. package/server/routes/payments.js +0 -227
  104. package/server/routes/projects.js +0 -655
  105. package/server/routes/sessions.js +0 -146
  106. package/server/routes/settings.js +0 -261
  107. package/server/routes/taskmaster.js +0 -1928
  108. package/server/routes/user.js +0 -106
  109. package/server/routes/vapi-chat.js +0 -624
  110. package/server/routes/voice.js +0 -235
  111. package/server/routes/webhooks.js +0 -166
  112. package/server/routes/workflows.js +0 -312
  113. package/server/sandbox.js +0 -120
  114. package/server/services/composio.js +0 -204
  115. package/server/services/sessionRegistry.js +0 -139
  116. package/server/services/whisperService.js +0 -84
  117. package/server/services/workflowScheduler.js +0 -211
  118. package/server/tests/relay-flow.test.js +0 -570
  119. package/server/tests/sessions.test.js +0 -259
  120. package/server/utils/commandParser.js +0 -303
  121. package/server/utils/email.js +0 -66
  122. package/server/utils/gitConfig.js +0 -24
  123. package/server/utils/mcp-detector.js +0 -198
  124. package/server/utils/taskmaster-websocket.js +0 -129
  125. package/shared/integrationCatalog.d.ts +0 -12
  126. package/shared/integrationCatalog.js +0 -172
  127. package/shared/modelConstants.js +0 -96
  128. /package/{shared → dist}/agents/claude.js +0 -0
  129. /package/{shared → dist}/agents/codex.js +0 -0
  130. /package/{shared → dist}/agents/cursor.js +0 -0
  131. /package/{shared → dist}/agents/detect.js +0 -0
  132. /package/{shared → dist}/agents/exec.js +0 -0
  133. /package/{shared → dist}/agents/files.js +0 -0
  134. /package/{shared → dist}/agents/git.js +0 -0
  135. /package/{shared → dist}/agents/gitagent.js +0 -0
  136. /package/{shared → dist}/agents/index.js +0 -0
  137. /package/{shared → dist}/agents/shell.js +0 -0
  138. /package/{shared → dist}/agents/utils.js +0 -0
  139. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  140. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  141. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  142. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  143. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  144. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  145. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  146. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  147. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  148. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  149. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  150. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  151. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  152. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  153. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  154. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  155. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  156. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  157. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  158. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  159. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  160. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  161. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  162. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  163. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  164. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  165. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  166. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  167. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  168. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  169. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  170. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  171. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  172. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  173. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  174. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  175. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  176. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  177. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  178. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  179. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  180. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  181. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  182. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  183. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  184. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  185. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  186. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  187. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  188. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  189. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  190. /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  191. /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  192. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  193. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  194. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  195. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  196. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  197. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  198. /package/{client/dist → dist/client}/assets/PreviewPanel-CqCa92Tf.js +0 -0
  199. /package/{client/dist → dist/client}/assets/pdf-CE_K4jFx.js +0 -0
  200. /package/{client/dist → dist/client}/assets/vendor-canvas-BZV40eAE.css +0 -0
  201. /package/{client/dist → dist/client}/assets/vendor-diff-DNQpbhrT.js +0 -0
  202. /package/{client/dist → dist/client}/assets/vendor-i18n-DCFGyhQR.js +0 -0
  203. /package/{client/dist → dist/client}/assets/vendor-markdown-CimbIo6Y.js +0 -0
  204. /package/{client/dist → dist/client}/assets/vendor-react-96lCPsRK.js +0 -0
  205. /package/{client/dist → dist/client}/assets/vendor-xterm-CZq1hqo1.js +0 -0
  206. /package/{client/dist → dist/client}/assets/vendor-xterm-qxJ8_QYu.css +0 -0
  207. /package/{client/dist → dist/client}/convert-icons.md +0 -0
  208. /package/{client/dist → dist/client}/generate-icons.js +0 -0
  209. /package/{client/dist → dist/client}/icons/claude-ai-icon.svg +0 -0
  210. /package/{client/dist → dist/client}/icons/codex-white.svg +0 -0
  211. /package/{client/dist → dist/client}/icons/codex.svg +0 -0
  212. /package/{client/dist → dist/client}/icons/cursor-white.svg +0 -0
  213. /package/{client/dist → dist/client}/icons/cursor.svg +0 -0
  214. /package/{client/dist → dist/client}/icons/icon-128x128.png +0 -0
  215. /package/{client/dist → dist/client}/icons/icon-128x128.svg +0 -0
  216. /package/{client/dist → dist/client}/icons/icon-144x144.png +0 -0
  217. /package/{client/dist → dist/client}/icons/icon-144x144.svg +0 -0
  218. /package/{client/dist → dist/client}/icons/icon-152x152.png +0 -0
  219. /package/{client/dist → dist/client}/icons/icon-152x152.svg +0 -0
  220. /package/{client/dist → dist/client}/icons/icon-192x192.png +0 -0
  221. /package/{client/dist → dist/client}/icons/icon-192x192.svg +0 -0
  222. /package/{client/dist → dist/client}/icons/icon-384x384.png +0 -0
  223. /package/{client/dist → dist/client}/icons/icon-384x384.svg +0 -0
  224. /package/{client/dist → dist/client}/icons/icon-512x512.png +0 -0
  225. /package/{client/dist → dist/client}/icons/icon-512x512.svg +0 -0
  226. /package/{client/dist → dist/client}/icons/icon-72x72.png +0 -0
  227. /package/{client/dist → dist/client}/icons/icon-72x72.svg +0 -0
  228. /package/{client/dist → dist/client}/icons/icon-96x96.png +0 -0
  229. /package/{client/dist → dist/client}/icons/icon-96x96.svg +0 -0
  230. /package/{client/dist → dist/client}/icons/icon-template.svg +0 -0
  231. /package/{client/dist → dist/client}/logo-128.png +0 -0
  232. /package/{client/dist → dist/client}/logo-256.png +0 -0
  233. /package/{client/dist → dist/client}/logo-32.png +0 -0
  234. /package/{client/dist → dist/client}/logo-512.png +0 -0
  235. /package/{client/dist → dist/client}/logo-64.png +0 -0
  236. /package/{client/dist → dist/client}/logo.svg +0 -0
  237. /package/{client/dist → dist/client}/screenshots/cli-selection.png +0 -0
  238. /package/{client/dist → dist/client}/screenshots/desktop-main.png +0 -0
  239. /package/{client/dist → dist/client}/screenshots/mobile-chat.png +0 -0
  240. /package/{client/dist → dist/client}/screenshots/tools-modal.png +0 -0
  241. /package/{shared → dist}/gitagent/index.js +0 -0
  242. /package/{shared → dist}/gitagent/parser.js +0 -0
  243. /package/{shared → dist}/gitagent/prompt-builder.js +0 -0
package/server/index.js DELETED
@@ -1,3814 +0,0 @@
1
- #!/usr/bin/env node
2
- // Load environment variables before other imports execute
3
- import './load-env.js';
4
-
5
- // Strip Claude Code session markers so spawned CLI processes don't fail with
6
- // "cannot be launched inside another Claude Code session" errors.
7
- delete process.env.CLAUDECODE;
8
- delete process.env.CLAUDE_CODE;
9
- import crypto from 'crypto';
10
- import fs from 'fs';
11
- import path from 'path';
12
- import jwt from 'jsonwebtoken';
13
- import { fileURLToPath } from 'url';
14
- import { dirname } from 'path';
15
-
16
- const __filename = fileURLToPath(import.meta.url);
17
- const __dirname = dirname(__filename);
18
-
19
- // Read version from package.json at startup
20
- const SERVER_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
21
-
22
- // ANSI color codes for terminal output
23
- const colors = {
24
- reset: '\x1b[0m',
25
- bright: '\x1b[1m',
26
- cyan: '\x1b[36m',
27
- green: '\x1b[32m',
28
- yellow: '\x1b[33m',
29
- blue: '\x1b[34m',
30
- dim: '\x1b[2m',
31
- };
32
-
33
- const c = {
34
- info: (text) => `${colors.cyan}${text}${colors.reset}`,
35
- ok: (text) => `${colors.green}${text}${colors.reset}`,
36
- warn: (text) => `${colors.yellow}${text}${colors.reset}`,
37
- tip: (text) => `${colors.blue}${text}${colors.reset}`,
38
- bright: (text) => `${colors.bright}${text}${colors.reset}`,
39
- dim: (text) => `${colors.dim}${text}${colors.reset}`,
40
- };
41
-
42
- // PORT read from env (no log)
43
-
44
- import express from 'express';
45
- import { WebSocketServer, WebSocket } from 'ws';
46
- import os from 'os';
47
- import http from 'http';
48
- import cors from 'cors';
49
- import cookieParser from 'cookie-parser';
50
- import { promises as fsPromises } from 'fs';
51
- import { spawn } from 'child_process';
52
- // node-pty: conditionally imported (not available on Vercel serverless)
53
- let pty = null;
54
- try {
55
- pty = (await import('node-pty')).default;
56
- } catch (e) {
57
- console.warn('[WARN] node-pty not available. Shell tab requires relay connection.');
58
- }
59
- // Node 22+ has built-in fetch — no need for node-fetch
60
- import mime from 'mime-types';
61
-
62
- import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, setCloudUserId } from './projects.js';
63
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, loadGitagentContext, clearGitagentCache, extractTokenBudget } from './claude-sdk.js';
64
- import { buildSystemPromptAppendix } from '../shared/gitagent/prompt-builder.js';
65
- import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
66
- import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
67
- import { queryOpenRouter, OPENROUTER_MODELS } from './openrouter.js';
68
- import { createMcpServer, mountMcpServer } from './mcp-server.js';
69
- import gitRoutes from './routes/git.js';
70
- import authRoutes from './routes/auth.js';
71
- import mcpRoutes from './routes/mcp.js';
72
- import cursorRoutes from './routes/cursor.js';
73
- import taskmasterRoutes from './routes/taskmaster.js';
74
- import mcpUtilsRoutes from './routes/mcp-utils.js';
75
- import commandsRoutes from './routes/commands.js';
76
- import settingsRoutes from './routes/settings.js';
77
- import agentRoutes from './routes/agent.js';
78
- import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
79
- import cliAuthRoutes from './routes/cli-auth.js';
80
- import userRoutes from './routes/user.js';
81
- import codexRoutes from './routes/codex.js';
82
- import paymentRoutes from './routes/payments.js';
83
- import webhookRoutes from './routes/webhooks.js';
84
- import workflowRoutes from './routes/workflows.js';
85
- import voiceRoutes from './routes/voice.js';
86
- import dashboardRoutes from './routes/dashboard.js';
87
- import keysRoutes from './routes/keys.js';
88
- import assistantRoutes from './routes/vapi-chat.js';
89
- import canvasRoutes from './routes/canvas.js';
90
- import composioRoutes from './routes/composio.js';
91
- import sessionRoutes from './routes/sessions.js';
92
- import { handleSandboxWebSocketCommand } from './middleware/sandboxRouter.js';
93
- import { createRelayMiddleware } from './middleware/relayHelpers.js';
94
- import { initScheduler } from './services/workflowScheduler.js';
95
- import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb, fileVersionDb, sessionUsageDb, connectionDb, projectDb, voiceCallDb } from './database/db.js';
96
- import { validateApiKey, authenticateToken, authenticateWebSocket, JWT_SECRET } from './middleware/auth.js';
97
- import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
98
- import { sandboxClient } from './sandbox.js';
99
- import { execSync } from 'child_process';
100
-
101
- // ─── Project ownership middleware (user isolation) ──────────────────────────────
102
- // Ensures the requesting user owns the project before allowing access.
103
- // In cloud mode: checks user_projects table. In local mode: always passes (single user).
104
- const IS_CLOUD_ENV = !!(process.env.TURSO_DATABASE_URL && !process.env.IS_LOCAL_SERVER);
105
-
106
- function authorizeProject(req, res, next) {
107
- const projectName = req.params.projectName;
108
- if (!projectName) return next();
109
-
110
- // Local mode — single user, no isolation needed
111
- if (!IS_CLOUD_ENV) return next();
112
-
113
- const userId = req.user?.id || req.user?.userId;
114
- if (!userId) return res.status(401).json({ error: 'Authentication required' });
115
-
116
- // Check user_projects table for ownership
117
- projectDb.getAll(userId).then(projects => {
118
- const owns = projects.some(p => p.project_name === projectName || p.original_path === projectName);
119
- if (!owns) {
120
- return res.status(403).json({ error: 'Access denied — you do not own this project' });
121
- }
122
- next();
123
- }).catch(() => {
124
- res.status(500).json({ error: 'Internal server error' });
125
- });
126
- }
127
-
128
- // File system watchers for provider project/session folders
129
- const PROVIDER_WATCH_PATHS = [
130
- { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
131
- { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
132
- { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
133
- ];
134
- const WATCHER_IGNORED_PATTERNS = [
135
- '**/node_modules/**',
136
- '**/.git/**',
137
- '**/dist/**',
138
- '**/build/**',
139
- '**/*.tmp',
140
- '**/*.swp',
141
- '**/.DS_Store'
142
- ];
143
- const WATCHER_DEBOUNCE_MS = 300;
144
- let projectsWatchers = [];
145
- let projectsWatcherDebounceTimer = null;
146
- const connectedClients = new Set();
147
- let isGetProjectsRunning = false; // Flag to prevent reentrant calls
148
-
149
- // Relay connections: Maps userId → { ws, capabilities, user }
150
- // Connects user's local machine to the hosted server
151
- const relayConnections = new Map();
152
- // Pending relay requests: Maps requestId → { resolve, reject, timeout }
153
- const pendingRelayRequests = new Map();
154
- // Relay session tracking: Maps sessionId → { userId, requestId } (for abort forwarding)
155
- const relaySessionRequests = new Map();
156
- // Sandbox cleanup timers: Maps userId → timeout (grace period before sandbox destroy)
157
- const sandboxCleanupTimers = new Map();
158
- const SANDBOX_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
159
-
160
- // ─── Relay Security ──────────────────────────────────────────────────────────
161
-
162
- // Allowed relay actions — only these can be sent to user's machine.
163
- // Anything not on this list is rejected to prevent arbitrary command execution.
164
- const ALLOWED_RELAY_ACTIONS = new Set([
165
- 'claude-query', 'codex-query', 'cursor-query',
166
- 'claude-task-query', // Sub-agent: read-only research (opencode pattern)
167
- 'exec', 'shell-command', 'shell-session-start', // Shell execution & interactive terminal
168
- 'file-read', 'file-write', 'file-tree',
169
- 'browse-dirs', 'validate-path', 'create-folder',
170
- 'git-operation', 'detect-agents',
171
- 'gitagent-detect', 'gitagent-parse', // Gitagent directory structure detection
172
- ]);
173
-
174
- // Dangerous shell patterns — block injection attempts in shell-command payloads.
175
- // These patterns catch common shell injection techniques: pipes, chaining,
176
- // backtick execution, subshells, redirection to sensitive files, etc.
177
- const DANGEROUS_SHELL_PATTERNS = [
178
- /;\s*(rm|del|format|mkfs|dd)\b/i, // destructive chained commands
179
- /\|\s*(bash|sh|cmd|powershell|nc|ncat|curl\s.*\|)/i, // pipe to shell/reverse-shell
180
- /`[^`]*`/, // backtick command substitution
181
- /\$\([^)]*\)/, // $() command substitution
182
- />\s*\/etc\//, // redirect to system files
183
- />\s*C:\\Windows\\/i, // redirect to Windows system
184
- /&&\s*(rm|del|format|shutdown|reboot)\b/i, // chain destructive commands
185
- /\|\|\s*(rm|del|format)\b/i, // fallback destructive
186
- /;\s*(curl|wget|nc)\s.*\s*\|/i, // download-and-execute
187
- /eval\s*\(/, // eval injection
188
- /\bsudo\b/, // privilege escalation
189
- /\bchmod\s+[0-7]*[67][0-7]*\s/, // making files world-writable
190
- ];
191
-
192
- /**
193
- * Extract real client IP from request (respects proxy headers on Railway/Vercel).
194
- * Railway sets x-forwarded-for; we trust the first IP in the chain.
195
- */
196
- function extractClientIp(request) {
197
- const forwarded = request?.headers?.['x-forwarded-for'];
198
- if (forwarded) {
199
- // x-forwarded-for may be comma-separated; first is the real client
200
- return forwarded.split(',')[0].trim();
201
- }
202
- return request?.socket?.remoteAddress || request?.connection?.remoteAddress || 'unknown';
203
- }
204
-
205
- /**
206
- * Validate a relay command payload for safety.
207
- * Returns { valid: true } or { valid: false, reason: string }.
208
- */
209
- function validateRelayPayload(action, payload) {
210
- if (!ALLOWED_RELAY_ACTIONS.has(action)) {
211
- return { valid: false, reason: `Unknown relay action: ${action}` };
212
- }
213
-
214
- // For shell-command, validate the command string
215
- if (action === 'shell-command' && payload?.command) {
216
- const cmd = payload.command;
217
-
218
- // Must be a string
219
- if (typeof cmd !== 'string') {
220
- return { valid: false, reason: 'Command must be a string' };
221
- }
222
-
223
- // Max command length (prevent buffer overflow attempts)
224
- if (cmd.length > 10000) {
225
- return { valid: false, reason: 'Command too long' };
226
- }
227
-
228
- // Only allow git commands and known safe commands via shell-command
229
- // This is the key security gate: shell-command is only used for git operations
230
- // and system commands like mkdir, rm (for discard). Block everything else.
231
- const allowedPrefixes = ['git ', 'mkdir ', 'rm ', 'wmic ', 'dir ', 'claude ', 'codex ', 'npm '];
232
- const isAllowedCommand = allowedPrefixes.some(prefix => cmd.trimStart().startsWith(prefix));
233
- if (!isAllowedCommand) {
234
- return { valid: false, reason: `Shell command not allowed: ${cmd.substring(0, 50)}` };
235
- }
236
-
237
- // Check for dangerous patterns even in allowed commands
238
- for (const pattern of DANGEROUS_SHELL_PATTERNS) {
239
- if (pattern.test(cmd)) {
240
- return { valid: false, reason: 'Dangerous command pattern detected' };
241
- }
242
- }
243
- }
244
-
245
- // For file operations, validate paths
246
- if (['file-read', 'file-write', 'file-tree', 'create-folder'].includes(action)) {
247
- const filePath = payload?.filePath || payload?.dirPath || payload?.folderPath;
248
- if (filePath && typeof filePath === 'string') {
249
- // Block path traversal attempts
250
- if (filePath.includes('..') && (filePath.includes('/etc/') || filePath.includes('/proc/') || filePath.includes('\\Windows\\System32'))) {
251
- return { valid: false, reason: 'Path traversal blocked' };
252
- }
253
- }
254
- }
255
-
256
- return { valid: true };
257
- }
258
-
259
- // Session-tab locking: Maps sessionId → WebSocket connection
260
- // Prevents the same session from being active in multiple tabs
261
- const sessionLocks = new Map();
262
-
263
- // Session-to-user ownership map — tracks which userId owns each sessionId
264
- // Used by session REST routes to enforce user isolation
265
- const sessionOwners = new Map();
266
-
267
- // Session busy detection (opencode pattern: IsSessionBusy)
268
- // Maps sessionId → true when a query is being processed
269
- const busySessions = new Map();
270
-
271
- function markSessionBusy(sessionId) {
272
- if (!sessionId) return true;
273
- if (busySessions.has(sessionId)) return false; // already busy
274
- busySessions.set(sessionId, true);
275
- return true;
276
- }
277
-
278
- function markSessionFree(sessionId) {
279
- if (sessionId) busySessions.delete(sessionId);
280
- }
281
-
282
- function acquireSessionLock(sessionId, ws) {
283
- if (!sessionId) return true; // New sessions don't need locks yet
284
- const existingWs = sessionLocks.get(sessionId);
285
- if (existingWs && existingWs !== ws && existingWs.readyState === 1) {
286
- return false; // Another tab has this session locked
287
- }
288
- sessionLocks.set(sessionId, ws);
289
- return true;
290
- }
291
-
292
- function releaseSessionLock(sessionId, ws) {
293
- const lockedWs = sessionLocks.get(sessionId);
294
- if (lockedWs === ws) {
295
- sessionLocks.delete(sessionId);
296
- }
297
- }
298
-
299
- function releaseAllLocksForWs(ws) {
300
- for (const [sessionId, lockedWs] of sessionLocks.entries()) {
301
- if (lockedWs === ws) {
302
- sessionLocks.delete(sessionId);
303
- }
304
- }
305
- }
306
-
307
- // Session subscribers: Maps sessionId → Set<WebSocket>
308
- // Allows multiple tabs to READ output from an active session
309
- const sessionSubscribers = new Map();
310
-
311
- function subscribeToSession(sessionId, ws) {
312
- if (!sessionId) return;
313
- if (!sessionSubscribers.has(sessionId)) {
314
- sessionSubscribers.set(sessionId, new Set());
315
- }
316
- sessionSubscribers.get(sessionId).add(ws);
317
- }
318
-
319
- function unsubscribeFromSession(sessionId, ws) {
320
- const subs = sessionSubscribers.get(sessionId);
321
- if (subs) {
322
- subs.delete(ws);
323
- if (subs.size === 0) sessionSubscribers.delete(sessionId);
324
- }
325
- }
326
-
327
- function unsubscribeAllForWs(ws) {
328
- for (const [sessionId, subs] of sessionSubscribers.entries()) {
329
- subs.delete(ws);
330
- if (subs.size === 0) sessionSubscribers.delete(sessionId);
331
- }
332
- }
333
-
334
- function broadcastToSessionSubscribers(sessionId, message) {
335
- const subs = sessionSubscribers.get(sessionId);
336
- if (!subs) return;
337
- const payload = typeof message === 'string' ? message : JSON.stringify(message);
338
- for (const ws of subs) {
339
- if (ws.readyState === 1) {
340
- ws.send(payload);
341
- }
342
- }
343
- }
344
-
345
- // Broadcast progress to all connected WebSocket clients
346
- function broadcastProgress(progress) {
347
- const message = JSON.stringify({
348
- type: 'loading_progress',
349
- ...progress
350
- });
351
- connectedClients.forEach(client => {
352
- if (client.readyState === WebSocket.OPEN) {
353
- client.send(message);
354
- }
355
- });
356
- }
357
-
358
- // Setup file system watchers for Claude, Cursor, and Codex project/session folders
359
- async function setupProjectsWatcher() {
360
- const chokidar = (await import('chokidar')).default;
361
-
362
- if (projectsWatcherDebounceTimer) {
363
- clearTimeout(projectsWatcherDebounceTimer);
364
- projectsWatcherDebounceTimer = null;
365
- }
366
-
367
- await Promise.all(
368
- projectsWatchers.map(async (watcher) => {
369
- try {
370
- await watcher.close();
371
- } catch (error) {
372
- // watcher close error handled silently
373
- }
374
- })
375
- );
376
- projectsWatchers = [];
377
-
378
- const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
379
- if (projectsWatcherDebounceTimer) {
380
- clearTimeout(projectsWatcherDebounceTimer);
381
- }
382
-
383
- projectsWatcherDebounceTimer = setTimeout(async () => {
384
- // Prevent reentrant calls
385
- if (isGetProjectsRunning) {
386
- return;
387
- }
388
-
389
- try {
390
- isGetProjectsRunning = true;
391
-
392
- // Clear project directory cache when files change
393
- clearProjectDirectoryCache();
394
-
395
- // Get updated projects list
396
- const updatedProjects = await getProjects(broadcastProgress);
397
-
398
- // Notify connected clients about project changes (per-user filtered)
399
- const basePayload = {
400
- type: 'projects_updated',
401
- timestamp: new Date().toISOString(),
402
- changeType: eventType,
403
- changedFile: path.relative(rootPath, filePath),
404
- watchProvider: provider
405
- };
406
-
407
- // Build per-user project lists, then send to all their clients
408
- const userProjectsCache = new Map();
409
- for (const client of connectedClients) {
410
- if (client.readyState !== WebSocket.OPEN) continue;
411
- const uid = client._wsUser?.userId;
412
- if (!uid) {
413
- // No auth — local mode, send all projects
414
- client.send(JSON.stringify({ ...basePayload, projects: updatedProjects }));
415
- continue;
416
- }
417
- try {
418
- if (!userProjectsCache.has(uid)) {
419
- userProjectsCache.set(uid, await getProjects(null, uid));
420
- }
421
- client.send(JSON.stringify({ ...basePayload, projects: userProjectsCache.get(uid) }));
422
- } catch { /* ignore per-user fetch errors */ }
423
- }
424
-
425
- } catch (error) {
426
- // project change error handled silently
427
- } finally {
428
- isGetProjectsRunning = false;
429
- }
430
- }, WATCHER_DEBOUNCE_MS);
431
- };
432
-
433
- for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
434
- try {
435
- // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
436
- // Ensure provider folders exist before creating the watcher so watching stays active.
437
- await fsPromises.mkdir(rootPath, { recursive: true });
438
-
439
- // Initialize chokidar watcher with optimized settings
440
- const watcher = chokidar.watch(rootPath, {
441
- ignored: WATCHER_IGNORED_PATTERNS,
442
- persistent: true,
443
- ignoreInitial: true, // Don't fire events for existing files on startup
444
- followSymlinks: false,
445
- depth: 10, // Reasonable depth limit
446
- awaitWriteFinish: {
447
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
448
- pollInterval: 50
449
- }
450
- });
451
-
452
- // Set up event listeners
453
- watcher
454
- .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
455
- .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
456
- .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
457
- .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
458
- .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
459
- .on('error', (error) => {
460
- // provider watcher error handled silently
461
- })
462
- .on('ready', () => {
463
- });
464
-
465
- projectsWatchers.push(watcher);
466
- } catch (error) {
467
- // provider watcher setup error handled silently
468
- }
469
- }
470
-
471
- if (projectsWatchers.length === 0) {
472
- // no provider watchers available
473
- }
474
- }
475
-
476
-
477
- const app = express();
478
-
479
- // On Vercel serverless, we don't need an HTTP server or WebSocket server
480
- const server = process.env.VERCEL ? null : http.createServer(app);
481
-
482
- const ptySessionsMap = new Map();
483
- const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
484
- const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
485
- const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
486
- const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
487
-
488
- function stripAnsiSequences(value = '') {
489
- return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
490
- }
491
-
492
- function normalizeDetectedUrl(url) {
493
- if (!url || typeof url !== 'string') return null;
494
-
495
- const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
496
- if (!cleaned) return null;
497
-
498
- try {
499
- const parsed = new URL(cleaned);
500
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
501
- return null;
502
- }
503
- return parsed.toString();
504
- } catch {
505
- return null;
506
- }
507
- }
508
-
509
- function extractUrlsFromText(value = '') {
510
- const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
511
-
512
- // Handle wrapped terminal URLs split across lines by terminal width.
513
- const wrappedMatches = [];
514
- const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
515
- const lines = value.split(/\r?\n/);
516
- for (let i = 0; i < lines.length; i++) {
517
- const line = lines[i].trim();
518
- const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
519
- if (!startMatch) continue;
520
-
521
- let combined = startMatch[0];
522
- let j = i + 1;
523
- while (j < lines.length) {
524
- const continuation = lines[j].trim();
525
- if (!continuation) break;
526
- if (!continuationRegex.test(continuation)) break;
527
- combined += continuation;
528
- j++;
529
- }
530
-
531
- wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
532
- }
533
-
534
- return Array.from(new Set([...directMatches, ...wrappedMatches]));
535
- }
536
-
537
- function shouldAutoOpenUrlFromOutput(value = '') {
538
- const normalized = value.toLowerCase();
539
- return (
540
- normalized.includes('browser didn\'t open') ||
541
- normalized.includes('open this url') ||
542
- normalized.includes('continue in your browser') ||
543
- normalized.includes('press enter to open') ||
544
- normalized.includes('open_url:')
545
- );
546
- }
547
-
548
- // Single WebSocket server that handles both paths (skip on Vercel serverless)
549
- let wss = null;
550
- if (server) {
551
- wss = new WebSocketServer({
552
- server,
553
- verifyClient: (info, done) => {
554
- const reqUrl = info.req.url || '';
555
- const clientIp = info.req.headers['x-forwarded-for']?.split(',')[0]?.trim() || info.req.socket?.remoteAddress || 'unknown';
556
- // Timeout the entire verifyClient to prevent hung upgrades
557
- const authTimeout = setTimeout(() => {
558
- console.warn(`[WS] Auth TIMEOUT for ${reqUrl} from ${clientIp}`);
559
- done(false, 504, 'Auth timeout');
560
- }, 15000);
561
- authenticateWebSocket(info.req).then(user => {
562
- clearTimeout(authTimeout);
563
- if (!user) {
564
- console.warn(`[WS] Auth REJECTED for ${reqUrl} from ${clientIp} — no valid token`);
565
- done(false, 401, 'Unauthorized');
566
- return;
567
- }
568
- info.req.user = user;
569
- done(true);
570
- }).catch(err => {
571
- clearTimeout(authTimeout);
572
- console.error(`[WS] Auth ERROR for ${reqUrl} from ${clientIp}:`, err.message);
573
- done(false, 500, 'Auth error');
574
- });
575
- }
576
- });
577
- }
578
-
579
- // Make WebSocket server available to routes
580
- app.locals.wss = wss;
581
-
582
- // Security headers — protect against common web attacks
583
- app.use((req, res, next) => {
584
- 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
- }
592
- res.setHeader('X-XSS-Protection', '1; mode=block');
593
- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
594
- if (process.env.NODE_ENV === 'production') {
595
- res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
596
- }
597
- next();
598
- });
599
-
600
- // CORS: require explicit CORS_ORIGINS in production, restrict to same-origin otherwise
601
- const CORS_ORIGINS = process.env.CORS_ORIGINS
602
- ? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
603
- : (process.env.NODE_ENV === 'production' ? ['https://cli.upfyn.com'] : true);
604
- app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
605
- app.use(cookieParser());
606
- app.use(express.json({
607
- limit: '50mb',
608
- type: (req) => {
609
- // Skip multipart/form-data requests (for file uploads like images)
610
- const contentType = req.headers['content-type'] || '';
611
- if (contentType.includes('multipart/form-data')) {
612
- return false;
613
- }
614
- return contentType.includes('json');
615
- }
616
- }));
617
- app.use(express.urlencoded({ limit: '50mb', extended: true }));
618
-
619
- // Rate limiting for auth endpoints — prevent brute force attacks
620
- const authRateLimitMap = new Map();
621
- const AUTH_RATE_WINDOW = 15 * 60 * 1000; // 15 minutes
622
- const AUTH_RATE_MAX = 10; // max attempts per window
623
-
624
- function authRateLimit(req, res, next) {
625
- const key = req.ip || req.connection.remoteAddress || 'unknown';
626
- const now = Date.now();
627
- const entry = authRateLimitMap.get(key);
628
-
629
- if (entry) {
630
- // Clean expired entries
631
- if (now - entry.windowStart > AUTH_RATE_WINDOW) {
632
- authRateLimitMap.set(key, { windowStart: now, count: 1 });
633
- return next();
634
- }
635
- entry.count++;
636
- if (entry.count > AUTH_RATE_MAX) {
637
- return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
638
- }
639
- } else {
640
- authRateLimitMap.set(key, { windowStart: now, count: 1 });
641
- }
642
- next();
643
- }
644
-
645
- // Clean up stale rate limit entries every 30 minutes
646
- setInterval(() => {
647
- const now = Date.now();
648
- for (const [key, entry] of authRateLimitMap) {
649
- if (now - entry.windowStart > AUTH_RATE_WINDOW) {
650
- authRateLimitMap.delete(key);
651
- }
652
- }
653
- }, 30 * 60 * 1000);
654
-
655
- app.locals.authRateLimit = authRateLimit;
656
-
657
- // Vercel serverless: lazy DB initialization on first request
658
- let dbInitialized = false;
659
- if (process.env.VERCEL) {
660
- app.use(async (req, res, next) => {
661
- if (!dbInitialized) {
662
- try {
663
- await initializeDatabase();
664
- dbInitialized = true;
665
- } catch (err) {
666
- // DB init error handled silently
667
- return res.status(500).json({ error: 'Service temporarily unavailable' });
668
- }
669
- }
670
- next();
671
- });
672
- }
673
-
674
- // Public health check endpoint (no authentication required)
675
- app.get('/health', (req, res) => {
676
- res.json({
677
- status: 'ok',
678
- version: SERVER_VERSION,
679
- timestamp: new Date().toISOString()
680
- });
681
- });
682
-
683
- // Optional API key validation (if configured)
684
- app.use('/api', validateApiKey);
685
-
686
- // Authentication routes (public)
687
- app.use('/api/auth', authRoutes);
688
-
689
- // Relay middleware — injects req.isCloud, req.hasRelay(), req.sendRelay(), req.requireRelay()
690
- // Must be created after hasActiveRelay/sendRelayCommand are defined (they're hoisted functions)
691
- const relayMiddleware = createRelayMiddleware(hasActiveRelay, sendRelayCommand, withRetry);
692
-
693
- // Projects API Routes (protected + relay-aware)
694
- app.use('/api/projects', authenticateToken, relayMiddleware, projectsRoutes);
695
-
696
- // Git API Routes (protected + relay-aware)
697
- app.use('/api/git', authenticateToken, relayMiddleware, gitRoutes);
698
-
699
- // MCP API Routes (protected + relay-aware)
700
- app.use('/api/mcp', authenticateToken, relayMiddleware, mcpRoutes);
701
-
702
- // Cursor API Routes (protected + relay-aware)
703
- app.use('/api/cursor', authenticateToken, relayMiddleware, cursorRoutes);
704
-
705
- // TaskMaster API Routes (protected)
706
- app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
707
-
708
- // MCP utilities
709
- app.use('/api/mcp-utils', authenticateToken, relayMiddleware, mcpUtilsRoutes);
710
-
711
- // Upfyn-Code MCP Server — exposes app capabilities to any MCP client (ChatGPT, Claude Desktop, Cursor, etc.)
712
- const mcpDeps = {
713
- getProjects,
714
- getSessions,
715
- getSessionMessages,
716
- queryClaudeSDK,
717
- abortClaudeSDKSession,
718
- getActiveClaudeSDKSessions,
719
- connectedClients,
720
- };
721
- const mcpAppServer = createMcpServer(mcpDeps);
722
- const mcpServerFactory = () => createMcpServer(mcpDeps);
723
- mountMcpServer(app, mcpAppServer, mcpServerFactory).catch(err => console.error('[MCP] Failed to mount:', err.message));
724
-
725
- // Commands API Routes (protected + relay-aware)
726
- app.use('/api/commands', authenticateToken, relayMiddleware, commandsRoutes);
727
-
728
- // Settings API Routes (protected)
729
- app.use('/api/settings', authenticateToken, settingsRoutes);
730
-
731
- // CLI Authentication API Routes (protected)
732
- app.use('/api/cli', authenticateToken, cliAuthRoutes);
733
-
734
- // User API Routes (protected)
735
- app.use('/api/user', authenticateToken, userRoutes);
736
-
737
- // Codex API Routes (protected + relay-aware)
738
- app.use('/api/codex', authenticateToken, relayMiddleware, codexRoutes);
739
-
740
- // Payment & Subscription Routes (protected)
741
- app.use('/api/payments', authenticateToken, paymentRoutes);
742
- app.use('/api/webhooks', authenticateToken, webhookRoutes);
743
- app.use('/api/workflows', authenticateToken, workflowRoutes);
744
- app.use('/api/voice', authenticateToken, voiceRoutes);
745
- app.use('/api/dashboard', authenticateToken, dashboardRoutes);
746
- // Inject sessionOwners map so session routes can filter by userId
747
- app.use('/api/sessions', authenticateToken, (req, _res, next) => {
748
- req.sessionOwners = sessionOwners;
749
- next();
750
- }, sessionRoutes);
751
- app.use('/api/keys', authenticateToken, keysRoutes);
752
- app.use('/api/assistant', assistantRoutes);
753
- app.use('/api/canvas', authenticateToken, canvasRoutes);
754
- app.use('/api/composio', authenticateToken, composioRoutes);
755
- app.use('/api/vapi', assistantRoutes); // Alias: VAPI dashboard webhook points to /api/vapi/webhook
756
-
757
- // Voice call history endpoints (per-user isolated)
758
- app.get('/api/voice/calls', authenticateToken, async (req, res) => {
759
- try {
760
- const userId = req.user.id || req.user.userId;
761
- const limit = Math.min(parseInt(req.query.limit) || 20, 100);
762
- const offset = parseInt(req.query.offset) || 0;
763
- const calls = await voiceCallDb.getByUser(userId, limit, offset);
764
- const stats = await voiceCallDb.getUserStats(userId);
765
- res.json({ calls, stats });
766
- } catch {
767
- res.status(500).json({ error: 'Failed to fetch voice call history' });
768
- }
769
- });
770
-
771
- // Agent API Routes (uses API key authentication)
772
- app.use('/api/agent', agentRoutes);
773
-
774
- // Relay token management routes
775
- app.get('/api/relay/tokens', authenticateToken, async (req, res) => {
776
- try {
777
- const tokens = await relayTokensDb.getTokens(req.user.id);
778
- res.json(tokens.map(t => ({ ...t, token: t.token.slice(0, 10) + '...' }))); // mask tokens
779
- } catch (err) {
780
- res.status(500).json({ error: 'Failed to fetch relay tokens' });
781
- }
782
- });
783
-
784
- app.post('/api/relay/tokens', authenticateToken, async (req, res) => {
785
- try {
786
- const name = req.body.name || 'default';
787
- const result = await relayTokensDb.createToken(req.user.id, name);
788
- res.json(result); // returns full token only on creation
789
- } catch (err) {
790
- res.status(500).json({ error: 'Failed to create relay token' });
791
- }
792
- });
793
-
794
- app.delete('/api/relay/tokens/:id', authenticateToken, async (req, res) => {
795
- try {
796
- await relayTokensDb.deleteToken(req.user.id, req.params.id);
797
- res.json({ success: true });
798
- } catch (err) {
799
- res.status(500).json({ error: 'Failed to delete relay token' });
800
- }
801
- });
802
-
803
- app.get('/api/relay/status', authenticateToken, async (req, res) => {
804
- // In local mode, always connected — SDK runs directly on this machine
805
- if (IS_LOCAL) {
806
- return res.json({ connected: true, local: true, connectedAt: Date.now() });
807
- }
808
- const relay = relayConnections.get(Number(req.user.id));
809
- const connected = !!(relay && relay.ws.readyState === 1);
810
-
811
- // Ensure relay CWD is registered as a project (handles post-deploy filesystem wipe)
812
- if (connected && relay.cwd) {
813
- try { await addProjectManually(relay.cwd, null, req.user.id); } catch { /* already exists */ }
814
- }
815
-
816
- // Check if sandbox is alive (even if relay is disconnected — grace period)
817
- let sandboxActive = false;
818
- try {
819
- const sbStatus = await sandboxClient.getStatus(req.user.id).catch(() => null);
820
- sandboxActive = !!(sbStatus?.running);
821
- } catch { /* ignore */ }
822
-
823
- // Get last connection info from DB for reconnection context
824
- let lastConnection = null;
825
- if (!connected) {
826
- try {
827
- const active = await connectionDb.getActive(String(req.user.id));
828
- if (active.length === 0) {
829
- // Check recent disconnections
830
- const all = await connectionDb.getAllActive();
831
- lastConnection = all.find(c => c.user_id === String(req.user.id)) || null;
832
- }
833
- } catch { /* ignore */ }
834
- }
835
-
836
- res.json({
837
- connected,
838
- connectedAt: relay?.connectedAt || null,
839
- cwd: connected ? relay.cwd : null,
840
- machine: connected ? relay.machine : null,
841
- platform: connected ? relay.platform : null,
842
- version: connected ? relay.version : null,
843
- sandboxActive,
844
- lastConnection: lastConnection ? {
845
- cwd: lastConnection.last_cwd,
846
- machine: lastConnection.last_machine,
847
- platform: lastConnection.last_platform,
848
- disconnectedAt: lastConnection.disconnected_at,
849
- } : null,
850
- // Mask the IP — only show last octet for user verification
851
- clientIp: connected && relay.clientIp ? relay.clientIp.replace(/(\d+\.\d+\.\d+\.)(\d+)/, '$1***') : null,
852
- });
853
- });
854
-
855
- // POST /api/relay/disconnect — user-initiated disconnect (saves state, destroys sandbox)
856
- app.post('/api/relay/disconnect', authenticateToken, async (req, res) => {
857
- const userId = Number(req.user.id);
858
- const relay = relayConnections.get(userId);
859
- if (!relay || relay.ws.readyState !== 1) {
860
- return res.json({ success: false, error: 'No active relay connection' });
861
- }
862
-
863
- // Cancel any pending sandbox grace period
864
- const pending = sandboxCleanupTimers.get(userId);
865
- if (pending) { clearTimeout(pending); sandboxCleanupTimers.delete(userId); }
866
-
867
- // Save sandbox state to connection record before destroying
868
- try {
869
- const sbStatus = await sandboxClient.getStatus(userId).catch(() => null);
870
- await connectionDb.disconnect(String(userId), 'relay');
871
- } catch { /* non-critical */ }
872
-
873
- // Destroy sandbox immediately (user-initiated = no grace period)
874
- try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
875
-
876
- // Close the relay WebSocket
877
- try {
878
- relay.ws.send(JSON.stringify({ type: 'server-disconnect', reason: 'User disconnected from web UI' }));
879
- relay.ws.close();
880
- } catch { /* may already be closing */ }
881
-
882
- res.json({ success: true, message: 'Relay disconnected' });
883
- });
884
-
885
- // ─── Sandbox API endpoints ──────────────────────────────────────────────────
886
-
887
- // Initialize sandbox for user
888
- app.post('/api/sandbox/init', authenticateToken, async (req, res) => {
889
- try {
890
- const result = await sandboxClient.initSandbox(req.user.id);
891
- res.json(result);
892
- } catch (err) {
893
- res.status(503).json({ error: err.message });
894
- }
895
- });
896
-
897
- // Get sandbox status
898
- app.get('/api/sandbox/status', authenticateToken, async (req, res) => {
899
- try {
900
- const result = await sandboxClient.getStatus(req.user.id);
901
- res.json(result);
902
- } catch (err) {
903
- res.status(503).json({ error: err.message });
904
- }
905
- });
906
-
907
- // ─── System update endpoint ─────────────────────────────────────────────────
908
- // Runs npm update on the user's machine. Only available in self-hosted mode.
909
- // Platform users (cli.upfyn.com) don't see update notifications — the server auto-deploys.
910
- app.post('/api/system/update', authenticateToken, async (req, res) => {
911
- try {
912
- if (IS_LOCAL) {
913
- // Self-hosted: run locally
914
- const { execSync } = await import('child_process');
915
- const output = execSync('npm install -g upfynai-code@latest', { encoding: 'utf8', timeout: 120000 });
916
- res.json({ output, exitCode: 0 });
917
- } else {
918
- // Platform mode: server is managed by Railway, no user action needed
919
- res.status(400).json({ error: 'Server updates are automatic on the platform. No action needed.' });
920
- }
921
- } catch (err) {
922
- res.status(500).json({ error: err.message || 'Update failed' });
923
- }
924
- });
925
-
926
- // Execute command in sandbox
927
- app.post('/api/sandbox/exec', authenticateToken, async (req, res) => {
928
- try {
929
- const { command, cwd, timeout } = req.body;
930
- // Get user's BYOK keys for sandbox env
931
- const userKeys = {};
932
- try {
933
- const anthropicKey = await credentialsDb.getCredentialByType(req.user.id, 'anthropic_key');
934
- if (anthropicKey) userKeys.anthropic_key = anthropicKey.credential_value;
935
- const openaiKey = await credentialsDb.getCredentialByType(req.user.id, 'openai_key');
936
- if (openaiKey) userKeys.openai_key = openaiKey.credential_value;
937
- } catch { /* no keys */ }
938
- const result = await sandboxClient.exec(req.user.id, command, { cwd, timeout, userKeys });
939
- res.json(result);
940
- } catch (err) {
941
- res.status(500).json({ error: err.message });
942
- }
943
- });
944
-
945
- // Read file from sandbox
946
- app.post('/api/sandbox/file/read', authenticateToken, async (req, res) => {
947
- try {
948
- const result = await sandboxClient.readFile(req.user.id, req.body.filePath);
949
- res.json(result);
950
- } catch (err) {
951
- const status = err.message?.includes('Access denied') ? 403
952
- : err.message?.includes('ENOENT') || err.message?.includes('404') ? 404 : 500;
953
- res.status(status).json({ error: err.message });
954
- }
955
- });
956
-
957
- // Write file to sandbox
958
- app.post('/api/sandbox/file/write', authenticateToken, async (req, res) => {
959
- try {
960
- const result = await sandboxClient.writeFile(req.user.id, req.body.filePath, req.body.content);
961
- res.json(result);
962
- } catch (err) {
963
- res.status(500).json({ error: err.message });
964
- }
965
- });
966
-
967
- // File tree from sandbox
968
- app.post('/api/sandbox/file/tree', authenticateToken, async (req, res) => {
969
- try {
970
- const result = await sandboxClient.getFileTree(req.user.id, req.body.dirPath, req.body.depth);
971
- res.json(result);
972
- } catch (err) {
973
- res.status(500).json({ error: err.message });
974
- }
975
- });
976
-
977
- // Git operation in sandbox
978
- app.post('/api/sandbox/git', authenticateToken, async (req, res) => {
979
- try {
980
- const result = await sandboxClient.gitOperation(req.user.id, req.body.gitCommand, req.body.cwd);
981
- res.json(result);
982
- } catch (err) {
983
- res.status(500).json({ error: err.message });
984
- }
985
- });
986
-
987
- // Destroy sandbox
988
- app.delete('/api/sandbox', authenticateToken, async (req, res) => {
989
- try {
990
- const result = await sandboxClient.destroySandbox(req.user.id);
991
- res.json(result);
992
- } catch (err) {
993
- res.status(500).json({ error: err.message });
994
- }
995
- });
996
-
997
- /**
998
- * Detect installed AI CLI agents on the local machine (server-side).
999
- * Used in self-hosted/local mode where no relay is needed.
1000
- */
1001
- let cachedLocalAgents = null;
1002
- let localAgentsCacheTime = 0;
1003
- function detectLocalAgents() {
1004
- // Cache for 60 seconds
1005
- if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
1006
- return cachedLocalAgents;
1007
- }
1008
- const isWindows = process.platform === 'win32';
1009
- const whichCmd = isWindows ? 'where' : 'which';
1010
- const agents = [
1011
- { name: 'claude', binary: 'claude', label: 'Claude Code' },
1012
- { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
1013
- { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
1014
- ];
1015
- const detected = {};
1016
- for (const agent of agents) {
1017
- try {
1018
- const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
1019
- detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
1020
- } catch {
1021
- detected[agent.name] = { installed: false, label: agent.label };
1022
- }
1023
- }
1024
- cachedLocalAgents = detected;
1025
- localAgentsCacheTime = Date.now();
1026
- return detected;
1027
- }
1028
-
1029
- // Connection status — alias at path the frontend expects
1030
- app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
1031
- const relay = relayConnections.get(Number(req.user.id));
1032
- const connected = !!(relay && relay.ws.readyState === 1);
1033
-
1034
- // In local mode, always "connected" — SDK runs directly on this machine
1035
- if (IS_LOCAL) {
1036
- const agents = detectLocalAgents();
1037
- return res.json({
1038
- connected: true,
1039
- local: true,
1040
- connectedAt: Date.now(),
1041
- agents,
1042
- machine: {
1043
- hostname: os.hostname(),
1044
- platform: process.platform,
1045
- cwd: process.cwd(),
1046
- }
1047
- });
1048
- }
1049
-
1050
- // Check sandbox service availability when no relay
1051
- let sandboxAvailable = false;
1052
- let sandboxInfo = null;
1053
- if (!connected) {
1054
- try {
1055
- sandboxAvailable = await sandboxClient.isAvailable();
1056
- if (sandboxAvailable) {
1057
- sandboxInfo = await sandboxClient.getStatus(req.user.id);
1058
- }
1059
- } catch { /* sandbox service unreachable */ }
1060
- }
1061
-
1062
- res.json({
1063
- connected,
1064
- local: false,
1065
- connectedAt: relay?.connectedAt || null,
1066
- agents: connected ? (relay.agents || null) : null,
1067
- machine: connected ? {
1068
- hostname: relay.machine,
1069
- platform: relay.platform,
1070
- cwd: relay.cwd,
1071
- version: relay.version,
1072
- } : null,
1073
- sandbox: {
1074
- available: sandboxAvailable,
1075
- active: sandboxInfo?.exists || false,
1076
- diskUsage: sandboxInfo?.exists ? {
1077
- usedMB: sandboxInfo.usedMB,
1078
- maxMB: sandboxInfo.maxMB,
1079
- } : null,
1080
- },
1081
- });
1082
- });
1083
-
1084
- // Serve public files (like api-docs.html)
1085
- app.use(express.static(path.join(__dirname, '../client/public')));
1086
-
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'), {
1090
- setHeaders: (res, filePath) => {
1091
- if (filePath.endsWith('.html')) {
1092
- // Prevent HTML caching to avoid service worker issues after builds
1093
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
1094
- res.setHeader('Pragma', 'no-cache');
1095
- res.setHeader('Expires', '0');
1096
- } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
1097
- // Cache static assets for 1 year (they have hashed names)
1098
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1099
- }
1100
- }
1101
- }));
1102
-
1103
- // API Routes (protected)
1104
- // /api/config endpoint removed - no longer needed
1105
- // Frontend now uses window.location for WebSocket URLs
1106
-
1107
- // System update endpoint — REMOVED for security (shell command execution risk)
1108
- // Use `uc update` from CLI instead
1109
-
1110
- app.get('/api/projects', authenticateToken, async (req, res) => {
1111
- try {
1112
- const projects = await getProjects(broadcastProgress, req.user.id);
1113
- res.json(projects);
1114
- } catch (error) {
1115
- res.status(500).json({ error: 'Internal server error' });
1116
- }
1117
- });
1118
-
1119
- app.get('/api/projects/:projectName/sessions', authenticateToken, authorizeProject, async (req, res) => {
1120
- try {
1121
- const { limit = 5, offset = 0 } = req.query;
1122
- const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
1123
- res.json(result);
1124
- } catch (error) {
1125
- res.status(500).json({ error: 'Internal server error' });
1126
- }
1127
- });
1128
-
1129
- // Get messages for a specific session
1130
- app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, authorizeProject, async (req, res) => {
1131
- try {
1132
- const { projectName, sessionId } = req.params;
1133
- const { limit, offset } = req.query;
1134
-
1135
- const parsedLimit = limit ? parseInt(limit, 10) : null;
1136
- const parsedOffset = offset ? parseInt(offset, 10) : 0;
1137
-
1138
- const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
1139
-
1140
- if (Array.isArray(result)) {
1141
- res.json({ messages: result });
1142
- } else {
1143
- res.json(result);
1144
- }
1145
- } catch (error) {
1146
- res.status(500).json({ error: 'Internal server error' });
1147
- }
1148
- });
1149
-
1150
- // Rename project endpoint
1151
- app.put('/api/projects/:projectName/rename', authenticateToken, authorizeProject, async (req, res) => {
1152
- try {
1153
- const { displayName } = req.body;
1154
- await renameProject(req.params.projectName, displayName, req.user.id);
1155
- res.json({ success: true });
1156
- } catch (error) {
1157
- res.status(500).json({ error: 'Internal server error' });
1158
- }
1159
- });
1160
-
1161
- // Delete session endpoint
1162
- app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, authorizeProject, async (req, res) => {
1163
- try {
1164
- const { projectName, sessionId } = req.params;
1165
- await deleteSession(projectName, sessionId);
1166
- res.json({ success: true });
1167
- } catch (error) {
1168
- res.status(500).json({ error: 'Internal server error' });
1169
- }
1170
- });
1171
-
1172
- // Delete project endpoint (force=true to delete with sessions)
1173
- app.delete('/api/projects/:projectName', authenticateToken, authorizeProject, async (req, res) => {
1174
- try {
1175
- const { projectName } = req.params;
1176
- const force = req.query.force === 'true';
1177
- await deleteProject(projectName, force, req.user.id);
1178
- res.json({ success: true });
1179
- } catch (error) {
1180
- res.status(500).json({ error: 'Internal server error' });
1181
- }
1182
- });
1183
-
1184
- // Read CLAUDE.md for a project
1185
- app.get('/api/projects/:projectName/claude-md', authenticateToken, authorizeProject, async (req, res) => {
1186
- try {
1187
- const projectDir = await extractProjectDirectory(req.params.projectName);
1188
- if (!projectDir) {
1189
- return res.json({ content: null, path: null, error: 'Could not resolve project path' });
1190
- }
1191
-
1192
- const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
1193
-
1194
- if (IS_CLOUD_ENV) {
1195
- const userId = req.user?.id || req.user?.userId;
1196
- if (!hasActiveRelay(Number(userId))) {
1197
- return res.json({ content: null, path: claudeMdPath, error: 'No machine connected' });
1198
- }
1199
- try {
1200
- const result = await sendRelayCommand(Number(userId), 'file-read', { filePath: claudeMdPath }, null, 10000);
1201
- return res.json({ content: result?.data?.content || null, path: claudeMdPath });
1202
- } catch {
1203
- return res.json({ content: null, path: claudeMdPath });
1204
- }
1205
- }
1206
-
1207
- // Local mode
1208
- try {
1209
- const content = await fsPromises.readFile(claudeMdPath, 'utf8');
1210
- res.json({ content, path: claudeMdPath });
1211
- } catch {
1212
- res.json({ content: null, path: claudeMdPath });
1213
- }
1214
- } catch {
1215
- res.json({ content: null, path: null });
1216
- }
1217
- });
1218
-
1219
- // Create project endpoint
1220
- app.post('/api/projects/create', authenticateToken, async (req, res) => {
1221
- try {
1222
- const { path: projectPath } = req.body;
1223
-
1224
- if (!projectPath || !projectPath.trim()) {
1225
- return res.status(400).json({ error: 'Project path is required' });
1226
- }
1227
-
1228
- const project = await addProjectManually(projectPath.trim(), null, req.user.id);
1229
- res.json({ success: true, project });
1230
- } catch (error) {
1231
- // project creation error handled silently
1232
- res.status(500).json({ error: 'Internal server error' });
1233
- }
1234
- });
1235
-
1236
- // ── Gitagent API endpoints ──────────────────────────────────────────
1237
-
1238
- // Get parsed gitagent definition + system prompt preview for a project
1239
- app.get('/api/projects/:projectName/gitagent', authenticateToken, authorizeProject, async (req, res) => {
1240
- try {
1241
- const projectDir = await extractProjectDirectory(req.params.projectName);
1242
- if (!projectDir) return res.status(404).json({ error: 'Project not found' });
1243
-
1244
- const ctx = await loadGitagentContext(projectDir);
1245
- if (!ctx) return res.json({ detected: false });
1246
-
1247
- res.json({
1248
- detected: true,
1249
- definition: ctx.definition,
1250
- systemPromptPreview: ctx.systemPromptAppendix,
1251
- model: ctx.model,
1252
- allowedTools: ctx.allowedTools,
1253
- runtime: ctx.runtime,
1254
- });
1255
- } catch {
1256
- res.status(500).json({ error: 'Internal server error' });
1257
- }
1258
- });
1259
-
1260
- // Clear cache and re-parse gitagent for a project
1261
- app.post('/api/projects/:projectName/gitagent/refresh', authenticateToken, authorizeProject, async (req, res) => {
1262
- try {
1263
- const projectDir = await extractProjectDirectory(req.params.projectName);
1264
- if (!projectDir) return res.status(404).json({ error: 'Project not found' });
1265
-
1266
- clearGitagentCache(projectDir);
1267
- const ctx = await loadGitagentContext(projectDir);
1268
- if (!ctx) return res.json({ detected: false });
1269
-
1270
- res.json({
1271
- detected: true,
1272
- definition: ctx.definition,
1273
- systemPromptPreview: ctx.systemPromptAppendix,
1274
- model: ctx.model,
1275
- allowedTools: ctx.allowedTools,
1276
- runtime: ctx.runtime,
1277
- });
1278
- } catch {
1279
- res.status(500).json({ error: 'Internal server error' });
1280
- }
1281
- });
1282
-
1283
- const expandWorkspacePath = (inputPath) => {
1284
- if (!inputPath) return inputPath;
1285
- if (inputPath === '~') {
1286
- return WORKSPACES_ROOT;
1287
- }
1288
- if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
1289
- return path.join(WORKSPACES_ROOT, inputPath.slice(2));
1290
- }
1291
- return inputPath;
1292
- };
1293
-
1294
- // Browse filesystem endpoint for project suggestions
1295
- // When relay is connected, proxies to user's local machine; otherwise uses server filesystem
1296
- app.get('/api/browse-filesystem', authenticateToken, relayMiddleware, async (req, res) => {
1297
- try {
1298
- const { path: dirPath } = req.query;
1299
-
1300
- // Cloud mode: always use relay
1301
- if (req.isCloud) {
1302
- if (!req.hasRelay()) {
1303
- return res.status(503).json({
1304
- error: 'Machine not connected',
1305
- message: 'Run "uc connect" on your local machine to use this feature.',
1306
- code: 'RELAY_NOT_CONNECTED'
1307
- });
1308
- }
1309
- try {
1310
- const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
1311
- return res.json(result);
1312
- } catch (err) {
1313
- return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
1314
- }
1315
- }
1316
-
1317
- // Local mode: if relay is connected, prefer it
1318
- if (req.hasRelay()) {
1319
- try {
1320
- const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
1321
- return res.json(result);
1322
- } catch (err) {
1323
- return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
1324
- }
1325
- }
1326
-
1327
- // Fallback: browse server filesystem (local/self-hosted mode)
1328
- const defaultRoot = WORKSPACES_ROOT || os.homedir();
1329
- let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
1330
-
1331
- // Resolve and normalize the path
1332
- targetPath = path.resolve(targetPath);
1333
-
1334
- // Security check - ensure path is within allowed workspace root
1335
- const validation = await validateWorkspacePath(targetPath);
1336
- if (!validation.valid) {
1337
- return res.status(403).json({ error: validation.error });
1338
- }
1339
- const resolvedPath = validation.resolvedPath || targetPath;
1340
-
1341
- // Security check - ensure path is accessible
1342
- try {
1343
- await fs.promises.access(resolvedPath);
1344
- const stats = await fs.promises.stat(resolvedPath);
1345
-
1346
- if (!stats.isDirectory()) {
1347
- return res.status(400).json({ error: 'Path is not a directory' });
1348
- }
1349
- } catch (err) {
1350
- return res.status(404).json({ error: 'Directory not accessible' });
1351
- }
1352
-
1353
- // Use existing getFileTree function with shallow depth (only direct children)
1354
- const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
1355
-
1356
- // Filter only directories and format for suggestions
1357
- const directories = fileTree
1358
- .filter(item => item.type === 'directory')
1359
- .map(item => ({
1360
- path: item.path,
1361
- name: item.name,
1362
- type: 'directory'
1363
- }))
1364
- .sort((a, b) => {
1365
- const aHidden = a.name.startsWith('.');
1366
- const bHidden = b.name.startsWith('.');
1367
- if (aHidden && !bHidden) return 1;
1368
- if (!aHidden && bHidden) return -1;
1369
- return a.name.localeCompare(b.name);
1370
- });
1371
-
1372
- // Add common directories if browsing home directory
1373
- const suggestions = [];
1374
- let resolvedWorkspaceRoot = defaultRoot;
1375
- try {
1376
- resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
1377
- } catch (error) {
1378
- // Use default root as-is if realpath fails
1379
- }
1380
- if (resolvedPath === resolvedWorkspaceRoot) {
1381
- const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
1382
- const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
1383
- const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
1384
-
1385
- suggestions.push(...existingCommon, ...otherDirs);
1386
- } else {
1387
- suggestions.push(...directories);
1388
- }
1389
-
1390
- res.json({
1391
- path: resolvedPath,
1392
- suggestions: suggestions
1393
- });
1394
-
1395
- } catch (error) {
1396
- // filesystem browse error handled silently
1397
- res.status(500).json({ error: 'Failed to browse filesystem' });
1398
- }
1399
- });
1400
-
1401
- app.post('/api/create-folder', authenticateToken, relayMiddleware, async (req, res) => {
1402
- try {
1403
- const { path: folderPath } = req.body;
1404
- if (!folderPath) {
1405
- return res.status(400).json({ error: 'Path is required' });
1406
- }
1407
-
1408
- // Cloud mode: create folder via relay on user's machine
1409
- if (req.isCloud) {
1410
- if (!req.requireRelay()) return;
1411
- try {
1412
- const result = await req.sendRelay('create-folder', { folderPath }, 15000);
1413
- return res.json(result);
1414
- } catch (err) {
1415
- return res.status(500).json({ error: err.message || 'Failed to create folder via relay' });
1416
- }
1417
- }
1418
-
1419
- // Local mode
1420
- const expandedPath = expandWorkspacePath(folderPath);
1421
- const resolvedInput = path.resolve(expandedPath);
1422
- const validation = await validateWorkspacePath(resolvedInput);
1423
- if (!validation.valid) {
1424
- return res.status(403).json({ error: validation.error });
1425
- }
1426
- const targetPath = validation.resolvedPath || resolvedInput;
1427
- const parentDir = path.dirname(targetPath);
1428
- try {
1429
- await fs.promises.access(parentDir);
1430
- } catch (err) {
1431
- return res.status(404).json({ error: 'Parent directory does not exist' });
1432
- }
1433
- try {
1434
- await fs.promises.access(targetPath);
1435
- return res.status(409).json({ error: 'Folder already exists' });
1436
- } catch (err) {
1437
- // Folder doesn't exist, which is what we want
1438
- }
1439
- try {
1440
- await fs.promises.mkdir(targetPath, { recursive: false });
1441
- res.json({ success: true, path: targetPath });
1442
- } catch (mkdirError) {
1443
- if (mkdirError.code === 'EEXIST') {
1444
- return res.status(409).json({ error: 'Folder already exists' });
1445
- }
1446
- throw mkdirError;
1447
- }
1448
- } catch (error) {
1449
- // folder creation error handled silently
1450
- res.status(500).json({ error: 'Failed to create folder' });
1451
- }
1452
- });
1453
-
1454
- // Read file content endpoint
1455
- app.get('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1456
- try {
1457
- const { projectName } = req.params;
1458
- const { filePath } = req.query;
1459
-
1460
- if (!filePath) {
1461
- return res.status(400).json({ error: 'Invalid file path' });
1462
- }
1463
-
1464
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1465
- if (!projectRoot) {
1466
- return res.status(404).json({ error: 'Project not found' });
1467
- }
1468
-
1469
- // Cloud mode: read file via relay on user's machine
1470
- if (req.isCloud) {
1471
- if (!req.requireRelay()) return;
1472
- try {
1473
- // Construct full path: project root + relative file path
1474
- const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1475
- ? filePath
1476
- : `${projectRoot}/${filePath}`;
1477
- const result = await req.sendRelay('file-read', { filePath: fullPath }, 15000);
1478
- return res.json({ content: result.content, path: fullPath });
1479
- } catch (err) {
1480
- if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
1481
- return res.status(404).json({ error: 'File not found' });
1482
- }
1483
- return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
1484
- }
1485
- }
1486
-
1487
- // Local mode
1488
- const resolved = path.resolve(projectRoot, filePath);
1489
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1490
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1491
- return res.status(403).json({ error: 'Access denied' });
1492
- }
1493
-
1494
- const content = await fsPromises.readFile(resolved, 'utf8');
1495
- res.json({ content, path: resolved });
1496
- } catch (error) {
1497
- if (error.code === 'ENOENT') {
1498
- res.status(404).json({ error: 'File not found' });
1499
- } else if (error.code === 'EACCES') {
1500
- res.status(403).json({ error: 'Permission denied' });
1501
- } else {
1502
- res.status(500).json({ error: 'Internal server error' });
1503
- }
1504
- }
1505
- });
1506
-
1507
- // Serve binary file content endpoint (for images, etc.)
1508
- app.get('/api/projects/:projectName/files/content', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1509
- try {
1510
- const { projectName } = req.params;
1511
- const { path: filePath } = req.query;
1512
-
1513
- if (!filePath) {
1514
- return res.status(400).json({ error: 'Invalid file path' });
1515
- }
1516
-
1517
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1518
- if (!projectRoot) {
1519
- return res.status(404).json({ error: 'Project not found' });
1520
- }
1521
-
1522
- // Cloud mode: read binary file via relay (base64 encoded)
1523
- if (req.isCloud) {
1524
- if (!req.requireRelay()) return;
1525
- try {
1526
- const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1527
- ? filePath
1528
- : `${projectRoot}/${filePath}`;
1529
- const result = await req.sendRelay('file-read', { filePath: fullPath, encoding: 'base64' }, 30000);
1530
- const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
1531
- res.setHeader('Content-Type', mimeType);
1532
- return res.send(Buffer.from(result.content, 'base64'));
1533
- } catch (err) {
1534
- if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
1535
- return res.status(404).json({ error: 'File not found' });
1536
- }
1537
- return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
1538
- }
1539
- }
1540
-
1541
- // Local mode
1542
- const resolved = path.resolve(projectRoot, filePath);
1543
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1544
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1545
- return res.status(403).json({ error: 'Access denied' });
1546
- }
1547
-
1548
- try {
1549
- await fsPromises.access(resolved);
1550
- } catch (error) {
1551
- return res.status(404).json({ error: 'File not found' });
1552
- }
1553
-
1554
- const mimeType = mime.lookup(resolved) || 'application/octet-stream';
1555
- res.setHeader('Content-Type', mimeType);
1556
-
1557
- const fileStream = fs.createReadStream(resolved);
1558
- fileStream.pipe(res);
1559
-
1560
- fileStream.on('error', (error) => {
1561
- if (!res.headersSent) {
1562
- res.status(500).json({ error: 'Error reading file' });
1563
- }
1564
- });
1565
-
1566
- } catch (error) {
1567
- if (!res.headersSent) {
1568
- res.status(500).json({ error: 'Internal server error' });
1569
- }
1570
- }
1571
- });
1572
-
1573
- // Save file content endpoint
1574
- app.put('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1575
- try {
1576
- const { projectName } = req.params;
1577
- const { filePath, content } = req.body;
1578
-
1579
- if (!filePath) {
1580
- return res.status(400).json({ error: 'Invalid file path' });
1581
- }
1582
-
1583
- if (content === undefined) {
1584
- return res.status(400).json({ error: 'Content is required' });
1585
- }
1586
-
1587
- const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1588
- if (!projectRoot) {
1589
- return res.status(404).json({ error: 'Project not found' });
1590
- }
1591
-
1592
- // Cloud mode: write file via relay on user's machine
1593
- if (req.isCloud) {
1594
- if (!req.requireRelay()) return;
1595
- try {
1596
- const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
1597
- ? filePath
1598
- : `${projectRoot}/${filePath}`;
1599
- const result = await req.sendRelay('file-write', { filePath: fullPath, content }, 15000);
1600
- // Track file version in Turso
1601
- try { await fileVersionDb.save(req.user.userId || req.user.id, projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
1602
- return res.json({ success: true, path: fullPath, message: 'File saved successfully' });
1603
- } catch (err) {
1604
- return res.status(500).json({ error: err.message || 'Failed to write file via relay' });
1605
- }
1606
- }
1607
-
1608
- // Local mode
1609
- const resolved = path.resolve(projectRoot, filePath);
1610
- const normalizedRoot = path.resolve(projectRoot) + path.sep;
1611
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
1612
- return res.status(403).json({ error: 'Access denied' });
1613
- }
1614
-
1615
- await fsPromises.writeFile(resolved, content, 'utf8');
1616
- // Track file version
1617
- try { await fileVersionDb.save(req.user?.userId || req.user?.id || 'local', projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
1618
-
1619
- res.json({
1620
- success: true,
1621
- path: resolved,
1622
- message: 'File saved successfully'
1623
- });
1624
- } catch (error) {
1625
- if (error.code === 'ENOENT') {
1626
- res.status(404).json({ error: 'File or directory not found' });
1627
- } else if (error.code === 'EACCES') {
1628
- res.status(403).json({ error: 'Permission denied' });
1629
- } else {
1630
- res.status(500).json({ error: 'Internal server error' });
1631
- }
1632
- }
1633
- });
1634
-
1635
- // File version history endpoint
1636
- app.get('/api/projects/:projectName/files/versions', authenticateToken, authorizeProject, async (req, res) => {
1637
- try {
1638
- const { projectName } = req.params;
1639
- const { path: filePath, limit } = req.query;
1640
- if (filePath) {
1641
- const versions = await fileVersionDb.getVersions(projectName, filePath, parseInt(limit) || 20);
1642
- return res.json({ versions });
1643
- }
1644
- // No path — return all files with versions in this project
1645
- const files = await fileVersionDb.getSessionFiles(projectName);
1646
- res.json({ files });
1647
- } catch (error) {
1648
- res.status(500).json({ error: 'Internal server error' });
1649
- }
1650
- });
1651
-
1652
- // Usage stats endpoint
1653
- app.get('/api/usage', authenticateToken, async (req, res) => {
1654
- try {
1655
- const userId = req.user.userId || req.user.id;
1656
- const days = parseInt(req.query.days) || 30;
1657
- const usage = await sessionUsageDb.getUserUsage(userId, days);
1658
- const sessions = await sessionUsageDb.getUserSessions(userId, parseInt(req.query.limit) || 20);
1659
- res.json({ usage, sessions });
1660
- } catch (error) {
1661
- res.status(500).json({ error: 'Internal server error' });
1662
- }
1663
- });
1664
-
1665
- app.get('/api/projects/:projectName/files', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
1666
- try {
1667
- let actualPath;
1668
- try {
1669
- actualPath = await extractProjectDirectory(req.params.projectName);
1670
- } catch (error) {
1671
- actualPath = req.params.projectName.replace(/-/g, '/');
1672
- }
1673
-
1674
- // Cloud mode: get file tree via relay on user's machine
1675
- if (req.isCloud) {
1676
- if (!req.requireRelay()) return;
1677
- try {
1678
- // In cloud mode, actualPath comes from extractProjectDirectory (which stores the original
1679
- // Windows/Unix path from when the project was added via relay CWD). If that failed and
1680
- // fell back to dash-replacement, use the relay connection's CWD as a better fallback.
1681
- const relay = relayConnections.get(Number(req.user?.id));
1682
- const relayCwd = relay?.cwd;
1683
- const dirPath = actualPath && actualPath !== req.params.projectName.replace(/-/g, '/')
1684
- ? actualPath
1685
- : (relayCwd || actualPath);
1686
- const result = await req.sendRelay('file-tree', { dirPath, maxDepth: 10 }, 30000);
1687
- return res.json(result.files || result);
1688
- } catch (err) {
1689
- return res.status(500).json({ error: err.message || 'Failed to get file tree via relay' });
1690
- }
1691
- }
1692
-
1693
- // Local mode
1694
- try {
1695
- await fsPromises.access(actualPath);
1696
- } catch (e) {
1697
- return res.status(404).json({ error: `Project path not found: ${actualPath}` });
1698
- }
1699
-
1700
- const files = await getFileTree(actualPath, 10, 0, true);
1701
- res.json(files);
1702
- } catch (error) {
1703
- res.status(500).json({ error: 'Internal server error' });
1704
- }
1705
- });
1706
-
1707
- // WebSocket connection handler that routes based on URL path (skip on Vercel)
1708
- if (wss) wss.on('connection', (ws, request) => {
1709
- const url = request.url;
1710
- // Client connected
1711
-
1712
- // Parse URL to get pathname without query parameters
1713
- const urlObj = new URL(url, 'http://localhost');
1714
- const pathname = urlObj.pathname;
1715
-
1716
- if (pathname === '/shell') {
1717
- handleShellConnection(ws, request);
1718
- } else if (pathname === '/ws') {
1719
- handleChatConnection(ws, request);
1720
- } else if (pathname === '/relay') {
1721
- handleRelayConnection(ws, urlObj.searchParams.get('token'), request);
1722
- } else {
1723
- // unknown WebSocket path
1724
- ws.close();
1725
- }
1726
- });
1727
-
1728
- /**
1729
- * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
1730
- */
1731
- class WebSocketWriter {
1732
- constructor(ws) {
1733
- this.ws = ws;
1734
- this.sessionId = null;
1735
- this.isWebSocketWriter = true; // Marker for transport detection
1736
- }
1737
-
1738
- send(data) {
1739
- if (this.ws.readyState === 1) { // WebSocket.OPEN
1740
- // Providers send raw objects, we stringify for WebSocket
1741
- this.ws.send(JSON.stringify(data));
1742
- }
1743
- }
1744
-
1745
- setSessionId(sessionId) {
1746
- this.sessionId = sessionId;
1747
- }
1748
-
1749
- getSessionId() {
1750
- return this.sessionId;
1751
- }
1752
- }
1753
-
1754
- /**
1755
- * Look up a user's stored API key for a given provider.
1756
- * Falls back to server env vars if user has none stored.
1757
- * @param {number} userId
1758
- * @param {string} providerType - e.g. 'anthropic_key', 'openai_key', 'openrouter_key', 'google_key'
1759
- * @returns {Promise<string|null>}
1760
- */
1761
- async function getUserProviderKey(userId, providerType) {
1762
- if (!userId) return null;
1763
- try {
1764
- const creds = await credentialsDb.getCredentials(userId, providerType);
1765
- const active = creds.find(c => c.is_active);
1766
- return active?.credential_value || null;
1767
- } catch { return null; }
1768
- }
1769
-
1770
- /**
1771
- * Temporarily set environment variable for an AI SDK call, then restore.
1772
- * @param {string} envKey - e.g. 'ANTHROPIC_API_KEY'
1773
- * @param {string|null} userKey - user's BYOK key, null to skip
1774
- * @param {Function} fn - async function to execute with the key set
1775
- */
1776
- async function withUserApiKey(envKey, userKey, fn) {
1777
- if (!userKey) return fn();
1778
- const prev = process.env[envKey];
1779
- process.env[envKey] = userKey;
1780
- try {
1781
- return await fn();
1782
- } finally {
1783
- if (prev !== undefined) process.env[envKey] = prev;
1784
- else delete process.env[envKey];
1785
- }
1786
- }
1787
-
1788
- // Handle chat WebSocket connections
1789
- function handleChatConnection(ws, request) {
1790
- // chat WebSocket connected
1791
- const wsUser = request?.user || null;
1792
-
1793
- // Add to connected clients for project updates + tag with user for permission routing
1794
- ws._wsUser = wsUser;
1795
- connectedClients.add(ws);
1796
-
1797
- // Wrap WebSocket with writer for consistent interface with SSEStreamWriter
1798
- const writer = new WebSocketWriter(ws);
1799
-
1800
- // Track which sessions this WebSocket has locked
1801
- const lockedSessionsForThisWs = new Set();
1802
-
1803
- // Track usage per-query for sessionUsageDb
1804
- let lastTokenBudget = null;
1805
- let lastCompletedSessionId = null;
1806
-
1807
- // Wrap the original writer.send to capture session-created events for auto-locking
1808
- const originalSend = writer.send.bind(writer);
1809
- writer.send = (data) => {
1810
- // When a new session is created, auto-lock it to this WebSocket + track ownership
1811
- if (data.type === 'session-created' && data.sessionId) {
1812
- sessionLocks.set(data.sessionId, ws);
1813
- lockedSessionsForThisWs.add(data.sessionId);
1814
- if (wsUser?.userId) sessionOwners.set(data.sessionId, wsUser.userId);
1815
- }
1816
- // Capture token usage for tracking
1817
- if (data.type === 'token-budget' && data.data) {
1818
- lastTokenBudget = data.data;
1819
- if (data.sessionId) lastCompletedSessionId = data.sessionId;
1820
- }
1821
- originalSend(data);
1822
- };
1823
-
1824
- ws.on('message', async (message) => {
1825
- try {
1826
- const data = JSON.parse(message);
1827
-
1828
- // Handle session lock request (tab claiming a session)
1829
- if (data.type === 'lock-session') {
1830
- const sid = data.sessionId;
1831
- if (!sid) return;
1832
- if (acquireSessionLock(sid, ws)) {
1833
- lockedSessionsForThisWs.add(sid);
1834
- writer.send({ type: 'session-locked', sessionId: sid, success: true });
1835
- // session locked to tab
1836
- } else {
1837
- writer.send({ type: 'session-locked', sessionId: sid, success: false,
1838
- error: 'Session is already open in another tab' });
1839
- // session lock denied
1840
- }
1841
- return;
1842
- }
1843
-
1844
- // Handle session unlock request
1845
- if (data.type === 'unlock-session') {
1846
- const sid = data.sessionId;
1847
- if (sid) {
1848
- releaseSessionLock(sid, ws);
1849
- lockedSessionsForThisWs.delete(sid);
1850
- writer.send({ type: 'session-unlocked', sessionId: sid });
1851
- // session unlocked
1852
- }
1853
- return;
1854
- }
1855
-
1856
- if (data.type === 'shell-exec') {
1857
- const { command, cwd } = data;
1858
- if (!command) {
1859
- writer.send({ type: 'shell-exec-output', output: 'No command provided.', exitCode: 1, status: 'error' });
1860
- return;
1861
- }
1862
-
1863
- if (hasActiveRelay(wsUser?.userId)) {
1864
- try {
1865
- const result = await sendRelayCommand(Number(wsUser.userId), 'exec', { command, cwd }, null, 30000);
1866
- const output = result?.data?.stdout || result?.data?.output || result?.data?.stderr || '';
1867
- writer.send({ type: 'shell-exec-output', output, exitCode: result?.data?.exitCode ?? 0, status: 'complete' });
1868
- } catch (err) {
1869
- writer.send({ type: 'shell-exec-output', output: err.message || 'Command execution failed.', exitCode: 1, status: 'error' });
1870
- }
1871
- } else {
1872
- writer.send({ type: 'shell-exec-output', output: 'No machine connected. Use `uc connect` to bridge your local machine.', exitCode: 1, status: 'error' });
1873
- }
1874
- return;
1875
- }
1876
-
1877
- if (data.type === 'claude-command') {
1878
- const sid = data.options?.sessionId;
1879
-
1880
- // Session-tab locking: check if this session is locked by another tab
1881
- if (sid && !acquireSessionLock(sid, ws)) {
1882
- writer.send({
1883
- type: 'session-lock-denied',
1884
- sessionId: sid,
1885
- error: 'This session is already active in another tab. Close it there first.'
1886
- });
1887
- return;
1888
- }
1889
- if (sid) lockedSessionsForThisWs.add(sid);
1890
-
1891
- // Session busy detection (opencode pattern: prevent duplicate requests)
1892
- if (sid && !markSessionBusy(sid)) {
1893
- writer.send({
1894
- type: 'session-busy',
1895
- sessionId: sid,
1896
- error: 'This session is already processing a request. Wait for it to finish or abort it first.'
1897
- });
1898
- return;
1899
- }
1900
-
1901
- try {
1902
- // Check if user has active relay → route to local machine
1903
- if (hasActiveRelay(wsUser?.userId)) {
1904
- // Lightweight gitagent check — 1.5s timeout, non-blocking on failure
1905
- if (data.options?.cwd) {
1906
- try {
1907
- const gaResult = await sendRelayCommand(Number(wsUser.userId), 'gitagent-parse', { projectPath: data.options.cwd }, null, 1500);
1908
- if (gaResult?.data?.definition) {
1909
- const appendix = buildSystemPromptAppendix(gaResult.data.definition);
1910
- data.command = `<gitagent-context>\n${appendix}\n</gitagent-context>\n\n${data.command}`;
1911
- }
1912
- } catch { /* not gitagent or timed out — proceed without delay */ }
1913
- }
1914
- await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
1915
- response: 'claude-response',
1916
- complete: 'claude-complete',
1917
- error: 'claude-error'
1918
- });
1919
- } else {
1920
- // No relay — try sandbox fallback, then server-side SDK
1921
- try {
1922
- const sandboxHandled = wsUser?.userId
1923
- ? await handleSandboxWebSocketCommand(wsUser.userId, 'claude-command', { ...data, sessionId: data.options?.sessionId }, writer)
1924
- : false;
1925
-
1926
- if (!sandboxHandled) {
1927
- // Fall back to server-side SDK
1928
- const userAnthropicKey = wsUser?.userId
1929
- ? await getUserProviderKey(wsUser.userId, 'anthropic_key')
1930
- : null;
1931
-
1932
- await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
1933
- queryClaudeSDK(data.command, data.options, writer)
1934
- );
1935
- }
1936
- } catch {
1937
- // SDK fallback failed — send claude-error (not generic error)
1938
- // so the frontend stops the spinner instead of hanging forever
1939
- writer.send({
1940
- type: 'claude-error',
1941
- error: 'No machine connected and server-side AI is unavailable. Please connect with "uc connect" and try again.',
1942
- sessionId: sid
1943
- });
1944
- }
1945
- }
1946
- } finally {
1947
- markSessionFree(sid);
1948
- // Track session usage in Turso
1949
- if (wsUser?.userId && lastTokenBudget) {
1950
- const usageSessionId = lastCompletedSessionId || sid || 'unknown';
1951
- try {
1952
- await sessionUsageDb.upsert(wsUser.userId, usageSessionId, {
1953
- provider: 'claude',
1954
- promptTokens: lastTokenBudget.used || 0,
1955
- completionTokens: 0,
1956
- costCents: 0,
1957
- model: data.options?.model || null,
1958
- });
1959
- } catch { /* non-critical */ }
1960
- lastTokenBudget = null;
1961
- lastCompletedSessionId = null;
1962
- }
1963
- }
1964
- } else if (data.type === 'cursor-command') {
1965
- // Check if user has active relay → route to local machine
1966
- if (hasActiveRelay(wsUser?.userId)) {
1967
- await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
1968
- response: 'cursor-response',
1969
- complete: 'cursor-complete',
1970
- error: 'cursor-error'
1971
- });
1972
- } else {
1973
- await spawnCursor(data.command, data.options, writer);
1974
- }
1975
- } else if (data.type === 'codex-command') {
1976
- // Check if user has active relay → route to local machine
1977
- if (hasActiveRelay(wsUser?.userId)) {
1978
- await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
1979
- response: 'codex-response',
1980
- complete: 'codex-complete',
1981
- error: 'codex-error'
1982
- });
1983
- } else {
1984
- const userOpenaiKey = wsUser?.userId
1985
- ? await getUserProviderKey(wsUser.userId, 'openai_key')
1986
- : null;
1987
-
1988
- await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
1989
- queryCodex(data.command, data.options, writer)
1990
- );
1991
- }
1992
- } else if (data.type === 'openrouter-command') {
1993
- // BYOK: OpenRouter requires user's own API key
1994
- const userOrKey = wsUser?.userId
1995
- ? await getUserProviderKey(wsUser.userId, 'openrouter_key')
1996
- : null;
1997
-
1998
- await queryOpenRouter(data.command, {
1999
- ...data.options,
2000
- apiKey: userOrKey,
2001
- }, writer);
2002
- } else if (data.type === 'cursor-resume') {
2003
- // Backward compatibility: treat as cursor-command with resume and no prompt
2004
- // cursor resume session
2005
- await spawnCursor('', {
2006
- sessionId: data.sessionId,
2007
- resume: true,
2008
- cwd: data.options?.cwd
2009
- }, writer);
2010
- } else if (data.type === 'abort-session') {
2011
- // abort session request
2012
- const provider = data.provider || 'claude';
2013
- let success;
2014
-
2015
- // Check if this is a relay session first (opencode pattern: cancel propagation)
2016
- const relayInfo = relaySessionRequests.get(data.sessionId);
2017
- if (relayInfo) {
2018
- const relay = relayConnections.get(relayInfo.userId);
2019
- if (relay && relay.ws.readyState === 1) {
2020
- // Forward abort to CLI via relay WebSocket
2021
- relay.ws.send(JSON.stringify({
2022
- type: 'relay-abort',
2023
- requestId: relayInfo.requestId,
2024
- }));
2025
- // Clean up pending relay request
2026
- const pending = pendingRelayRequests.get(relayInfo.requestId);
2027
- if (pending) {
2028
- clearTimeout(pending.timeout);
2029
- pending.resolve({ exitCode: -1, aborted: true });
2030
- pendingRelayRequests.delete(relayInfo.requestId);
2031
- }
2032
- relaySessionRequests.delete(data.sessionId);
2033
- success = true;
2034
- } else {
2035
- success = false;
2036
- }
2037
- } else if (provider === 'cursor') {
2038
- success = abortCursorSession(data.sessionId);
2039
- } else if (provider === 'codex') {
2040
- success = abortCodexSession(data.sessionId);
2041
- } else {
2042
- // Use Claude Agents SDK
2043
- success = await abortClaudeSDKSession(data.sessionId);
2044
- }
2045
-
2046
- writer.send({
2047
- type: 'session-aborted',
2048
- sessionId: data.sessionId,
2049
- provider,
2050
- success
2051
- });
2052
- } else if (data.type === 'claude-permission-response') {
2053
- // Relay UI approval decisions back into the SDK control flow.
2054
- // This does not persist permissions; it only resolves the in-flight request,
2055
- // introduced so the SDK can resume once the user clicks Allow/Deny.
2056
- if (data.requestId) {
2057
- resolveToolApproval(data.requestId, {
2058
- allow: Boolean(data.allow),
2059
- updatedInput: data.updatedInput,
2060
- message: data.message,
2061
- rememberEntry: data.rememberEntry
2062
- });
2063
- }
2064
- } else if (data.type === 'relay-permission-response') {
2065
- // Forward permission response from browser → CLI relay (opencode pattern)
2066
- if (wsUser?.userId && hasActiveRelay(wsUser.userId)) {
2067
- const relay = relayConnections.get(Number(wsUser.userId));
2068
- if (relay?.ws?.readyState === 1) {
2069
- relay.ws.send(JSON.stringify({
2070
- type: 'relay-permission-response',
2071
- permissionId: data.permissionId,
2072
- approved: data.approved,
2073
- }));
2074
- }
2075
- }
2076
- } else if (data.type === 'cursor-abort') {
2077
- // abort cursor session
2078
- const success = abortCursorSession(data.sessionId);
2079
- writer.send({
2080
- type: 'session-aborted',
2081
- sessionId: data.sessionId,
2082
- provider: 'cursor',
2083
- success
2084
- });
2085
- } else if (data.type === 'check-session-status') {
2086
- // Check if a specific session is currently processing
2087
- const provider = data.provider || 'claude';
2088
- const sessionId = data.sessionId;
2089
- let isActive;
2090
-
2091
- if (provider === 'cursor') {
2092
- isActive = isCursorSessionActive(sessionId);
2093
- } else if (provider === 'codex') {
2094
- isActive = isCodexSessionActive(sessionId);
2095
- } else {
2096
- // Use Claude Agents SDK
2097
- isActive = isClaudeSDKSessionActive(sessionId);
2098
- }
2099
-
2100
- writer.send({
2101
- type: 'session-status',
2102
- sessionId,
2103
- provider,
2104
- isProcessing: isActive
2105
- });
2106
- } else if (data.type === 'get-active-sessions') {
2107
- // Get all currently active sessions
2108
- const activeSessions = {
2109
- claude: getActiveClaudeSDKSessions(),
2110
- cursor: getActiveCursorSessions(),
2111
- codex: getActiveCodexSessions()
2112
- };
2113
- writer.send({
2114
- type: 'active-sessions',
2115
- sessions: activeSessions
2116
- });
2117
- } else if (data.type === 'subscribe-session') {
2118
- // Multi-tab read access: subscribe to session output without locking
2119
- const sid = data.sessionId;
2120
- if (sid) {
2121
- subscribeToSession(sid, ws);
2122
- writer.send({ type: 'session-subscribed', sessionId: sid });
2123
- }
2124
- } else if (data.type === 'unsubscribe-session') {
2125
- const sid = data.sessionId;
2126
- if (sid) {
2127
- unsubscribeFromSession(sid, ws);
2128
- writer.send({ type: 'session-unsubscribed', sessionId: sid });
2129
- }
2130
- }
2131
- } catch (error) {
2132
- // chat WebSocket error
2133
- writer.send({
2134
- type: 'error',
2135
- error: 'An unexpected error occurred'
2136
- });
2137
- }
2138
- });
2139
-
2140
- ws.on('close', () => {
2141
- // Chat client disconnected
2142
- // Release all session locks held by this WebSocket
2143
- releaseAllLocksForWs(ws);
2144
- unsubscribeAllForWs(ws);
2145
- // Remove from connected clients
2146
- connectedClients.delete(ws);
2147
- });
2148
- }
2149
-
2150
- // Handle relay WebSocket connections (local machine ↔ server bridge)
2151
- async function handleRelayConnection(ws, token, request) {
2152
- if (!token) {
2153
- ws.send(JSON.stringify({ type: 'error', error: 'Relay token required. Use ?token=upfyn_xxx' }));
2154
- ws.close();
2155
- return;
2156
- }
2157
-
2158
- // Extract and pin client IP for this relay session
2159
- const clientIp = extractClientIp(request);
2160
-
2161
- const tokenData = await relayTokensDb.validateToken(token, clientIp);
2162
- if (!tokenData) {
2163
- ws.send(JSON.stringify({ type: 'error', error: 'Invalid or expired relay token' }));
2164
- ws.close();
2165
- return;
2166
- }
2167
-
2168
- const userId = Number(tokenData.user_id);
2169
- const username = tokenData.username;
2170
-
2171
- // Reject if another relay is already connected for this user from a DIFFERENT IP
2172
- // This prevents session hijacking — only the original machine can hold the relay
2173
- const existingRelay = relayConnections.get(userId);
2174
- if (existingRelay && existingRelay.ws.readyState === 1 && existingRelay.clientIp !== clientIp) {
2175
- ws.send(JSON.stringify({
2176
- type: 'error',
2177
- error: 'Another machine is already connected for this account. Disconnect it first or use a different token.'
2178
- }));
2179
- ws.close();
2180
- return;
2181
- }
2182
-
2183
- // Extract optional headers from relay handshake
2184
- const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
2185
- const relayVersion = request?.headers?.['x-upfyn-version'] || null;
2186
- const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
2187
- const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
2188
- const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
2189
-
2190
- // Store relay connection with IP pinning — all commands must originate from authenticated user
2191
- // API key is held per-user in the relay connection, NOT in process.env
2192
- relayConnections.set(userId, {
2193
- ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
2194
- version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
2195
- agents: null, // populated when client sends agent-capabilities
2196
- lastPong: Date.now(),
2197
- clientIp, // Pinned IP — used for security validation
2198
- tokenId: tokenData.id, // Token ID used for this connection
2199
- });
2200
-
2201
- ws.send(JSON.stringify({
2202
- type: 'relay-connected',
2203
- message: `Connected as ${username}. Your local machine is now bridged to the server.`
2204
- }));
2205
-
2206
- // Auto-add relay CWD as a project so the user lands with a ready workspace
2207
- if (relayCwd) {
2208
- try {
2209
- await addProjectManually(relayCwd, null, userId);
2210
- } catch { /* project may already exist, ignore */ }
2211
- }
2212
-
2213
- // Cancel any pending sandbox cleanup (user reconnected within grace period)
2214
- const pendingCleanup = sandboxCleanupTimers.get(userId);
2215
- if (pendingCleanup) {
2216
- clearTimeout(pendingCleanup);
2217
- sandboxCleanupTimers.delete(userId);
2218
- }
2219
-
2220
- // Initialize sandbox for user isolation (reuses existing if still alive)
2221
- let sandboxActive = false;
2222
- try {
2223
- // Check if sandbox is still running from previous session
2224
- const status = await sandboxClient.getStatus(userId).catch(() => null);
2225
- if (status?.running) {
2226
- sandboxActive = true;
2227
- } else {
2228
- await sandboxClient.initSandbox(userId, { projectName: relayCwd });
2229
- sandboxActive = true;
2230
- }
2231
- } catch { /* sandbox is optional — don't block relay */ }
2232
-
2233
- // Track active connection in Turso (with last cwd/machine/platform for smooth reconnection)
2234
- try { await connectionDb.connect(String(userId), relayCwd, 'relay', sandboxActive ? String(userId) : null, { cwd: relayCwd, machine: relayMachine, platform: relayPlatform }); } catch { /* non-critical */ }
2235
-
2236
- // Broadcast relay status to this user's browser clients only
2237
- for (const client of connectedClients) {
2238
- try {
2239
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
2240
- client.send(JSON.stringify({
2241
- type: 'relay-status',
2242
- userId,
2243
- connected: true,
2244
- cwd: relayCwd,
2245
- machine: relayMachine,
2246
- platform: relayPlatform,
2247
- sandboxActive,
2248
- }));
2249
- }
2250
- } catch (e) { /* ignore */ }
2251
- }
2252
-
2253
- // Native WebSocket pong handler — Railway proxy recognizes protocol-level
2254
- // ping/pong as keepalive, preventing idle connection termination
2255
- ws.on('pong', () => {
2256
- const relay = relayConnections.get(userId);
2257
- if (relay && relay.ws === ws) relay.lastPong = Date.now();
2258
- });
2259
-
2260
- ws.on('message', async (message) => {
2261
- try {
2262
- const data = JSON.parse(message);
2263
-
2264
- // Relay response from local machine → resolve pending request
2265
- if (data.type === 'relay-response' && data.requestId) {
2266
- const pending = pendingRelayRequests.get(data.requestId);
2267
- if (pending) {
2268
- clearTimeout(pending.timeout);
2269
- pendingRelayRequests.delete(data.requestId);
2270
- // If CLI sent an error, reject; otherwise unwrap data.data
2271
- if (data.error) {
2272
- pending.reject(new Error(data.error));
2273
- } else {
2274
- pending.resolve(data.data || data);
2275
- }
2276
- }
2277
- return;
2278
- }
2279
-
2280
- // Relay stream chunk from local machine → forward to browser WebSocket
2281
- if (data.type === 'relay-stream' && data.requestId) {
2282
- const pending = pendingRelayRequests.get(data.requestId);
2283
- if (pending && pending.onStream) {
2284
- pending.onStream(data.data);
2285
- }
2286
- return;
2287
- }
2288
-
2289
- // Relay complete signal
2290
- if (data.type === 'relay-complete' && data.requestId) {
2291
- const pending = pendingRelayRequests.get(data.requestId);
2292
- if (pending) {
2293
- clearTimeout(pending.timeout);
2294
- pendingRelayRequests.delete(data.requestId);
2295
- pending.resolve(data);
2296
- }
2297
- return;
2298
- }
2299
-
2300
- // Permission request from CLI → forward to browser (opencode pattern)
2301
- if (data.type === 'relay-permission-request') {
2302
- for (const client of connectedClients) {
2303
- try {
2304
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
2305
- client.send(JSON.stringify({
2306
- type: 'relay-permission-request',
2307
- ...data,
2308
- }));
2309
- }
2310
- } catch { /* ignore */ }
2311
- }
2312
- return;
2313
- }
2314
-
2315
- // Agent capabilities report from relay client
2316
- if (data.type === 'agent-capabilities') {
2317
- const relay = relayConnections.get(userId);
2318
- if (relay) {
2319
- relay.agents = data.agents || {};
2320
- relay.machine = data.machine || relay.machine;
2321
- }
2322
- // Broadcast agent info to this user's browser clients only
2323
- for (const client of connectedClients) {
2324
- try {
2325
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
2326
- client.send(JSON.stringify({
2327
- type: 'relay-agents',
2328
- userId,
2329
- agents: data.agents || {},
2330
- machine: data.machine || {}
2331
- }));
2332
- }
2333
- } catch (e) { /* ignore */ }
2334
- }
2335
- return;
2336
- }
2337
-
2338
- // Relay shell output from CLI → forward to browser shell WebSocket
2339
- if (data.type === 'relay-shell-output' && data.shellSessionId) {
2340
- const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2341
- if (browserWs?.readyState === 1) {
2342
- browserWs.send(JSON.stringify({ type: 'output', data: data.data }));
2343
- }
2344
- return;
2345
- }
2346
-
2347
- // Relay shell exited on CLI
2348
- if (data.type === 'relay-shell-exited' && data.shellSessionId) {
2349
- const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2350
- if (browserWs?.readyState === 1) {
2351
- browserWs.send(JSON.stringify({
2352
- type: 'output',
2353
- data: `\r\n\x1b[33mProcess exited with code ${data.exitCode ?? 0}\x1b[0m\r\n`
2354
- }));
2355
- }
2356
- relayShellBrowserSockets.delete(data.shellSessionId);
2357
- return;
2358
- }
2359
-
2360
- // Relay shell auth URL detected
2361
- if (data.type === 'relay-shell-auth-url' && data.shellSessionId) {
2362
- const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
2363
- if (browserWs?.readyState === 1) {
2364
- browserWs.send(JSON.stringify({ type: 'auth_url', url: data.url, autoOpen: data.autoOpen }));
2365
- }
2366
- return;
2367
- }
2368
-
2369
- // Relay init: CLI sends working directory after connect
2370
- // Auto-register CWD as default project so user lands with a ready workspace
2371
- if (data.type === 'relay-init') {
2372
- const relay = relayConnections.get(userId);
2373
- if (relay) {
2374
- relay.cwd = data.cwd || relay.cwd;
2375
- relay.platform = data.platform || relay.platform;
2376
- relay.machine = data.hostname || relay.machine;
2377
- }
2378
- // Auto-add CWD as project (pass userId for cloud DB storage)
2379
- if (data.cwd) {
2380
- try {
2381
- await addProjectManually(data.cwd, null, userId);
2382
- } catch { /* already exists */ }
2383
- }
2384
- // Broadcast to browser so it auto-selects this project
2385
- for (const client of connectedClients) {
2386
- try {
2387
- if (client.readyState === 1) {
2388
- client.send(JSON.stringify({
2389
- type: 'relay-init',
2390
- userId,
2391
- cwd: data.cwd,
2392
- dirName: data.dirName,
2393
- platform: data.platform,
2394
- }));
2395
- }
2396
- } catch { /* ignore */ }
2397
- }
2398
- return;
2399
- }
2400
-
2401
- // Heartbeat
2402
- if (data.type === 'ping') {
2403
- const relay = relayConnections.get(userId);
2404
- if (relay) relay.lastPong = Date.now();
2405
- ws.send(JSON.stringify({ type: 'pong' }));
2406
- return;
2407
- }
2408
- } catch (e) {
2409
- // relay message processing error
2410
- }
2411
- });
2412
-
2413
- // Server-side heartbeat: ping relay client every 25s, terminate if no pong in 60s
2414
- const relayHeartbeat = setInterval(() => {
2415
- const relay = relayConnections.get(userId);
2416
- if (!relay || relay.ws !== ws) {
2417
- clearInterval(relayHeartbeat);
2418
- return;
2419
- }
2420
- // If no ping received from client in 60s, consider connection stale
2421
- if (Date.now() - relay.lastPong > 60000) {
2422
- clearInterval(relayHeartbeat);
2423
- ws.terminate();
2424
- return;
2425
- }
2426
- // Send native WebSocket ping frame — recognized by Railway proxy as keepalive
2427
- try { ws.ping(); } catch { /* ignore */ }
2428
- // Also send JSON-level ping to keep application-layer alive
2429
- try {
2430
- if (ws.readyState === 1) {
2431
- ws.send(JSON.stringify({ type: 'server-ping' }));
2432
- }
2433
- } catch { /* ignore */ }
2434
- }, 25000);
2435
-
2436
- ws.on('close', async () => {
2437
- clearInterval(relayHeartbeat);
2438
-
2439
- // Guard: only delete if THIS ws is still the active relay for this user.
2440
- // Prevents race condition where CLI reconnects quickly and the old close
2441
- // handler fires after the new connection is stored, wiping it out.
2442
- const currentRelay = relayConnections.get(userId);
2443
- if (!currentRelay || currentRelay.ws !== ws) {
2444
- // New connection already replaced this one — skip all cleanup
2445
- return;
2446
- }
2447
-
2448
- relayConnections.delete(userId);
2449
-
2450
- // Clean up pending requests for this user (relay is truly gone)
2451
- for (const [reqId, pending] of pendingRelayRequests) {
2452
- if (pending.userId === userId) {
2453
- clearTimeout(pending.timeout);
2454
- pending.reject(new Error('Relay disconnected'));
2455
- pendingRelayRequests.delete(reqId);
2456
- }
2457
- }
2458
-
2459
- // Track disconnection in Turso (but keep sandbox alive for grace period)
2460
- try { await connectionDb.disconnect(String(userId), 'relay'); } catch { /* non-critical */ }
2461
-
2462
- // Schedule sandbox cleanup after grace period (5 min)
2463
- // If user reconnects within this window, the timer is cancelled and sandbox reused
2464
- const cleanupTimer = setTimeout(async () => {
2465
- sandboxCleanupTimers.delete(userId);
2466
- // Only destroy if user hasn't reconnected
2467
- if (!relayConnections.has(userId)) {
2468
- try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
2469
- }
2470
- }, SANDBOX_GRACE_PERIOD_MS);
2471
- sandboxCleanupTimers.set(userId, cleanupTimer);
2472
-
2473
- // Broadcast relay disconnect to this user's browser clients only
2474
- // Note: sandboxActive stays true during grace period (sandbox is still alive)
2475
- for (const client of connectedClients) {
2476
- try {
2477
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
2478
- client.send(JSON.stringify({ type: 'relay-status', userId, connected: false, sandboxActive: true }));
2479
- }
2480
- } catch (e) { /* ignore */ }
2481
- }
2482
- });
2483
-
2484
- ws.on('error', () => {
2485
- clearInterval(relayHeartbeat);
2486
- });
2487
- }
2488
-
2489
- /**
2490
- * Retry helper with exponential backoff (opencode pattern)
2491
- * Only retries on transient errors (timeout, disconnection). Not for validation/security.
2492
- */
2493
- async function withRetry(fn, { maxRetries = 3, baseDelayMs = 2000, jitter = 0.2 } = {}) {
2494
- let lastError;
2495
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
2496
- try {
2497
- return await fn();
2498
- } catch (err) {
2499
- lastError = err;
2500
- const msg = err.message || '';
2501
- const isTransient = msg.includes('timed out') || msg.includes('disconnected');
2502
- if (!isTransient || attempt === maxRetries) throw err;
2503
- const delay = baseDelayMs * Math.pow(2, attempt) * (1 + (Math.random() * jitter * 2 - jitter));
2504
- await new Promise(r => setTimeout(r, delay));
2505
- }
2506
- }
2507
- throw lastError;
2508
- }
2509
-
2510
- /**
2511
- * Send a command to a user's relay and wait for response
2512
- * @param {number} userId - User ID
2513
- * @param {string} action - Action type (claude-query, shell-command, file-read, etc.)
2514
- * @param {object} payload - Action payload
2515
- * @param {function} onStream - Optional callback for streaming chunks
2516
- * @param {number} timeoutMs - Timeout in milliseconds (default 5 min)
2517
- * @returns {Promise<object>} Relay response
2518
- */
2519
- function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs = 300000, externalRequestId = null) {
2520
- return new Promise((resolve, reject) => {
2521
- const relay = relayConnections.get(userId);
2522
- if (!relay || relay.ws.readyState !== 1) {
2523
- reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
2524
- return;
2525
- }
2526
-
2527
- // Security: validate action is in allowlist and payload is safe
2528
- const validation = validateRelayPayload(action, payload);
2529
- if (!validation.valid) {
2530
- reject(new Error(`Relay command blocked: ${validation.reason}`));
2531
- return;
2532
- }
2533
-
2534
- // Use external requestId if provided (for abort tracking), else generate
2535
- const requestId = externalRequestId || crypto.randomUUID();
2536
- const timeout = setTimeout(() => {
2537
- pendingRelayRequests.delete(requestId);
2538
- reject(new Error('Relay request timed out'));
2539
- }, timeoutMs);
2540
-
2541
- pendingRelayRequests.set(requestId, { resolve, reject, timeout, userId, onStream });
2542
-
2543
- relay.ws.send(JSON.stringify({
2544
- type: 'relay-command',
2545
- requestId,
2546
- action,
2547
- ...payload
2548
- }));
2549
- });
2550
- }
2551
-
2552
- /**
2553
- * Check if a user has an active relay connection
2554
- */
2555
- function hasActiveRelay(userId) {
2556
- if (!userId) return false;
2557
- const relay = relayConnections.get(Number(userId));
2558
- return relay && relay.ws.readyState === 1;
2559
- }
2560
-
2561
- /**
2562
- * Route a chat command through the user's relay connection to their local machine.
2563
- * Translates relay-stream/relay-complete events into the format the frontend expects.
2564
- *
2565
- * @param {number} userId - User ID
2566
- * @param {string} action - Relay action (claude-query, codex-query, cursor-query)
2567
- * @param {object} data - Original command data from the browser
2568
- * @param {object} writer - WebSocket writer to send events to browser
2569
- * @param {object} eventMap - Maps relay stream data types to chat event types
2570
- */
2571
- async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
2572
- const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2573
-
2574
- // Generate requestId upfront so we can track it for abort (opencode pattern)
2575
- const requestId = crypto.randomUUID();
2576
-
2577
- // Track relay session → requestId mapping for abort forwarding
2578
- relaySessionRequests.set(sessionId, { userId: Number(userId), requestId });
2579
-
2580
- // Send session-created so the frontend can track this query
2581
- writer.send({ type: 'session-created', sessionId });
2582
-
2583
- // Determine event types from the provider
2584
- const responseType = eventMap.response || 'claude-response';
2585
- const completeType = eventMap.complete || 'claude-complete';
2586
- const errorType = eventMap.error || 'claude-error';
2587
-
2588
- let fullContent = '';
2589
- let capturedCliSessionId = null;
2590
- let usedSDKFormat = false;
2591
-
2592
- try {
2593
- const result = await sendRelayCommand(
2594
- Number(userId),
2595
- action,
2596
- {
2597
- command: data.command,
2598
- options: data.options || {}
2599
- },
2600
- // onStream callback — translates relay events to chat events
2601
- (streamData) => {
2602
- // ── NEW: Handle SDK messages from upgraded CLI agent ──
2603
- // The CLI now uses @anthropic-ai/claude-agent-sdk query() which
2604
- // sends rich typed messages (tool_use, tool_result, text, system, result).
2605
- // Forward them directly — same format as queryClaudeSDK() in claude-sdk.js.
2606
- if (streamData.type === 'claude-sdk-message') {
2607
- const sdkMessage = streamData.data;
2608
- if (!sdkMessage) return;
2609
-
2610
- // Capture session ID from SDK messages
2611
- if (sdkMessage.session_id && !capturedCliSessionId) {
2612
- capturedCliSessionId = sdkMessage.session_id;
2613
-
2614
- // Send session-created with the real CLI session ID
2615
- writer.send({
2616
- type: 'relay-session-id',
2617
- sessionId,
2618
- cliSessionId: capturedCliSessionId,
2619
- model: sdkMessage.model,
2620
- cwd: sdkMessage.cwd,
2621
- });
2622
- }
2623
-
2624
- // Transform parent_tool_use_id for subagent grouping (match queryClaudeSDK format)
2625
- const transformedMessage = sdkMessage.parent_tool_use_id
2626
- ? { ...sdkMessage, parentToolUseId: sdkMessage.parent_tool_use_id }
2627
- : sdkMessage;
2628
-
2629
- // Forward SDK message — SAME format as queryClaudeSDK()
2630
- writer.send({
2631
- type: responseType,
2632
- data: transformedMessage,
2633
- sessionId: capturedCliSessionId || sessionId || null,
2634
- });
2635
-
2636
- // Extract and send token budget from result messages
2637
- if (sdkMessage.type === 'result' && sdkMessage.modelUsage) {
2638
- const tokenBudget = extractTokenBudget(sdkMessage);
2639
- if (tokenBudget) {
2640
- writer.send({
2641
- type: 'token-budget',
2642
- data: tokenBudget,
2643
- sessionId: capturedCliSessionId || sessionId || null,
2644
- });
2645
- }
2646
- }
2647
-
2648
- // Track that SDK format was used (skip content_block_stop on completion)
2649
- usedSDKFormat = true;
2650
- if (sdkMessage.type === 'assistant' || sdkMessage.type === 'result') {
2651
- fullContent += '1'; // Mark that we got content
2652
- }
2653
-
2654
- return;
2655
- }
2656
-
2657
- // ── LEGACY: Handle old CLI format (backward compat with pre-SDK CLI versions) ──
2658
- // Capture CLI-reported session ID for resume support
2659
- // Forward immediately so frontend can use it for --continue
2660
- if (streamData.type === 'claude-system' && streamData.sessionId) {
2661
- capturedCliSessionId = streamData.sessionId;
2662
- writer.send({
2663
- type: 'relay-session-id',
2664
- sessionId,
2665
- cliSessionId: capturedCliSessionId,
2666
- model: streamData.model,
2667
- cwd: streamData.cwd,
2668
- });
2669
- }
2670
-
2671
- if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
2672
- const chunk = streamData.content || '';
2673
- if (chunk) {
2674
- fullContent += chunk;
2675
- // Send in content_block_delta format — matches what useChatRealtimeHandlers expects
2676
- writer.send({
2677
- type: responseType,
2678
- data: { type: 'content_block_delta', delta: { text: chunk } },
2679
- sessionId
2680
- });
2681
- }
2682
- } else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
2683
- const errChunk = streamData.content || '';
2684
- if (errChunk) {
2685
- writer.send({
2686
- type: responseType,
2687
- data: { type: 'content_block_delta', delta: { text: errChunk } },
2688
- sessionId
2689
- });
2690
- }
2691
- } else if (streamData.type === 'claude-result') {
2692
- // Result event from stream-json — capture session ID
2693
- if (streamData.sessionId) capturedCliSessionId = streamData.sessionId;
2694
- }
2695
- },
2696
- 600000, // 10 minute timeout for AI queries
2697
- requestId // Pass pre-generated requestId for abort tracking
2698
- );
2699
-
2700
- // Clean up relay session tracking
2701
- relaySessionRequests.delete(sessionId);
2702
-
2703
- // Finalize any open streaming message before sending complete
2704
- // (only for legacy text-streaming mode — SDK messages are self-contained)
2705
- if (fullContent && !usedSDKFormat) {
2706
- writer.send({ type: responseType, data: { type: 'content_block_stop' }, sessionId });
2707
- }
2708
-
2709
- // Send completion event
2710
- writer.send({
2711
- type: completeType,
2712
- sessionId,
2713
- cliSessionId: capturedCliSessionId || null,
2714
- exitCode: result?.exitCode ?? 0,
2715
- isNewSession: !data.options?.sessionId,
2716
- viaRelay: true
2717
- });
2718
- } catch (error) {
2719
- relaySessionRequests.delete(sessionId);
2720
- const isRelayLost = error.message?.includes('Relay disconnected') || error.message?.includes('No relay connection') || error.message?.includes('Relay request timed out');
2721
- writer.send({
2722
- type: errorType,
2723
- error: isRelayLost
2724
- ? 'Your machine disconnected. Please reconnect with "uc connect" and try again.'
2725
- : 'An error occurred while processing your request',
2726
- sessionId,
2727
- relayDisconnected: isRelayLost
2728
- });
2729
- }
2730
- }
2731
-
2732
- // Handle shell WebSocket connections
2733
- /**
2734
- * Bridge a browser shell WebSocket to the user's local machine via relay.
2735
- * Creates a relay shell session on the CLI and forwards I/O bidirectionally.
2736
- */
2737
- function handleRelayShell(browserWs, userId) {
2738
- const relay = relayConnections.get(userId);
2739
- if (!relay || relay.ws.readyState !== 1) {
2740
- browserWs.send(JSON.stringify({ type: 'output', data: '\r\n\x1b[31m[Error] Relay connection lost. Reconnect with "uc connect".\x1b[0m\r\n' }));
2741
- browserWs.close();
2742
- return;
2743
- }
2744
-
2745
- const shellSessionId = `relay-shell-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2746
-
2747
- browserWs.send(JSON.stringify({ type: 'output', data: '\x1b[36m[Connected to local machine via relay]\x1b[0m\r\n' }));
2748
-
2749
- // Forward browser shell messages to relay CLI
2750
- browserWs.on('message', async (message) => {
2751
- try {
2752
- const data = JSON.parse(message);
2753
-
2754
- if (data.type === 'init') {
2755
- // Auto-register project path so it appears in sidebar
2756
- if (data.projectPath) {
2757
- try { await addProjectManually(data.projectPath, null, userId); } catch { /* exists */ }
2758
- // Notify browser clients — if this is a Claude/Cursor/Codex session,
2759
- // tell the frontend to auto-select this project and open a new chat
2760
- const isAgentSession = data.provider && data.provider !== 'plain-shell';
2761
- for (const client of connectedClients) {
2762
- try {
2763
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
2764
- if (isAgentSession) {
2765
- client.send(JSON.stringify({
2766
- type: 'shell-project-selected',
2767
- projectPath: data.projectPath,
2768
- provider: data.provider,
2769
- }));
2770
- } else {
2771
- client.send(JSON.stringify({ type: 'projects_updated' }));
2772
- }
2773
- }
2774
- } catch { /* ignore */ }
2775
- }
2776
- }
2777
- // Start relay shell session on CLI
2778
- const relayConn = relayConnections.get(userId);
2779
- if (relayConn?.ws?.readyState === 1) {
2780
- relayConn.ws.send(JSON.stringify({
2781
- type: 'relay-command',
2782
- requestId: shellSessionId,
2783
- action: 'shell-session-start',
2784
- projectPath: data.projectPath,
2785
- cols: data.cols || 80,
2786
- rows: data.rows || 24,
2787
- shellType: data.shellType,
2788
- initialCommand: data.initialCommand,
2789
- provider: data.provider,
2790
- sessionId: data.sessionId,
2791
- hasSession: data.hasSession,
2792
- isPlainShell: data.isPlainShell,
2793
- }));
2794
- }
2795
- } else if (data.type === 'input') {
2796
- // Forward keystroke to CLI shell
2797
- const relayConn = relayConnections.get(userId);
2798
- if (relayConn?.ws?.readyState === 1) {
2799
- relayConn.ws.send(JSON.stringify({
2800
- type: 'relay-shell-input',
2801
- shellSessionId,
2802
- data: data.data,
2803
- }));
2804
- }
2805
- } else if (data.type === 'resize') {
2806
- // Forward resize to CLI shell
2807
- const relayConn = relayConnections.get(userId);
2808
- if (relayConn?.ws?.readyState === 1) {
2809
- relayConn.ws.send(JSON.stringify({
2810
- type: 'relay-shell-resize',
2811
- shellSessionId,
2812
- cols: data.cols,
2813
- rows: data.rows,
2814
- }));
2815
- }
2816
- }
2817
- } catch { /* ignore parse errors */ }
2818
- });
2819
-
2820
- // Listen for relay shell output → forward to browser
2821
- // We tag this browser WS so the relay message handler can find it
2822
- browserWs._relayShellSessionId = shellSessionId;
2823
- browserWs._relayShellUserId = userId;
2824
-
2825
- // Register this shell WS for output routing
2826
- if (!relayShellBrowserSockets) {
2827
- // Will be initialized below
2828
- }
2829
- relayShellBrowserSockets.set(shellSessionId, browserWs);
2830
-
2831
- browserWs.on('close', () => {
2832
- relayShellBrowserSockets.delete(shellSessionId);
2833
- // Tell CLI to kill the shell session
2834
- const relayConn = relayConnections.get(userId);
2835
- if (relayConn?.ws?.readyState === 1) {
2836
- relayConn.ws.send(JSON.stringify({
2837
- type: 'relay-shell-kill',
2838
- shellSessionId,
2839
- }));
2840
- }
2841
- });
2842
- }
2843
-
2844
- // Maps shellSessionId → browser WebSocket (for routing relay shell output)
2845
- const relayShellBrowserSockets = new Map();
2846
-
2847
- function handleShellConnection(ws, request) {
2848
- const shellUserId = request?.user?.id || request?.user?.userId || null;
2849
-
2850
- // ── Relay mode: bridge shell WebSocket to user's local machine ─────────
2851
- if (shellUserId && hasActiveRelay(Number(shellUserId))) {
2852
- handleRelayShell(ws, Number(shellUserId));
2853
- return;
2854
- }
2855
-
2856
- if (!pty) {
2857
- ws.send(JSON.stringify({ type: 'output', data: '\r\n[Shell unavailable] node-pty not installed. Use relay connection for shell access.\r\n' }));
2858
- ws.close();
2859
- return;
2860
- }
2861
- // Shell client connected
2862
- let shellProcess = null;
2863
- let ptySessionKey = null;
2864
- let urlDetectionBuffer = '';
2865
- const announcedAuthUrls = new Set();
2866
-
2867
- ws.on('message', async (message) => {
2868
- try {
2869
- const data = JSON.parse(message);
2870
- // Shell message received
2871
-
2872
- if (data.type === 'init') {
2873
- const projectPath = data.projectPath || process.cwd();
2874
- const sessionId = data.sessionId;
2875
- const hasSession = data.hasSession;
2876
- const provider = data.provider || 'claude';
2877
- const initialCommand = data.initialCommand;
2878
- const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2879
- const shellType = data.shellType || null;
2880
- urlDetectionBuffer = '';
2881
- announcedAuthUrls.clear();
2882
-
2883
- // Login commands (Claude/Cursor auth) should never reuse cached sessions
2884
- const isLoginCommand = initialCommand && (
2885
- initialCommand.includes('setup-token') ||
2886
- initialCommand.includes('cursor-agent login') ||
2887
- initialCommand.includes('auth login')
2888
- );
2889
-
2890
- // Include command hash in session key so different commands get separate sessions
2891
- const commandSuffix = isPlainShell && initialCommand
2892
- ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
2893
- : '';
2894
- ptySessionKey = `${shellUserId || 'anon'}_${projectPath}_${sessionId || 'default'}${commandSuffix}`;
2895
-
2896
- // Kill any existing login session before starting fresh
2897
- if (isLoginCommand) {
2898
- const oldSession = ptySessionsMap.get(ptySessionKey);
2899
- if (oldSession) {
2900
- // cleaning up existing session
2901
- if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
2902
- if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
2903
- ptySessionsMap.delete(ptySessionKey);
2904
- }
2905
- }
2906
-
2907
- const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
2908
- if (existingSession) {
2909
- // reconnecting to existing PTY session
2910
- shellProcess = existingSession.pty;
2911
-
2912
- clearTimeout(existingSession.timeoutId);
2913
-
2914
- ws.send(JSON.stringify({
2915
- type: 'output',
2916
- data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
2917
- }));
2918
-
2919
- if (existingSession.buffer && existingSession.buffer.length > 0) {
2920
- // sending buffered messages
2921
- existingSession.buffer.forEach(bufferedData => {
2922
- ws.send(JSON.stringify({
2923
- type: 'output',
2924
- data: bufferedData
2925
- }));
2926
- });
2927
- }
2928
-
2929
- existingSession.ws = ws;
2930
-
2931
- return;
2932
- }
2933
-
2934
- // shell start path logged silently
2935
- // shell session started
2936
- // provider info logged silently
2937
- if (initialCommand) {
2938
- // initial command logged silently
2939
- }
2940
-
2941
- // First send a welcome message
2942
- let welcomeMsg;
2943
- if (isPlainShell) {
2944
- welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
2945
- } else {
2946
- const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
2947
- welcomeMsg = hasSession ?
2948
- `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
2949
- `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
2950
- }
2951
-
2952
- ws.send(JSON.stringify({
2953
- type: 'output',
2954
- data: welcomeMsg
2955
- }));
2956
-
2957
- try {
2958
- // Prepare the shell command adapted to the platform and provider
2959
- let shellCommand;
2960
- if (isPlainShell) {
2961
- // Plain shell mode - run initial command or open interactive shell
2962
- const usesPowerShell = !shellType || shellType === 'powershell';
2963
- if (initialCommand) {
2964
- if (usesPowerShell) {
2965
- shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
2966
- } else {
2967
- shellCommand = `cd "${projectPath}" && ${initialCommand}`;
2968
- }
2969
- } else {
2970
- // Interactive shell tab — spawn shell directly (no command wrapper)
2971
- shellCommand = null;
2972
- }
2973
- } else if (provider === 'cursor') {
2974
- // Use cursor-agent command
2975
- if (os.platform() === 'win32') {
2976
- if (hasSession && sessionId) {
2977
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
2978
- } else {
2979
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
2980
- }
2981
- } else {
2982
- if (hasSession && sessionId) {
2983
- shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
2984
- } else {
2985
- shellCommand = `cd "${projectPath}" && cursor-agent`;
2986
- }
2987
- }
2988
- } else {
2989
- // Use claude command (default) or initialCommand if provided
2990
- const command = initialCommand || 'claude';
2991
- if (os.platform() === 'win32') {
2992
- if (hasSession && sessionId) {
2993
- // Try to resume session, but with fallback to new session if it fails
2994
- shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
2995
- } else {
2996
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
2997
- }
2998
- } else {
2999
- if (hasSession && sessionId) {
3000
- shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
3001
- } else {
3002
- shellCommand = `cd "${projectPath}" && ${command}`;
3003
- }
3004
- }
3005
- }
3006
-
3007
- // shell command logged silently
3008
-
3009
- // Use appropriate shell based on platform and requested shellType
3010
- const shellMap = {
3011
- 'powershell': { cmd: 'powershell.exe', args: ['-Command'] },
3012
- 'cmd': { cmd: 'cmd.exe', args: ['/c'] },
3013
- 'bash': { cmd: 'bash', args: ['-c'] },
3014
- };
3015
- const defaultShell = os.platform() === 'win32'
3016
- ? { cmd: 'powershell.exe', args: ['-Command'] }
3017
- : { cmd: 'bash', args: ['-c'] };
3018
- const selectedShell = (shellType && shellMap[shellType]) || defaultShell;
3019
- const shell = selectedShell.cmd;
3020
- // If shellCommand is null, spawn an interactive shell with no args
3021
- const shellArgs = shellCommand ? [...selectedShell.args, shellCommand] : [];
3022
-
3023
- // Use terminal dimensions from client if provided, otherwise use defaults
3024
- const termCols = data.cols || 80;
3025
- const termRows = data.rows || 24;
3026
- // terminal dimensions logged silently
3027
-
3028
- shellProcess = pty.spawn(shell, shellArgs, {
3029
- name: 'xterm-256color',
3030
- cols: termCols,
3031
- rows: termRows,
3032
- cwd: shellCommand ? os.homedir() : projectPath,
3033
- env: {
3034
- ...process.env,
3035
- TERM: 'xterm-256color',
3036
- COLORTERM: 'truecolor',
3037
- FORCE_COLOR: '3'
3038
- }
3039
- });
3040
-
3041
- // shell process started
3042
-
3043
- ptySessionsMap.set(ptySessionKey, {
3044
- pty: shellProcess,
3045
- ws: ws,
3046
- buffer: [],
3047
- timeoutId: null,
3048
- projectPath,
3049
- sessionId
3050
- });
3051
-
3052
- // Handle data output
3053
- shellProcess.onData((data) => {
3054
- const session = ptySessionsMap.get(ptySessionKey);
3055
- if (!session) return;
3056
-
3057
- if (session.buffer.length < 5000) {
3058
- session.buffer.push(data);
3059
- } else {
3060
- session.buffer.shift();
3061
- session.buffer.push(data);
3062
- }
3063
-
3064
- if (session.ws && session.ws.readyState === WebSocket.OPEN) {
3065
- let outputData = data;
3066
-
3067
- const cleanChunk = stripAnsiSequences(data);
3068
- urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
3069
-
3070
- outputData = outputData.replace(
3071
- /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
3072
- '[INFO] Opening in browser: $1'
3073
- );
3074
-
3075
- const emitAuthUrl = (detectedUrl, autoOpen = false) => {
3076
- const normalizedUrl = normalizeDetectedUrl(detectedUrl);
3077
- if (!normalizedUrl) return;
3078
-
3079
- const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
3080
- if (isNewUrl) {
3081
- announcedAuthUrls.add(normalizedUrl);
3082
- session.ws.send(JSON.stringify({
3083
- type: 'auth_url',
3084
- url: normalizedUrl,
3085
- autoOpen
3086
- }));
3087
- }
3088
-
3089
- };
3090
-
3091
- const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
3092
- .map((url) => normalizeDetectedUrl(url))
3093
- .filter(Boolean);
3094
-
3095
- // Prefer the most complete URL if shorter prefix variants are also present.
3096
- const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
3097
- !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
3098
- );
3099
-
3100
- dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
3101
-
3102
- if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
3103
- const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
3104
- current.length > longest.length ? current : longest
3105
- );
3106
- emitAuthUrl(bestUrl, true);
3107
- }
3108
-
3109
- // Send regular output
3110
- session.ws.send(JSON.stringify({
3111
- type: 'output',
3112
- data: outputData
3113
- }));
3114
- }
3115
- });
3116
-
3117
- // Handle process exit
3118
- shellProcess.onExit((exitCode) => {
3119
- // shell process exited
3120
- const session = ptySessionsMap.get(ptySessionKey);
3121
- if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
3122
- session.ws.send(JSON.stringify({
3123
- type: 'output',
3124
- data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
3125
- }));
3126
- }
3127
- if (session && session.timeoutId) {
3128
- clearTimeout(session.timeoutId);
3129
- }
3130
- ptySessionsMap.delete(ptySessionKey);
3131
- shellProcess = null;
3132
- });
3133
-
3134
- } catch (spawnError) {
3135
- // process spawn error handled silently
3136
- ws.send(JSON.stringify({
3137
- type: 'output',
3138
- data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
3139
- }));
3140
- }
3141
-
3142
- } else if (data.type === 'input') {
3143
- // Send input to shell process
3144
- if (shellProcess && shellProcess.write) {
3145
- try {
3146
- shellProcess.write(data.data);
3147
- } catch (error) {
3148
- // shell write error handled silently
3149
- }
3150
- } else {
3151
- // no active shell process
3152
- }
3153
- } else if (data.type === 'resize') {
3154
- // Handle terminal resize
3155
- if (shellProcess && shellProcess.resize) {
3156
- // terminal resize handled
3157
- shellProcess.resize(data.cols, data.rows);
3158
- }
3159
- }
3160
- } catch (error) {
3161
- // shell WebSocket error
3162
- if (ws.readyState === WebSocket.OPEN) {
3163
- ws.send(JSON.stringify({
3164
- type: 'output',
3165
- data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
3166
- }));
3167
- }
3168
- }
3169
- });
3170
-
3171
- ws.on('close', () => {
3172
- // shell client disconnected
3173
-
3174
- if (ptySessionKey) {
3175
- const session = ptySessionsMap.get(ptySessionKey);
3176
- if (session) {
3177
- // PTY session kept alive
3178
- session.ws = null;
3179
-
3180
- session.timeoutId = setTimeout(() => {
3181
- // PTY session timeout
3182
- if (session.pty && session.pty.kill) {
3183
- session.pty.kill();
3184
- }
3185
- ptySessionsMap.delete(ptySessionKey);
3186
- }, PTY_SESSION_TIMEOUT);
3187
- }
3188
- }
3189
- });
3190
-
3191
- ws.on('error', (error) => {
3192
- // shell error
3193
- });
3194
- }
3195
- // Audio transcription endpoint
3196
- // Priority: 1) Self-hosted Whisper (WHISPER_URL), 2) BYOK OpenAI key, 3) Server OpenAI key
3197
- app.post('/api/transcribe', authenticateToken, async (req, res) => {
3198
- try {
3199
- const multer = (await import('multer')).default;
3200
- const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
3201
-
3202
- upload.single('audio')(req, res, async (err) => {
3203
- if (err) {
3204
- return res.status(400).json({ error: 'Failed to process audio file' });
3205
- }
3206
-
3207
- if (!req.file) {
3208
- return res.status(400).json({ error: 'No audio file provided' });
3209
- }
3210
-
3211
- const mode = req.body?.mode || 'default';
3212
- let transcribedText = '';
3213
-
3214
- try {
3215
- const FormData = (await import('form-data')).default;
3216
-
3217
- // --- Strategy 1: Self-hosted Whisper (no API key needed) ---
3218
- const whisperUrl = process.env.WHISPER_URL;
3219
- if (whisperUrl) {
3220
- try {
3221
- const formData = new FormData();
3222
- formData.append('file', req.file.buffer, {
3223
- filename: req.file.originalname || 'audio.webm',
3224
- contentType: req.file.mimetype || 'audio/webm'
3225
- });
3226
- formData.append('model', 'whisper-1');
3227
- formData.append('response_format', 'json');
3228
- formData.append('language', 'en');
3229
-
3230
- const endpoint = whisperUrl.replace(/\/+$/, '') + '/v1/audio/transcriptions';
3231
- const response = await fetch(endpoint, {
3232
- method: 'POST',
3233
- headers: formData.getHeaders(),
3234
- body: formData
3235
- });
3236
-
3237
- if (response.ok) {
3238
- const data = await response.json();
3239
- transcribedText = data.text || '';
3240
- }
3241
- } catch {
3242
- // Self-hosted whisper failed, try next strategy
3243
- }
3244
- }
3245
-
3246
- // --- Strategy 2: OpenAI Whisper API (BYOK or server key) ---
3247
- if (!transcribedText) {
3248
- const userOpenaiKey = req.user?.id
3249
- ? await getUserProviderKey(req.user.id, 'openai_key')
3250
- : null;
3251
- const apiKey = userOpenaiKey || process.env.OPENAI_API_KEY;
3252
-
3253
- if (apiKey) {
3254
- const formData = new FormData();
3255
- formData.append('file', req.file.buffer, {
3256
- filename: req.file.originalname || 'audio.webm',
3257
- contentType: req.file.mimetype || 'audio/webm'
3258
- });
3259
- formData.append('model', 'whisper-1');
3260
- formData.append('response_format', 'json');
3261
- formData.append('language', 'en');
3262
-
3263
- const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
3264
- method: 'POST',
3265
- headers: {
3266
- 'Authorization': `Bearer ${apiKey}`,
3267
- ...formData.getHeaders()
3268
- },
3269
- body: formData
3270
- });
3271
-
3272
- if (response.ok) {
3273
- const data = await response.json();
3274
- transcribedText = data.text || '';
3275
- }
3276
- }
3277
- }
3278
-
3279
- // No STT engine worked
3280
- if (!transcribedText) {
3281
- return res.status(500).json({ error: 'Speech recognition is temporarily unavailable. Please try again.' });
3282
- }
3283
-
3284
- // Default mode — return raw transcription
3285
- if (mode === 'default') {
3286
- return res.json({ text: transcribedText });
3287
- }
3288
-
3289
- // Enhancement modes (prompt, vibe, architect) — use OpenAI GPT if available
3290
- try {
3291
- const userOpenaiKey = req.user?.id
3292
- ? await getUserProviderKey(req.user.id, 'openai_key')
3293
- : null;
3294
- const enhanceKey = userOpenaiKey || process.env.OPENAI_API_KEY;
3295
-
3296
- if (enhanceKey) {
3297
- const OpenAI = (await import('openai')).default;
3298
- const openai = new OpenAI({ apiKey: enhanceKey });
3299
-
3300
- let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
3301
-
3302
- switch (mode) {
3303
- case 'prompt':
3304
- systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
3305
- prompt = `Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.\n\nYour enhanced prompt should:\n1. Be specific and unambiguous\n2. Include relevant context and constraints\n3. Specify the desired output format\n4. Use clear, actionable language\n\nRough instruction: "${transcribedText}"\n\nEnhanced prompt:`;
3306
- break;
3307
- case 'vibe':
3308
- case 'instructions':
3309
- case 'architect':
3310
- systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
3311
- temperature = 0.5;
3312
- prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.\n\nIMPORTANT RULES:\n- Format as clear, step-by-step instructions\n- Add reasonable implementation details based on common patterns\n- Only include details directly related to what was asked\n- Do NOT add features or functionality not mentioned\n- Keep the original intent and scope intact\n\nIdea: "${transcribedText}"\n\nAgent instructions:`;
3313
- break;
3314
- }
3315
-
3316
- if (prompt) {
3317
- const completion = await openai.chat.completions.create({
3318
- model: 'gpt-4o-mini',
3319
- messages: [
3320
- { role: 'system', content: systemMessage },
3321
- { role: 'user', content: prompt }
3322
- ],
3323
- temperature,
3324
- max_tokens: maxTokens
3325
- });
3326
- transcribedText = completion.choices[0]?.message?.content || transcribedText;
3327
- }
3328
- }
3329
- // If no OpenAI key for enhancement, just return raw transcription
3330
- } catch {
3331
- // Enhancement failed silently — return raw transcription
3332
- }
3333
-
3334
- res.json({ text: transcribedText });
3335
-
3336
- } catch {
3337
- res.status(500).json({ error: 'Transcription failed. Please try again.' });
3338
- }
3339
- });
3340
- } catch {
3341
- res.status(500).json({ error: 'Internal server error' });
3342
- }
3343
- });
3344
-
3345
- // Image upload endpoint
3346
- app.post('/api/projects/:projectName/upload-images', authenticateToken, authorizeProject, async (req, res) => {
3347
- try {
3348
- const multer = (await import('multer')).default;
3349
- const path = (await import('path')).default;
3350
- const fs = (await import('fs')).promises;
3351
- const os = (await import('os')).default;
3352
-
3353
- // Configure multer for image uploads
3354
- const storage = multer.diskStorage({
3355
- destination: async (req, file, cb) => {
3356
- const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
3357
- await fs.mkdir(uploadDir, { recursive: true });
3358
- cb(null, uploadDir);
3359
- },
3360
- filename: (req, file, cb) => {
3361
- const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
3362
- const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
3363
- cb(null, uniqueSuffix + '-' + sanitizedName);
3364
- }
3365
- });
3366
-
3367
- const fileFilter = (req, file, cb) => {
3368
- const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
3369
- if (allowedMimes.includes(file.mimetype)) {
3370
- cb(null, true);
3371
- } else {
3372
- cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
3373
- }
3374
- };
3375
-
3376
- const upload = multer({
3377
- storage,
3378
- fileFilter,
3379
- limits: {
3380
- fileSize: 5 * 1024 * 1024, // 5MB
3381
- files: 5
3382
- }
3383
- });
3384
-
3385
- // Handle multipart form data
3386
- upload.array('images', 5)(req, res, async (err) => {
3387
- if (err) {
3388
- const uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.code === 'LIMIT_FILE_COUNT' ? 'Too many files (max 5)' : 'Invalid file upload';
3389
- return res.status(400).json({ error: uploadError });
3390
- }
3391
-
3392
- if (!req.files || req.files.length === 0) {
3393
- return res.status(400).json({ error: 'No image files provided' });
3394
- }
3395
-
3396
- try {
3397
- // Process uploaded images
3398
- const processedImages = await Promise.all(
3399
- req.files.map(async (file) => {
3400
- // Read file and convert to base64
3401
- const buffer = await fs.readFile(file.path);
3402
- const base64 = buffer.toString('base64');
3403
- const mimeType = file.mimetype;
3404
-
3405
- // Clean up temp file immediately
3406
- await fs.unlink(file.path);
3407
-
3408
- return {
3409
- name: file.originalname,
3410
- data: `data:${mimeType};base64,${base64}`,
3411
- size: file.size,
3412
- mimeType: mimeType
3413
- };
3414
- })
3415
- );
3416
-
3417
- res.json({ images: processedImages });
3418
- } catch (error) {
3419
- // image processing error handled silently
3420
- // Clean up any remaining files
3421
- await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
3422
- res.status(500).json({ error: 'Failed to process images' });
3423
- }
3424
- });
3425
- } catch (error) {
3426
- // image upload error handled silently
3427
- res.status(500).json({ error: 'Internal server error' });
3428
- }
3429
- });
3430
-
3431
- // Get token usage for a specific session
3432
- app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, authorizeProject, async (req, res) => {
3433
- try {
3434
- const { projectName, sessionId } = req.params;
3435
- const { provider = 'claude' } = req.query;
3436
- const homeDir = os.homedir();
3437
-
3438
- // Allow only safe characters in sessionId
3439
- const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
3440
- if (!safeSessionId) {
3441
- return res.status(400).json({ error: 'Invalid sessionId' });
3442
- }
3443
-
3444
- // Handle Cursor sessions - they use SQLite and don't have token usage info
3445
- if (provider === 'cursor') {
3446
- return res.json({
3447
- used: 0,
3448
- total: 0,
3449
- breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
3450
- unsupported: true,
3451
- message: 'Token usage tracking not available for Cursor sessions'
3452
- });
3453
- }
3454
-
3455
- // Handle Codex sessions
3456
- if (provider === 'codex') {
3457
- const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
3458
-
3459
- // Find the session file by searching for the session ID
3460
- const findSessionFile = async (dir) => {
3461
- try {
3462
- const entries = await fsPromises.readdir(dir, { withFileTypes: true });
3463
- for (const entry of entries) {
3464
- const fullPath = path.join(dir, entry.name);
3465
- if (entry.isDirectory()) {
3466
- const found = await findSessionFile(fullPath);
3467
- if (found) return found;
3468
- } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
3469
- return fullPath;
3470
- }
3471
- }
3472
- } catch (error) {
3473
- // Skip directories we can't read
3474
- }
3475
- return null;
3476
- };
3477
-
3478
- const sessionFilePath = await findSessionFile(codexSessionsDir);
3479
-
3480
- if (!sessionFilePath) {
3481
- return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
3482
- }
3483
-
3484
- // Read and parse the Codex JSONL file
3485
- let fileContent;
3486
- try {
3487
- fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
3488
- } catch (error) {
3489
- if (error.code === 'ENOENT') {
3490
- return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
3491
- }
3492
- throw error;
3493
- }
3494
- const lines = fileContent.trim().split('\n');
3495
- let totalTokens = 0;
3496
- let contextWindow = 200000; // Default for Codex/OpenAI
3497
-
3498
- // Find the latest token_count event with info (scan from end)
3499
- for (let i = lines.length - 1; i >= 0; i--) {
3500
- try {
3501
- const entry = JSON.parse(lines[i]);
3502
-
3503
- // Codex stores token info in event_msg with type: "token_count"
3504
- if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
3505
- const tokenInfo = entry.payload.info;
3506
- if (tokenInfo.total_token_usage) {
3507
- totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
3508
- }
3509
- if (tokenInfo.model_context_window) {
3510
- contextWindow = tokenInfo.model_context_window;
3511
- }
3512
- break; // Stop after finding the latest token count
3513
- }
3514
- } catch (parseError) {
3515
- // Skip lines that can't be parsed
3516
- continue;
3517
- }
3518
- }
3519
-
3520
- return res.json({
3521
- used: totalTokens,
3522
- total: contextWindow
3523
- });
3524
- }
3525
-
3526
- // Handle Claude sessions (default)
3527
- // Extract actual project path
3528
- let projectPath;
3529
- try {
3530
- projectPath = await extractProjectDirectory(projectName);
3531
- } catch (error) {
3532
- // project dir extraction error handled silently
3533
- return res.status(500).json({ error: 'Failed to determine project path' });
3534
- }
3535
-
3536
- // Construct the JSONL file path
3537
- // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
3538
- // The encoding replaces /, spaces, ~, and _ with -
3539
- const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
3540
- const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
3541
-
3542
- const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
3543
-
3544
- // Constrain to projectDir
3545
- const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
3546
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
3547
- return res.status(400).json({ error: 'Invalid path' });
3548
- }
3549
-
3550
- // Read and parse the JSONL file
3551
- let fileContent;
3552
- try {
3553
- fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
3554
- } catch (error) {
3555
- if (error.code === 'ENOENT') {
3556
- return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
3557
- }
3558
- throw error; // Re-throw other errors to be caught by outer try-catch
3559
- }
3560
- const lines = fileContent.trim().split('\n');
3561
-
3562
- const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
3563
- const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
3564
- let inputTokens = 0;
3565
- let cacheCreationTokens = 0;
3566
- let cacheReadTokens = 0;
3567
-
3568
- // Find the latest assistant message with usage data (scan from end)
3569
- for (let i = lines.length - 1; i >= 0; i--) {
3570
- try {
3571
- const entry = JSON.parse(lines[i]);
3572
-
3573
- // Only count assistant messages which have usage data
3574
- if (entry.type === 'assistant' && entry.message?.usage) {
3575
- const usage = entry.message.usage;
3576
-
3577
- // Use token counts from latest assistant message only
3578
- inputTokens = usage.input_tokens || 0;
3579
- cacheCreationTokens = usage.cache_creation_input_tokens || 0;
3580
- cacheReadTokens = usage.cache_read_input_tokens || 0;
3581
-
3582
- break; // Stop after finding the latest assistant message
3583
- }
3584
- } catch (parseError) {
3585
- // Skip lines that can't be parsed
3586
- continue;
3587
- }
3588
- }
3589
-
3590
- // Calculate total context usage (excluding output_tokens, as per ccusage)
3591
- const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
3592
-
3593
- res.json({
3594
- used: totalUsed,
3595
- total: contextWindow,
3596
- breakdown: {
3597
- input: inputTokens,
3598
- cacheCreation: cacheCreationTokens,
3599
- cacheRead: cacheReadTokens
3600
- }
3601
- });
3602
- } catch (error) {
3603
- // token usage read error
3604
- res.status(500).json({ error: 'Failed to read session token usage' });
3605
- }
3606
- });
3607
-
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)
3615
- if (path.extname(req.path)) {
3616
- return res.status(404).send('Not found');
3617
- }
3618
-
3619
- // If a JWT token is in the query param and no session cookie exists,
3620
- // set the cookie now so the client-side AuthContext can authenticate on subsequent API calls.
3621
- if (req.query?.token && !req.cookies?.session) {
3622
- try {
3623
- const decoded = jwt.verify(req.query.token, JWT_SECRET);
3624
- if (decoded?.userId) {
3625
- const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
3626
- res.cookie('session', req.query.token, {
3627
- httpOnly: true,
3628
- secure: isSecure,
3629
- sameSite: isSecure ? 'none' : 'strict',
3630
- maxAge: 30 * 24 * 60 * 60 * 1000,
3631
- path: '/',
3632
- });
3633
- }
3634
- } catch (e) {
3635
- // Invalid token — just serve the page without setting cookie
3636
- }
3637
- }
3638
-
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
- const indexPath = path.join(__dirname, '../client/dist/index.html');
3642
-
3643
- // Check if dist/index.html exists (production build available)
3644
- if (fs.existsSync(indexPath)) {
3645
- // Set no-cache headers for HTML to prevent service worker issues
3646
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
3647
- res.setHeader('Pragma', 'no-cache');
3648
- res.setHeader('Expires', '0');
3649
-
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)
3652
- let html = fs.readFileSync(indexPath, 'utf8');
3653
- const runtimeConfig = JSON.stringify({ isPlatform: IS_PLATFORM, isLocal: IS_LOCAL });
3654
- html = html.replace('</head>', `<script>window.__UPFYN_CONFIG__=${runtimeConfig}</script>\n</head>`);
3655
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
3656
- res.send(html);
3657
- } 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}`);
3660
- }
3661
- });
3662
-
3663
- // Helper function to convert permissions to rwx format
3664
- function permToRwx(perm) {
3665
- const r = perm & 4 ? 'r' : '-';
3666
- const w = perm & 2 ? 'w' : '-';
3667
- const x = perm & 1 ? 'x' : '-';
3668
- return r + w + x;
3669
- }
3670
-
3671
- async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
3672
- // Using fsPromises from import
3673
- const items = [];
3674
-
3675
- try {
3676
- const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
3677
-
3678
- for (const entry of entries) {
3679
- // Debug: log all entries including hidden files
3680
-
3681
-
3682
- // Skip heavy build directories and VCS directories
3683
- if (entry.name === 'node_modules' ||
3684
- entry.name === 'dist' ||
3685
- entry.name === 'build' ||
3686
- entry.name === '.git' ||
3687
- entry.name === '.svn' ||
3688
- entry.name === '.hg') continue;
3689
-
3690
- const itemPath = path.join(dirPath, entry.name);
3691
- const item = {
3692
- name: entry.name,
3693
- path: itemPath,
3694
- type: entry.isDirectory() ? 'directory' : 'file'
3695
- };
3696
-
3697
- // Get file stats for additional metadata
3698
- try {
3699
- const stats = await fsPromises.stat(itemPath);
3700
- item.size = stats.size;
3701
- item.modified = stats.mtime.toISOString();
3702
-
3703
- // Convert permissions to rwx format
3704
- const mode = stats.mode;
3705
- const ownerPerm = (mode >> 6) & 7;
3706
- const groupPerm = (mode >> 3) & 7;
3707
- const otherPerm = mode & 7;
3708
- item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
3709
- item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
3710
- } catch (statError) {
3711
- // If stat fails, provide default values
3712
- item.size = 0;
3713
- item.modified = null;
3714
- item.permissions = '000';
3715
- item.permissionsRwx = '---------';
3716
- }
3717
-
3718
- if (entry.isDirectory() && currentDepth < maxDepth) {
3719
- // Recursively get subdirectories but limit depth
3720
- try {
3721
- // Check if we can access the directory before trying to read it
3722
- await fsPromises.access(item.path, fs.constants.R_OK);
3723
- item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
3724
- } catch (e) {
3725
- // Silently skip directories we can't access (permission denied, etc.)
3726
- item.children = [];
3727
- }
3728
- }
3729
-
3730
- items.push(item);
3731
- }
3732
- } catch (error) {
3733
- // Only log non-permission errors to avoid spam
3734
- if (error.code !== 'EACCES' && error.code !== 'EPERM') {
3735
- // directory read error handled silently
3736
- }
3737
- }
3738
-
3739
- return items.sort((a, b) => {
3740
- if (a.type !== b.type) {
3741
- return a.type === 'directory' ? -1 : 1;
3742
- }
3743
- return a.name.localeCompare(b.name);
3744
- });
3745
- }
3746
-
3747
- const PORT = process.env.PORT || 3001;
3748
-
3749
- // Initialize database and start server
3750
- async function startServer() {
3751
- try {
3752
- // Initialize authentication database
3753
- await initializeDatabase();
3754
-
3755
- // In local mode, ensure a default user exists (no signup needed)
3756
- if (IS_LOCAL) {
3757
- const hasUsers = await userDb.hasUsers();
3758
- if (!hasUsers) {
3759
- const localUsername = os.userInfo().username || 'local';
3760
- const dummyHash = crypto.randomBytes(32).toString('hex');
3761
- await userDb.createUser(localUsername, dummyHash);
3762
- console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
3763
- }
3764
- console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
3765
- }
3766
-
3767
- // Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
3768
- const distIndexPath = path.join(__dirname, '../client/dist/index.html');
3769
- const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
3770
-
3771
- // Log Claude implementation mode
3772
- console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
3773
- console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
3774
-
3775
- if (!isProduction) {
3776
- console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
3777
- }
3778
-
3779
- server.listen(PORT, '0.0.0.0', async () => {
3780
- const appInstallPath = path.join(__dirname, '..');
3781
-
3782
- console.log('');
3783
- console.log(c.dim('═'.repeat(63)));
3784
- console.log(` ${c.bright('Upfyn-Code Server - Ready')}`);
3785
- console.log(c.dim('═'.repeat(63)));
3786
- console.log('');
3787
- console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
3788
- console.log(`${c.info('[INFO]')} MCP Server: ${c.bright('http://0.0.0.0:' + PORT + '/mcp')}`);
3789
- console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
3790
- console.log(`${c.tip('[TIP]')} Run "uc status" for full configuration details`);
3791
-
3792
- // Start workflow cron scheduler
3793
- initScheduler().catch(err => console.warn('[Scheduler]', err.message));
3794
- console.log('');
3795
-
3796
- // Start watching the projects folder for changes (skip on Vercel)
3797
- if (!process.env.VERCEL) {
3798
- await setupProjectsWatcher();
3799
- }
3800
- });
3801
- } catch (error) {
3802
- console.error('[ERROR] Failed to start server:', error);
3803
- process.exit(1);
3804
- }
3805
- }
3806
-
3807
- // Only start server when not running on Vercel (Vercel uses the exported app)
3808
- if (!process.env.VERCEL) {
3809
- startServer();
3810
- }
3811
-
3812
- // Export for Vercel serverless and testing
3813
- export default app;
3814
- export { app, server, relayConnections, sendRelayCommand };