upfynai-code 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +66 -91
  2. package/bin/cli.js +191 -0
  3. package/{client/dist/assets/AppContent-CwrTP6TW.js → dist/client/assets/AppContent-BofJquUs.js} +4 -4
  4. package/{client/dist/assets/BrowserPanel-0TLEl-IC.js → dist/client/assets/BrowserPanel-CSvD4jOX.js} +2 -2
  5. package/dist/client/assets/CanvasFullScreen-onRfarpc.js +1 -0
  6. package/dist/client/assets/CanvasWorkspace-DvGKdL-k.js +259 -0
  7. package/dist/client/assets/DashboardPanel-DqAHbXDO.js +1 -0
  8. package/dist/client/assets/FileTree-BE0h-9M9.js +1 -0
  9. package/{client/dist/assets/GitPanel-C_xFM-N2.js → dist/client/assets/GitPanel-DdeJ0bp5.js} +2 -2
  10. package/{client/dist/assets/LoginModal-CImJHRjX.js → dist/client/assets/LoginModal-BP0pCTrH.js} +3 -3
  11. package/dist/client/assets/MermaidBlock-D0rfEhrT.js +2 -0
  12. package/dist/client/assets/Onboarding-B2zQy-_6.js +1 -0
  13. package/dist/client/assets/SetupForm-Be7-WBe-.js +1 -0
  14. package/dist/client/assets/WorkflowsPanel-CusLbVJ6.js +1 -0
  15. package/{client/dist/assets/index-HaY-3pK1.js → dist/client/assets/index-BQy15irW.js} +24 -24
  16. package/dist/client/assets/index-CS0fDqEC.js +1 -0
  17. package/dist/client/assets/index-DYLSCCCp.css +1 -0
  18. package/dist/client/assets/vendor-canvas-QWTduIvM.js +23 -0
  19. package/{client/dist/assets/vendor-icons-GyYE35HP.js → dist/client/assets/vendor-icons-kix3Gb31.js} +1 -1
  20. package/{client/dist/assets/vendor-mermaid-DucWyDEe.js → dist/client/assets/vendor-mermaid-CS3J4_Bz.js} +329 -326
  21. package/dist/client/favicon.png +0 -0
  22. package/dist/client/favicon.svg +15 -0
  23. package/{client/dist → dist/client}/index.html +3 -3
  24. package/{client/dist → dist/client}/manifest.json +12 -12
  25. package/package.json +55 -104
  26. package/scripts/postinstall.js +9 -0
  27. package/scripts/prepublish.js +77 -0
  28. package/src/animation.js +228 -0
  29. package/src/auth.js +142 -0
  30. package/src/config.js +40 -0
  31. package/src/connect.js +416 -0
  32. package/src/launch.js +81 -0
  33. package/src/mcp.js +57 -0
  34. package/src/permissions.js +140 -0
  35. package/src/persistent-shell.js +261 -0
  36. package/src/server.js +54 -0
  37. package/client/dist/assets/CanvasFullScreen-D1GWQsGL.js +0 -1
  38. package/client/dist/assets/CanvasWorkspace-D7ORj358.js +0 -163
  39. package/client/dist/assets/DashboardPanel-BV7ybUDe.js +0 -1
  40. package/client/dist/assets/FileTree-5qfhBqdE.js +0 -1
  41. package/client/dist/assets/MermaidBlock-BFM21cwe.js +0 -2
  42. package/client/dist/assets/Onboarding-B3cteLu2.js +0 -1
  43. package/client/dist/assets/SetupForm-P6dsYgHO.js +0 -1
  44. package/client/dist/assets/WorkflowsPanel-CBoN80kc.js +0 -1
  45. package/client/dist/assets/index-46kkVu2i.css +0 -1
  46. package/client/dist/assets/vendor-canvas-DvHJ_Pn2.js +0 -49
  47. package/client/dist/favicon.png +0 -0
  48. package/client/dist/favicon.svg +0 -5
  49. package/commands/upfynai-connect.md +0 -59
  50. package/commands/upfynai-disconnect.md +0 -31
  51. package/commands/upfynai-doctor.md +0 -99
  52. package/commands/upfynai-export.md +0 -49
  53. package/commands/upfynai-local.md +0 -82
  54. package/commands/upfynai-status.md +0 -75
  55. package/commands/upfynai-stop.md +0 -49
  56. package/commands/upfynai-uninstall.md +0 -58
  57. package/commands/upfynai.md +0 -69
  58. package/scripts/build-client.js +0 -17
  59. package/scripts/fix-node-pty.js +0 -67
  60. package/scripts/install-commands.js +0 -78
  61. package/server/agent-loop.js +0 -242
  62. package/server/auto-compact.js +0 -99
  63. package/server/browser.js +0 -131
  64. package/server/claude-sdk.js +0 -797
  65. package/server/cli-ui.js +0 -798
  66. package/server/cli.js +0 -751
  67. package/server/constants/config.js +0 -31
  68. package/server/cursor-cli.js +0 -270
  69. package/server/database/auth.db +0 -0
  70. package/server/database/db.js +0 -1547
  71. package/server/database/init.sql +0 -70
  72. package/server/index.js +0 -3813
  73. package/server/load-env.js +0 -26
  74. package/server/mcp-server.js +0 -621
  75. package/server/middleware/auth.js +0 -184
  76. package/server/middleware/relayHelpers.js +0 -44
  77. package/server/middleware/sandboxRouter.js +0 -174
  78. package/server/openai-codex.js +0 -403
  79. package/server/openrouter.js +0 -137
  80. package/server/projects.js +0 -1807
  81. package/server/provider-factory.js +0 -174
  82. package/server/relay-client.js +0 -390
  83. package/server/routes/agent.js +0 -1234
  84. package/server/routes/auth.js +0 -559
  85. package/server/routes/browser.js +0 -419
  86. package/server/routes/canvas.js +0 -53
  87. package/server/routes/cli-auth.js +0 -263
  88. package/server/routes/codex.js +0 -396
  89. package/server/routes/commands.js +0 -707
  90. package/server/routes/composio.js +0 -176
  91. package/server/routes/cursor.js +0 -770
  92. package/server/routes/dashboard.js +0 -295
  93. package/server/routes/git.js +0 -1208
  94. package/server/routes/keys.js +0 -34
  95. package/server/routes/mcp-utils.js +0 -48
  96. package/server/routes/mcp.js +0 -661
  97. package/server/routes/payments.js +0 -227
  98. package/server/routes/projects.js +0 -754
  99. package/server/routes/sessions.js +0 -146
  100. package/server/routes/settings.js +0 -261
  101. package/server/routes/taskmaster.js +0 -1928
  102. package/server/routes/user.js +0 -106
  103. package/server/routes/vapi-chat.js +0 -624
  104. package/server/routes/voice.js +0 -235
  105. package/server/routes/webhooks.js +0 -166
  106. package/server/routes/workflows.js +0 -312
  107. package/server/sandbox.js +0 -120
  108. package/server/services/browser-ai.js +0 -154
  109. package/server/services/composio.js +0 -204
  110. package/server/services/sessionRegistry.js +0 -139
  111. package/server/services/whisperService.js +0 -84
  112. package/server/services/workflowScheduler.js +0 -211
  113. package/server/tests/relay-flow.test.js +0 -570
  114. package/server/tests/sessions.test.js +0 -259
  115. package/server/utils/commandParser.js +0 -303
  116. package/server/utils/email.js +0 -66
  117. package/server/utils/gitConfig.js +0 -24
  118. package/server/utils/mcp-detector.js +0 -198
  119. package/server/utils/taskmaster-websocket.js +0 -129
  120. package/shared/integrationCatalog.d.ts +0 -12
  121. package/shared/integrationCatalog.js +0 -172
  122. package/shared/modelConstants.js +0 -96
  123. /package/{shared → dist}/agents/claude.js +0 -0
  124. /package/{shared → dist}/agents/codex.js +0 -0
  125. /package/{shared → dist}/agents/cursor.js +0 -0
  126. /package/{shared → dist}/agents/detect.js +0 -0
  127. /package/{shared → dist}/agents/exec.js +0 -0
  128. /package/{shared → dist}/agents/files.js +0 -0
  129. /package/{shared → dist}/agents/git.js +0 -0
  130. /package/{shared → dist}/agents/gitagent.js +0 -0
  131. /package/{shared → dist}/agents/index.js +0 -0
  132. /package/{shared → dist}/agents/shell.js +0 -0
  133. /package/{shared → dist}/agents/utils.js +0 -0
  134. /package/{client/dist → dist/client}/api-docs.html +0 -0
  135. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  136. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  137. /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  138. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  139. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  140. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  141. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  142. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  143. /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  144. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  145. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  146. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  147. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  148. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  149. /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  150. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  151. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  152. /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  153. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  154. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  155. /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  156. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  157. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  158. /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  159. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  160. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  161. /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  162. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  163. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  164. /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  165. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  166. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  167. /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  168. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  169. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  170. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  171. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  172. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  173. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  174. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  175. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  176. /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  177. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  178. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  179. /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  180. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  181. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  182. /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  183. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  184. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  185. /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  186. /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  187. /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  188. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  189. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  190. /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  191. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  192. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  193. /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  194. /package/{client/dist → dist/client}/assets/MarkdownPreview-CESjI261.js +0 -0
  195. /package/{client/dist → dist/client}/assets/PreviewPanel-CqCa92Tf.js +0 -0
  196. /package/{client/dist → dist/client}/assets/pdf-CE_K4jFx.js +0 -0
  197. /package/{client/dist → dist/client}/assets/vendor-canvas-BZV40eAE.css +0 -0
  198. /package/{client/dist → dist/client}/assets/vendor-codemirror-D2ALgpaX.js +0 -0
  199. /package/{client/dist → dist/client}/assets/vendor-diff-DNQpbhrT.js +0 -0
  200. /package/{client/dist → dist/client}/assets/vendor-i18n-DCFGyhQR.js +0 -0
  201. /package/{client/dist → dist/client}/assets/vendor-markdown-CimbIo6Y.js +0 -0
  202. /package/{client/dist → dist/client}/assets/vendor-react-96lCPsRK.js +0 -0
  203. /package/{client/dist → dist/client}/assets/vendor-syntax-LS_Nt30I.js +0 -0
  204. /package/{client/dist → dist/client}/assets/vendor-xterm-CZq1hqo1.js +0 -0
  205. /package/{client/dist → dist/client}/assets/vendor-xterm-qxJ8_QYu.css +0 -0
  206. /package/{client/dist → dist/client}/clear-cache.html +0 -0
  207. /package/{client/dist → dist/client}/convert-icons.md +0 -0
  208. /package/{client/dist → dist/client}/generate-icons.js +0 -0
  209. /package/{client/dist → dist/client}/icons/claude-ai-icon.svg +0 -0
  210. /package/{client/dist → dist/client}/icons/codex-white.svg +0 -0
  211. /package/{client/dist → dist/client}/icons/codex.svg +0 -0
  212. /package/{client/dist → dist/client}/icons/cursor-white.svg +0 -0
  213. /package/{client/dist → dist/client}/icons/cursor.svg +0 -0
  214. /package/{client/dist → dist/client}/icons/icon-128x128.png +0 -0
  215. /package/{client/dist → dist/client}/icons/icon-128x128.svg +0 -0
  216. /package/{client/dist → dist/client}/icons/icon-144x144.png +0 -0
  217. /package/{client/dist → dist/client}/icons/icon-144x144.svg +0 -0
  218. /package/{client/dist → dist/client}/icons/icon-152x152.png +0 -0
  219. /package/{client/dist → dist/client}/icons/icon-152x152.svg +0 -0
  220. /package/{client/dist → dist/client}/icons/icon-192x192.png +0 -0
  221. /package/{client/dist → dist/client}/icons/icon-192x192.svg +0 -0
  222. /package/{client/dist → dist/client}/icons/icon-384x384.png +0 -0
  223. /package/{client/dist → dist/client}/icons/icon-384x384.svg +0 -0
  224. /package/{client/dist → dist/client}/icons/icon-512x512.png +0 -0
  225. /package/{client/dist → dist/client}/icons/icon-512x512.svg +0 -0
  226. /package/{client/dist → dist/client}/icons/icon-72x72.png +0 -0
  227. /package/{client/dist → dist/client}/icons/icon-72x72.svg +0 -0
  228. /package/{client/dist → dist/client}/icons/icon-96x96.png +0 -0
  229. /package/{client/dist → dist/client}/icons/icon-96x96.svg +0 -0
  230. /package/{client/dist → dist/client}/icons/icon-template.svg +0 -0
  231. /package/{client/dist → dist/client}/logo-128.png +0 -0
  232. /package/{client/dist → dist/client}/logo-256.png +0 -0
  233. /package/{client/dist → dist/client}/logo-32.png +0 -0
  234. /package/{client/dist → dist/client}/logo-512.png +0 -0
  235. /package/{client/dist → dist/client}/logo-64.png +0 -0
  236. /package/{client/dist → dist/client}/logo.svg +0 -0
  237. /package/{client/dist → dist/client}/mcp-docs.html +0 -0
  238. /package/{client/dist → dist/client}/offline.html +0 -0
  239. /package/{client/dist → dist/client}/screenshots/cli-selection.png +0 -0
  240. /package/{client/dist → dist/client}/screenshots/desktop-main.png +0 -0
  241. /package/{client/dist → dist/client}/screenshots/mobile-chat.png +0 -0
  242. /package/{client/dist → dist/client}/screenshots/tools-modal.png +0 -0
  243. /package/{client/dist → dist/client}/sw.js +0 -0
  244. /package/{shared → dist}/gitagent/index.js +0 -0
  245. /package/{shared → dist}/gitagent/parser.js +0 -0
  246. /package/{shared → dist}/gitagent/prompt-builder.js +0 -0
@@ -1,570 +0,0 @@
1
- /**
2
- * End-to-end tests for Relay AI Chat Flow & Multi-User Isolation
3
- *
4
- * Tests verify:
5
- * 1. Relay message routing (UI → Backend → CLI → AI → Response)
6
- * 2. Streaming event translation (relay-stream → content_block_delta)
7
- * 3. Multi-user isolation (broadcasts, sessions, projects, relay connections)
8
- * 4. Session locking and ownership
9
- * 5. Agent abstraction (executeAction dispatch)
10
- *
11
- * Run: node --test backend/server/tests/relay-flow.test.js
12
- */
13
-
14
- import { describe, it } from 'node:test';
15
- import assert from 'node:assert/strict';
16
-
17
- // ── Test 1: Relay Connection Isolation ──
18
-
19
- describe('Relay Connection Isolation', () => {
20
- it('relayConnections map keys by userId — different users get separate entries', () => {
21
- const relayConnections = new Map();
22
-
23
- // User A connects
24
- relayConnections.set(1, { ws: { readyState: 1 }, user: { userId: 1, username: 'userA' }, connectedAt: Date.now() });
25
- // User B connects
26
- relayConnections.set(2, { ws: { readyState: 1 }, user: { userId: 2, username: 'userB' }, connectedAt: Date.now() });
27
-
28
- assert.equal(relayConnections.size, 2, 'Two separate relay connections');
29
- assert.equal(relayConnections.get(1).user.username, 'userA');
30
- assert.equal(relayConnections.get(2).user.username, 'userB');
31
-
32
- // User A's lookup cannot access User B's relay
33
- const relayA = relayConnections.get(1);
34
- const relayB = relayConnections.get(2);
35
- assert.notEqual(relayA.ws, relayB.ws, 'Different users have different WebSocket connections');
36
- });
37
-
38
- it('hasActiveRelay returns true only for the correct user', () => {
39
- const relayConnections = new Map();
40
- relayConnections.set(1, { ws: { readyState: 1 } });
41
- relayConnections.set(2, { ws: { readyState: 3 } }); // User 2 disconnected (readyState=3)
42
-
43
- function hasActiveRelay(userId) {
44
- if (!userId) return false;
45
- const relay = relayConnections.get(Number(userId));
46
- return relay && relay.ws.readyState === 1;
47
- }
48
-
49
- assert.equal(hasActiveRelay(1), true, 'User 1 has active relay');
50
- assert.ok(!hasActiveRelay(2), 'User 2 relay is disconnected');
51
- assert.ok(!hasActiveRelay(3), 'User 3 has no relay');
52
- assert.ok(!hasActiveRelay(null), 'Null userId returns falsy');
53
- assert.ok(!hasActiveRelay(undefined), 'Undefined userId returns falsy');
54
- });
55
-
56
- it('sendRelayCommand targets only the correct userId relay', () => {
57
- const relayConnections = new Map();
58
- const sentMessages = { user1: [], user2: [] };
59
-
60
- relayConnections.set(1, {
61
- ws: {
62
- readyState: 1,
63
- send: (msg) => sentMessages.user1.push(JSON.parse(msg)),
64
- }
65
- });
66
- relayConnections.set(2, {
67
- ws: {
68
- readyState: 1,
69
- send: (msg) => sentMessages.user2.push(JSON.parse(msg)),
70
- }
71
- });
72
-
73
- // Send command to user 1's relay
74
- const relay = relayConnections.get(1);
75
- relay.ws.send(JSON.stringify({ type: 'relay-command', action: 'claude-query', command: 'hello' }));
76
-
77
- assert.equal(sentMessages.user1.length, 1, 'User 1 received the command');
78
- assert.equal(sentMessages.user2.length, 0, 'User 2 did NOT receive the command');
79
- assert.equal(sentMessages.user1[0].action, 'claude-query');
80
- });
81
- });
82
-
83
- // ── Test 2: Broadcast User Filtering ──
84
-
85
- describe('Broadcast User Filtering', () => {
86
- it('relay-status broadcast only reaches clients with matching userId', () => {
87
- const connectedClients = new Set();
88
- const received = { userA: [], userB: [], noAuth: [] };
89
-
90
- const clientA = {
91
- readyState: 1,
92
- _wsUser: { userId: 1 },
93
- send: (msg) => received.userA.push(JSON.parse(msg)),
94
- };
95
- const clientB = {
96
- readyState: 1,
97
- _wsUser: { userId: 2 },
98
- send: (msg) => received.userB.push(JSON.parse(msg)),
99
- };
100
- const clientNoAuth = {
101
- readyState: 1,
102
- _wsUser: null,
103
- send: (msg) => received.noAuth.push(JSON.parse(msg)),
104
- };
105
-
106
- connectedClients.add(clientA);
107
- connectedClients.add(clientB);
108
- connectedClients.add(clientNoAuth);
109
-
110
- // Broadcast relay-status for userId=1
111
- const userId = 1;
112
- for (const client of connectedClients) {
113
- try {
114
- if (client.readyState === 1 && client._wsUser?.userId === userId) {
115
- client.send(JSON.stringify({ type: 'relay-status', userId, connected: true }));
116
- }
117
- } catch { /* ignore */ }
118
- }
119
-
120
- assert.equal(received.userA.length, 1, 'User A received the broadcast');
121
- assert.equal(received.userB.length, 0, 'User B did NOT receive it');
122
- assert.equal(received.noAuth.length, 0, 'Unauthenticated client did NOT receive it');
123
- assert.equal(received.userA[0].type, 'relay-status');
124
- assert.equal(received.userA[0].connected, true);
125
- });
126
-
127
- it('projects_updated broadcast only sends user-owned projects', () => {
128
- const connectedClients = new Set();
129
- const received = { userA: [], userB: [] };
130
-
131
- const clientA = {
132
- readyState: 1,
133
- _wsUser: { userId: 1 },
134
- send: (msg) => received.userA.push(JSON.parse(msg)),
135
- };
136
- const clientB = {
137
- readyState: 1,
138
- _wsUser: { userId: 2 },
139
- send: (msg) => received.userB.push(JSON.parse(msg)),
140
- };
141
- connectedClients.add(clientA);
142
- connectedClients.add(clientB);
143
-
144
- // Simulate per-user project lists
145
- const userProjects = {
146
- 1: [{ name: 'projectA' }],
147
- 2: [{ name: 'projectB' }],
148
- };
149
-
150
- for (const client of connectedClients) {
151
- if (client.readyState !== 1) continue;
152
- const uid = client._wsUser?.userId;
153
- if (uid && userProjects[uid]) {
154
- client.send(JSON.stringify({ type: 'projects_updated', projects: userProjects[uid] }));
155
- }
156
- }
157
-
158
- assert.equal(received.userA.length, 1);
159
- assert.equal(received.userB.length, 1);
160
- assert.equal(received.userA[0].projects[0].name, 'projectA', 'User A sees only projectA');
161
- assert.equal(received.userB[0].projects[0].name, 'projectB', 'User B sees only projectB');
162
- });
163
- });
164
-
165
- // ── Test 3: Streaming Event Translation ──
166
-
167
- describe('Streaming Event Translation', () => {
168
- it('relay-stream with claude-response translates to content_block_delta', () => {
169
- const frontendMessages = [];
170
- const writer = {
171
- send: (msg) => frontendMessages.push(msg),
172
- };
173
-
174
- // Simulate onStream callback from routeViaRelay
175
- const responseType = 'claude-response';
176
- const sessionId = 'session-123';
177
- let fullContent = '';
178
-
179
- function onStream(streamData) {
180
- if (streamData.type === 'claude-response') {
181
- const chunk = streamData.content || '';
182
- if (chunk) {
183
- fullContent += chunk;
184
- writer.send({
185
- type: responseType,
186
- data: { type: 'content_block_delta', delta: { text: chunk } },
187
- sessionId,
188
- });
189
- }
190
- }
191
- }
192
-
193
- // Simulate streaming chunks
194
- onStream({ type: 'claude-response', content: 'Hello' });
195
- onStream({ type: 'claude-response', content: ', I am Claude.' });
196
- onStream({ type: 'claude-response', content: ' How can I help?' });
197
-
198
- assert.equal(frontendMessages.length, 3, 'Three streaming events sent to frontend');
199
- assert.equal(frontendMessages[0].data.type, 'content_block_delta');
200
- assert.equal(frontendMessages[0].data.delta.text, 'Hello');
201
- assert.equal(frontendMessages[1].data.delta.text, ', I am Claude.');
202
- assert.equal(frontendMessages[2].data.delta.text, ' How can I help?');
203
- assert.equal(fullContent, 'Hello, I am Claude. How can I help?', 'Full content accumulated correctly');
204
- });
205
-
206
- it('claude-system event translates to relay-session-id', () => {
207
- const frontendMessages = [];
208
- const writer = { send: (msg) => frontendMessages.push(msg) };
209
- const sessionId = 'session-456';
210
- let capturedCliSessionId = null;
211
-
212
- function onStream(streamData) {
213
- if (streamData.type === 'claude-system' && streamData.sessionId) {
214
- capturedCliSessionId = streamData.sessionId;
215
- writer.send({
216
- type: 'relay-session-id',
217
- sessionId,
218
- cliSessionId: capturedCliSessionId,
219
- model: streamData.model,
220
- cwd: streamData.cwd,
221
- });
222
- }
223
- }
224
-
225
- onStream({ type: 'claude-system', sessionId: 'cli-sess-789', model: 'claude-sonnet-4-6', cwd: '/home/user/project' });
226
-
227
- assert.equal(frontendMessages.length, 1);
228
- assert.equal(frontendMessages[0].type, 'relay-session-id');
229
- assert.equal(frontendMessages[0].cliSessionId, 'cli-sess-789');
230
- assert.equal(frontendMessages[0].model, 'claude-sonnet-4-6');
231
- assert.equal(capturedCliSessionId, 'cli-sess-789');
232
- });
233
-
234
- it('completion event includes viaRelay: true', () => {
235
- const frontendMessages = [];
236
- const writer = { send: (msg) => frontendMessages.push(msg) };
237
-
238
- // Simulate routeViaRelay completion
239
- writer.send({
240
- type: 'claude-complete',
241
- sessionId: 'session-123',
242
- cliSessionId: 'cli-sess-456',
243
- exitCode: 0,
244
- isNewSession: true,
245
- viaRelay: true,
246
- });
247
-
248
- assert.equal(frontendMessages[0].type, 'claude-complete');
249
- assert.equal(frontendMessages[0].viaRelay, true, 'Must indicate response came via relay');
250
- assert.equal(frontendMessages[0].exitCode, 0);
251
- });
252
- });
253
-
254
- // ── Test 4: Session Ownership Isolation ──
255
-
256
- describe('Session Ownership Isolation', () => {
257
- it('sessionOwners map enforces user isolation', () => {
258
- const sessionOwners = new Map();
259
-
260
- // User 1 creates a session
261
- sessionOwners.set('session-A', 1);
262
- // User 2 creates a session
263
- sessionOwners.set('session-B', 2);
264
-
265
- // Simulate getUserSessions filter
266
- function getUserSessions(userId, allSessions) {
267
- return allSessions.filter(s => {
268
- const owner = sessionOwners.get(s.sessionId);
269
- return !owner || owner === userId;
270
- });
271
- }
272
-
273
- const allSessions = [
274
- { sessionId: 'session-A', provider: 'claude' },
275
- { sessionId: 'session-B', provider: 'claude' },
276
- { sessionId: 'session-C', provider: 'cursor' }, // unowned
277
- ];
278
-
279
- const user1Sessions = getUserSessions(1, allSessions);
280
- const user2Sessions = getUserSessions(2, allSessions);
281
-
282
- assert.equal(user1Sessions.length, 2, 'User 1 sees session-A + unowned session-C');
283
- assert.ok(user1Sessions.some(s => s.sessionId === 'session-A'), 'User 1 sees their own session');
284
- assert.ok(!user1Sessions.some(s => s.sessionId === 'session-B'), 'User 1 cannot see User 2 session');
285
-
286
- assert.equal(user2Sessions.length, 2, 'User 2 sees session-B + unowned session-C');
287
- assert.ok(user2Sessions.some(s => s.sessionId === 'session-B'), 'User 2 sees their own session');
288
- assert.ok(!user2Sessions.some(s => s.sessionId === 'session-A'), 'User 2 cannot see User 1 session');
289
- });
290
-
291
- it('abort rejects when user does not own the session', () => {
292
- const sessionOwners = new Map();
293
- sessionOwners.set('session-X', 1); // owned by user 1
294
-
295
- const userId = 2; // user 2 tries to abort
296
- const owner = sessionOwners.get('session-X');
297
- const canAbort = !owner || owner === userId;
298
-
299
- assert.equal(canAbort, false, 'User 2 cannot abort User 1 session');
300
- });
301
- });
302
-
303
- // ── Test 5: Agent Action Dispatch ──
304
-
305
- describe('Agent Action Dispatch', () => {
306
- it('executeAction routes to correct handler', async () => {
307
- const callLog = [];
308
- const actionMap = new Map([
309
- ['claude-query', async (params) => { callLog.push('claude'); return { exitCode: 0 }; }],
310
- ['cursor-query', async (params) => { callLog.push('cursor'); return { exitCode: 0 }; }],
311
- ['codex-query', async (params) => { callLog.push('codex'); return { exitCode: 0 }; }],
312
- ['shell-exec', async (params) => { callLog.push('shell'); return { output: 'hello' }; }],
313
- ]);
314
-
315
- async function executeAction(action, params) {
316
- const handler = actionMap.get(action);
317
- if (!handler) throw new Error(`Unknown action: ${action}`);
318
- return handler(params);
319
- }
320
-
321
- await executeAction('claude-query', { command: 'hey' });
322
- await executeAction('cursor-query', { command: 'hey' });
323
- await executeAction('shell-exec', { command: 'ls' });
324
-
325
- assert.deepEqual(callLog, ['claude', 'cursor', 'shell']);
326
-
327
- // Unknown action throws
328
- await assert.rejects(
329
- () => executeAction('unknown-action', {}),
330
- { message: 'Unknown action: unknown-action' }
331
- );
332
- });
333
-
334
- it('streaming actions are identified correctly', () => {
335
- const streamingActions = new Set(['claude-query', 'claude-task-query', 'codex-query', 'cursor-query']);
336
-
337
- function isStreamingAction(action) {
338
- return streamingActions.has(action);
339
- }
340
-
341
- assert.equal(isStreamingAction('claude-query'), true);
342
- assert.equal(isStreamingAction('codex-query'), true);
343
- assert.equal(isStreamingAction('cursor-query'), true);
344
- assert.equal(isStreamingAction('shell-exec'), false);
345
- assert.equal(isStreamingAction('file-read'), false);
346
- assert.equal(isStreamingAction('git-status'), false);
347
- });
348
- });
349
-
350
- // ── Test 6: Relay Payload Validation ──
351
-
352
- describe('Relay Payload Validation', () => {
353
- it('validates action is in allowlist', () => {
354
- const ALLOWED_RELAY_ACTIONS = new Set([
355
- 'claude-query', 'claude-task-query', 'cursor-query', 'codex-query',
356
- 'shell-exec', 'file-read', 'file-write', 'file-list',
357
- 'git-status', 'git-diff', 'git-log', 'git-branches',
358
- 'gitagent-parse', 'detect-agents',
359
- ]);
360
-
361
- function validateRelayPayload(action) {
362
- if (!ALLOWED_RELAY_ACTIONS.has(action)) {
363
- return { valid: false, reason: `Action "${action}" is not allowed` };
364
- }
365
- return { valid: true };
366
- }
367
-
368
- assert.equal(validateRelayPayload('claude-query').valid, true);
369
- assert.equal(validateRelayPayload('shell-exec').valid, true);
370
- assert.equal(validateRelayPayload('rm-rf-everything').valid, false, 'Unknown action blocked');
371
- assert.equal(validateRelayPayload('').valid, false, 'Empty action blocked');
372
- });
373
- });
374
-
375
- // ── Test 7: IP Pinning Security ──
376
-
377
- describe('IP Pinning Security', () => {
378
- it('blocks relay connection from different IP when another is active', () => {
379
- const relayConnections = new Map();
380
-
381
- // User 1 connects from IP A
382
- relayConnections.set(1, { ws: { readyState: 1 }, clientIp: '1.2.3.4', connectedAt: Date.now() });
383
-
384
- // Same user tries from IP B
385
- const existing = relayConnections.get(1);
386
- const newIp = '5.6.7.8';
387
- const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== newIp;
388
-
389
- assert.equal(blocked, true, 'Second connection from different IP is blocked');
390
- });
391
-
392
- it('allows reconnection from same IP', () => {
393
- const relayConnections = new Map();
394
- relayConnections.set(1, { ws: { readyState: 1 }, clientIp: '1.2.3.4' });
395
-
396
- const existing = relayConnections.get(1);
397
- const sameIp = '1.2.3.4';
398
- const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== sameIp;
399
-
400
- assert.equal(blocked, false, 'Reconnection from same IP is allowed');
401
- });
402
-
403
- it('allows new connection when previous relay is disconnected', () => {
404
- const relayConnections = new Map();
405
- relayConnections.set(1, { ws: { readyState: 3 }, clientIp: '1.2.3.4' }); // disconnected
406
-
407
- const existing = relayConnections.get(1);
408
- const newIp = '5.6.7.8';
409
- const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== newIp;
410
-
411
- assert.equal(blocked, false, 'New connection allowed when previous relay is disconnected');
412
- });
413
- });
414
-
415
- // ── Test 8: Dashboard Stats Cache ──
416
-
417
- describe('Dashboard Stats Cache', () => {
418
- it('cache returns fresh data within TTL', () => {
419
- const cache = new Map();
420
- const CACHE_TTL = 60_000;
421
-
422
- function getCachedStats(userId) {
423
- const entry = cache.get(userId);
424
- if (entry && Date.now() - entry.ts < CACHE_TTL) return entry.data;
425
- return null;
426
- }
427
-
428
- function setCachedStats(userId, data) {
429
- cache.set(userId, { data, ts: Date.now() });
430
- }
431
-
432
- // Set cache
433
- const testData = { account: { username: 'testuser' } };
434
- setCachedStats(1, testData);
435
-
436
- // Immediately retrieve — should hit
437
- const cached = getCachedStats(1);
438
- assert.deepEqual(cached, testData, 'Cache hit returns data');
439
-
440
- // Different user — should miss
441
- const otherUser = getCachedStats(2);
442
- assert.equal(otherUser, null, 'Different user has no cache');
443
- });
444
-
445
- it('cache invalidation clears entry for specific user', () => {
446
- const cache = new Map();
447
-
448
- cache.set(1, { data: { user: 'A' }, ts: Date.now() });
449
- cache.set(2, { data: { user: 'B' }, ts: Date.now() });
450
-
451
- // Invalidate user 1 only
452
- cache.delete(1);
453
-
454
- assert.equal(cache.has(1), false, 'User 1 cache invalidated');
455
- assert.equal(cache.has(2), true, 'User 2 cache unaffected');
456
- });
457
- });
458
-
459
- // ── Test 9: Frontend SessionStorage Stale-While-Revalidate ──
460
-
461
- describe('Stale-While-Revalidate Pattern', () => {
462
- it('shows cached data immediately then updates with fresh data', () => {
463
- // Simulate sessionStorage
464
- const storage = {};
465
- const setItem = (k, v) => { storage[k] = v; };
466
- const getItem = (k) => storage[k] || null;
467
-
468
- // Initial state — no cache
469
- let stats = null;
470
- let loading = true;
471
-
472
- const cached = getItem('dash_stats');
473
- assert.equal(cached, null, 'No cache initially');
474
- assert.equal(loading, true, 'Loading state true initially');
475
-
476
- // Simulate first load — data comes from server
477
- const serverData = { account: { username: 'user1' }, apiKeys: { keys: [] } };
478
- stats = serverData;
479
- loading = false;
480
- setItem('dash_stats', JSON.stringify(serverData));
481
-
482
- assert.equal(loading, false);
483
- assert.deepEqual(stats, serverData);
484
-
485
- // Simulate second page load — cache hit
486
- stats = null;
487
- loading = true;
488
-
489
- const cachedStr = getItem('dash_stats');
490
- if (cachedStr) {
491
- stats = JSON.parse(cachedStr);
492
- loading = false; // Show cached data immediately
493
- }
494
-
495
- assert.equal(loading, false, 'Loading resolved immediately from cache');
496
- assert.deepEqual(stats.account.username, 'user1', 'Cached data shown immediately');
497
- });
498
- });
499
-
500
- // ── Test 10: Multi-User Chat Isolation (End-to-End) ──
501
-
502
- describe('Multi-User Chat Isolation (E2E)', () => {
503
- it('two users sending commands simultaneously route to correct relays', () => {
504
- const relayConnections = new Map();
505
- const relayReceived = { user1: [], user2: [] };
506
-
507
- relayConnections.set(1, {
508
- ws: { readyState: 1, send: (msg) => relayReceived.user1.push(JSON.parse(msg)) }
509
- });
510
- relayConnections.set(2, {
511
- ws: { readyState: 1, send: (msg) => relayReceived.user2.push(JSON.parse(msg)) }
512
- });
513
-
514
- // User 1 sends a claude-command
515
- function routeToRelay(userId, action, command) {
516
- const relay = relayConnections.get(userId);
517
- if (!relay || relay.ws.readyState !== 1) throw new Error('No relay');
518
- relay.ws.send(JSON.stringify({ type: 'relay-command', action, command }));
519
- }
520
-
521
- routeToRelay(1, 'claude-query', 'Hey, I am user 1');
522
- routeToRelay(2, 'claude-query', 'Hey, I am user 2');
523
- routeToRelay(1, 'claude-query', 'Second message from user 1');
524
-
525
- assert.equal(relayReceived.user1.length, 2, 'User 1 relay received 2 commands');
526
- assert.equal(relayReceived.user2.length, 1, 'User 2 relay received 1 command');
527
- assert.equal(relayReceived.user1[0].command, 'Hey, I am user 1');
528
- assert.equal(relayReceived.user2[0].command, 'Hey, I am user 2');
529
- assert.equal(relayReceived.user1[1].command, 'Second message from user 1');
530
- });
531
-
532
- it('streaming responses go back to the correct frontend client', () => {
533
- // Simulate pending relay requests with different userIds
534
- const pendingRelayRequests = new Map();
535
- const frontendReceived = { user1: [], user2: [] };
536
-
537
- const writer1 = { send: (msg) => frontendReceived.user1.push(msg) };
538
- const writer2 = { send: (msg) => frontendReceived.user2.push(msg) };
539
-
540
- pendingRelayRequests.set('req-1', {
541
- userId: 1,
542
- onStream: (data) => {
543
- if (data.type === 'claude-response') {
544
- writer1.send({ type: 'claude-response', data: { type: 'content_block_delta', delta: { text: data.content } } });
545
- }
546
- }
547
- });
548
- pendingRelayRequests.set('req-2', {
549
- userId: 2,
550
- onStream: (data) => {
551
- if (data.type === 'claude-response') {
552
- writer2.send({ type: 'claude-response', data: { type: 'content_block_delta', delta: { text: data.content } } });
553
- }
554
- }
555
- });
556
-
557
- // Relay stream for user 1
558
- const pending1 = pendingRelayRequests.get('req-1');
559
- pending1.onStream({ type: 'claude-response', content: 'Hello user 1!' });
560
-
561
- // Relay stream for user 2
562
- const pending2 = pendingRelayRequests.get('req-2');
563
- pending2.onStream({ type: 'claude-response', content: 'Hello user 2!' });
564
-
565
- assert.equal(frontendReceived.user1.length, 1);
566
- assert.equal(frontendReceived.user2.length, 1);
567
- assert.equal(frontendReceived.user1[0].data.delta.text, 'Hello user 1!');
568
- assert.equal(frontendReceived.user2[0].data.delta.text, 'Hello user 2!');
569
- });
570
- });