upfynai-code 2.9.0 → 2.9.2

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 (229) hide show
  1. package/README.md +91 -66
  2. package/client/dist/api-docs.html +838 -0
  3. package/client/dist/assets/AppContent-BXZDeSIC.js +545 -0
  4. package/client/dist/assets/CanvasFullScreen-mnpCnLZ9.js +1 -0
  5. package/client/dist/assets/CanvasWorkspace-4CqmjAVQ.js +163 -0
  6. package/client/dist/assets/DashboardPanel-zFIFlw56.js +1 -0
  7. package/client/dist/assets/FileTree-B0c_GaB3.js +1 -0
  8. package/client/dist/assets/GitPanel-DUP4zVU4.js +2 -0
  9. package/client/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/client/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/client/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/client/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/client/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/client/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/client/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/client/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/client/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/client/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/client/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/client/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/client/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/client/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/client/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/client/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/client/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/client/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/client/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/client/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/client/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/client/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/client/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/client/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/client/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/client/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/client/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/client/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/client/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/client/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/client/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/client/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/client/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/client/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/client/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/client/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/client/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/client/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/client/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/client/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/client/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/client/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/client/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/client/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/client/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/client/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/client/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/client/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/client/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/client/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/client/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/client/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/client/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/client/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/client/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/client/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/client/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/client/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/client/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/client/dist/assets/LoginModal-BRycfsyD.js +13 -0
  69. package/client/dist/assets/MarkdownPreview-DHmk3qzu.js +1 -0
  70. package/client/dist/assets/MermaidBlock-BuBc_G-F.js +2 -0
  71. package/client/dist/assets/Onboarding-BcnaZZ0o.js +1 -0
  72. package/client/dist/assets/PreviewPanel-CqCa92Tf.js +32 -0
  73. package/client/dist/assets/SetupForm-S0g6u5yT.js +1 -0
  74. package/client/dist/assets/WorkflowsPanel-CouH9JDO.js +1 -0
  75. package/client/dist/assets/index-BFuqS0tY.css +1 -0
  76. package/client/dist/assets/index-CNDcVl2g.js +68 -0
  77. package/client/dist/assets/pdf-CE_K4jFx.js +12 -0
  78. package/client/dist/assets/vendor-canvas-BZV40eAE.css +1 -0
  79. package/client/dist/assets/vendor-canvas-D39yWul6.js +49 -0
  80. package/client/dist/assets/vendor-codemirror-CbtmxxaB.js +35 -0
  81. package/client/dist/assets/vendor-diff-DNQpbhrT.js +69 -0
  82. package/client/dist/assets/vendor-i18n-DCFGyhQR.js +1 -0
  83. package/client/dist/assets/vendor-icons-BaD0x9SL.js +711 -0
  84. package/client/dist/assets/vendor-markdown-CimbIo6Y.js +296 -0
  85. package/client/dist/assets/vendor-mermaid-CH7SGc99.js +2556 -0
  86. package/client/dist/assets/vendor-react-96lCPsRK.js +67 -0
  87. package/client/dist/assets/vendor-syntax-DuHI9Ok6.js +16 -0
  88. package/client/dist/assets/vendor-xterm-CZq1hqo1.js +66 -0
  89. package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +32 -0
  90. package/client/dist/clear-cache.html +85 -0
  91. package/client/dist/convert-icons.md +53 -0
  92. package/client/dist/favicon.png +0 -0
  93. package/client/dist/favicon.svg +5 -0
  94. package/client/dist/generate-icons.js +49 -0
  95. package/client/dist/icons/claude-ai-icon.svg +1 -0
  96. package/client/dist/icons/codex-white.svg +3 -0
  97. package/client/dist/icons/codex.svg +3 -0
  98. package/client/dist/icons/cursor-white.svg +12 -0
  99. package/client/dist/icons/cursor.svg +1 -0
  100. package/client/dist/icons/icon-128x128.png +0 -0
  101. package/client/dist/icons/icon-128x128.svg +5 -0
  102. package/client/dist/icons/icon-144x144.png +0 -0
  103. package/client/dist/icons/icon-144x144.svg +5 -0
  104. package/client/dist/icons/icon-152x152.png +0 -0
  105. package/client/dist/icons/icon-152x152.svg +5 -0
  106. package/client/dist/icons/icon-192x192.png +0 -0
  107. package/client/dist/icons/icon-192x192.svg +5 -0
  108. package/client/dist/icons/icon-384x384.png +0 -0
  109. package/client/dist/icons/icon-384x384.svg +5 -0
  110. package/client/dist/icons/icon-512x512.png +0 -0
  111. package/client/dist/icons/icon-512x512.svg +5 -0
  112. package/client/dist/icons/icon-72x72.png +0 -0
  113. package/client/dist/icons/icon-72x72.svg +5 -0
  114. package/client/dist/icons/icon-96x96.png +0 -0
  115. package/client/dist/icons/icon-96x96.svg +5 -0
  116. package/client/dist/icons/icon-template.svg +5 -0
  117. package/client/dist/index.html +119 -0
  118. package/client/dist/logo-128.png +0 -0
  119. package/client/dist/logo-256.png +0 -0
  120. package/client/dist/logo-32.png +0 -0
  121. package/client/dist/logo-512.png +0 -0
  122. package/client/dist/logo-64.png +0 -0
  123. package/client/dist/logo.svg +14 -0
  124. package/client/dist/manifest.json +61 -0
  125. package/client/dist/mcp-docs.html +108 -0
  126. package/client/dist/offline.html +84 -0
  127. package/client/dist/screenshots/cli-selection.png +0 -0
  128. package/client/dist/screenshots/desktop-main.png +0 -0
  129. package/client/dist/screenshots/mobile-chat.png +0 -0
  130. package/client/dist/screenshots/tools-modal.png +0 -0
  131. package/client/dist/sw.js +82 -0
  132. package/commands/upfynai-connect.md +59 -0
  133. package/commands/upfynai-disconnect.md +31 -0
  134. package/commands/upfynai-doctor.md +99 -0
  135. package/commands/upfynai-export.md +49 -0
  136. package/commands/upfynai-local.md +82 -0
  137. package/commands/upfynai-status.md +75 -0
  138. package/commands/upfynai-stop.md +49 -0
  139. package/commands/upfynai-uninstall.md +58 -0
  140. package/commands/upfynai.md +69 -0
  141. package/package.json +143 -82
  142. package/scripts/build-client.js +17 -0
  143. package/scripts/fix-node-pty.js +67 -0
  144. package/scripts/install-commands.js +78 -0
  145. package/server/agent-loop.js +242 -0
  146. package/server/auto-compact.js +99 -0
  147. package/server/claude-sdk.js +797 -0
  148. package/server/cli-ui.js +785 -0
  149. package/server/cli.js +596 -0
  150. package/server/constants/config.js +31 -0
  151. package/server/cursor-cli.js +270 -0
  152. package/server/database/auth.db +0 -0
  153. package/server/database/db.js +1391 -0
  154. package/server/database/init.sql +70 -0
  155. package/server/index.js +3799 -0
  156. package/server/load-env.js +26 -0
  157. package/server/mcp-server.js +621 -0
  158. package/server/middleware/auth.js +176 -0
  159. package/server/middleware/relayHelpers.js +44 -0
  160. package/server/middleware/sandboxRouter.js +174 -0
  161. package/server/openai-codex.js +403 -0
  162. package/server/openrouter.js +137 -0
  163. package/server/projects.js +1807 -0
  164. package/server/provider-factory.js +174 -0
  165. package/server/relay-client.js +379 -0
  166. package/server/routes/agent.js +1226 -0
  167. package/server/routes/auth.js +554 -0
  168. package/server/routes/canvas.js +53 -0
  169. package/server/routes/cli-auth.js +263 -0
  170. package/server/routes/codex.js +396 -0
  171. package/server/routes/commands.js +707 -0
  172. package/server/routes/composio.js +176 -0
  173. package/server/routes/cursor.js +770 -0
  174. package/server/routes/dashboard.js +295 -0
  175. package/server/routes/git.js +1208 -0
  176. package/server/routes/keys.js +34 -0
  177. package/server/routes/mcp-utils.js +48 -0
  178. package/server/routes/mcp.js +661 -0
  179. package/server/routes/payments.js +227 -0
  180. package/server/routes/projects.js +655 -0
  181. package/server/routes/sessions.js +146 -0
  182. package/server/routes/settings.js +261 -0
  183. package/server/routes/taskmaster.js +1928 -0
  184. package/server/routes/user.js +106 -0
  185. package/server/routes/vapi-chat.js +624 -0
  186. package/server/routes/voice.js +235 -0
  187. package/server/routes/webhooks.js +166 -0
  188. package/server/routes/workflows.js +312 -0
  189. package/server/sandbox.js +120 -0
  190. package/server/services/composio.js +204 -0
  191. package/server/services/sessionRegistry.js +139 -0
  192. package/server/services/whisperService.js +84 -0
  193. package/server/services/workflowScheduler.js +206 -0
  194. package/server/tests/relay-flow.test.js +570 -0
  195. package/server/tests/sessions.test.js +259 -0
  196. package/server/utils/commandParser.js +303 -0
  197. package/server/utils/email.js +61 -0
  198. package/server/utils/gitConfig.js +24 -0
  199. package/server/utils/mcp-detector.js +198 -0
  200. package/server/utils/taskmaster-websocket.js +129 -0
  201. package/shared/integrationCatalog.d.ts +12 -0
  202. package/shared/integrationCatalog.js +172 -0
  203. package/shared/modelConstants.js +96 -0
  204. package/bin/cli.js +0 -97
  205. package/dist/agents/claude.js +0 -229
  206. package/dist/agents/codex.js +0 -48
  207. package/dist/agents/cursor.js +0 -48
  208. package/dist/agents/detect.js +0 -51
  209. package/dist/agents/exec.js +0 -31
  210. package/dist/agents/files.js +0 -105
  211. package/dist/agents/git.js +0 -18
  212. package/dist/agents/gitagent.js +0 -67
  213. package/dist/agents/index.js +0 -88
  214. package/dist/agents/shell.js +0 -38
  215. package/dist/agents/utils.js +0 -136
  216. package/scripts/postinstall.js +0 -9
  217. package/scripts/prepublish.js +0 -58
  218. package/src/animation.js +0 -228
  219. package/src/auth.js +0 -122
  220. package/src/config.js +0 -40
  221. package/src/connect.js +0 -416
  222. package/src/launch.js +0 -78
  223. package/src/mcp.js +0 -57
  224. package/src/permissions.js +0 -140
  225. package/src/persistent-shell.js +0 -261
  226. package/src/server.js +0 -54
  227. /package/{dist → shared}/gitagent/index.js +0 -0
  228. /package/{dist → shared}/gitagent/parser.js +0 -0
  229. /package/{dist → shared}/gitagent/prompt-builder.js +0 -0
@@ -0,0 +1,570 @@
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
+ });