@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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +19 -19
- package/.next/standalone/.next/server/app/(desktop)/admin/analytics/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/admin/users/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/analytics/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/network/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/sessions/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/sessions/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/terminal/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/terminal/pane/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/terminal/remote/[nodeId]/[workspaceId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/workspaces/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +2 -2
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/analytics.html +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/analytics.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/analytics.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/analytics.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/users.html +1 -1
- package/.next/standalone/.next/server/app/admin/users.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/users.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/users.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/admin/users.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/admin/users.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/analytics.html +1 -1
- package/.next/standalone/.next/server/app/analytics.rsc +2 -2
- package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/analytics.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/analytics.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/analytics.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/analytics.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/analytics/overview/route.js +2 -2
- package/.next/standalone/.next/server/app/api/analytics/overview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk/route.js +2 -2
- package/.next/standalone/.next/server/app/api/bulk/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/events/route.js +2 -2
- package/.next/standalone/.next/server/app/api/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/folders/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/handshake/route.js +1 -1
- package/.next/standalone/.next/server/app/api/network/handshake/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/panes/[id]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/panes/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/panes/route.js +2 -2
- package/.next/standalone/.next/server/app/api/panes/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/projects/route.js +2 -2
- package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/search/route.js +2 -2
- package/.next/standalone/.next/server/app/api/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js +2 -2
- package/.next/standalone/.next/server/app/api/sessions/[id]/chat/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js +2 -2
- package/.next/standalone/.next/server/app/api/sessions/[id]/messages/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/[id]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/route.js +2 -2
- package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sync/route.js +3 -3
- package/.next/standalone/.next/server/app/api/sync/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/tags/route.js +2 -2
- package/.next/standalone/.next/server/app/api/tags/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/tier/route.js +1 -1
- package/.next/standalone/.next/server/app/api/tier/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/context/[key]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/context/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/[msgId]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/messages/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/[id]/sessions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/workspaces/route.js +2 -2
- package/.next/standalone/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/login.html +1 -1
- package/.next/standalone/.next/server/app/login.rsc +2 -2
- package/.next/standalone/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/projects.html +1 -1
- package/.next/standalone/.next/server/app/m/projects.rsc +2 -2
- package/.next/standalone/.next/server/app/m/projects.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/projects.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/projects.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/projects.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/projects.segments/m/projects/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/projects.segments/m/projects.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/projects.segments/m.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/sessions/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/sessions/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/sessions.html +1 -1
- package/.next/standalone/.next/server/app/m/sessions.rsc +2 -2
- package/.next/standalone/.next/server/app/m/sessions.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/sessions.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/sessions.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/sessions.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/sessions.segments/m/sessions.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/sessions.segments/m.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/settings.html +1 -1
- package/.next/standalone/.next/server/app/m/settings.rsc +2 -2
- package/.next/standalone/.next/server/app/m/settings.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/settings.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/settings.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/settings.segments/m/settings/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/settings.segments/m/settings.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/settings.segments/m.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/terminal/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/m/terminal.html +1 -1
- package/.next/standalone/.next/server/app/m/terminal.rsc +2 -2
- package/.next/standalone/.next/server/app/m/terminal.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/terminal.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/terminal.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/terminal.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/terminal.segments/m/terminal.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m/terminal.segments/m.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m.html +1 -1
- package/.next/standalone/.next/server/app/m.rsc +2 -2
- package/.next/standalone/.next/server/app/m.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/m.segments/m/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/m.segments/m.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/network.html +1 -1
- package/.next/standalone/.next/server/app/network.rsc +2 -2
- package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/network.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/network.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/network.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/network.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/projects.html +1 -1
- package/.next/standalone/.next/server/app/projects.rsc +2 -2
- package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/projects.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/projects.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/projects.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/projects.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/sessions.html +1 -1
- package/.next/standalone/.next/server/app/sessions.rsc +2 -2
- package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/sessions.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/sessions.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/sessions.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/sessions.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/terminal.html +1 -1
- package/.next/standalone/.next/server/app/terminal.rsc +2 -2
- package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/terminal.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/terminal.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/terminal.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/terminal.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/workspaces.html +1 -1
- package/.next/standalone/.next/server/app/workspaces.rsc +2 -2
- package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/workspaces.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/workspaces.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/workspaces.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/workspaces.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__e742ef33._.js → [root-of-the-server]__0e4c2d35._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__130dee4b._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__142c2f41._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__160e7c73._.js +148 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2861e096._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2d3d8d52._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__57b966d5._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5a0020ba._.js +106 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5bd0f118._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__64f5810e._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__74c48d65._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__7d6610c4._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__84f3af14._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__8765f2fc._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__a2ee9884._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__a6eb742d._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4c83e91._.js +114 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__c546cf71._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__cae0486f._.js +106 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d5615808._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d877df12._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f66ceeb8._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f84e3cf3._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f893957c._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__fcd26315._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__fd505913._.js +98 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__feff7b91._.js +98 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__66aca5d4._.js +1 -1
- package/.next/standalone/.next/server/edge/chunks/_d73df637._.js +1 -1
- package/.next/standalone/.next/server/middleware-manifest.json +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +2 -2
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/67c7bf5024309fca.css +3 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/README.md +46 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/glib-2.0/include/glibconfig.h +221 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/index.js +1 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.17.3 +0 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linux-x64/package.json +42 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/README.md +46 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/glib-2.0/include/glibconfig.h +221 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/index.js +1 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/lib/libvips-cpp.so.8.17.3 +0 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/package.json +42 -0
- package/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64/versions.json +30 -0
- package/.next/standalone/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64.node +0 -0
- package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-linux-x64}/package.json +46 -39
- package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/lib/sharp-linuxmusl-x64.node +0 -0
- package/.next/standalone/node_modules/@img/sharp-linuxmusl-x64/package.json +46 -0
- package/.next/standalone/package.json +89 -89
- package/.next/standalone/server.js +1 -1
- package/.next/standalone/tsconfig.json +34 -34
- package/LICENSE +661 -661
- package/README.md +131 -131
- package/bin/fix-standalone-externals.js +79 -79
- package/bin/lib/auto-setup.js +101 -101
- package/bin/mdns-service.js +171 -171
- package/bin/postinstall.js +35 -35
- package/bin/setup-admin.js +189 -189
- package/bin/spaces-dev.js +208 -208
- package/bin/spaces-install.js +483 -451
- package/bin/spaces-setup.js +247 -242
- package/bin/spaces.js +465 -465
- package/bin/terminal-server.js +1117 -1117
- package/package.json +89 -89
- package/.next/standalone/.claude/settings.local.json +0 -11
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__086be15a._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__20353b76._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2b6758fc._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2e9d1d5a._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__34c34116._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__3ce30e4e._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__43112c34._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__47a63195._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__60689e2b._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__83b9bcf3._.js +0 -11
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__89124c96._.js +0 -243
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__8c2eee30._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__8ffcf827._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e343d64._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__a651ede9._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4b692df._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4edb4d6._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b71497ab._.js +0 -243
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__bef2ed87._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__c525457e._.js +0 -19
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__ccc27199._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__ce5edd98._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__dd2e81be._.js +0 -11
- package/.next/standalone/.next/static/chunks/ff5fac1bd7b518dd.css +0 -3
- package/.next/standalone/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node +0 -0
- /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_clientMiddlewareManifest.json +0 -0
- /package/.next/standalone/.next/static/{O6DdjswRa9GKIUCn6RwBy → nbYSIjUtkSwSvTgyR5p00}/_ssgManifest.js +0 -0
- /package/.next/standalone/node_modules/@img/{sharp-win32-x64 → sharp-libvips-linux-x64}/versions.json +0 -0
package/bin/terminal-server.js
CHANGED
|
@@ -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 };
|