@jlongo78/agent-spaces 0.5.2 → 0.5.3

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 (317) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +2 -2
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +19 -19
  5. package/.next/standalone/.next/server/app/(desktop)/admin/analytics/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/(desktop)/admin/users/page_client-reference-manifest.js +1 -1
  7. package/.next/standalone/.next/server/app/(desktop)/analytics/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/(desktop)/network/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/(desktop)/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/.next/server/app/(desktop)/projects/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/(desktop)/sessions/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/(desktop)/settings/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/(desktop)/terminal/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page_client-reference-manifest.js +1 -1
  17. package/.next/standalone/.next/server/app/(desktop)/workspaces/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_global-error.html +2 -2
  19. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  24. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  25. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  27. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  29. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  32. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  33. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  34. package/.next/standalone/.next/server/app/admin/analytics.html +1 -1
  35. package/.next/standalone/.next/server/app/admin/analytics.rsc +2 -2
  36. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics/__PAGE__.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics.segment.rsc +1 -1
  38. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/admin/analytics.segments/_full.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/admin/analytics.segments/_head.segment.rsc +1 -1
  42. package/.next/standalone/.next/server/app/admin/analytics.segments/_index.segment.rsc +2 -2
  43. package/.next/standalone/.next/server/app/admin/analytics.segments/_tree.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/admin/users.html +1 -1
  45. package/.next/standalone/.next/server/app/admin/users.rsc +2 -2
  46. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users/__PAGE__.segment.rsc +1 -1
  47. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users.segment.rsc +1 -1
  48. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
  49. package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  50. package/.next/standalone/.next/server/app/admin/users.segments/_full.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/admin/users.segments/_head.segment.rsc +1 -1
  52. package/.next/standalone/.next/server/app/admin/users.segments/_index.segment.rsc +2 -2
  53. package/.next/standalone/.next/server/app/admin/users.segments/_tree.segment.rsc +2 -2
  54. package/.next/standalone/.next/server/app/analytics.html +1 -1
  55. package/.next/standalone/.next/server/app/analytics.rsc +2 -2
  56. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics/__PAGE__.segment.rsc +1 -1
  57. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics.segment.rsc +1 -1
  58. package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  59. package/.next/standalone/.next/server/app/analytics.segments/_full.segment.rsc +2 -2
  60. package/.next/standalone/.next/server/app/analytics.segments/_head.segment.rsc +1 -1
  61. package/.next/standalone/.next/server/app/analytics.segments/_index.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/analytics.segments/_tree.segment.rsc +2 -2
  63. package/.next/standalone/.next/server/app/api/analytics/overview/route.js +2 -2
  64. package/.next/standalone/.next/server/app/api/analytics/overview/route.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/api/bulk/route.js +2 -2
  66. package/.next/standalone/.next/server/app/api/bulk/route.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/api/config/route.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/api/events/route.js +2 -2
  69. package/.next/standalone/.next/server/app/api/events/route.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/api/folders/route.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/api/network/handshake/route.js +1 -1
  72. package/.next/standalone/.next/server/app/api/network/handshake/route.js.nft.json +1 -1
  73. package/.next/standalone/.next/server/app/api/panes/[id]/route.js +2 -2
  74. package/.next/standalone/.next/server/app/api/panes/[id]/route.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/api/panes/route.js +2 -2
  76. package/.next/standalone/.next/server/app/api/panes/route.js.nft.json +1 -1
  77. package/.next/standalone/.next/server/app/api/projects/route.js +2 -2
  78. package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/api/search/route.js +2 -2
  80. package/.next/standalone/.next/server/app/api/search/route.js.nft.json +1 -1
  81. package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js +2 -2
  82. package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js.nft.json +1 -1
  83. package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js +2 -2
  84. package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js.nft.json +1 -1
  85. package/.next/standalone/.next/server/app/api/sessions/[id]/route.js +2 -2
  86. package/.next/standalone/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/api/sessions/route.js +2 -2
  88. package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
  89. package/.next/standalone/.next/server/app/api/sync/route.js +3 -3
  90. package/.next/standalone/.next/server/app/api/sync/route.js.nft.json +1 -1
  91. package/.next/standalone/.next/server/app/api/tags/route.js +2 -2
  92. package/.next/standalone/.next/server/app/api/tags/route.js.nft.json +1 -1
  93. package/.next/standalone/.next/server/app/api/tier/route.js +1 -1
  94. package/.next/standalone/.next/server/app/api/tier/route.js.nft.json +1 -1
  95. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js +2 -2
  96. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js.nft.json +1 -1
  97. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js +2 -2
  98. package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js.nft.json +1 -1
  99. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js +2 -2
  100. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js.nft.json +1 -1
  101. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js +2 -2
  102. package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js.nft.json +1 -1
  103. package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js +2 -2
  104. package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js.nft.json +1 -1
  105. package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js +2 -2
  106. package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js.nft.json +1 -1
  107. package/.next/standalone/.next/server/app/api/workspaces/route.js +2 -2
  108. package/.next/standalone/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  109. package/.next/standalone/.next/server/app/login/page_client-reference-manifest.js +1 -1
  110. package/.next/standalone/.next/server/app/login.html +1 -1
  111. package/.next/standalone/.next/server/app/login.rsc +2 -2
  112. package/.next/standalone/.next/server/app/login.segments/_full.segment.rsc +2 -2
  113. package/.next/standalone/.next/server/app/login.segments/_head.segment.rsc +1 -1
  114. package/.next/standalone/.next/server/app/login.segments/_index.segment.rsc +2 -2
  115. package/.next/standalone/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  116. package/.next/standalone/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  117. package/.next/standalone/.next/server/app/login.segments/login.segment.rsc +1 -1
  118. package/.next/standalone/.next/server/app/m/page_client-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/app/m/projects/page_client-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/app/m/projects.html +1 -1
  121. package/.next/standalone/.next/server/app/m/projects.rsc +2 -2
  122. package/.next/standalone/.next/server/app/m/projects.segments/_full.segment.rsc +2 -2
  123. package/.next/standalone/.next/server/app/m/projects.segments/_head.segment.rsc +1 -1
  124. package/.next/standalone/.next/server/app/m/projects.segments/_index.segment.rsc +2 -2
  125. package/.next/standalone/.next/server/app/m/projects.segments/_tree.segment.rsc +2 -2
  126. package/.next/standalone/.next/server/app/m/projects.segments/m/projects/__PAGE__.segment.rsc +1 -1
  127. package/.next/standalone/.next/server/app/m/projects.segments/m/projects.segment.rsc +1 -1
  128. package/.next/standalone/.next/server/app/m/projects.segments/m.segment.rsc +1 -1
  129. package/.next/standalone/.next/server/app/m/sessions/[id]/page_client-reference-manifest.js +1 -1
  130. package/.next/standalone/.next/server/app/m/sessions/page_client-reference-manifest.js +1 -1
  131. package/.next/standalone/.next/server/app/m/sessions.html +1 -1
  132. package/.next/standalone/.next/server/app/m/sessions.rsc +2 -2
  133. package/.next/standalone/.next/server/app/m/sessions.segments/_full.segment.rsc +2 -2
  134. package/.next/standalone/.next/server/app/m/sessions.segments/_head.segment.rsc +1 -1
  135. package/.next/standalone/.next/server/app/m/sessions.segments/_index.segment.rsc +2 -2
  136. package/.next/standalone/.next/server/app/m/sessions.segments/_tree.segment.rsc +2 -2
  137. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions/__PAGE__.segment.rsc +1 -1
  138. package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions.segment.rsc +1 -1
  139. package/.next/standalone/.next/server/app/m/sessions.segments/m.segment.rsc +1 -1
  140. package/.next/standalone/.next/server/app/m/settings/page_client-reference-manifest.js +1 -1
  141. package/.next/standalone/.next/server/app/m/settings.html +1 -1
  142. package/.next/standalone/.next/server/app/m/settings.rsc +2 -2
  143. package/.next/standalone/.next/server/app/m/settings.segments/_full.segment.rsc +2 -2
  144. package/.next/standalone/.next/server/app/m/settings.segments/_head.segment.rsc +1 -1
  145. package/.next/standalone/.next/server/app/m/settings.segments/_index.segment.rsc +2 -2
  146. package/.next/standalone/.next/server/app/m/settings.segments/_tree.segment.rsc +2 -2
  147. package/.next/standalone/.next/server/app/m/settings.segments/m/settings/__PAGE__.segment.rsc +1 -1
  148. package/.next/standalone/.next/server/app/m/settings.segments/m/settings.segment.rsc +1 -1
  149. package/.next/standalone/.next/server/app/m/settings.segments/m.segment.rsc +1 -1
  150. package/.next/standalone/.next/server/app/m/terminal/page_client-reference-manifest.js +1 -1
  151. package/.next/standalone/.next/server/app/m/terminal.html +1 -1
  152. package/.next/standalone/.next/server/app/m/terminal.rsc +2 -2
  153. package/.next/standalone/.next/server/app/m/terminal.segments/_full.segment.rsc +2 -2
  154. package/.next/standalone/.next/server/app/m/terminal.segments/_head.segment.rsc +1 -1
  155. package/.next/standalone/.next/server/app/m/terminal.segments/_index.segment.rsc +2 -2
  156. package/.next/standalone/.next/server/app/m/terminal.segments/_tree.segment.rsc +2 -2
  157. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal/__PAGE__.segment.rsc +1 -1
  158. package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal.segment.rsc +1 -1
  159. package/.next/standalone/.next/server/app/m/terminal.segments/m.segment.rsc +1 -1
  160. package/.next/standalone/.next/server/app/m.html +1 -1
  161. package/.next/standalone/.next/server/app/m.rsc +2 -2
  162. package/.next/standalone/.next/server/app/m.segments/_full.segment.rsc +2 -2
  163. package/.next/standalone/.next/server/app/m.segments/_head.segment.rsc +1 -1
  164. package/.next/standalone/.next/server/app/m.segments/_index.segment.rsc +2 -2
  165. package/.next/standalone/.next/server/app/m.segments/_tree.segment.rsc +2 -2
  166. package/.next/standalone/.next/server/app/m.segments/m/__PAGE__.segment.rsc +1 -1
  167. package/.next/standalone/.next/server/app/m.segments/m.segment.rsc +1 -1
  168. package/.next/standalone/.next/server/app/network.html +1 -1
  169. package/.next/standalone/.next/server/app/network.rsc +2 -2
  170. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network/__PAGE__.segment.rsc +1 -1
  171. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network.segment.rsc +1 -1
  172. package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  173. package/.next/standalone/.next/server/app/network.segments/_full.segment.rsc +2 -2
  174. package/.next/standalone/.next/server/app/network.segments/_head.segment.rsc +1 -1
  175. package/.next/standalone/.next/server/app/network.segments/_index.segment.rsc +2 -2
  176. package/.next/standalone/.next/server/app/network.segments/_tree.segment.rsc +2 -2
  177. package/.next/standalone/.next/server/app/projects.html +1 -1
  178. package/.next/standalone/.next/server/app/projects.rsc +2 -2
  179. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects/__PAGE__.segment.rsc +1 -1
  180. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects.segment.rsc +1 -1
  181. package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  182. package/.next/standalone/.next/server/app/projects.segments/_full.segment.rsc +2 -2
  183. package/.next/standalone/.next/server/app/projects.segments/_head.segment.rsc +1 -1
  184. package/.next/standalone/.next/server/app/projects.segments/_index.segment.rsc +2 -2
  185. package/.next/standalone/.next/server/app/projects.segments/_tree.segment.rsc +2 -2
  186. package/.next/standalone/.next/server/app/sessions.html +1 -1
  187. package/.next/standalone/.next/server/app/sessions.rsc +2 -2
  188. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions/__PAGE__.segment.rsc +1 -1
  189. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions.segment.rsc +1 -1
  190. package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  191. package/.next/standalone/.next/server/app/sessions.segments/_full.segment.rsc +2 -2
  192. package/.next/standalone/.next/server/app/sessions.segments/_head.segment.rsc +1 -1
  193. package/.next/standalone/.next/server/app/sessions.segments/_index.segment.rsc +2 -2
  194. package/.next/standalone/.next/server/app/sessions.segments/_tree.segment.rsc +2 -2
  195. package/.next/standalone/.next/server/app/settings.html +1 -1
  196. package/.next/standalone/.next/server/app/settings.rsc +2 -2
  197. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings/__PAGE__.segment.rsc +1 -1
  198. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings.segment.rsc +1 -1
  199. package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  200. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  201. package/.next/standalone/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  202. package/.next/standalone/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  203. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  204. package/.next/standalone/.next/server/app/terminal.html +1 -1
  205. package/.next/standalone/.next/server/app/terminal.rsc +2 -2
  206. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal/__PAGE__.segment.rsc +1 -1
  207. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal.segment.rsc +1 -1
  208. package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  209. package/.next/standalone/.next/server/app/terminal.segments/_full.segment.rsc +2 -2
  210. package/.next/standalone/.next/server/app/terminal.segments/_head.segment.rsc +1 -1
  211. package/.next/standalone/.next/server/app/terminal.segments/_index.segment.rsc +2 -2
  212. package/.next/standalone/.next/server/app/terminal.segments/_tree.segment.rsc +2 -2
  213. package/.next/standalone/.next/server/app/workspaces.html +1 -1
  214. package/.next/standalone/.next/server/app/workspaces.rsc +2 -2
  215. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces/__PAGE__.segment.rsc +1 -1
  216. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces.segment.rsc +1 -1
  217. package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
  218. package/.next/standalone/.next/server/app/workspaces.segments/_full.segment.rsc +2 -2
  219. package/.next/standalone/.next/server/app/workspaces.segments/_head.segment.rsc +1 -1
  220. package/.next/standalone/.next/server/app/workspaces.segments/_index.segment.rsc +2 -2
  221. package/.next/standalone/.next/server/app/workspaces.segments/_tree.segment.rsc +2 -2
  222. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__e742ef33._.js → [root-of-the-server]__0e4c2d35._.js} +2 -2
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__130dee4b._.js +98 -0
  224. package/.next/standalone/.next/server/chunks/[root-of-the-server]__142c2f41._.js +98 -0
  225. package/.next/standalone/.next/server/chunks/[root-of-the-server]__160e7c73._.js +148 -0
  226. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2861e096._.js +1 -1
  227. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2d3d8d52._.js +98 -0
  228. package/.next/standalone/.next/server/chunks/[root-of-the-server]__57b966d5._.js +98 -0
  229. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5a0020ba._.js +106 -0
  230. package/.next/standalone/.next/server/chunks/[root-of-the-server]__5bd0f118._.js +98 -0
  231. package/.next/standalone/.next/server/chunks/[root-of-the-server]__64f5810e._.js +98 -0
  232. package/.next/standalone/.next/server/chunks/[root-of-the-server]__74c48d65._.js +98 -0
  233. package/.next/standalone/.next/server/chunks/[root-of-the-server]__7d6610c4._.js +98 -0
  234. package/.next/standalone/.next/server/chunks/[root-of-the-server]__84f3af14._.js +98 -0
  235. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8765f2fc._.js +98 -0
  236. package/.next/standalone/.next/server/chunks/[root-of-the-server]__a2ee9884._.js +1 -1
  237. package/.next/standalone/.next/server/chunks/[root-of-the-server]__a6eb742d._.js +98 -0
  238. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4c83e91._.js +114 -0
  239. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c546cf71._.js +1 -1
  240. package/.next/standalone/.next/server/chunks/[root-of-the-server]__cae0486f._.js +106 -0
  241. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d5615808._.js +98 -0
  242. package/.next/standalone/.next/server/chunks/[root-of-the-server]__d877df12._.js +1 -1
  243. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f66ceeb8._.js +98 -0
  244. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f84e3cf3._.js +98 -0
  245. package/.next/standalone/.next/server/chunks/[root-of-the-server]__f893957c._.js +98 -0
  246. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fcd26315._.js +98 -0
  247. package/.next/standalone/.next/server/chunks/[root-of-the-server]__fd505913._.js +98 -0
  248. package/.next/standalone/.next/server/chunks/[root-of-the-server]__feff7b91._.js +98 -0
  249. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__66aca5d4._.js +1 -1
  250. package/.next/standalone/.next/server/edge/chunks/_d73df637._.js +1 -1
  251. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  252. package/.next/standalone/.next/server/pages/404.html +1 -1
  253. package/.next/standalone/.next/server/pages/500.html +2 -2
  254. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  255. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  256. package/.next/standalone/.next/static/chunks/67c7bf5024309fca.css +3 -0
  257. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/README.md +46 -0
  258. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  259. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/index.js +1 -0
  260. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  261. package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/package.json +42 -0
  262. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/README.md +46 -0
  263. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/glib-2.0/include/glibconfig.h +221 -0
  264. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/index.js +1 -0
  265. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/libvips-cpp.so.8.17.3 +0 -0
  266. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/package.json +42 -0
  267. package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/versions.json +30 -0
  268. package/.next/standalone/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64.node +0 -0
  269. package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-linux-x64}/package.json +46 -39
  270. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/lib/sharp-linuxmusl-x64.node +0 -0
  271. package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/package.json +46 -0
  272. package/.next/standalone/package.json +89 -89
  273. package/.next/standalone/server.js +1 -1
  274. package/.next/standalone/tsconfig.json +34 -34
  275. package/LICENSE +661 -661
  276. package/README.md +131 -131
  277. package/bin/fix-standalone-externals.js +79 -79
  278. package/bin/lib/auto-setup.js +101 -101
  279. package/bin/mdns-service.js +171 -171
  280. package/bin/postinstall.js +35 -35
  281. package/bin/setup-admin.js +189 -189
  282. package/bin/spaces-dev.js +208 -208
  283. package/bin/spaces-install.js +483 -451
  284. package/bin/spaces-setup.js +247 -242
  285. package/bin/spaces.js +465 -465
  286. package/bin/terminal-server.js +1117 -1117
  287. package/package.json +89 -89
  288. package/.next/standalone/.claude/settings.local.json +0 -11
  289. package/.next/standalone/.next/server/chunks/[root-of-the-server]__086be15a._.js +0 -3
  290. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20353b76._.js +0 -3
  291. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2b6758fc._.js +0 -3
  292. package/.next/standalone/.next/server/chunks/[root-of-the-server]__2e9d1d5a._.js +0 -3
  293. package/.next/standalone/.next/server/chunks/[root-of-the-server]__34c34116._.js +0 -3
  294. package/.next/standalone/.next/server/chunks/[root-of-the-server]__3ce30e4e._.js +0 -3
  295. package/.next/standalone/.next/server/chunks/[root-of-the-server]__43112c34._.js +0 -3
  296. package/.next/standalone/.next/server/chunks/[root-of-the-server]__47a63195._.js +0 -3
  297. package/.next/standalone/.next/server/chunks/[root-of-the-server]__60689e2b._.js +0 -3
  298. package/.next/standalone/.next/server/chunks/[root-of-the-server]__83b9bcf3._.js +0 -11
  299. package/.next/standalone/.next/server/chunks/[root-of-the-server]__89124c96._.js +0 -243
  300. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8c2eee30._.js +0 -3
  301. package/.next/standalone/.next/server/chunks/[root-of-the-server]__8ffcf827._.js +0 -3
  302. package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e343d64._.js +0 -3
  303. package/.next/standalone/.next/server/chunks/[root-of-the-server]__a651ede9._.js +0 -3
  304. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4b692df._.js +0 -3
  305. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4edb4d6._.js +0 -3
  306. package/.next/standalone/.next/server/chunks/[root-of-the-server]__b71497ab._.js +0 -243
  307. package/.next/standalone/.next/server/chunks/[root-of-the-server]__bef2ed87._.js +0 -3
  308. package/.next/standalone/.next/server/chunks/[root-of-the-server]__c525457e._.js +0 -19
  309. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ccc27199._.js +0 -3
  310. package/.next/standalone/.next/server/chunks/[root-of-the-server]__ce5edd98._.js +0 -3
  311. package/.next/standalone/.next/server/chunks/[root-of-the-server]__dd2e81be._.js +0 -11
  312. package/.next/standalone/.next/static/chunks/ff5fac1bd7b518dd.css +0 -3
  313. package/.next/standalone/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node +0 -0
  314. /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_buildManifest.js +0 -0
  315. /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_clientMiddlewareManifest.json +0 -0
  316. /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_ssgManifest.js +0 -0
  317. /package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-libvips-linux-x64}/versions.json +0 -0
@@ -1,1117 +1,1117 @@
1
- #!/usr/bin/env node
2
-
3
- const { WebSocketServer } = require('ws');
4
- const pty = require('node-pty');
5
- const http = require('http');
6
- const fs = require('fs');
7
- const path = require('path');
8
- const os = require('os');
9
- const crypto = require('crypto');
10
-
11
- const PORT = parseInt(process.env.SPACES_WS_PORT || '3458', 10);
12
- const SPACES_TIER = process.env.SPACES_TIER || 'community';
13
- // API_PORT is the port where Next.js API routes are reachable.
14
- // In attached mode, createTerminalServer() updates this to the parent server's port.
15
- let API_PORT = parseInt(process.env.SPACES_PORT || '3457', 10);
16
-
17
- // ─── Terminal token verification ──────────────────────────
18
-
19
- const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
20
-
21
- function getTerminalSecret() {
22
- if (fs.existsSync(SECRET_PATH)) {
23
- return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
24
- }
25
- const secret = crypto.randomBytes(32);
26
- const dir = path.dirname(SECRET_PATH);
27
- if (!fs.existsSync(dir)) {
28
- fs.mkdirSync(dir, { recursive: true });
29
- }
30
- fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
31
- return secret;
32
- }
33
-
34
- let _terminalSecret = null;
35
- function terminalSecret() {
36
- if (!_terminalSecret) {
37
- _terminalSecret = getTerminalSecret();
38
- }
39
- return _terminalSecret;
40
- }
41
-
42
- function verifyTerminalToken(token) {
43
- if (!token) return null;
44
- const parts = token.split('.');
45
- if (parts.length !== 2) return null;
46
-
47
- const [payloadB64, sig] = parts;
48
- const expectedSig = crypto.createHmac('sha256', terminalSecret())
49
- .update(payloadB64)
50
- .digest('base64url');
51
-
52
- try {
53
- if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
54
- return null;
55
- }
56
- } catch {
57
- return null;
58
- }
59
-
60
- try {
61
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
62
- if (payload.exp < Math.floor(Date.now() / 1000)) {
63
- return null;
64
- }
65
- return payload.sub || null;
66
- } catch {
67
- return null;
68
- }
69
- }
70
-
71
- // ─── Session token verification (for self-contained auth) ──
72
-
73
- const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
74
-
75
- function getSessionSecret() {
76
- if (fs.existsSync(SESSION_SECRET_PATH)) {
77
- return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
78
- }
79
- return null;
80
- }
81
-
82
- let _sessionSecret = null;
83
- function sessionSecret() {
84
- if (!_sessionSecret) {
85
- _sessionSecret = getSessionSecret();
86
- }
87
- return _sessionSecret;
88
- }
89
-
90
- function verifySessionToken(token) {
91
- const secret = sessionSecret();
92
- if (!token || !secret) return null;
93
- const parts = token.split('.');
94
- if (parts.length !== 2) return null;
95
-
96
- const [payloadB64, sig] = parts;
97
- const expectedSig = crypto.createHmac('sha256', secret)
98
- .update(payloadB64)
99
- .digest('base64url');
100
-
101
- try {
102
- if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
103
- return null;
104
- }
105
- } catch {
106
- return null;
107
- }
108
-
109
- try {
110
- const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
111
- if (payload.exp < Math.floor(Date.now() / 1000)) {
112
- return null;
113
- }
114
- return { sub: payload.sub, role: payload.role || 'user' };
115
- } catch {
116
- return null;
117
- }
118
- }
119
-
120
- // ─── Admin DB for shell user lookup ─────────────────────────
121
-
122
- const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
123
- let _adminDb = null;
124
-
125
- function getAdminDb() {
126
- if (_adminDb) return _adminDb;
127
- if (!fs.existsSync(ADMIN_DB_PATH)) return null;
128
- try {
129
- const Database = require('better-sqlite3');
130
- _adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
131
- return _adminDb;
132
- } catch {
133
- return null;
134
- }
135
- }
136
-
137
- function lookupShellUser(appUsername) {
138
- const db = getAdminDb();
139
- if (!db) return appUsername;
140
- try {
141
- const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
142
- return row ? row.shell_user : appUsername;
143
- } catch {
144
- return appUsername;
145
- }
146
- }
147
-
148
- // ─── Network DB for federation ───────────────────────────────
149
-
150
- const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
151
- let _networkDb = null;
152
-
153
- function getNetworkDb() {
154
- if (_networkDb) return _networkDb;
155
- if (!fs.existsSync(NETWORK_DB_PATH)) return null;
156
- try {
157
- const Database = require('better-sqlite3');
158
- _networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
159
- return _networkDb;
160
- } catch {
161
- return null;
162
- }
163
- }
164
-
165
- function validateNetworkApiKey(rawKey) {
166
- const db = getNetworkDb();
167
- if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
168
- try {
169
- const keys = db.prepare('SELECT * FROM api_keys').all();
170
- for (const key of keys) {
171
- if (key.expires && new Date(key.expires) < new Date()) continue;
172
- const [salt, hash] = key.key_hash.split(':');
173
- if (!salt || !hash) continue;
174
- const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
175
- try {
176
- if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
177
- return key;
178
- }
179
- } catch { continue; }
180
- }
181
- } catch { /* ignore */ }
182
- return null;
183
- }
184
-
185
- function getNodeInfo(nodeId) {
186
- const db = getNetworkDb();
187
- if (!db) return null;
188
- try {
189
- return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
190
- } catch {
191
- return null;
192
- }
193
- }
194
-
195
- function decryptNodeApiKey(encrypted) {
196
- try {
197
- const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
198
- const [ivB64, tagB64, dataB64] = encrypted.split(':');
199
- if (!ivB64 || !tagB64 || !dataB64) return null;
200
- const iv = Buffer.from(ivB64, 'base64');
201
- const tag = Buffer.from(tagB64, 'base64');
202
- const data = Buffer.from(dataB64, 'base64');
203
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
204
- decipher.setAuthTag(tag);
205
- return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
206
- } catch {
207
- return null;
208
- }
209
- }
210
-
211
- // ─── Writable Admin DB for analytics ─────────────────────────
212
-
213
- let _adminDbRW = null;
214
-
215
- function getAdminDbRW() {
216
- if (_adminDbRW) return _adminDbRW;
217
- try {
218
- const Database = require('better-sqlite3');
219
- const dir = path.dirname(ADMIN_DB_PATH);
220
- if (!fs.existsSync(dir)) {
221
- fs.mkdirSync(dir, { recursive: true });
222
- }
223
- const db = new Database(ADMIN_DB_PATH);
224
- db.pragma('journal_mode = WAL');
225
- db.pragma('busy_timeout = 5000');
226
- db.exec(`
227
- CREATE TABLE IF NOT EXISTS login_events (
228
- id INTEGER PRIMARY KEY AUTOINCREMENT,
229
- username TEXT NOT NULL,
230
- timestamp TEXT NOT NULL DEFAULT (datetime('now')),
231
- ip_address TEXT,
232
- user_agent TEXT
233
- );
234
- CREATE TABLE IF NOT EXISTS terminal_sessions (
235
- id TEXT PRIMARY KEY,
236
- username TEXT NOT NULL,
237
- agent_type TEXT NOT NULL DEFAULT 'shell',
238
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
239
- ended_at TEXT,
240
- duration_seconds INTEGER
241
- );
242
- CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
243
- CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
244
- CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
245
- CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
246
- `);
247
- // Clean up stale sessions from previous crashes
248
- db.prepare(`
249
- UPDATE terminal_sessions
250
- SET ended_at = datetime('now'),
251
- duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
252
- WHERE ended_at IS NULL
253
- `).run();
254
- _adminDbRW = db;
255
- console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
256
- return db;
257
- } catch (err) {
258
- console.error('[Analytics] Failed to open writable admin DB:', err.message);
259
- return null;
260
- }
261
- }
262
-
263
- function analyticsRecordSessionStart(paneId, username, agentType) {
264
- try {
265
- const db = getAdminDbRW();
266
- if (!db) return;
267
- db.prepare(
268
- 'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
269
- ).run(paneId, username, agentType);
270
- } catch (err) {
271
- console.error('[Analytics] recordSessionStart error:', err.message);
272
- }
273
- }
274
-
275
- function analyticsRecordSessionEnd(paneId) {
276
- try {
277
- const db = getAdminDbRW();
278
- if (!db) return;
279
- db.prepare(`
280
- UPDATE terminal_sessions
281
- SET ended_at = datetime('now'),
282
- duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
283
- WHERE id = ? AND ended_at IS NULL
284
- `).run(paneId);
285
- } catch (err) {
286
- console.error('[Analytics] recordSessionEnd error:', err.message);
287
- }
288
- }
289
-
290
- // ─── Cookie parser ──────────────────────────────────────────
291
-
292
- function parseCookies(cookieHeader) {
293
- const cookies = {};
294
- if (!cookieHeader) return cookies;
295
- cookieHeader.split(';').forEach(part => {
296
- const [key, ...rest] = part.trim().split('=');
297
- if (key) cookies[key] = rest.join('=');
298
- });
299
- return cookies;
300
- }
301
-
302
- // ─── SSH service key path (used to spawn shells as other OS users) ──
303
-
304
- const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
305
-
306
- // Session store: keeps ptys alive across WebSocket reconnections
307
- // Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
308
- const sessions = new Map();
309
-
310
- const MAX_BUFFER_LINES = 500;
311
-
312
- // ─── Agent definitions (mirrors src/lib/agents.ts) ────────
313
- const AGENTS = {
314
- shell: { command: '', resumeFlag: '', resumeStyle: '' },
315
- claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
316
- codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
317
- gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
318
- aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
319
- custom: { command: '', resumeFlag: '', resumeStyle: '' },
320
- };
321
-
322
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
323
-
324
- // ─── Git Bash detection (Windows) ────────────────────────
325
- function findGitBash() {
326
- const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
327
- if (custom && fs.existsSync(custom)) return custom;
328
- const localAppData = process.env.LOCALAPPDATA || '';
329
- const candidates = [
330
- 'C:\\Program Files\\Git\\bin\\bash.exe',
331
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
332
- path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
333
- ];
334
- for (const p of candidates) {
335
- if (p && fs.existsSync(p)) return p;
336
- }
337
- // Last resort: check if bash is on PATH via where command
338
- try {
339
- const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
340
- const first = result.trim().split('\n')[0].trim();
341
- if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
342
- } catch { /* not found */ }
343
- return null;
344
- }
345
-
346
- // ─── Origin validation ───────────────────────────────────
347
- function isAllowedOrigin(origin) {
348
- if (!origin) return false;
349
- try {
350
- const url = new URL(origin);
351
- // Allow localhost/127.0.0.1 (any port)
352
- if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
353
- // Allow configured hostname from env (e.g., spaces.example.com)
354
- const allowed = process.env.SPACES_ALLOWED_ORIGINS;
355
- if (allowed) {
356
- return allowed.split(',').some(h => url.hostname === h.trim());
357
- }
358
- // In non-community modes, require explicit allowed origins
359
- if (SPACES_TIER !== 'community') return false;
360
- // Desktop/community: allow any origin
361
- return true;
362
- } catch {
363
- return false;
364
- }
365
- }
366
-
367
- // ─── Live collab toggle handler ─────────────────────────
368
- function handleCollabToggle(paneId, session) {
369
- try {
370
- const teams = require('@spaces/teams');
371
- const config = teams.terminal.getCollabConfig(paneId, session.username);
372
-
373
- if (config) {
374
- // Enabling collaboration
375
- session.isCollaborating = true;
376
- session.workspaceId = config.workspaceId;
377
- session.paneName = config.paneName;
378
-
379
- const env = {
380
- SPACES_PANE_ID: paneId,
381
- SPACES_WORKSPACE_ID: config.workspaceId,
382
- SPACES_PANE_NAME: config.paneName,
383
- SPACES_API_URL: `http://localhost:${API_PORT}`,
384
- SPACES_COLLABORATING: '1',
385
- };
386
- teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
387
-
388
- // Nudge the agent so it knows collaboration is available
389
- if (session.pty && !session.exited) {
390
- const nudge = 'Workspace collaboration has been enabled. Hooks are active — you will receive messages on the next prompt. MCP tools (post_message, read_messages) require reconnecting the MCP server (use /mcp).';
391
- session.pty.write(nudge);
392
- setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
393
- }
394
-
395
- console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
396
- } else {
397
- // Disabling collaboration
398
- teams.terminal.removeAgentConfig(session.agentType, session.cwd);
399
- session.isCollaborating = false;
400
- session.workspaceId = null;
401
-
402
- console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
403
- }
404
-
405
- // Confirm to browser
406
- if (session.ws && session.ws.readyState === 1) {
407
- session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
408
- }
409
- } catch (e) {
410
- console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
411
- }
412
- }
413
-
414
- // ─── Shared connection handler ──────────────────────────
415
- function handleConnection(wss, ws, req) {
416
- ws.isAlive = true;
417
- ws.on('pong', () => { ws.isAlive = true; });
418
-
419
- const url = new URL(req.url || '/', 'http://localhost');
420
- const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
421
- const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
422
- const agentType = url.searchParams.get('agentType') || 'shell';
423
- const rawAgentSession = url.searchParams.get('agentSession') || '';
424
- const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
425
- const rawCustomCommand = url.searchParams.get('customCommand') || '';
426
- // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
427
- const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
428
- const cols = parseInt(url.searchParams.get('cols') || '120', 10);
429
- const rows = parseInt(url.searchParams.get('rows') || '30', 10);
430
-
431
- // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
432
- let username = null;
433
- const cookies = parseCookies(req.headers.cookie);
434
- const sessionToken = cookies['spaces-session'];
435
- const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
436
-
437
- console.log(`[Auth] pane=${paneId.slice(0, 8)} cookie=${sessionToken ? 'present' : 'MISSING'} sessionValid=${!!sessionPayload} terminalToken=${(url.searchParams.get('terminalToken') || '').slice(0, 12) || 'NONE'} nodeId=${url.searchParams.get('nodeId') || 'NONE'} apiKey=${url.searchParams.get('apiKey') ? 'present' : 'NONE'}`);
438
-
439
- if (sessionPayload) {
440
- // Self-contained auth: session cookie is valid
441
- username = sessionPayload.sub;
442
- console.log(`[Auth] Authenticated via session cookie: ${username}`);
443
- } else {
444
- const terminalToken = url.searchParams.get('terminalToken') || '';
445
-
446
- // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
447
- const remoteIp = req.socket.remoteAddress || '';
448
- const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
449
- if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
450
- username = os.userInfo().username;
451
- console.log(`[Auth] Authenticated via desktop token: ${username}`);
452
- } else {
453
- // Verify terminal token — if signed by this server's secret, trust it
454
- const tokenUser = verifyTerminalToken(terminalToken);
455
- if (tokenUser) {
456
- // Use the user from the signed token — do NOT trust x-auth-user header
457
- // as it can be spoofed by clients
458
- username = tokenUser;
459
- console.log(`[Auth] Authenticated via terminal token: ${username}`);
460
- } else if (terminalToken) {
461
- console.log(`[Auth] Terminal token FAILED: invalid or expired`);
462
- }
463
- }
464
- }
465
-
466
- // Network API key auth (for proxied connections from remote nodes)
467
- if (!username) {
468
- const apiKey = url.searchParams.get('apiKey');
469
- if (apiKey) {
470
- console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
471
- const keyRecord = validateNetworkApiKey(apiKey);
472
- if (keyRecord) {
473
- console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
474
- if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
475
- username = keyRecord.username || os.userInfo().username;
476
- } else {
477
- console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
478
- }
479
- } else {
480
- console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
481
- }
482
- } else {
483
- console.log(`[Auth] No apiKey param in WebSocket URL`);
484
- }
485
- }
486
-
487
- if (!username) {
488
- console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
489
- ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
490
- ws.close();
491
- return;
492
- }
493
-
494
- // Proxy to remote node (federation tier only)
495
- const nodeId = url.searchParams.get('nodeId');
496
- if (nodeId) {
497
- if (SPACES_TIER !== 'federation') {
498
- ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
499
- ws.close();
500
- return;
501
- }
502
- handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
503
- return;
504
- }
505
-
506
- // Check for existing session to reattach
507
- const existing = sessions.get(paneId);
508
- if (existing && existing.pty && !existing.exited) {
509
- existing.ws = ws;
510
-
511
- // Replay buffered output so user sees context
512
- for (const chunk of existing.buffer) {
513
- ws.send(JSON.stringify({ type: 'data', data: chunk }));
514
- }
515
-
516
- try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
517
-
518
- ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
519
-
520
- ws.on('message', (raw) => {
521
- try {
522
- const msg = JSON.parse(raw.toString());
523
- if (msg.type === 'data') {
524
- existing.pty.write(msg.data);
525
- } else if (msg.type === 'resize') {
526
- try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
527
- } else if (msg.type === 'collab-toggle') {
528
- handleCollabToggle(paneId, existing);
529
- }
530
- } catch {
531
- existing.pty.write(raw.toString());
532
- }
533
- });
534
-
535
- ws.on('close', () => {
536
- if (existing.ws === ws) existing.ws = null;
537
- });
538
-
539
- return;
540
- }
541
-
542
- // Create new pty session
543
- const isWindows = process.platform === 'win32';
544
-
545
- // Resolve the OS shell user for this app user
546
- const shellUser = lookupShellUser(username);
547
- const processUser = os.userInfo().username;
548
- let shell, args;
549
- const isSSH = !isWindows && shellUser !== processUser;
550
- if (isSSH) {
551
- // SSH to localhost as the mapped shell user using the service key
552
- shell = '/usr/bin/ssh';
553
- args = [
554
- '-4',
555
- '-i', SERVICE_KEY,
556
- '-o', 'StrictHostKeyChecking=accept-new',
557
- '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
558
- '-t',
559
- `${shellUser}@localhost`,
560
- ];
561
- } else if (isWindows && agentType !== 'shell') {
562
- // Agents like Claude Code require bash on Windows — find git-bash
563
- shell = findGitBash();
564
- args = [];
565
- if (!shell) {
566
- shell = 'cmd.exe';
567
- }
568
- } else {
569
- shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
570
- args = [];
571
- }
572
-
573
- const env = { ...process.env };
574
- delete env.CLAUDECODE;
575
- // Tell Claude Code where git-bash is so it doesn't fail the bash detection
576
- if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
577
- env.CLAUDE_CODE_GIT_BASH_PATH = shell;
578
- }
579
-
580
- // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
581
- let safeCwd = cwd;
582
- if (!fs.existsSync(safeCwd)) {
583
- safeCwd = process.env.HOME || '/root';
584
- console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
585
- }
586
-
587
- // Inject Spaces bus environment for agent communication
588
- env.SPACES_PANE_ID = paneId;
589
- env.SPACES_API_URL = `http://localhost:${API_PORT}`;
590
-
591
- // Look up workspace collaboration config from @spaces/teams
592
- let isCollaborating = false;
593
- try {
594
- const teams = require('@spaces/teams');
595
- const config = teams.terminal.getCollabConfig(paneId, username);
596
- if (config) {
597
- env.SPACES_WORKSPACE_ID = config.workspaceId;
598
- env.SPACES_PANE_NAME = config.paneName;
599
- isCollaborating = true;
600
- env.SPACES_COLLABORATING = '1';
601
- console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
602
- }
603
- } catch (e) {
604
- console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
605
- }
606
-
607
- console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
608
-
609
- let term;
610
- try {
611
- term = pty.spawn(shell, args, {
612
- name: 'xterm-256color',
613
- cols,
614
- rows,
615
- cwd: safeCwd,
616
- env,
617
- });
618
- } catch (err) {
619
- console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
620
- ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
621
- ws.close();
622
- return;
623
- }
624
-
625
- const session = {
626
- pty: term, ws, buffer: [], exited: false, username,
627
- agentType,
628
- cwd: safeCwd,
629
- paneName: env.SPACES_PANE_NAME || paneId,
630
- lastOutputTime: Date.now(),
631
- lastNudgeTime: 0,
632
- startedAt: Date.now(),
633
- workspaceId: env.SPACES_WORKSPACE_ID || null,
634
- isCollaborating,
635
- };
636
- sessions.set(paneId, session);
637
- analyticsRecordSessionStart(paneId, username, agentType);
638
-
639
- // ─── Inject cd for SSH sessions, then agent command ─────
640
- const agent = AGENTS[agentType] || AGENTS.shell;
641
-
642
- // SSH sessions start in the remote user's home dir — cd to target cwd first
643
- if (isSSH) {
644
- // Use single quotes to prevent shell expansion; escape any single quotes in the path
645
- const escapedCwd = safeCwd.replace(/'/g, "'\\''");
646
- setTimeout(() => {
647
- if (!session.exited) {
648
- term.write(`cd '${escapedCwd}'\r`);
649
- }
650
- }, 300);
651
- }
652
-
653
- // Write collaboration config for agent panes via @spaces/teams
654
- if (isCollaborating && agentType !== 'shell') {
655
- try {
656
- const teams = require('@spaces/teams');
657
- teams.terminal.writeAgentConfig(agentType, safeCwd, env);
658
- console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
659
- } catch (e) {
660
- console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
661
- }
662
- }
663
-
664
- if (agentType !== 'shell') {
665
- const command = agentType === 'custom' ? customCommand : agent.command;
666
-
667
- if (command) {
668
- const delay = isSSH ? 800 : 300;
669
-
670
- if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
671
- // Resume an existing session
672
- if (agentType === 'claude') {
673
- // Claude needs to be run from the correct project CWD
674
- const sessionCwd = findSessionCwd(agentSession, username);
675
- setTimeout(() => {
676
- if (session.exited) return;
677
- if (sessionCwd && sessionCwd !== safeCwd) {
678
- const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
679
- term.write(cdCmd + '\r');
680
- setTimeout(() => {
681
- if (!session.exited) {
682
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
683
- }
684
- }, 300);
685
- } else {
686
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
687
- }
688
- }, delay);
689
- } else {
690
- // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
691
- setTimeout(() => {
692
- if (!session.exited) {
693
- term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
694
- }
695
- }, delay);
696
- }
697
- } else {
698
- // Start new session
699
- setTimeout(() => {
700
- if (!session.exited) {
701
- term.write(`${command}\r`);
702
- }
703
- }, delay);
704
- }
705
- }
706
- }
707
-
708
- // pty -> ws (and buffer)
709
- term.onData((data) => {
710
- session.lastOutputTime = Date.now();
711
- session.buffer.push(data);
712
- if (session.buffer.length > MAX_BUFFER_LINES) {
713
- session.buffer.shift();
714
- }
715
-
716
- if (session.ws && session.ws.readyState === 1) {
717
- session.ws.send(JSON.stringify({ type: 'data', data }));
718
- }
719
- });
720
-
721
- term.onExit(({ exitCode }) => {
722
- session.exited = true;
723
- analyticsRecordSessionEnd(paneId);
724
- // Clean up hook state file
725
- try {
726
- const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
727
- if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
728
- } catch { /* ignore */ }
729
- if (session.ws && session.ws.readyState === 1) {
730
- session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
731
- }
732
- setTimeout(() => {
733
- if (sessions.get(paneId) === session) {
734
- sessions.delete(paneId);
735
- }
736
- }, 120000);
737
- });
738
-
739
- // ws -> pty
740
- ws.on('message', (raw) => {
741
- try {
742
- const msg = JSON.parse(raw.toString());
743
- if (msg.type === 'data') {
744
- term.write(msg.data);
745
- } else if (msg.type === 'resize') {
746
- try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
747
- } else if (msg.type === 'collab-toggle') {
748
- handleCollabToggle(paneId, session);
749
- }
750
- } catch {
751
- term.write(raw.toString());
752
- }
753
- });
754
-
755
- ws.on('close', () => {
756
- if (session.ws === ws) session.ws = null;
757
- });
758
-
759
- ws.send(JSON.stringify({ type: 'ready', paneId }));
760
-
761
- // ─── Session ID detection for new Claude sessions ────────
762
- if (agentType === 'claude' && (!agentSession || agentSession === 'new')) {
763
- detectNewClaudeSession(paneId, cwd, ws, session, username);
764
- }
765
- }
766
-
767
- // ─── Claude-specific helpers ──────────────────────────────
768
-
769
- function getUserHome(username) {
770
- const shellUser = lookupShellUser(username);
771
- if (shellUser === os.userInfo().username) return os.homedir();
772
- return `/home/${shellUser}`;
773
- }
774
-
775
- const UUID_JSONL_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
776
-
777
- function findSessionCwd(sessionId, username) {
778
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
779
- try {
780
- if (!fs.existsSync(claudeProjectsDir)) return null;
781
- const fileName = `${sessionId}.jsonl`;
782
-
783
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
784
- if (!projDir.isDirectory()) continue;
785
- const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
786
- if (fs.existsSync(filePath)) {
787
- // Try to find cwd in the jsonl first few lines
788
- const fd = fs.openSync(filePath, 'r');
789
- const buf = Buffer.alloc(4096);
790
- const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
791
- fs.closeSync(fd);
792
-
793
- const chunk = buf.toString('utf-8', 0, bytesRead);
794
- const lines = chunk.split('\n');
795
- for (const line of lines) {
796
- if (!line.trim()) continue;
797
- try {
798
- const entry = JSON.parse(line);
799
- if (entry.cwd) {
800
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
801
- return entry.cwd;
802
- }
803
- } catch { /* skip */ }
804
- }
805
-
806
- // Fallback: derive CWD from the project directory name
807
- // Claude encodes paths as e.g. "-home-user-projects-myapp"
808
- const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
809
- if (fs.existsSync(derivedPath)) {
810
- console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
811
- return derivedPath;
812
- }
813
- }
814
- }
815
- } catch (err) {
816
- console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
817
- }
818
- return null;
819
- }
820
-
821
- function detectNewClaudeSession(paneId, cwd, ws, session, username) {
822
- const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
823
-
824
- const knownSessionIds = new Set();
825
- try {
826
- if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
827
- else {
828
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
829
- if (!projDir.isDirectory()) continue;
830
- const projPath = path.join(claudeProjectsDir, projDir.name);
831
- try {
832
- for (const item of fs.readdirSync(projPath)) {
833
- const m = item.match(UUID_JSONL_RE);
834
- if (m) knownSessionIds.add(m[1]);
835
- }
836
- const indexPath = path.join(projPath, 'sessions-index.json');
837
- if (fs.existsSync(indexPath)) {
838
- try {
839
- const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
840
- if (data.entries) {
841
- for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
842
- }
843
- } catch { /* ignore */ }
844
- }
845
- } catch { /* ignore */ }
846
- }
847
- }
848
- } catch { /* ignore */ }
849
-
850
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
851
-
852
- let attempts = 0;
853
- const maxAttempts = 45;
854
- const interval = setInterval(() => {
855
- attempts++;
856
- if (attempts > maxAttempts || session.exited) {
857
- clearInterval(interval);
858
- return;
859
- }
860
-
861
- try {
862
- if (!fs.existsSync(claudeProjectsDir)) return;
863
-
864
- for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
865
- if (!projDir.isDirectory()) continue;
866
- const projPath = path.join(claudeProjectsDir, projDir.name);
867
- try {
868
- for (const item of fs.readdirSync(projPath)) {
869
- const m = item.match(UUID_JSONL_RE);
870
- if (m && !knownSessionIds.has(m[1])) {
871
- const newSessionId = m[1];
872
- clearInterval(interval);
873
- console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
874
- if (session.ws && session.ws.readyState === 1) {
875
- session.ws.send(JSON.stringify({
876
- type: 'session-detected',
877
- sessionId: newSessionId,
878
- paneId,
879
- }));
880
- }
881
- return;
882
- }
883
- }
884
- } catch { /* ignore */ }
885
- }
886
- } catch { /* ignore */ }
887
- }, 2000);
888
- }
889
-
890
- // ─── Proxy: forward connection to remote node ──────────
891
-
892
- async function handleProxyConnection(clientWs, nodeId, opts) {
893
- const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
894
-
895
- const node = getNodeInfo(nodeId);
896
- if (!node) {
897
- clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
898
- clientWs.close();
899
- return;
900
- }
901
-
902
- const apiKey = decryptNodeApiKey(node.api_key_encrypted);
903
- if (!apiKey) {
904
- clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
905
- clientWs.close();
906
- return;
907
- }
908
-
909
- // Get the remote WebSocket URL via the terminal token endpoint
910
- // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
911
- const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
912
- const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
913
- if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
914
- let remoteWsUrl;
915
- try {
916
- const tokenUrl = `${node.url}/api/network/terminal/token/`;
917
- const res = await fetch(tokenUrl, {
918
- method: 'POST',
919
- headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
920
- signal: AbortSignal.timeout(10000),
921
- });
922
-
923
- if (!res.ok) {
924
- let detail = '';
925
- try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
926
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
927
- clientWs.close();
928
- return;
929
- }
930
-
931
- const data = await res.json();
932
- remoteWsUrl = data.wsUrl;
933
- } catch (err) {
934
- clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
935
- clientWs.close();
936
- return;
937
- } finally {
938
- if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
939
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
940
- }
941
-
942
- // Connect to remote terminal server using the API key directly.
943
- // The terminal token approach fails because the proxied WebSocket has no
944
- // x-auth-user header, so the remote server can't match the token's username
945
- // to the request. API key auth on the WebSocket is the reliable path.
946
- const WebSocket = require('ws');
947
- const remoteParams = new URLSearchParams({
948
- paneId,
949
- cwd,
950
- agentType,
951
- cols: String(cols),
952
- rows: String(rows),
953
- apiKey: apiKey,
954
- });
955
- if (agentSession) remoteParams.set('agentSession', agentSession);
956
- // Never forward customCommand to remote nodes — too dangerous
957
-
958
- // Upgrade ws:// to wss:// if the node uses https
959
- let wsUrl = remoteWsUrl;
960
- if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
961
- wsUrl = 'wss://' + wsUrl.slice(5);
962
- }
963
- const remoteUrl = `${wsUrl}?${remoteParams}`;
964
- console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
965
-
966
- const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
967
-
968
- remoteWs.on('open', () => {
969
- console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
970
- });
971
-
972
- // Pipe data bidirectionally
973
- let firstMsg = true;
974
- remoteWs.on('message', (data) => {
975
- const str = data.toString();
976
- if (firstMsg) {
977
- console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
978
- firstMsg = false;
979
- }
980
- if (clientWs.readyState === 1) {
981
- clientWs.send(str);
982
- }
983
- });
984
-
985
- clientWs.on('message', (data) => {
986
- if (remoteWs.readyState === 1) {
987
- remoteWs.send(data.toString());
988
- }
989
- });
990
-
991
- // Handle closes
992
- remoteWs.on('close', () => {
993
- console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
994
- if (clientWs.readyState === 1) {
995
- clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
996
- }
997
- });
998
-
999
- remoteWs.on('error', (err) => {
1000
- console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1001
- if (clientWs.readyState === 1) {
1002
- clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1003
- }
1004
- });
1005
-
1006
- clientWs.on('close', () => {
1007
- console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1008
- if (remoteWs.readyState === 1) {
1009
- remoteWs.close();
1010
- }
1011
- });
1012
- }
1013
-
1014
- // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1015
-
1016
- function setupWss(wss) {
1017
- const pingInterval = setInterval(() => {
1018
- wss.clients.forEach((ws) => {
1019
- if (ws.isAlive === false) return ws.terminate();
1020
- ws.isAlive = false;
1021
- ws.ping();
1022
- });
1023
- }, 30000);
1024
-
1025
- wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1026
-
1027
- wss.on('error', (err) => {
1028
- console.error('[Terminal Server] Error:', err.message);
1029
- });
1030
-
1031
- // Initialize analytics DB
1032
- getAdminDbRW();
1033
-
1034
- return pingInterval;
1035
- }
1036
-
1037
- function startMdnsIfNeeded() {
1038
- if (SPACES_TIER === 'federation') {
1039
- try {
1040
- const { startMdns } = require('./mdns-service');
1041
- startMdns(PORT);
1042
- } catch (err) {
1043
- console.log('[mDNS] Discovery not available:', err.message);
1044
- }
1045
- } else {
1046
- console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1047
- }
1048
- }
1049
-
1050
- // ─── Poll-based idle nudge for agent collaboration ───────
1051
-
1052
- function startMessageWatcher(apiPort) {
1053
- try {
1054
- const teams = require('@spaces/teams');
1055
- teams.terminal.startMessageWatcher(apiPort, sessions);
1056
- } catch { /* @spaces/teams not installed — no message watcher */ }
1057
- }
1058
-
1059
- // ─── Attached mode: mount on an existing HTTP server ─────
1060
-
1061
- function createTerminalServer(httpServer) {
1062
- // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1063
- if (httpServer.listening) {
1064
- API_PORT = httpServer.address().port;
1065
- } else {
1066
- httpServer.on('listening', () => { API_PORT = httpServer.address().port; });
1067
- }
1068
-
1069
- const wss = new WebSocketServer({ noServer: true });
1070
- setupWss(wss);
1071
-
1072
- httpServer.on('upgrade', (req, socket, head) => {
1073
- const url = new URL(req.url, 'http://localhost');
1074
- if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1075
- // Verify origin for browser clients
1076
- const origin = req.headers.origin;
1077
- if (origin && !isAllowedOrigin(origin)) {
1078
- socket.destroy();
1079
- return;
1080
- }
1081
- wss.handleUpgrade(req, socket, head, (ws) => {
1082
- wss.emit('connection', ws, req);
1083
- });
1084
- }
1085
- // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1086
- });
1087
-
1088
- startMdnsIfNeeded();
1089
- // Start message watcher once the server is listening
1090
- if (httpServer.listening) {
1091
- startMessageWatcher(httpServer.address().port);
1092
- } else {
1093
- httpServer.on('listening', () => {
1094
- startMessageWatcher(httpServer.address().port);
1095
- });
1096
- }
1097
- return wss;
1098
- }
1099
-
1100
- // ─── Standalone mode (run directly) ──────────────────────
1101
-
1102
- if (require.main === module) {
1103
- const wss = new WebSocketServer({
1104
- port: PORT,
1105
- verifyClient: ({ req }) => {
1106
- const origin = req.headers.origin;
1107
- if (!origin) return true;
1108
- return isAllowedOrigin(origin);
1109
- },
1110
- });
1111
- setupWss(wss);
1112
- startMdnsIfNeeded();
1113
- startMessageWatcher(PORT);
1114
- console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1115
- }
1116
-
1117
- module.exports = { createTerminalServer };
1
+ #!/usr/bin/env node
2
+
3
+ const { WebSocketServer } = require('ws');
4
+ const pty = require('node-pty');
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const crypto = require('crypto');
10
+
11
+ const PORT = parseInt(process.env.SPACES_WS_PORT || '3458', 10);
12
+ const SPACES_TIER = process.env.SPACES_TIER || 'community';
13
+ // API_PORT is the port where Next.js API routes are reachable.
14
+ // In attached mode, createTerminalServer() updates this to the parent server's port.
15
+ let API_PORT = parseInt(process.env.SPACES_PORT || '3457', 10);
16
+
17
+ // ─── Terminal token verification ──────────────────────────
18
+
19
+ const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
20
+
21
+ function getTerminalSecret() {
22
+ if (fs.existsSync(SECRET_PATH)) {
23
+ return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
24
+ }
25
+ const secret = crypto.randomBytes(32);
26
+ const dir = path.dirname(SECRET_PATH);
27
+ if (!fs.existsSync(dir)) {
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ }
30
+ fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
31
+ return secret;
32
+ }
33
+
34
+ let _terminalSecret = null;
35
+ function terminalSecret() {
36
+ if (!_terminalSecret) {
37
+ _terminalSecret = getTerminalSecret();
38
+ }
39
+ return _terminalSecret;
40
+ }
41
+
42
+ function verifyTerminalToken(token) {
43
+ if (!token) return null;
44
+ const parts = token.split('.');
45
+ if (parts.length !== 2) return null;
46
+
47
+ const [payloadB64, sig] = parts;
48
+ const expectedSig = crypto.createHmac('sha256', terminalSecret())
49
+ .update(payloadB64)
50
+ .digest('base64url');
51
+
52
+ try {
53
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
54
+ return null;
55
+ }
56
+ } catch {
57
+ return null;
58
+ }
59
+
60
+ try {
61
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
62
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
63
+ return null;
64
+ }
65
+ return payload.sub || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // ─── Session token verification (for self-contained auth) ──
72
+
73
+ const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
74
+
75
+ function getSessionSecret() {
76
+ if (fs.existsSync(SESSION_SECRET_PATH)) {
77
+ return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
78
+ }
79
+ return null;
80
+ }
81
+
82
+ let _sessionSecret = null;
83
+ function sessionSecret() {
84
+ if (!_sessionSecret) {
85
+ _sessionSecret = getSessionSecret();
86
+ }
87
+ return _sessionSecret;
88
+ }
89
+
90
+ function verifySessionToken(token) {
91
+ const secret = sessionSecret();
92
+ if (!token || !secret) return null;
93
+ const parts = token.split('.');
94
+ if (parts.length !== 2) return null;
95
+
96
+ const [payloadB64, sig] = parts;
97
+ const expectedSig = crypto.createHmac('sha256', secret)
98
+ .update(payloadB64)
99
+ .digest('base64url');
100
+
101
+ try {
102
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
103
+ return null;
104
+ }
105
+ } catch {
106
+ return null;
107
+ }
108
+
109
+ try {
110
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
111
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
112
+ return null;
113
+ }
114
+ return { sub: payload.sub, role: payload.role || 'user' };
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // ─── Admin DB for shell user lookup ─────────────────────────
121
+
122
+ const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
123
+ let _adminDb = null;
124
+
125
+ function getAdminDb() {
126
+ if (_adminDb) return _adminDb;
127
+ if (!fs.existsSync(ADMIN_DB_PATH)) return null;
128
+ try {
129
+ const Database = require('better-sqlite3');
130
+ _adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
131
+ return _adminDb;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function lookupShellUser(appUsername) {
138
+ const db = getAdminDb();
139
+ if (!db) return appUsername;
140
+ try {
141
+ const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
142
+ return row ? row.shell_user : appUsername;
143
+ } catch {
144
+ return appUsername;
145
+ }
146
+ }
147
+
148
+ // ─── Network DB for federation ───────────────────────────────
149
+
150
+ const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
151
+ let _networkDb = null;
152
+
153
+ function getNetworkDb() {
154
+ if (_networkDb) return _networkDb;
155
+ if (!fs.existsSync(NETWORK_DB_PATH)) return null;
156
+ try {
157
+ const Database = require('better-sqlite3');
158
+ _networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
159
+ return _networkDb;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ function validateNetworkApiKey(rawKey) {
166
+ const db = getNetworkDb();
167
+ if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
168
+ try {
169
+ const keys = db.prepare('SELECT * FROM api_keys').all();
170
+ for (const key of keys) {
171
+ if (key.expires && new Date(key.expires) < new Date()) continue;
172
+ const [salt, hash] = key.key_hash.split(':');
173
+ if (!salt || !hash) continue;
174
+ const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
175
+ try {
176
+ if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
177
+ return key;
178
+ }
179
+ } catch { continue; }
180
+ }
181
+ } catch { /* ignore */ }
182
+ return null;
183
+ }
184
+
185
+ function getNodeInfo(nodeId) {
186
+ const db = getNetworkDb();
187
+ if (!db) return null;
188
+ try {
189
+ return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ function decryptNodeApiKey(encrypted) {
196
+ try {
197
+ const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
198
+ const [ivB64, tagB64, dataB64] = encrypted.split(':');
199
+ if (!ivB64 || !tagB64 || !dataB64) return null;
200
+ const iv = Buffer.from(ivB64, 'base64');
201
+ const tag = Buffer.from(tagB64, 'base64');
202
+ const data = Buffer.from(dataB64, 'base64');
203
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
204
+ decipher.setAuthTag(tag);
205
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ // ─── Writable Admin DB for analytics ─────────────────────────
212
+
213
+ let _adminDbRW = null;
214
+
215
+ function getAdminDbRW() {
216
+ if (_adminDbRW) return _adminDbRW;
217
+ try {
218
+ const Database = require('better-sqlite3');
219
+ const dir = path.dirname(ADMIN_DB_PATH);
220
+ if (!fs.existsSync(dir)) {
221
+ fs.mkdirSync(dir, { recursive: true });
222
+ }
223
+ const db = new Database(ADMIN_DB_PATH);
224
+ db.pragma('journal_mode = WAL');
225
+ db.pragma('busy_timeout = 5000');
226
+ db.exec(`
227
+ CREATE TABLE IF NOT EXISTS login_events (
228
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
229
+ username TEXT NOT NULL,
230
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
231
+ ip_address TEXT,
232
+ user_agent TEXT
233
+ );
234
+ CREATE TABLE IF NOT EXISTS terminal_sessions (
235
+ id TEXT PRIMARY KEY,
236
+ username TEXT NOT NULL,
237
+ agent_type TEXT NOT NULL DEFAULT 'shell',
238
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
239
+ ended_at TEXT,
240
+ duration_seconds INTEGER
241
+ );
242
+ CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
243
+ CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
244
+ CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
245
+ CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
246
+ `);
247
+ // Clean up stale sessions from previous crashes
248
+ db.prepare(`
249
+ UPDATE terminal_sessions
250
+ SET ended_at = datetime('now'),
251
+ duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
252
+ WHERE ended_at IS NULL
253
+ `).run();
254
+ _adminDbRW = db;
255
+ console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
256
+ return db;
257
+ } catch (err) {
258
+ console.error('[Analytics] Failed to open writable admin DB:', err.message);
259
+ return null;
260
+ }
261
+ }
262
+
263
+ function analyticsRecordSessionStart(paneId, username, agentType) {
264
+ try {
265
+ const db = getAdminDbRW();
266
+ if (!db) return;
267
+ db.prepare(
268
+ 'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
269
+ ).run(paneId, username, agentType);
270
+ } catch (err) {
271
+ console.error('[Analytics] recordSessionStart error:', err.message);
272
+ }
273
+ }
274
+
275
+ function analyticsRecordSessionEnd(paneId) {
276
+ try {
277
+ const db = getAdminDbRW();
278
+ if (!db) return;
279
+ db.prepare(`
280
+ UPDATE terminal_sessions
281
+ SET ended_at = datetime('now'),
282
+ duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
283
+ WHERE id = ? AND ended_at IS NULL
284
+ `).run(paneId);
285
+ } catch (err) {
286
+ console.error('[Analytics] recordSessionEnd error:', err.message);
287
+ }
288
+ }
289
+
290
+ // ─── Cookie parser ──────────────────────────────────────────
291
+
292
+ function parseCookies(cookieHeader) {
293
+ const cookies = {};
294
+ if (!cookieHeader) return cookies;
295
+ cookieHeader.split(';').forEach(part => {
296
+ const [key, ...rest] = part.trim().split('=');
297
+ if (key) cookies[key] = rest.join('=');
298
+ });
299
+ return cookies;
300
+ }
301
+
302
+ // ─── SSH service key path (used to spawn shells as other OS users) ──
303
+
304
+ const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
305
+
306
+ // Session store: keeps ptys alive across WebSocket reconnections
307
+ // Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
308
+ const sessions = new Map();
309
+
310
+ const MAX_BUFFER_LINES = 500;
311
+
312
+ // ─── Agent definitions (mirrors src/lib/agents.ts) ────────
313
+ const AGENTS = {
314
+ shell: { command: '', resumeFlag: '', resumeStyle: '' },
315
+ claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
316
+ codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
317
+ gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
318
+ aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
319
+ custom: { command: '', resumeFlag: '', resumeStyle: '' },
320
+ };
321
+
322
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
323
+
324
+ // ─── Git Bash detection (Windows) ────────────────────────
325
+ function findGitBash() {
326
+ const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
327
+ if (custom && fs.existsSync(custom)) return custom;
328
+ const localAppData = process.env.LOCALAPPDATA || '';
329
+ const candidates = [
330
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
331
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
332
+ path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
333
+ ];
334
+ for (const p of candidates) {
335
+ if (p && fs.existsSync(p)) return p;
336
+ }
337
+ // Last resort: check if bash is on PATH via where command
338
+ try {
339
+ const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
340
+ const first = result.trim().split('\n')[0].trim();
341
+ if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
342
+ } catch { /* not found */ }
343
+ return null;
344
+ }
345
+
346
+ // ─── Origin validation ───────────────────────────────────
347
+ function isAllowedOrigin(origin) {
348
+ if (!origin) return false;
349
+ try {
350
+ const url = new URL(origin);
351
+ // Allow localhost/127.0.0.1 (any port)
352
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
353
+ // Allow configured hostname from env (e.g., spaces.example.com)
354
+ const allowed = process.env.SPACES_ALLOWED_ORIGINS;
355
+ if (allowed) {
356
+ return allowed.split(',').some(h => url.hostname === h.trim());
357
+ }
358
+ // In non-community modes, require explicit allowed origins
359
+ if (SPACES_TIER !== 'community') return false;
360
+ // Desktop/community: allow any origin
361
+ return true;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ // ─── Live collab toggle handler ─────────────────────────
368
+ function handleCollabToggle(paneId, session) {
369
+ try {
370
+ const teams = require('@spaces/teams');
371
+ const config = teams.terminal.getCollabConfig(paneId, session.username);
372
+
373
+ if (config) {
374
+ // Enabling collaboration
375
+ session.isCollaborating = true;
376
+ session.workspaceId = config.workspaceId;
377
+ session.paneName = config.paneName;
378
+
379
+ const env = {
380
+ SPACES_PANE_ID: paneId,
381
+ SPACES_WORKSPACE_ID: config.workspaceId,
382
+ SPACES_PANE_NAME: config.paneName,
383
+ SPACES_API_URL: `http://localhost:${API_PORT}`,
384
+ SPACES_COLLABORATING: '1',
385
+ };
386
+ teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
387
+
388
+ // Nudge the agent so it knows collaboration is available
389
+ if (session.pty && !session.exited) {
390
+ const nudge = 'Workspace collaboration has been enabled. Hooks are active — you will receive messages on the next prompt. MCP tools (post_message, read_messages) require reconnecting the MCP server (use /mcp).';
391
+ session.pty.write(nudge);
392
+ setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
393
+ }
394
+
395
+ console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
396
+ } else {
397
+ // Disabling collaboration
398
+ teams.terminal.removeAgentConfig(session.agentType, session.cwd);
399
+ session.isCollaborating = false;
400
+ session.workspaceId = null;
401
+
402
+ console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
403
+ }
404
+
405
+ // Confirm to browser
406
+ if (session.ws && session.ws.readyState === 1) {
407
+ session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
408
+ }
409
+ } catch (e) {
410
+ console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
411
+ }
412
+ }
413
+
414
+ // ─── Shared connection handler ──────────────────────────
415
+ function handleConnection(wss, ws, req) {
416
+ ws.isAlive = true;
417
+ ws.on('pong', () => { ws.isAlive = true; });
418
+
419
+ const url = new URL(req.url || '/', 'http://localhost');
420
+ const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
421
+ const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
422
+ const agentType = url.searchParams.get('agentType') || 'shell';
423
+ const rawAgentSession = url.searchParams.get('agentSession') || '';
424
+ const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
425
+ const rawCustomCommand = url.searchParams.get('customCommand') || '';
426
+ // Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
427
+ const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
428
+ const cols = parseInt(url.searchParams.get('cols') || '120', 10);
429
+ const rows = parseInt(url.searchParams.get('rows') || '30', 10);
430
+
431
+ // Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
432
+ let username = null;
433
+ const cookies = parseCookies(req.headers.cookie);
434
+ const sessionToken = cookies['spaces-session'];
435
+ const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
436
+
437
+ console.log(`[Auth] pane=${paneId.slice(0, 8)} cookie=${sessionToken ? 'present' : 'MISSING'} sessionValid=${!!sessionPayload} terminalToken=${(url.searchParams.get('terminalToken') || '').slice(0, 12) || 'NONE'} nodeId=${url.searchParams.get('nodeId') || 'NONE'} apiKey=${url.searchParams.get('apiKey') ? 'present' : 'NONE'}`);
438
+
439
+ if (sessionPayload) {
440
+ // Self-contained auth: session cookie is valid
441
+ username = sessionPayload.sub;
442
+ console.log(`[Auth] Authenticated via session cookie: ${username}`);
443
+ } else {
444
+ const terminalToken = url.searchParams.get('terminalToken') || '';
445
+
446
+ // Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
447
+ const remoteIp = req.socket.remoteAddress || '';
448
+ const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
449
+ if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
450
+ username = os.userInfo().username;
451
+ console.log(`[Auth] Authenticated via desktop token: ${username}`);
452
+ } else {
453
+ // Verify terminal token — if signed by this server's secret, trust it
454
+ const tokenUser = verifyTerminalToken(terminalToken);
455
+ if (tokenUser) {
456
+ // Use the user from the signed token — do NOT trust x-auth-user header
457
+ // as it can be spoofed by clients
458
+ username = tokenUser;
459
+ console.log(`[Auth] Authenticated via terminal token: ${username}`);
460
+ } else if (terminalToken) {
461
+ console.log(`[Auth] Terminal token FAILED: invalid or expired`);
462
+ }
463
+ }
464
+ }
465
+
466
+ // Network API key auth (for proxied connections from remote nodes)
467
+ if (!username) {
468
+ const apiKey = url.searchParams.get('apiKey');
469
+ if (apiKey) {
470
+ console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
471
+ const keyRecord = validateNetworkApiKey(apiKey);
472
+ if (keyRecord) {
473
+ console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
474
+ if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
475
+ username = keyRecord.username || os.userInfo().username;
476
+ } else {
477
+ console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
478
+ }
479
+ } else {
480
+ console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
481
+ }
482
+ } else {
483
+ console.log(`[Auth] No apiKey param in WebSocket URL`);
484
+ }
485
+ }
486
+
487
+ if (!username) {
488
+ console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
489
+ ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
490
+ ws.close();
491
+ return;
492
+ }
493
+
494
+ // Proxy to remote node (federation tier only)
495
+ const nodeId = url.searchParams.get('nodeId');
496
+ if (nodeId) {
497
+ if (SPACES_TIER !== 'federation') {
498
+ ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
499
+ ws.close();
500
+ return;
501
+ }
502
+ handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
503
+ return;
504
+ }
505
+
506
+ // Check for existing session to reattach
507
+ const existing = sessions.get(paneId);
508
+ if (existing && existing.pty && !existing.exited) {
509
+ existing.ws = ws;
510
+
511
+ // Replay buffered output so user sees context
512
+ for (const chunk of existing.buffer) {
513
+ ws.send(JSON.stringify({ type: 'data', data: chunk }));
514
+ }
515
+
516
+ try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
517
+
518
+ ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
519
+
520
+ ws.on('message', (raw) => {
521
+ try {
522
+ const msg = JSON.parse(raw.toString());
523
+ if (msg.type === 'data') {
524
+ existing.pty.write(msg.data);
525
+ } else if (msg.type === 'resize') {
526
+ try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
527
+ } else if (msg.type === 'collab-toggle') {
528
+ handleCollabToggle(paneId, existing);
529
+ }
530
+ } catch {
531
+ existing.pty.write(raw.toString());
532
+ }
533
+ });
534
+
535
+ ws.on('close', () => {
536
+ if (existing.ws === ws) existing.ws = null;
537
+ });
538
+
539
+ return;
540
+ }
541
+
542
+ // Create new pty session
543
+ const isWindows = process.platform === 'win32';
544
+
545
+ // Resolve the OS shell user for this app user
546
+ const shellUser = lookupShellUser(username);
547
+ const processUser = os.userInfo().username;
548
+ let shell, args;
549
+ const isSSH = !isWindows && shellUser !== processUser;
550
+ if (isSSH) {
551
+ // SSH to localhost as the mapped shell user using the service key
552
+ shell = '/usr/bin/ssh';
553
+ args = [
554
+ '-4',
555
+ '-i', SERVICE_KEY,
556
+ '-o', 'StrictHostKeyChecking=accept-new',
557
+ '-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
558
+ '-t',
559
+ `${shellUser}@localhost`,
560
+ ];
561
+ } else if (isWindows && agentType !== 'shell') {
562
+ // Agents like Claude Code require bash on Windows — find git-bash
563
+ shell = findGitBash();
564
+ args = [];
565
+ if (!shell) {
566
+ shell = 'cmd.exe';
567
+ }
568
+ } else {
569
+ shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
570
+ args = [];
571
+ }
572
+
573
+ const env = { ...process.env };
574
+ delete env.CLAUDECODE;
575
+ // Tell Claude Code where git-bash is so it doesn't fail the bash detection
576
+ if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
577
+ env.CLAUDE_CODE_GIT_BASH_PATH = shell;
578
+ }
579
+
580
+ // Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
581
+ let safeCwd = cwd;
582
+ if (!fs.existsSync(safeCwd)) {
583
+ safeCwd = process.env.HOME || '/root';
584
+ console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
585
+ }
586
+
587
+ // Inject Spaces bus environment for agent communication
588
+ env.SPACES_PANE_ID = paneId;
589
+ env.SPACES_API_URL = `http://localhost:${API_PORT}`;
590
+
591
+ // Look up workspace collaboration config from @spaces/teams
592
+ let isCollaborating = false;
593
+ try {
594
+ const teams = require('@spaces/teams');
595
+ const config = teams.terminal.getCollabConfig(paneId, username);
596
+ if (config) {
597
+ env.SPACES_WORKSPACE_ID = config.workspaceId;
598
+ env.SPACES_PANE_NAME = config.paneName;
599
+ isCollaborating = true;
600
+ env.SPACES_COLLABORATING = '1';
601
+ console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
602
+ }
603
+ } catch (e) {
604
+ console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
605
+ }
606
+
607
+ console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
608
+
609
+ let term;
610
+ try {
611
+ term = pty.spawn(shell, args, {
612
+ name: 'xterm-256color',
613
+ cols,
614
+ rows,
615
+ cwd: safeCwd,
616
+ env,
617
+ });
618
+ } catch (err) {
619
+ console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
620
+ ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
621
+ ws.close();
622
+ return;
623
+ }
624
+
625
+ const session = {
626
+ pty: term, ws, buffer: [], exited: false, username,
627
+ agentType,
628
+ cwd: safeCwd,
629
+ paneName: env.SPACES_PANE_NAME || paneId,
630
+ lastOutputTime: Date.now(),
631
+ lastNudgeTime: 0,
632
+ startedAt: Date.now(),
633
+ workspaceId: env.SPACES_WORKSPACE_ID || null,
634
+ isCollaborating,
635
+ };
636
+ sessions.set(paneId, session);
637
+ analyticsRecordSessionStart(paneId, username, agentType);
638
+
639
+ // ─── Inject cd for SSH sessions, then agent command ─────
640
+ const agent = AGENTS[agentType] || AGENTS.shell;
641
+
642
+ // SSH sessions start in the remote user's home dir — cd to target cwd first
643
+ if (isSSH) {
644
+ // Use single quotes to prevent shell expansion; escape any single quotes in the path
645
+ const escapedCwd = safeCwd.replace(/'/g, "'\\''");
646
+ setTimeout(() => {
647
+ if (!session.exited) {
648
+ term.write(`cd '${escapedCwd}'\r`);
649
+ }
650
+ }, 300);
651
+ }
652
+
653
+ // Write collaboration config for agent panes via @spaces/teams
654
+ if (isCollaborating && agentType !== 'shell') {
655
+ try {
656
+ const teams = require('@spaces/teams');
657
+ teams.terminal.writeAgentConfig(agentType, safeCwd, env);
658
+ console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
659
+ } catch (e) {
660
+ console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
661
+ }
662
+ }
663
+
664
+ if (agentType !== 'shell') {
665
+ const command = agentType === 'custom' ? customCommand : agent.command;
666
+
667
+ if (command) {
668
+ const delay = isSSH ? 800 : 300;
669
+
670
+ if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
671
+ // Resume an existing session
672
+ if (agentType === 'claude') {
673
+ // Claude needs to be run from the correct project CWD
674
+ const sessionCwd = findSessionCwd(agentSession, username);
675
+ setTimeout(() => {
676
+ if (session.exited) return;
677
+ if (sessionCwd && sessionCwd !== safeCwd) {
678
+ const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
679
+ term.write(cdCmd + '\r');
680
+ setTimeout(() => {
681
+ if (!session.exited) {
682
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
683
+ }
684
+ }, 300);
685
+ } else {
686
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
687
+ }
688
+ }, delay);
689
+ } else {
690
+ // Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
691
+ setTimeout(() => {
692
+ if (!session.exited) {
693
+ term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
694
+ }
695
+ }, delay);
696
+ }
697
+ } else {
698
+ // Start new session
699
+ setTimeout(() => {
700
+ if (!session.exited) {
701
+ term.write(`${command}\r`);
702
+ }
703
+ }, delay);
704
+ }
705
+ }
706
+ }
707
+
708
+ // pty -> ws (and buffer)
709
+ term.onData((data) => {
710
+ session.lastOutputTime = Date.now();
711
+ session.buffer.push(data);
712
+ if (session.buffer.length > MAX_BUFFER_LINES) {
713
+ session.buffer.shift();
714
+ }
715
+
716
+ if (session.ws && session.ws.readyState === 1) {
717
+ session.ws.send(JSON.stringify({ type: 'data', data }));
718
+ }
719
+ });
720
+
721
+ term.onExit(({ exitCode }) => {
722
+ session.exited = true;
723
+ analyticsRecordSessionEnd(paneId);
724
+ // Clean up hook state file
725
+ try {
726
+ const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
727
+ if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
728
+ } catch { /* ignore */ }
729
+ if (session.ws && session.ws.readyState === 1) {
730
+ session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
731
+ }
732
+ setTimeout(() => {
733
+ if (sessions.get(paneId) === session) {
734
+ sessions.delete(paneId);
735
+ }
736
+ }, 120000);
737
+ });
738
+
739
+ // ws -> pty
740
+ ws.on('message', (raw) => {
741
+ try {
742
+ const msg = JSON.parse(raw.toString());
743
+ if (msg.type === 'data') {
744
+ term.write(msg.data);
745
+ } else if (msg.type === 'resize') {
746
+ try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
747
+ } else if (msg.type === 'collab-toggle') {
748
+ handleCollabToggle(paneId, session);
749
+ }
750
+ } catch {
751
+ term.write(raw.toString());
752
+ }
753
+ });
754
+
755
+ ws.on('close', () => {
756
+ if (session.ws === ws) session.ws = null;
757
+ });
758
+
759
+ ws.send(JSON.stringify({ type: 'ready', paneId }));
760
+
761
+ // ─── Session ID detection for new Claude sessions ────────
762
+ if (agentType === 'claude' && (!agentSession || agentSession === 'new')) {
763
+ detectNewClaudeSession(paneId, cwd, ws, session, username);
764
+ }
765
+ }
766
+
767
+ // ─── Claude-specific helpers ──────────────────────────────
768
+
769
+ function getUserHome(username) {
770
+ const shellUser = lookupShellUser(username);
771
+ if (shellUser === os.userInfo().username) return os.homedir();
772
+ return `/home/${shellUser}`;
773
+ }
774
+
775
+ const UUID_JSONL_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
776
+
777
+ function findSessionCwd(sessionId, username) {
778
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
779
+ try {
780
+ if (!fs.existsSync(claudeProjectsDir)) return null;
781
+ const fileName = `${sessionId}.jsonl`;
782
+
783
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
784
+ if (!projDir.isDirectory()) continue;
785
+ const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
786
+ if (fs.existsSync(filePath)) {
787
+ // Try to find cwd in the jsonl first few lines
788
+ const fd = fs.openSync(filePath, 'r');
789
+ const buf = Buffer.alloc(4096);
790
+ const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
791
+ fs.closeSync(fd);
792
+
793
+ const chunk = buf.toString('utf-8', 0, bytesRead);
794
+ const lines = chunk.split('\n');
795
+ for (const line of lines) {
796
+ if (!line.trim()) continue;
797
+ try {
798
+ const entry = JSON.parse(line);
799
+ if (entry.cwd) {
800
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
801
+ return entry.cwd;
802
+ }
803
+ } catch { /* skip */ }
804
+ }
805
+
806
+ // Fallback: derive CWD from the project directory name
807
+ // Claude encodes paths as e.g. "-home-user-projects-myapp"
808
+ const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
809
+ if (fs.existsSync(derivedPath)) {
810
+ console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
811
+ return derivedPath;
812
+ }
813
+ }
814
+ }
815
+ } catch (err) {
816
+ console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
817
+ }
818
+ return null;
819
+ }
820
+
821
+ function detectNewClaudeSession(paneId, cwd, ws, session, username) {
822
+ const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
823
+
824
+ const knownSessionIds = new Set();
825
+ try {
826
+ if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
827
+ else {
828
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
829
+ if (!projDir.isDirectory()) continue;
830
+ const projPath = path.join(claudeProjectsDir, projDir.name);
831
+ try {
832
+ for (const item of fs.readdirSync(projPath)) {
833
+ const m = item.match(UUID_JSONL_RE);
834
+ if (m) knownSessionIds.add(m[1]);
835
+ }
836
+ const indexPath = path.join(projPath, 'sessions-index.json');
837
+ if (fs.existsSync(indexPath)) {
838
+ try {
839
+ const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
840
+ if (data.entries) {
841
+ for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
842
+ }
843
+ } catch { /* ignore */ }
844
+ }
845
+ } catch { /* ignore */ }
846
+ }
847
+ }
848
+ } catch { /* ignore */ }
849
+
850
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
851
+
852
+ let attempts = 0;
853
+ const maxAttempts = 45;
854
+ const interval = setInterval(() => {
855
+ attempts++;
856
+ if (attempts > maxAttempts || session.exited) {
857
+ clearInterval(interval);
858
+ return;
859
+ }
860
+
861
+ try {
862
+ if (!fs.existsSync(claudeProjectsDir)) return;
863
+
864
+ for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
865
+ if (!projDir.isDirectory()) continue;
866
+ const projPath = path.join(claudeProjectsDir, projDir.name);
867
+ try {
868
+ for (const item of fs.readdirSync(projPath)) {
869
+ const m = item.match(UUID_JSONL_RE);
870
+ if (m && !knownSessionIds.has(m[1])) {
871
+ const newSessionId = m[1];
872
+ clearInterval(interval);
873
+ console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
874
+ if (session.ws && session.ws.readyState === 1) {
875
+ session.ws.send(JSON.stringify({
876
+ type: 'session-detected',
877
+ sessionId: newSessionId,
878
+ paneId,
879
+ }));
880
+ }
881
+ return;
882
+ }
883
+ }
884
+ } catch { /* ignore */ }
885
+ }
886
+ } catch { /* ignore */ }
887
+ }, 2000);
888
+ }
889
+
890
+ // ─── Proxy: forward connection to remote node ──────────
891
+
892
+ async function handleProxyConnection(clientWs, nodeId, opts) {
893
+ const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
894
+
895
+ const node = getNodeInfo(nodeId);
896
+ if (!node) {
897
+ clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
898
+ clientWs.close();
899
+ return;
900
+ }
901
+
902
+ const apiKey = decryptNodeApiKey(node.api_key_encrypted);
903
+ if (!apiKey) {
904
+ clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
905
+ clientWs.close();
906
+ return;
907
+ }
908
+
909
+ // Get the remote WebSocket URL via the terminal token endpoint
910
+ // Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
911
+ const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
912
+ const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
913
+ if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
914
+ let remoteWsUrl;
915
+ try {
916
+ const tokenUrl = `${node.url}/api/network/terminal/token/`;
917
+ const res = await fetch(tokenUrl, {
918
+ method: 'POST',
919
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
920
+ signal: AbortSignal.timeout(10000),
921
+ });
922
+
923
+ if (!res.ok) {
924
+ let detail = '';
925
+ try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
926
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
927
+ clientWs.close();
928
+ return;
929
+ }
930
+
931
+ const data = await res.json();
932
+ remoteWsUrl = data.wsUrl;
933
+ } catch (err) {
934
+ clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
935
+ clientWs.close();
936
+ return;
937
+ } finally {
938
+ if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
939
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
940
+ }
941
+
942
+ // Connect to remote terminal server using the API key directly.
943
+ // The terminal token approach fails because the proxied WebSocket has no
944
+ // x-auth-user header, so the remote server can't match the token's username
945
+ // to the request. API key auth on the WebSocket is the reliable path.
946
+ const WebSocket = require('ws');
947
+ const remoteParams = new URLSearchParams({
948
+ paneId,
949
+ cwd,
950
+ agentType,
951
+ cols: String(cols),
952
+ rows: String(rows),
953
+ apiKey: apiKey,
954
+ });
955
+ if (agentSession) remoteParams.set('agentSession', agentSession);
956
+ // Never forward customCommand to remote nodes — too dangerous
957
+
958
+ // Upgrade ws:// to wss:// if the node uses https
959
+ let wsUrl = remoteWsUrl;
960
+ if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
961
+ wsUrl = 'wss://' + wsUrl.slice(5);
962
+ }
963
+ const remoteUrl = `${wsUrl}?${remoteParams}`;
964
+ console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
965
+
966
+ const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
967
+
968
+ remoteWs.on('open', () => {
969
+ console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
970
+ });
971
+
972
+ // Pipe data bidirectionally
973
+ let firstMsg = true;
974
+ remoteWs.on('message', (data) => {
975
+ const str = data.toString();
976
+ if (firstMsg) {
977
+ console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
978
+ firstMsg = false;
979
+ }
980
+ if (clientWs.readyState === 1) {
981
+ clientWs.send(str);
982
+ }
983
+ });
984
+
985
+ clientWs.on('message', (data) => {
986
+ if (remoteWs.readyState === 1) {
987
+ remoteWs.send(data.toString());
988
+ }
989
+ });
990
+
991
+ // Handle closes
992
+ remoteWs.on('close', () => {
993
+ console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
994
+ if (clientWs.readyState === 1) {
995
+ clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
996
+ }
997
+ });
998
+
999
+ remoteWs.on('error', (err) => {
1000
+ console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
1001
+ if (clientWs.readyState === 1) {
1002
+ clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
1003
+ }
1004
+ });
1005
+
1006
+ clientWs.on('close', () => {
1007
+ console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
1008
+ if (remoteWs.readyState === 1) {
1009
+ remoteWs.close();
1010
+ }
1011
+ });
1012
+ }
1013
+
1014
+ // ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
1015
+
1016
+ function setupWss(wss) {
1017
+ const pingInterval = setInterval(() => {
1018
+ wss.clients.forEach((ws) => {
1019
+ if (ws.isAlive === false) return ws.terminate();
1020
+ ws.isAlive = false;
1021
+ ws.ping();
1022
+ });
1023
+ }, 30000);
1024
+
1025
+ wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
1026
+
1027
+ wss.on('error', (err) => {
1028
+ console.error('[Terminal Server] Error:', err.message);
1029
+ });
1030
+
1031
+ // Initialize analytics DB
1032
+ getAdminDbRW();
1033
+
1034
+ return pingInterval;
1035
+ }
1036
+
1037
+ function startMdnsIfNeeded() {
1038
+ if (SPACES_TIER === 'federation') {
1039
+ try {
1040
+ const { startMdns } = require('./mdns-service');
1041
+ startMdns(PORT);
1042
+ } catch (err) {
1043
+ console.log('[mDNS] Discovery not available:', err.message);
1044
+ }
1045
+ } else {
1046
+ console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
1047
+ }
1048
+ }
1049
+
1050
+ // ─── Poll-based idle nudge for agent collaboration ───────
1051
+
1052
+ function startMessageWatcher(apiPort) {
1053
+ try {
1054
+ const teams = require('@spaces/teams');
1055
+ teams.terminal.startMessageWatcher(apiPort, sessions);
1056
+ } catch { /* @spaces/teams not installed — no message watcher */ }
1057
+ }
1058
+
1059
+ // ─── Attached mode: mount on an existing HTTP server ─────
1060
+
1061
+ function createTerminalServer(httpServer) {
1062
+ // In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
1063
+ if (httpServer.listening) {
1064
+ API_PORT = httpServer.address().port;
1065
+ } else {
1066
+ httpServer.on('listening', () => { API_PORT = httpServer.address().port; });
1067
+ }
1068
+
1069
+ const wss = new WebSocketServer({ noServer: true });
1070
+ setupWss(wss);
1071
+
1072
+ httpServer.on('upgrade', (req, socket, head) => {
1073
+ const url = new URL(req.url, 'http://localhost');
1074
+ if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
1075
+ // Verify origin for browser clients
1076
+ const origin = req.headers.origin;
1077
+ if (origin && !isAllowedOrigin(origin)) {
1078
+ socket.destroy();
1079
+ return;
1080
+ }
1081
+ wss.handleUpgrade(req, socket, head, (ws) => {
1082
+ wss.emit('connection', ws, req);
1083
+ });
1084
+ }
1085
+ // Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
1086
+ });
1087
+
1088
+ startMdnsIfNeeded();
1089
+ // Start message watcher once the server is listening
1090
+ if (httpServer.listening) {
1091
+ startMessageWatcher(httpServer.address().port);
1092
+ } else {
1093
+ httpServer.on('listening', () => {
1094
+ startMessageWatcher(httpServer.address().port);
1095
+ });
1096
+ }
1097
+ return wss;
1098
+ }
1099
+
1100
+ // ─── Standalone mode (run directly) ──────────────────────
1101
+
1102
+ if (require.main === module) {
1103
+ const wss = new WebSocketServer({
1104
+ port: PORT,
1105
+ verifyClient: ({ req }) => {
1106
+ const origin = req.headers.origin;
1107
+ if (!origin) return true;
1108
+ return isAllowedOrigin(origin);
1109
+ },
1110
+ });
1111
+ setupWss(wss);
1112
+ startMdnsIfNeeded();
1113
+ startMessageWatcher(PORT);
1114
+ console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
1115
+ }
1116
+
1117
+ module.exports = { createTerminalServer };