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