@jlongo78/agent-spaces 0.7.4 → 0.7.5
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.js.nft.json +1 -1
- 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.js.nft.json +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.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/(desktop)/analytics/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/cortex/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/(desktop)/cortex/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/(desktop)/network/page.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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.js.nft.json +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 +5 -5
- package/.next/standalone/.next/server/app/admin/analytics.segments/!KGRlc2t0b3Ap/admin/analytics/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/admin/analytics.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/admin/users.segments/!KGRlc2t0b3Ap/admin/users/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/admin/users.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/analytics.segments/!KGRlc2t0b3Ap/analytics/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/analytics.segments/_full.segment.rsc +5 -5
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/chat/route.js +1 -1
- package/.next/standalone/.next/server/app/api/chat/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/cortex/context/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/curation/assess/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/curation/publish/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/curation/refine/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/curation/review/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/curation/seed/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/export/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/federation/pending/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/federation/resolve/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/federation/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/federation/teach/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/graph/edges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/graph/entities/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/graph/entities/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/graph/populate/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/import/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/ingest/bootstrap/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/ingest/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/knowledge/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/knowledge/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/lobes/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/lobes/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/lobes/share/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/marketplace/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/marketplace/preview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/mcp/call/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/mcp/tools/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/timeline/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/usage/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/cortex/workspace/[id]/context/route.js.nft.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/projects/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/sessions/[id]/messages/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/sessions/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/sessions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/workspaces/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/workspaces/route.js +1 -1
- package/.next/standalone/.next/server/app/api/network/workspaces/route.js.nft.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
- 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.nft.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/sync/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/tags/route.js.nft.json +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.nft.json +1 -1
- 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.nft.json +1 -1
- 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.nft.json +1 -1
- 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.nft.json +1 -1
- package/.next/standalone/.next/server/app/cortex.html +1 -1
- package/.next/standalone/.next/server/app/cortex.rsc +5 -5
- package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap/cortex.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/cortex.segments/!KGRlc2t0b3Ap.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/cortex.segments/_full.segment.rsc +5 -5
- package/.next/standalone/.next/server/app/cortex.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/cortex.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/cortex.segments/_tree.segment.rsc +2 -2
- 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.js.nft.json +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.js.nft.json +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 +5 -5
- package/.next/standalone/.next/server/app/network.segments/!KGRlc2t0b3Ap/network/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/network.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/projects.segments/!KGRlc2t0b3Ap/projects/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/projects.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/sessions.segments/!KGRlc2t0b3Ap/sessions/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/sessions.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/settings.segments/!KGRlc2t0b3Ap/settings/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/terminal.segments/!KGRlc2t0b3Ap/terminal/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/terminal.segments/_full.segment.rsc +5 -5
- 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 +5 -5
- package/.next/standalone/.next/server/app/workspaces.segments/!KGRlc2t0b3Ap/workspaces/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/workspaces.segments/_full.segment.rsc +5 -5
- 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]__0041efe4._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__00bf0ace._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e71d908._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e9142f3._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10e47926._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1665dc78._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__175cbabf._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adae357._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1d359752._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1e8fabeb._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1f8deca0._.js +8 -8
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__253fdda1._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__28e6434f._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2a386564._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__2c20fb38._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__309132cd._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__cf3c60c2._.js → [root-of-the-server]__33fec964._.js} +4 -4
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__3786d8ae._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__3ae92407._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__3beda9fe._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__4619e9bd._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__4a051043._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__508002e4._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5086c373._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5913e097._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5b5f68d2._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5c1f2459._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__5ec8c977._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__63cebc6c._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__64d30d4d._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__6c54fc2e._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__6dc1fb7e._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__6e568102._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__6faa04c0._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__74a34dc3._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__7e7250a4._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__8309e0a4._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__86cc0e2b._.js +6 -6
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__89c2565a._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__8d178ad9._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__93ee06f3._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__9e4c154a._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__a9d2e1d3._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__ae53d343._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b3a04cef._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b4270b77._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__b6b6ce60._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__c88b63f7._.js +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__eee4c5e8._.js → [root-of-the-server]__cba5f007._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__cbf4ceb0._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__cefdba2f._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__cf9e82bb._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d2897392._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d3b2d856._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d73273ca._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__d8417eb6._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__dc2a55de._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__e0d4690b._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__e678dd53._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__e9223f55._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__ea630076._.js +3 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__eb8acb65._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f26ca49d._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f33e1101._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__f515f865._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__fceb5d60._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__fed41403._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__ff2e98c2._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19afc53d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__66aca5d4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_036cbaa2._.js → _078dd64d._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_a4eeff0d._.js → _2230ad2d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_43455582._.js → _701606d5._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_72b1de37._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_d39bcfda._.js → _93ef0f79._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_950142a4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_a22b5eb0._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_aeeff784._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_c1cfdd09._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_c2d3f6de._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_8167090e._.js → _db2fec84._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/src_components_terminal_terminal-pane_tsx_803c5e2c._.js +2 -2
- 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/25b7a243a404a1a7.js +1 -0
- package/.next/standalone/.next/static/chunks/2b997e211a5d547b.js +1 -0
- package/.next/standalone/.next/static/chunks/5e08abb00653754a.js +1 -0
- package/.next/standalone/.next/static/chunks/61f2ed39b75b1efc.js +1 -0
- package/.next/standalone/.next/static/chunks/6c78a1dfa7ec2959.css +3 -0
- package/.next/standalone/.next/static/chunks/7246f1ee445f7024.js +1 -0
- package/.next/standalone/.next/static/chunks/7424664c6ffa94bd.js +1 -0
- package/.next/standalone/.next/static/chunks/7e0091ab6c5ee8bd.js +1 -0
- package/.next/standalone/.next/static/chunks/8b3f4572fec83caa.js +5 -0
- package/.next/standalone/.next/static/chunks/{224aab5107987011.js → 9899cf4c2bdbe61d.js} +2 -2
- package/.next/standalone/.next/static/chunks/ac339e970df82fa5.js +5 -0
- package/.next/standalone/.next/static/chunks/f9f2628207848ac2.js +1 -0
- package/.next/standalone/bin/cortex-hook.sh +62 -62
- package/.next/standalone/bin/cortex-mcp.js +60 -60
- package/.next/standalone/docs/superpowers/plans/2026-03-13-cortex-wiring.md +1387 -1387
- package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-entity-graph.md +1923 -1923
- package/.next/standalone/docs/superpowers/plans/2026-03-14-cortex-v2-knowledge-evolution.md +1113 -1113
- package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-boundary-engine.md +853 -853
- package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-context-engine.md +1274 -1274
- package/.next/standalone/docs/superpowers/plans/2026-03-15-cortex-v2-signal-ingestion.md +933 -933
- package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-lobes.md +1080 -1080
- package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-gravity-system.md +768 -768
- package/.next/standalone/docs/superpowers/plans/2026-03-16-cortex-v2-ui.md +1108 -1108
- package/.next/standalone/docs/superpowers/plans/2026-03-18-cortex-ui-integration.md +1846 -1846
- package/.next/standalone/docs/superpowers/specs/2026-03-13-cortex-wiring-design.md +268 -268
- package/.next/standalone/docs/superpowers/specs/2026-03-14-cortex-v2-design.md +623 -623
- package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-lobes-design.md +263 -263
- package/.next/standalone/docs/superpowers/specs/2026-03-16-cortex-v2-ui-design.md +240 -240
- package/.next/standalone/docs/superpowers/specs/2026-03-18-cortex-ui-integration-design.md +341 -341
- 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 +102 -102
- package/.next/standalone/server.js +1 -1
- package/.next/standalone/src/app/(desktop)/cortex/page.tsx +78 -78
- package/.next/standalone/src/app/api/cortex/context/route.ts +78 -78
- package/.next/standalone/src/app/api/cortex/curation/assess/route.ts +27 -27
- package/.next/standalone/src/app/api/cortex/curation/publish/route.ts +23 -23
- package/.next/standalone/src/app/api/cortex/curation/refine/route.ts +23 -23
- package/.next/standalone/src/app/api/cortex/curation/review/route.ts +29 -29
- package/.next/standalone/src/app/api/cortex/curation/seed/route.ts +23 -23
- package/.next/standalone/src/app/api/cortex/export/route.ts +40 -40
- package/.next/standalone/src/app/api/cortex/federation/pending/route.ts +20 -20
- package/.next/standalone/src/app/api/cortex/federation/resolve/route.ts +43 -43
- package/.next/standalone/src/app/api/cortex/federation/search/route.ts +35 -35
- package/.next/standalone/src/app/api/cortex/federation/teach/route.ts +76 -76
- package/.next/standalone/src/app/api/cortex/graph/edges/route.ts +112 -112
- package/.next/standalone/src/app/api/cortex/graph/entities/[id]/route.ts +73 -73
- package/.next/standalone/src/app/api/cortex/graph/entities/route.ts +75 -75
- package/.next/standalone/src/app/api/cortex/graph/populate/route.ts +203 -203
- package/.next/standalone/src/app/api/cortex/import/route.ts +75 -75
- package/.next/standalone/src/app/api/cortex/import/status/route.ts +15 -15
- package/.next/standalone/src/app/api/cortex/ingest/bootstrap/route.ts +29 -29
- package/.next/standalone/src/app/api/cortex/ingest/status/route.ts +15 -15
- package/.next/standalone/src/app/api/cortex/knowledge/[id]/route.ts +91 -91
- package/.next/standalone/src/app/api/cortex/knowledge/route.ts +93 -93
- package/.next/standalone/src/app/api/cortex/lobes/[id]/route.ts +67 -67
- package/.next/standalone/src/app/api/cortex/lobes/route.ts +22 -22
- package/.next/standalone/src/app/api/cortex/lobes/share/route.ts +80 -80
- package/.next/standalone/src/app/api/cortex/marketplace/browse/route.ts +43 -43
- package/.next/standalone/src/app/api/cortex/marketplace/preview/route.ts +46 -46
- package/.next/standalone/src/app/api/cortex/mcp/call/route.ts +11 -11
- package/.next/standalone/src/app/api/cortex/mcp/tools/route.ts +6 -6
- package/.next/standalone/src/app/api/cortex/search/route.ts +43 -43
- package/.next/standalone/src/app/api/cortex/settings/route.ts +33 -33
- package/.next/standalone/src/app/api/cortex/status/route.ts +169 -169
- package/.next/standalone/src/app/api/cortex/timeline/route.ts +42 -42
- package/.next/standalone/src/app/api/cortex/usage/route.ts +31 -31
- package/.next/standalone/src/app/api/cortex/workspace/[id]/context/route.ts +41 -41
- package/.next/standalone/src/components/cortex/constants.ts +29 -29
- package/.next/standalone/src/components/cortex/cortex-dashboard.tsx +304 -304
- package/.next/standalone/src/components/cortex/cortex-indicator.tsx +44 -44
- package/.next/standalone/src/components/cortex/cortex-panel.tsx +140 -140
- package/.next/standalone/src/components/cortex/cortex-settings.tsx +221 -221
- package/.next/standalone/src/components/cortex/curation-tab.tsx +810 -810
- package/.next/standalone/src/components/cortex/entity-detail.tsx +101 -101
- package/.next/standalone/src/components/cortex/entity-graph.tsx +382 -382
- package/.next/standalone/src/components/cortex/import-dialog.tsx +212 -212
- package/.next/standalone/src/components/cortex/injection-badge.tsx +72 -72
- package/.next/standalone/src/components/cortex/knowledge-card.tsx +109 -109
- package/.next/standalone/src/components/cortex/knowledge-tab.tsx +158 -158
- package/.next/standalone/src/components/cortex/lobe-settings.tsx +215 -215
- package/.next/standalone/src/components/cortex/marketplace-card.tsx +126 -126
- package/.next/standalone/src/components/cortex/marketplace-tab.tsx +113 -113
- package/.next/standalone/src/lib/cortex/config.ts +40 -40
- package/.next/standalone/src/lib/cortex/debug.ts +10 -10
- package/.next/standalone/src/lib/cortex/distillation/usage-store.ts +18 -18
- package/.next/standalone/src/lib/cortex/graph/resolver.ts +10 -10
- package/.next/standalone/src/lib/cortex/graph/types.ts +22 -22
- package/.next/standalone/src/lib/cortex/index.ts +56 -56
- package/.next/standalone/src/lib/cortex/ingestion/bootstrap.ts +14 -14
- package/.next/standalone/src/lib/cortex/knowledge/compat.ts +14 -14
- package/.next/standalone/src/lib/cortex/knowledge/contradiction.ts +10 -10
- package/.next/standalone/src/lib/cortex/knowledge/types.ts +67 -67
- package/.next/standalone/src/lib/cortex/lobes/config.ts +16 -16
- package/.next/standalone/src/lib/cortex/lobes/resolver.ts +8 -8
- package/.next/standalone/src/lib/cortex/lobes/shares.ts +14 -14
- package/.next/standalone/src/lib/cortex/mcp/server.ts +8 -8
- package/.next/standalone/src/lib/cortex/portability/exporter.ts +6 -6
- package/.next/standalone/src/lib/cortex/portability/importer.ts +10 -10
- package/.next/standalone/src/lib/cortex/retrieval/context-engine.ts +10 -10
- package/.next/standalone/src/lib/cortex/types.ts +39 -39
- package/.next/standalone/tsconfig.json +34 -34
- package/LICENSE +661 -661
- package/README.md +131 -131
- package/bin/cortex-hook.sh +62 -62
- package/bin/cortex-mcp.js +60 -60
- package/bin/fix-standalone-externals.js +79 -79
- package/bin/lib/auto-setup.js +110 -110
- package/bin/mdns-service.js +171 -171
- package/bin/postinstall.js +35 -35
- package/bin/setup-admin.js +195 -195
- package/bin/spaces-dev.js +208 -208
- package/bin/spaces-install.js +599 -599
- package/bin/spaces-reset-totp.js +50 -50
- package/bin/spaces-service.js +1020 -1020
- package/bin/spaces-setup.js +253 -253
- package/bin/spaces.js +776 -717
- package/bin/ssh-auth-keys.sh +68 -68
- package/bin/terminal-server.js +1649 -1646
- package/package.json +102 -102
- package/.next/standalone/.claude/settings.local.json +0 -55
- package/.next/standalone/.claude/spaces-env.json +0 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__23f374a2._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_01842037._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_10c0d382._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_67887e33._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_a012c43b._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_ed6c285b._.js +0 -3
- package/.next/standalone/.next/static/chunks/17d164c01fa1aaa9.js +0 -5
- package/.next/standalone/.next/static/chunks/19da759dde107f02.js +0 -1
- package/.next/standalone/.next/static/chunks/415827af44b8c374.js +0 -1
- package/.next/standalone/.next/static/chunks/596bd56e0ad09173.js +0 -5
- package/.next/standalone/.next/static/chunks/79aacab676df80c4.js +0 -1
- package/.next/standalone/.next/static/chunks/846d6ef408f69390.js +0 -1
- package/.next/standalone/.next/static/chunks/8a7f58872c123a53.css +0 -3
- package/.next/standalone/.next/static/chunks/8de5e432a2fc563a.js +0 -1
- package/.next/standalone/.next/static/chunks/b0484608571d975a.js +0 -1
- package/.next/standalone/.next/static/chunks/b42a38df3e418ff0.js +0 -1
- package/.next/standalone/.next/static/chunks/d6474f65a17c483e.js +0 -1
- package/.next/standalone/.spaces/cortex-context.md +0 -70
- package/.next/standalone/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node +0 -0
- /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_clientMiddlewareManifest.json +0 -0
- /package/.next/standalone/.next/static/{Ku7_sP1T1UuqtRhUTiJZw → 77VYbwIoyxFNr5xevTrCu}/_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,1646 +1,1649 @@
|
|
|
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
|
-
// Track whether the Next.js API is ready — avoids timeout spam during startup
|
|
18
|
-
let _apiReady = false;
|
|
19
|
-
function setApiReady() { _apiReady = true; }
|
|
20
|
-
function isApiReady() { return _apiReady; }
|
|
21
|
-
// Poll until the API responds, then mark ready
|
|
22
|
-
function waitForApi() {
|
|
23
|
-
const check = () => {
|
|
24
|
-
const req = http.get(`http://localhost:${API_PORT}/api/tier`, { timeout: 1000 }, (res) => {
|
|
25
|
-
res.resume(); // consume body to free socket
|
|
26
|
-
if (res.statusCode < 500) { setApiReady(); return; }
|
|
27
|
-
setTimeout(check, 2000);
|
|
28
|
-
});
|
|
29
|
-
req.on('error', () => setTimeout(check, 2000));
|
|
30
|
-
req.on('timeout', () => { req.destroy(); setTimeout(check, 2000); });
|
|
31
|
-
};
|
|
32
|
-
setTimeout(check, 1000);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// ─── Terminal token verification ──────────────────────────
|
|
36
|
-
|
|
37
|
-
const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
|
|
38
|
-
|
|
39
|
-
function getTerminalSecret() {
|
|
40
|
-
if (fs.existsSync(SECRET_PATH)) {
|
|
41
|
-
return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
42
|
-
}
|
|
43
|
-
const secret = crypto.randomBytes(32);
|
|
44
|
-
const dir = path.dirname(SECRET_PATH);
|
|
45
|
-
if (!fs.existsSync(dir)) {
|
|
46
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
-
}
|
|
48
|
-
fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
|
|
49
|
-
return secret;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
let _terminalSecret = null;
|
|
53
|
-
function terminalSecret() {
|
|
54
|
-
if (!_terminalSecret) {
|
|
55
|
-
_terminalSecret = getTerminalSecret();
|
|
56
|
-
}
|
|
57
|
-
return _terminalSecret;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function verifyTerminalToken(token) {
|
|
61
|
-
if (!token) return null;
|
|
62
|
-
const parts = token.split('.');
|
|
63
|
-
if (parts.length !== 2) return null;
|
|
64
|
-
|
|
65
|
-
const [payloadB64, sig] = parts;
|
|
66
|
-
const expectedSig = crypto.createHmac('sha256', terminalSecret())
|
|
67
|
-
.update(payloadB64)
|
|
68
|
-
.digest('base64url');
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
80
|
-
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
return payload.sub || null;
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ─── Session token verification (for self-contained auth) ──
|
|
90
|
-
|
|
91
|
-
const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
|
|
92
|
-
|
|
93
|
-
function getSessionSecret() {
|
|
94
|
-
if (fs.existsSync(SESSION_SECRET_PATH)) {
|
|
95
|
-
return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
let _sessionSecret = null;
|
|
101
|
-
function sessionSecret() {
|
|
102
|
-
if (!_sessionSecret) {
|
|
103
|
-
_sessionSecret = getSessionSecret();
|
|
104
|
-
}
|
|
105
|
-
return _sessionSecret;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function verifySessionToken(token) {
|
|
109
|
-
const secret = sessionSecret();
|
|
110
|
-
if (!token || !secret) return null;
|
|
111
|
-
const parts = token.split('.');
|
|
112
|
-
if (parts.length !== 2) return null;
|
|
113
|
-
|
|
114
|
-
const [payloadB64, sig] = parts;
|
|
115
|
-
const expectedSig = crypto.createHmac('sha256', secret)
|
|
116
|
-
.update(payloadB64)
|
|
117
|
-
.digest('base64url');
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
129
|
-
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
return { sub: payload.sub, role: payload.role || 'user' };
|
|
133
|
-
} catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─── Admin DB for shell user lookup ─────────────────────────
|
|
139
|
-
|
|
140
|
-
const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
|
|
141
|
-
let _adminDb = null;
|
|
142
|
-
|
|
143
|
-
function getAdminDb() {
|
|
144
|
-
if (_adminDb) return _adminDb;
|
|
145
|
-
if (!fs.existsSync(ADMIN_DB_PATH)) return null;
|
|
146
|
-
try {
|
|
147
|
-
const Database = require('better-sqlite3');
|
|
148
|
-
_adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
|
|
149
|
-
return _adminDb;
|
|
150
|
-
} catch {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function lookupShellUser(appUsername) {
|
|
156
|
-
const db = getAdminDb();
|
|
157
|
-
if (!db) return appUsername;
|
|
158
|
-
try {
|
|
159
|
-
const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
|
|
160
|
-
return row ? row.shell_user : appUsername;
|
|
161
|
-
} catch {
|
|
162
|
-
return appUsername;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ─── Network DB for federation ───────────────────────────────
|
|
167
|
-
|
|
168
|
-
const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
|
|
169
|
-
let _networkDb = null;
|
|
170
|
-
|
|
171
|
-
function getNetworkDb() {
|
|
172
|
-
if (_networkDb) return _networkDb;
|
|
173
|
-
if (!fs.existsSync(NETWORK_DB_PATH)) return null;
|
|
174
|
-
try {
|
|
175
|
-
const Database = require('better-sqlite3');
|
|
176
|
-
_networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
|
|
177
|
-
return _networkDb;
|
|
178
|
-
} catch {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function validateNetworkApiKey(rawKey) {
|
|
184
|
-
const db = getNetworkDb();
|
|
185
|
-
if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
|
|
186
|
-
try {
|
|
187
|
-
const keys = db.prepare('SELECT * FROM api_keys').all();
|
|
188
|
-
for (const key of keys) {
|
|
189
|
-
if (key.expires && new Date(key.expires) < new Date()) continue;
|
|
190
|
-
const [salt, hash] = key.key_hash.split(':');
|
|
191
|
-
if (!salt || !hash) continue;
|
|
192
|
-
const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
|
|
193
|
-
try {
|
|
194
|
-
if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
|
|
195
|
-
return key;
|
|
196
|
-
}
|
|
197
|
-
} catch { continue; }
|
|
198
|
-
}
|
|
199
|
-
} catch { /* ignore */ }
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function getNodeInfo(nodeId) {
|
|
204
|
-
const db = getNetworkDb();
|
|
205
|
-
if (!db) return null;
|
|
206
|
-
try {
|
|
207
|
-
return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
|
|
208
|
-
} catch {
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function decryptNodeApiKey(encrypted) {
|
|
214
|
-
try {
|
|
215
|
-
const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
216
|
-
const [ivB64, tagB64, dataB64] = encrypted.split(':');
|
|
217
|
-
if (!ivB64 || !tagB64 || !dataB64) return null;
|
|
218
|
-
const iv = Buffer.from(ivB64, 'base64');
|
|
219
|
-
const tag = Buffer.from(tagB64, 'base64');
|
|
220
|
-
const data = Buffer.from(dataB64, 'base64');
|
|
221
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
222
|
-
decipher.setAuthTag(tag);
|
|
223
|
-
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
|
|
224
|
-
} catch {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── Writable Admin DB for analytics ─────────────────────────
|
|
230
|
-
|
|
231
|
-
let _adminDbRW = null;
|
|
232
|
-
|
|
233
|
-
function getAdminDbRW() {
|
|
234
|
-
if (_adminDbRW) return _adminDbRW;
|
|
235
|
-
try {
|
|
236
|
-
const Database = require('better-sqlite3');
|
|
237
|
-
const dir = path.dirname(ADMIN_DB_PATH);
|
|
238
|
-
if (!fs.existsSync(dir)) {
|
|
239
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
240
|
-
}
|
|
241
|
-
const db = new Database(ADMIN_DB_PATH);
|
|
242
|
-
db.pragma('journal_mode = WAL');
|
|
243
|
-
db.pragma('busy_timeout = 5000');
|
|
244
|
-
db.exec(`
|
|
245
|
-
CREATE TABLE IF NOT EXISTS login_events (
|
|
246
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
247
|
-
username TEXT NOT NULL,
|
|
248
|
-
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
249
|
-
ip_address TEXT,
|
|
250
|
-
user_agent TEXT
|
|
251
|
-
);
|
|
252
|
-
CREATE TABLE IF NOT EXISTS terminal_sessions (
|
|
253
|
-
id TEXT PRIMARY KEY,
|
|
254
|
-
username TEXT NOT NULL,
|
|
255
|
-
agent_type TEXT NOT NULL DEFAULT 'shell',
|
|
256
|
-
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
257
|
-
ended_at TEXT,
|
|
258
|
-
duration_seconds INTEGER
|
|
259
|
-
);
|
|
260
|
-
CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
|
|
261
|
-
CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
|
|
262
|
-
CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
|
|
263
|
-
CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
|
|
264
|
-
`);
|
|
265
|
-
// Clean up stale sessions from previous crashes
|
|
266
|
-
db.prepare(`
|
|
267
|
-
UPDATE terminal_sessions
|
|
268
|
-
SET ended_at = datetime('now'),
|
|
269
|
-
duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
|
|
270
|
-
WHERE ended_at IS NULL
|
|
271
|
-
`).run();
|
|
272
|
-
_adminDbRW = db;
|
|
273
|
-
console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
|
|
274
|
-
return db;
|
|
275
|
-
} catch (err) {
|
|
276
|
-
console.error('[Analytics] Failed to open writable admin DB:', err.message);
|
|
277
|
-
return null;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function analyticsRecordSessionStart(paneId, username, agentType) {
|
|
282
|
-
try {
|
|
283
|
-
const db = getAdminDbRW();
|
|
284
|
-
if (!db) return;
|
|
285
|
-
db.prepare(
|
|
286
|
-
'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
|
|
287
|
-
).run(paneId, username, agentType);
|
|
288
|
-
} catch (err) {
|
|
289
|
-
console.error('[Analytics] recordSessionStart error:', err.message);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function analyticsRecordSessionEnd(paneId) {
|
|
294
|
-
try {
|
|
295
|
-
const db = getAdminDbRW();
|
|
296
|
-
if (!db) return;
|
|
297
|
-
db.prepare(`
|
|
298
|
-
UPDATE terminal_sessions
|
|
299
|
-
SET ended_at = datetime('now'),
|
|
300
|
-
duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
|
|
301
|
-
WHERE id = ? AND ended_at IS NULL
|
|
302
|
-
`).run(paneId);
|
|
303
|
-
} catch (err) {
|
|
304
|
-
console.error('[Analytics] recordSessionEnd error:', err.message);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ─── Cookie parser ──────────────────────────────────────────
|
|
309
|
-
|
|
310
|
-
function parseCookies(cookieHeader) {
|
|
311
|
-
const cookies = {};
|
|
312
|
-
if (!cookieHeader) return cookies;
|
|
313
|
-
cookieHeader.split(';').forEach(part => {
|
|
314
|
-
const [key, ...rest] = part.trim().split('=');
|
|
315
|
-
if (key) cookies[key] = rest.join('=');
|
|
316
|
-
});
|
|
317
|
-
return cookies;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ─── SSH service key path (used to spawn shells as other OS users) ──
|
|
321
|
-
|
|
322
|
-
const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
|
|
323
|
-
|
|
324
|
-
// Ensure the SSH service key exists, has correct permissions, and is authorized.
|
|
325
|
-
function ensureServiceKeyAtRuntime() {
|
|
326
|
-
const { spawnSync } = require('child_process');
|
|
327
|
-
const currentUser = os.userInfo().username;
|
|
328
|
-
const isWindows = process.platform === 'win32';
|
|
329
|
-
|
|
330
|
-
// Generate key if missing (all platforms)
|
|
331
|
-
if (!fs.existsSync(SERVICE_KEY)) {
|
|
332
|
-
const dir = path.dirname(SERVICE_KEY);
|
|
333
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
334
|
-
const result = spawnSync('ssh-keygen', [
|
|
335
|
-
'-t', 'ed25519', '-f', SERVICE_KEY, '-N', '', '-C', 'spaces-service-key',
|
|
336
|
-
], { stdio: 'pipe', timeout: 10000 });
|
|
337
|
-
if (result.status !== 0) {
|
|
338
|
-
console.error('[SSH] Failed to generate service key');
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
if (isWindows) {
|
|
342
|
-
// Lock down permissions: only the process owner + SYSTEM
|
|
343
|
-
spawnSync('icacls', [SERVICE_KEY, '/inheritance:r',
|
|
344
|
-
'/remove', 'BUILTIN\\Administrators', '/remove', 'BUILTIN\\Users', '/remove', 'Everyone',
|
|
345
|
-
'/grant:r', currentUser + ':(F)',
|
|
346
|
-
'/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
|
|
347
|
-
} else {
|
|
348
|
-
spawnSync('chmod', ['600', SERVICE_KEY], { stdio: 'pipe', timeout: 5000 });
|
|
349
|
-
}
|
|
350
|
-
console.log('[SSH] Generated service key as ' + currentUser);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Always ensure the public key is authorized
|
|
354
|
-
if (!fs.existsSync(SERVICE_KEY + '.pub')) return;
|
|
355
|
-
const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
|
|
356
|
-
|
|
357
|
-
if (isWindows) {
|
|
358
|
-
// Authorize in administrators_authorized_keys (for admin shell users)
|
|
359
|
-
try {
|
|
360
|
-
const adminAuthKeys = path.join(process.env.ProgramData || 'C:\\ProgramData', 'ssh', 'administrators_authorized_keys');
|
|
361
|
-
const authDir = path.dirname(adminAuthKeys);
|
|
362
|
-
if (!fs.existsSync(authDir)) fs.mkdirSync(authDir, { recursive: true });
|
|
363
|
-
spawnSync('icacls', [adminAuthKeys, '/inheritance:r',
|
|
364
|
-
'/grant:r', 'SYSTEM:(F)', '/grant', 'Administrators:(R)'], { stdio: 'pipe', timeout: 5000 });
|
|
365
|
-
let existing = '';
|
|
366
|
-
try { existing = fs.readFileSync(adminAuthKeys, 'utf-8'); } catch {}
|
|
367
|
-
if (!existing.includes(pubKey)) {
|
|
368
|
-
fs.appendFileSync(adminAuthKeys, pubKey + String.fromCharCode(10));
|
|
369
|
-
console.log('[SSH] Authorized service key in administrators_authorized_keys');
|
|
370
|
-
}
|
|
371
|
-
} catch (e) {
|
|
372
|
-
console.error('[SSH] Could not authorize admin key (non-fatal):', e.message);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Authorize in each shell user's ~/.ssh/authorized_keys (for non-admin users)
|
|
376
|
-
try {
|
|
377
|
-
const usersDir = path.dirname(os.homedir());
|
|
378
|
-
const skip = new Set(['Public', 'Default', 'Default User', 'All Users']);
|
|
379
|
-
const profiles = fs.readdirSync(usersDir)
|
|
380
|
-
.filter(name => !skip.has(name) && !name.startsWith('.'))
|
|
381
|
-
.filter(name => fs.existsSync(path.join(usersDir, name, '.claude')));
|
|
382
|
-
for (const username of profiles) {
|
|
383
|
-
try {
|
|
384
|
-
const sshDir = path.join(usersDir, username, '.ssh');
|
|
385
|
-
if (!fs.existsSync(sshDir)) fs.mkdirSync(sshDir, { recursive: true });
|
|
386
|
-
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
387
|
-
let existing = '';
|
|
388
|
-
try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
|
|
389
|
-
if (!existing.includes(pubKey)) {
|
|
390
|
-
fs.appendFileSync(authKeysPath, pubKey + String.fromCharCode(10));
|
|
391
|
-
spawnSync('icacls', [authKeysPath, '/inheritance:r',
|
|
392
|
-
'/grant:r', username + ':(F)',
|
|
393
|
-
'/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
|
|
394
|
-
console.log('[SSH] Authorized service key for user ' + username);
|
|
395
|
-
}
|
|
396
|
-
} catch (e) {
|
|
397
|
-
console.error('[SSH] Could not authorize key for ' + username + ' (non-fatal):', e.message);
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
} catch (e) {
|
|
401
|
-
console.error('[SSH] Could not scan user profiles (non-fatal):', e.message);
|
|
402
|
-
}
|
|
403
|
-
} else {
|
|
404
|
-
// Linux/macOS: authorize all shell users from admin DB as a fallback
|
|
405
|
-
// (AuthorizedKeysCommand is the primary mechanism, this is belt-and-suspenders)
|
|
406
|
-
const db = getAdminDb();
|
|
407
|
-
if (db) {
|
|
408
|
-
try {
|
|
409
|
-
const users = db.prepare('SELECT DISTINCT shell_user FROM users').all();
|
|
410
|
-
for (const row of users) {
|
|
411
|
-
const shellUser = row.shell_user;
|
|
412
|
-
try {
|
|
413
|
-
authorizeShellUser(shellUser, pubKey);
|
|
414
|
-
} catch (e) {
|
|
415
|
-
console.error('[SSH] Could not authorize key for ' + shellUser + ' (non-fatal):', e.message);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
} catch (e) {
|
|
419
|
-
console.error('[SSH] Could not query admin DB for shell users (non-fatal):', e.message);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Authorize the service key for a Linux/macOS shell user
|
|
426
|
-
function authorizeShellUser(shellUser, pubKey) {
|
|
427
|
-
const { spawnSync } = require('child_process');
|
|
428
|
-
|
|
429
|
-
// Resolve home directory
|
|
430
|
-
let userHome;
|
|
431
|
-
try {
|
|
432
|
-
const result = spawnSync('getent', ['passwd', shellUser], { encoding: 'utf-8', timeout: 5000 });
|
|
433
|
-
const fields = (result.stdout || '').split(':');
|
|
434
|
-
userHome = fields[5];
|
|
435
|
-
} catch {}
|
|
436
|
-
if (!userHome) {
|
|
437
|
-
userHome = process.platform === 'darwin' ? `/Users/${shellUser}` : `/home/${shellUser}`;
|
|
438
|
-
}
|
|
439
|
-
if (!fs.existsSync(userHome)) return;
|
|
440
|
-
|
|
441
|
-
const sshDir = path.join(userHome, '.ssh');
|
|
442
|
-
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
443
|
-
|
|
444
|
-
// Check if already authorized
|
|
445
|
-
let existing = '';
|
|
446
|
-
try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
|
|
447
|
-
if (existing.includes(pubKey)) return;
|
|
448
|
-
|
|
449
|
-
// Create .ssh dir with correct ownership
|
|
450
|
-
if (!fs.existsSync(sshDir)) {
|
|
451
|
-
spawnSync('sudo', ['mkdir', '-p', sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
452
|
-
spawnSync('sudo', ['chmod', '700', sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
453
|
-
spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Append key and fix permissions
|
|
457
|
-
const tmpFile = `/tmp/spaces-authkey-${shellUser}-${Date.now()}`;
|
|
458
|
-
fs.writeFileSync(tmpFile, existing + pubKey + '\n');
|
|
459
|
-
spawnSync('sudo', ['cp', tmpFile, authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
460
|
-
spawnSync('sudo', ['chmod', '600', authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
461
|
-
spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
462
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
463
|
-
|
|
464
|
-
console.log('[SSH] Authorized service key for user ' + shellUser);
|
|
465
|
-
}
|
|
466
|
-
try { ensureServiceKeyAtRuntime(); } catch (e) { console.error('[SSH] Key setup failed (non-fatal):', e.message); }
|
|
467
|
-
|
|
468
|
-
// Session store: keeps ptys alive across WebSocket reconnections
|
|
469
|
-
// Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
|
|
470
|
-
const sessions = new Map();
|
|
471
|
-
|
|
472
|
-
const MAX_BUFFER_LINES = 500;
|
|
473
|
-
|
|
474
|
-
// ─── Agent definitions (mirrors src/lib/agents.ts) ────────
|
|
475
|
-
const AGENTS = {
|
|
476
|
-
shell: { command: '', resumeFlag: '', resumeStyle: '' },
|
|
477
|
-
claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
|
|
478
|
-
codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
|
|
479
|
-
gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
|
|
480
|
-
aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
|
|
481
|
-
custom: { command: '', resumeFlag: '', resumeStyle: '' },
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
485
|
-
|
|
486
|
-
// ─── Remove Cortex hooks from Claude Code config ─────────
|
|
487
|
-
function removeCortexHookConfig(cwd) {
|
|
488
|
-
try {
|
|
489
|
-
const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
|
|
490
|
-
if (!fs.existsSync(settingsPath)) return;
|
|
491
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
492
|
-
let changed = false;
|
|
493
|
-
|
|
494
|
-
// Remove cortex hooks from UserPromptSubmit and Stop
|
|
495
|
-
if (settings.hooks?.UserPromptSubmit) {
|
|
496
|
-
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
497
|
-
(g) => !g.hooks?.some((h) => h.command?.includes('cortex-hook'))
|
|
498
|
-
);
|
|
499
|
-
if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit;
|
|
500
|
-
changed = true;
|
|
501
|
-
}
|
|
502
|
-
if (settings.hooks?.Stop) {
|
|
503
|
-
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
504
|
-
(g) => !g.hooks?.some((h) => h.command?.includes('cortex-learn-hook'))
|
|
505
|
-
);
|
|
506
|
-
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
507
|
-
changed = true;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Remove cortex MCP server
|
|
511
|
-
if (settings.mcpServers?.cortex) {
|
|
512
|
-
delete settings.mcpServers.cortex;
|
|
513
|
-
changed = true;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (changed) {
|
|
517
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
518
|
-
console.log(`[Cortex] Removed hooks and MCP server from ${settingsPath}`);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Remove spaces-env.json
|
|
522
|
-
const envFile = path.join(cwd, '.claude', 'spaces-env.json');
|
|
523
|
-
if (fs.existsSync(envFile)) fs.unlinkSync(envFile);
|
|
524
|
-
} catch (err) {
|
|
525
|
-
console.error(`[Cortex] Failed to remove hook config:`, err.message);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// ─── Cortex Claude Code hook config ──────────────────────
|
|
530
|
-
// Write a UserPromptSubmit hook into .claude/settings.local.json
|
|
531
|
-
// so every prompt gets a RAG search before Claude sees it.
|
|
532
|
-
function writeCortexHookConfig(cwd) {
|
|
533
|
-
try {
|
|
534
|
-
const claudeDir = path.join(cwd, '.claude');
|
|
535
|
-
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
536
|
-
|
|
537
|
-
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
538
|
-
let settings = {};
|
|
539
|
-
if (fs.existsSync(settingsPath)) {
|
|
540
|
-
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); } catch {}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Resolve hook paths from @spaces/cortex addon or legacy bin/
|
|
544
|
-
let ragHook, learnHook;
|
|
545
|
-
try {
|
|
546
|
-
const cortexDir = path.dirname(require.resolve('@spaces/cortex'));
|
|
547
|
-
ragHook = path.join(cortexDir, 'hooks', 'cortex-hook.js');
|
|
548
|
-
learnHook = path.join(cortexDir, 'hooks', 'cortex-learn-hook.js');
|
|
549
|
-
} catch {
|
|
550
|
-
ragHook = path.resolve(__dirname, 'cortex-hook.js');
|
|
551
|
-
learnHook = path.resolve(__dirname, 'cortex-learn-hook.js');
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Merge — don't clobber existing hooks for other events
|
|
555
|
-
if (!settings.hooks) settings.hooks = {};
|
|
556
|
-
|
|
557
|
-
// Bake env vars into hook commands so they're always available
|
|
558
|
-
// (Claude Code hook subprocesses may not inherit the PTY env)
|
|
559
|
-
const hookEnv = `SPACES_PORT=${API_PORT} SPACES_SESSION_SECRET="${process.env.SPACES_SESSION_SECRET || ''}"`;
|
|
560
|
-
|
|
561
|
-
// RAG search: runs on every prompt, injects relevant context
|
|
562
|
-
settings.hooks.UserPromptSubmit = [
|
|
563
|
-
{
|
|
564
|
-
hooks: [
|
|
565
|
-
{
|
|
566
|
-
type: 'command',
|
|
567
|
-
command: `${hookEnv} node "${ragHook}"`,
|
|
568
|
-
timeout: 5,
|
|
569
|
-
},
|
|
570
|
-
],
|
|
571
|
-
},
|
|
572
|
-
];
|
|
573
|
-
|
|
574
|
-
// Learn: runs after Claude finishes, ingests the exchange back into Cortex
|
|
575
|
-
settings.hooks.Stop = [
|
|
576
|
-
{
|
|
577
|
-
hooks: [
|
|
578
|
-
{
|
|
579
|
-
type: 'command',
|
|
580
|
-
command: `${hookEnv} node "${learnHook}"`,
|
|
581
|
-
timeout: 10,
|
|
582
|
-
},
|
|
583
|
-
],
|
|
584
|
-
},
|
|
585
|
-
];
|
|
586
|
-
|
|
587
|
-
// Register Cortex MCP server
|
|
588
|
-
const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
|
|
589
|
-
if (!settings.mcpServers) settings.mcpServers = {};
|
|
590
|
-
settings.mcpServers.cortex = {
|
|
591
|
-
command: 'node',
|
|
592
|
-
args: [mcpServer],
|
|
593
|
-
env: {
|
|
594
|
-
SPACES_URL: `http://localhost:${API_PORT}`,
|
|
595
|
-
SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
|
|
596
|
-
},
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
600
|
-
console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
|
|
601
|
-
} catch (err) {
|
|
602
|
-
console.error(`[Cortex] Failed to write hook config:`, err.message);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// ─── Cortex context injection ────────────────────────────
|
|
607
|
-
// Fetch relevant knowledge from Cortex API and write a context file
|
|
608
|
-
// in the workspace before the agent launches.
|
|
609
|
-
async function injectCortexContext(cwd, workspaceId, ws) {
|
|
610
|
-
if (!isApiReady()) return 0;
|
|
611
|
-
if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
|
|
612
|
-
// Check if Cortex is actually enabled in user config
|
|
613
|
-
try {
|
|
614
|
-
const configPath = path.join(os.homedir(), '.spaces', 'config.json');
|
|
615
|
-
if (fs.existsSync(configPath)) {
|
|
616
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
617
|
-
if (!cfg.cortex?.enabled) return 0;
|
|
618
|
-
} else {
|
|
619
|
-
return 0;
|
|
620
|
-
}
|
|
621
|
-
} catch { return 0; }
|
|
622
|
-
try {
|
|
623
|
-
const projectName = path.basename(cwd);
|
|
624
|
-
const query = encodeURIComponent(`${projectName} workspace context`);
|
|
625
|
-
const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
|
|
626
|
-
const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
|
|
627
|
-
|
|
628
|
-
// Use internal auth bypass (x-spaces-internal header) to skip session middleware
|
|
629
|
-
const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
|
|
630
|
-
const options = {
|
|
631
|
-
timeout: 5000,
|
|
632
|
-
headers: {
|
|
633
|
-
'x-spaces-internal': internalToken,
|
|
634
|
-
},
|
|
635
|
-
};
|
|
636
|
-
|
|
637
|
-
const body = await new Promise((resolve, reject) => {
|
|
638
|
-
const req = http.get(url, options, (res) => {
|
|
639
|
-
// Follow redirects (Next.js trailing-slash redirects)
|
|
640
|
-
if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
|
|
641
|
-
const redirectUrl = res.headers.location;
|
|
642
|
-
if (redirectUrl) {
|
|
643
|
-
const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
|
|
644
|
-
const req2 = http.get(fullUrl, options, (res2) => {
|
|
645
|
-
let data = '';
|
|
646
|
-
res2.on('data', (chunk) => { data += chunk; });
|
|
647
|
-
res2.on('end', () => {
|
|
648
|
-
if (res2.statusCode !== 200) {
|
|
649
|
-
reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
|
|
650
|
-
} else {
|
|
651
|
-
resolve(data);
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
req2.on('error', reject);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
let data = '';
|
|
660
|
-
res.on('data', (chunk) => { data += chunk; });
|
|
661
|
-
res.on('end', () => {
|
|
662
|
-
if (res.statusCode !== 200) {
|
|
663
|
-
reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
664
|
-
} else {
|
|
665
|
-
resolve(data);
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
});
|
|
669
|
-
req.on('error', reject);
|
|
670
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
const parsed = JSON.parse(body);
|
|
674
|
-
const results = parsed.results;
|
|
675
|
-
if (!results || results.length === 0) {
|
|
676
|
-
console.log(`[Cortex] No knowledge found for "${projectName}"`);
|
|
677
|
-
return 0;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Format context (mirrors src/lib/cortex/retrieval/injection.ts)
|
|
681
|
-
const TYPE_LABELS = {
|
|
682
|
-
decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
|
|
683
|
-
error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
|
|
684
|
-
command: 'Command', conversation: 'Conversation', summary: 'Summary',
|
|
685
|
-
};
|
|
686
|
-
const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
|
|
687
|
-
let tokens = 20;
|
|
688
|
-
const included = [];
|
|
689
|
-
for (const unit of results) {
|
|
690
|
-
const label = TYPE_LABELS[unit.type] || unit.type;
|
|
691
|
-
const date = (unit.source_timestamp || '').slice(0, 10);
|
|
692
|
-
const confidence = (unit.confidence * 100).toFixed(0);
|
|
693
|
-
let entry = `[${label}]`;
|
|
694
|
-
if (date) entry += ` ${date}:`;
|
|
695
|
-
entry += ` ${unit.text}`;
|
|
696
|
-
if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
|
|
697
|
-
const entryTokens = Math.ceil(entry.length / 4);
|
|
698
|
-
if (tokens + entryTokens > 2000) break;
|
|
699
|
-
lines.push(entry, '');
|
|
700
|
-
tokens += entryTokens;
|
|
701
|
-
included.push({ type: unit.type, text: unit.text.slice(0, 80) });
|
|
702
|
-
}
|
|
703
|
-
lines.push('</cortex-context>');
|
|
704
|
-
|
|
705
|
-
// Write context file (readable artifact for any agent)
|
|
706
|
-
const spacesDir = path.join(cwd, '.spaces');
|
|
707
|
-
if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
|
|
708
|
-
fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
|
|
709
|
-
console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
|
|
710
|
-
|
|
711
|
-
// Notify client for injection badge
|
|
712
|
-
if (ws && ws.readyState === 1) {
|
|
713
|
-
ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
return included.length;
|
|
717
|
-
} catch (err) {
|
|
718
|
-
console.error(`[Cortex] Injection failed:`, err.message);
|
|
719
|
-
return 0;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// ─── Git Bash detection (Windows) ────────────────────────
|
|
724
|
-
function findGitBash() {
|
|
725
|
-
const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
726
|
-
if (custom && fs.existsSync(custom)) return custom;
|
|
727
|
-
const localAppData = process.env.LOCALAPPDATA || '';
|
|
728
|
-
const candidates = [
|
|
729
|
-
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
730
|
-
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
731
|
-
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
732
|
-
];
|
|
733
|
-
for (const p of candidates) {
|
|
734
|
-
if (p && fs.existsSync(p)) return p;
|
|
735
|
-
}
|
|
736
|
-
// Last resort: check if bash is on PATH via where command
|
|
737
|
-
try {
|
|
738
|
-
const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
|
|
739
|
-
const first = result.trim().split('\n')[0].trim();
|
|
740
|
-
if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
|
|
741
|
-
} catch { /* not found */ }
|
|
742
|
-
return null;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// ─── SSH binary detection (Windows) ──────────────────────
|
|
746
|
-
function findSshBinary() {
|
|
747
|
-
if (process.platform !== 'win32') return '/usr/bin/ssh';
|
|
748
|
-
// Windows OpenSSH ships in System32
|
|
749
|
-
const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
|
|
750
|
-
if (fs.existsSync(sysSSH)) return sysSSH;
|
|
751
|
-
// Git for Windows also bundles ssh
|
|
752
|
-
const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
|
|
753
|
-
if (fs.existsSync(gitSSH)) return gitSSH;
|
|
754
|
-
try {
|
|
755
|
-
const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
|
|
756
|
-
const first = result.trim().split(String.fromCharCode(10))[0].trim();
|
|
757
|
-
if (first && fs.existsSync(first)) return first;
|
|
758
|
-
} catch {}
|
|
759
|
-
return null;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// ─── Origin validation ───────────────────────────────────
|
|
763
|
-
function isAllowedOrigin(origin, req) {
|
|
764
|
-
if (!origin) return false;
|
|
765
|
-
try {
|
|
766
|
-
const url = new URL(origin);
|
|
767
|
-
// Allow localhost/127.0.0.1 (any port)
|
|
768
|
-
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
|
|
769
|
-
// Allow if origin matches the server's own Host header (same-origin requests)
|
|
770
|
-
const host = req && req.headers && req.headers.host;
|
|
771
|
-
if (host && url.host === host) return true;
|
|
772
|
-
// Allow configured hostname from env (e.g., spaces.example.com)
|
|
773
|
-
const allowed = process.env.SPACES_ALLOWED_ORIGINS;
|
|
774
|
-
if (allowed) {
|
|
775
|
-
return allowed.split(',').some(h => url.hostname === h.trim());
|
|
776
|
-
}
|
|
777
|
-
// In non-community modes, require explicit allowed origins
|
|
778
|
-
if (SPACES_TIER !== 'community') return false;
|
|
779
|
-
// Desktop/community: allow any origin
|
|
780
|
-
return true;
|
|
781
|
-
} catch {
|
|
782
|
-
return false;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// ─── Live collab toggle handler ─────────────────────────
|
|
787
|
-
function handleCollabToggle(paneId, session) {
|
|
788
|
-
try {
|
|
789
|
-
const teams = require('@spaces/teams');
|
|
790
|
-
const config = teams.terminal.getCollabConfig(paneId, session.username);
|
|
791
|
-
|
|
792
|
-
if (config) {
|
|
793
|
-
// Enabling collaboration
|
|
794
|
-
session.isCollaborating = true;
|
|
795
|
-
session.workspaceId = config.workspaceId;
|
|
796
|
-
session.paneName = config.paneName;
|
|
797
|
-
|
|
798
|
-
const env = {
|
|
799
|
-
SPACES_PANE_ID: paneId,
|
|
800
|
-
SPACES_WORKSPACE_ID: config.workspaceId,
|
|
801
|
-
SPACES_PANE_NAME: config.paneName,
|
|
802
|
-
SPACES_USERNAME: session.username,
|
|
803
|
-
SPACES_API_URL: `http://localhost:${API_PORT}`,
|
|
804
|
-
SPACES_COLLABORATING: '1',
|
|
805
|
-
};
|
|
806
|
-
teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
|
|
807
|
-
|
|
808
|
-
// Nudge the agent so it knows collaboration is available
|
|
809
|
-
if (session.pty && !session.exited) {
|
|
810
|
-
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).';
|
|
811
|
-
session.pty.write(nudge);
|
|
812
|
-
setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
|
|
816
|
-
} else {
|
|
817
|
-
// Disabling collaboration
|
|
818
|
-
teams.terminal.removeAgentConfig(session.agentType, session.cwd);
|
|
819
|
-
session.isCollaborating = false;
|
|
820
|
-
session.workspaceId = null;
|
|
821
|
-
|
|
822
|
-
console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
// Confirm to browser
|
|
826
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
827
|
-
session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
|
|
828
|
-
}
|
|
829
|
-
} catch (e) {
|
|
830
|
-
console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// ─── Shared connection handler ──────────────────────────
|
|
835
|
-
function handleConnection(wss, ws, req) {
|
|
836
|
-
ws.isAlive = true;
|
|
837
|
-
ws.on('pong', () => { ws.isAlive = true; });
|
|
838
|
-
|
|
839
|
-
const url = new URL(req.url || '/', 'http://localhost');
|
|
840
|
-
const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
|
|
841
|
-
const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
|
|
842
|
-
const agentType = url.searchParams.get('agentType') || 'shell';
|
|
843
|
-
const rawAgentSession = url.searchParams.get('agentSession') || '';
|
|
844
|
-
const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
|
|
845
|
-
const rawCustomCommand = url.searchParams.get('customCommand') || '';
|
|
846
|
-
// Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
|
|
847
|
-
const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
|
|
848
|
-
const cols = parseInt(url.searchParams.get('cols') || '120', 10);
|
|
849
|
-
const rows = parseInt(url.searchParams.get('rows') || '30', 10);
|
|
850
|
-
|
|
851
|
-
// Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
|
|
852
|
-
let username = null;
|
|
853
|
-
const cookies = parseCookies(req.headers.cookie);
|
|
854
|
-
const sessionToken = cookies['spaces-session'];
|
|
855
|
-
const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
|
|
856
|
-
|
|
857
|
-
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'}`);
|
|
858
|
-
|
|
859
|
-
if (sessionPayload) {
|
|
860
|
-
// Self-contained auth: session cookie is valid
|
|
861
|
-
username = sessionPayload.sub;
|
|
862
|
-
console.log(`[Auth] Authenticated via session cookie: ${username}`);
|
|
863
|
-
} else {
|
|
864
|
-
const terminalToken = url.searchParams.get('terminalToken') || '';
|
|
865
|
-
|
|
866
|
-
// Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
|
|
867
|
-
const remoteIp = req.socket.remoteAddress || '';
|
|
868
|
-
const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
|
|
869
|
-
if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
|
|
870
|
-
username = os.userInfo().username;
|
|
871
|
-
// When running as SYSTEM, resolve to the first real user from admin DB
|
|
872
|
-
if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
|
|
873
|
-
const db = getAdminDb();
|
|
874
|
-
if (db) {
|
|
875
|
-
try {
|
|
876
|
-
const row = db.prepare("SELECT username FROM users LIMIT 1").get();
|
|
877
|
-
if (row) username = row.username;
|
|
878
|
-
} catch {}
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
console.log(`[Auth] Authenticated via desktop token: ${username}`);
|
|
882
|
-
} else {
|
|
883
|
-
// Verify terminal token — if signed by this server's secret, trust it
|
|
884
|
-
const tokenUser = verifyTerminalToken(terminalToken);
|
|
885
|
-
if (tokenUser) {
|
|
886
|
-
// Use the user from the signed token — do NOT trust x-auth-user header
|
|
887
|
-
// as it can be spoofed by clients
|
|
888
|
-
username = tokenUser;
|
|
889
|
-
console.log(`[Auth] Authenticated via terminal token: ${username}`);
|
|
890
|
-
} else if (terminalToken) {
|
|
891
|
-
console.log(`[Auth] Terminal token FAILED: invalid or expired`);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Network API key auth (for proxied connections from remote nodes)
|
|
897
|
-
if (!username) {
|
|
898
|
-
const apiKey = url.searchParams.get('apiKey');
|
|
899
|
-
if (apiKey) {
|
|
900
|
-
console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
|
|
901
|
-
const keyRecord = validateNetworkApiKey(apiKey);
|
|
902
|
-
if (keyRecord) {
|
|
903
|
-
console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
|
|
904
|
-
if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
|
|
905
|
-
username = keyRecord.username || os.userInfo().username;
|
|
906
|
-
} else {
|
|
907
|
-
console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
|
|
908
|
-
}
|
|
909
|
-
} else {
|
|
910
|
-
console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
|
|
911
|
-
}
|
|
912
|
-
} else {
|
|
913
|
-
console.log(`[Auth] No apiKey param in WebSocket URL`);
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
if (!username) {
|
|
918
|
-
console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
|
|
919
|
-
ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
|
|
920
|
-
ws.close();
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Proxy to remote node (federation tier only)
|
|
925
|
-
const nodeId = url.searchParams.get('nodeId');
|
|
926
|
-
if (nodeId) {
|
|
927
|
-
if (SPACES_TIER !== 'federation') {
|
|
928
|
-
ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
|
|
929
|
-
ws.close();
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
|
-
handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// Check for existing session to reattach
|
|
937
|
-
const existing = sessions.get(paneId);
|
|
938
|
-
if (existing && existing.pty && !existing.exited) {
|
|
939
|
-
console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
|
|
940
|
-
existing.ws = ws;
|
|
941
|
-
|
|
942
|
-
// Replay buffered output so user sees context
|
|
943
|
-
for (const chunk of existing.buffer) {
|
|
944
|
-
ws.send(JSON.stringify({ type: 'data', data: chunk }));
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
|
|
948
|
-
|
|
949
|
-
ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
|
|
950
|
-
|
|
951
|
-
// Skip Cortex injection on reattach — context was already injected at spawn.
|
|
952
|
-
// The badge polls /api/cortex/status independently.
|
|
953
|
-
|
|
954
|
-
ws.on('message', (raw) => {
|
|
955
|
-
try {
|
|
956
|
-
const msg = JSON.parse(raw.toString());
|
|
957
|
-
if (msg.type === 'data') {
|
|
958
|
-
existing.pty.write(msg.data);
|
|
959
|
-
} else if (msg.type === 'resize') {
|
|
960
|
-
try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
|
|
961
|
-
} else if (msg.type === 'collab-toggle') {
|
|
962
|
-
handleCollabToggle(paneId, existing);
|
|
963
|
-
}
|
|
964
|
-
} catch {
|
|
965
|
-
existing.pty.write(raw.toString());
|
|
966
|
-
}
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
ws.on('close', (code, reason) => {
|
|
970
|
-
console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
|
|
971
|
-
if (existing.ws === ws) existing.ws = null;
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
ws.on('error', (err) => {
|
|
975
|
-
console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// Create new pty session
|
|
982
|
-
const isWindows = process.platform === 'win32';
|
|
983
|
-
|
|
984
|
-
// Resolve the OS shell user for this app user
|
|
985
|
-
const shellUser = lookupShellUser(username);
|
|
986
|
-
const processUser = os.userInfo().username;
|
|
987
|
-
let shell, args;
|
|
988
|
-
const isSSH = shellUser !== processUser;
|
|
989
|
-
if (isSSH) {
|
|
990
|
-
// SSH to localhost as the mapped shell user using the service key
|
|
991
|
-
const sshBin = findSshBinary();
|
|
992
|
-
if (!sshBin) {
|
|
993
|
-
console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
|
|
994
|
-
ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
|
|
995
|
-
ws.close();
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
shell = sshBin;
|
|
999
|
-
args = [
|
|
1000
|
-
'-o', 'StrictHostKeyChecking=accept-new',
|
|
1001
|
-
'-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
|
|
1002
|
-
'-i', SERVICE_KEY,
|
|
1003
|
-
'-t',
|
|
1004
|
-
`${shellUser}@localhost`,
|
|
1005
|
-
];
|
|
1006
|
-
// Force IPv4 — localhost may resolve to ::1 (IPv6) which sshd can reject
|
|
1007
|
-
args.unshift('-4');
|
|
1008
|
-
|
|
1009
|
-
// On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
|
|
1010
|
-
// before attempting the connection. This handles users added after service install.
|
|
1011
|
-
if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
|
|
1012
|
-
try {
|
|
1013
|
-
const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
|
|
1014
|
-
authorizeShellUser(shellUser, pubKey);
|
|
1015
|
-
} catch (e) {
|
|
1016
|
-
console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
} else if (isWindows && agentType !== 'shell') {
|
|
1020
|
-
// Agents like Claude Code require bash on Windows — find git-bash
|
|
1021
|
-
shell = findGitBash();
|
|
1022
|
-
args = [];
|
|
1023
|
-
if (!shell) {
|
|
1024
|
-
shell = 'cmd.exe';
|
|
1025
|
-
}
|
|
1026
|
-
} else {
|
|
1027
|
-
shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
|
|
1028
|
-
args = [];
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
const env = { ...process.env };
|
|
1032
|
-
delete env.CLAUDECODE;
|
|
1033
|
-
// Enable prompt suggestions in spawned Claude Code sessions
|
|
1034
|
-
env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
|
|
1035
|
-
// Tell Claude Code where git-bash is so it doesn't fail the bash detection
|
|
1036
|
-
if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
|
|
1037
|
-
env.CLAUDE_CODE_GIT_BASH_PATH = shell;
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
// Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
|
|
1041
|
-
let safeCwd = cwd;
|
|
1042
|
-
if (!fs.existsSync(safeCwd)) {
|
|
1043
|
-
safeCwd = os.homedir();
|
|
1044
|
-
console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Inject Spaces bus environment for agent communication
|
|
1048
|
-
env.SPACES_PANE_ID = paneId;
|
|
1049
|
-
env.SPACES_API_URL = `http://localhost:${API_PORT}`;
|
|
1050
|
-
|
|
1051
|
-
// Look up workspace collaboration config from @spaces/teams
|
|
1052
|
-
let isCollaborating = false;
|
|
1053
|
-
try {
|
|
1054
|
-
const teams = require('@spaces/teams');
|
|
1055
|
-
const config = teams.terminal.getCollabConfig(paneId, username);
|
|
1056
|
-
if (config) {
|
|
1057
|
-
env.SPACES_WORKSPACE_ID = config.workspaceId;
|
|
1058
|
-
env.SPACES_PANE_NAME = config.paneName;
|
|
1059
|
-
env.SPACES_USERNAME = username;
|
|
1060
|
-
isCollaborating = true;
|
|
1061
|
-
env.SPACES_COLLABORATING = '1';
|
|
1062
|
-
console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
|
|
1063
|
-
}
|
|
1064
|
-
} catch (e) {
|
|
1065
|
-
console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
|
|
1069
|
-
|
|
1070
|
-
// Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
|
|
1071
|
-
if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
|
|
1072
|
-
try {
|
|
1073
|
-
const userHome = getUserHome(username);
|
|
1074
|
-
const configPath = path.join(userHome, '.spaces', 'config.json');
|
|
1075
|
-
let cortexEnabled = false;
|
|
1076
|
-
if (fs.existsSync(configPath)) {
|
|
1077
|
-
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1078
|
-
cortexEnabled = cfg.cortex?.enabled === true;
|
|
1079
|
-
}
|
|
1080
|
-
if (!cortexEnabled) {
|
|
1081
|
-
removeCortexHookConfig(safeCwd);
|
|
1082
|
-
} else {
|
|
1083
|
-
writeCortexHookConfig(safeCwd);
|
|
1084
|
-
// Resolve workspace ID: from collab config, or look up from pane DB
|
|
1085
|
-
let wsId = env.SPACES_WORKSPACE_ID || null;
|
|
1086
|
-
if (!wsId) {
|
|
1087
|
-
try {
|
|
1088
|
-
const Database = require('better-sqlite3');
|
|
1089
|
-
const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
|
|
1090
|
-
const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
|
|
1091
|
-
if (row && row.workspace_id) wsId = String(row.workspace_id);
|
|
1092
|
-
spacesDb.close();
|
|
1093
|
-
} catch { /* non-fatal */ }
|
|
1094
|
-
}
|
|
1095
|
-
if (wsId) env.SPACES_WORKSPACE_ID = wsId;
|
|
1096
|
-
// Write workspace ID for hooks to read (they can't inherit PTY env)
|
|
1097
|
-
try {
|
|
1098
|
-
const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
|
|
1099
|
-
fs.writeFileSync(envFile, JSON.stringify({
|
|
1100
|
-
workspaceId: wsId,
|
|
1101
|
-
port: API_PORT,
|
|
1102
|
-
}), 'utf-8');
|
|
1103
|
-
} catch { /* non-fatal */ }
|
|
1104
|
-
}
|
|
1105
|
-
} catch (e) {
|
|
1106
|
-
console.error('[Cortex] Config check failed (non-fatal):', e.message);
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
let term;
|
|
1111
|
-
try {
|
|
1112
|
-
term = pty.spawn(shell, args, {
|
|
1113
|
-
name: 'xterm-256color',
|
|
1114
|
-
cols,
|
|
1115
|
-
rows,
|
|
1116
|
-
cwd: safeCwd,
|
|
1117
|
-
env,
|
|
1118
|
-
});
|
|
1119
|
-
} catch (err) {
|
|
1120
|
-
console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
|
|
1121
|
-
ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
|
|
1122
|
-
ws.close();
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const session = {
|
|
1127
|
-
pty: term, ws, buffer: [], exited: false, username,
|
|
1128
|
-
agentType,
|
|
1129
|
-
cwd: safeCwd,
|
|
1130
|
-
paneName: env.SPACES_PANE_NAME || paneId,
|
|
1131
|
-
lastOutputTime: Date.now(),
|
|
1132
|
-
lastNudgeTime: 0,
|
|
1133
|
-
startedAt: Date.now(),
|
|
1134
|
-
workspaceId: env.SPACES_WORKSPACE_ID || null,
|
|
1135
|
-
isCollaborating,
|
|
1136
|
-
};
|
|
1137
|
-
sessions.set(paneId, session);
|
|
1138
|
-
analyticsRecordSessionStart(paneId, username, agentType);
|
|
1139
|
-
|
|
1140
|
-
// ─── Cortex context injection (async, non-blocking) ─────
|
|
1141
|
-
if (agentType !== 'shell') {
|
|
1142
|
-
injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
// ─── Inject cd for SSH sessions, then agent command ─────
|
|
1146
|
-
const agent = AGENTS[agentType] || AGENTS.shell;
|
|
1147
|
-
|
|
1148
|
-
// SSH sessions start in the remote user's home dir — cd to target cwd first
|
|
1149
|
-
if (isSSH) {
|
|
1150
|
-
setTimeout(() => {
|
|
1151
|
-
if (!session.exited) {
|
|
1152
|
-
if (isWindows) {
|
|
1153
|
-
// Windows cmd.exe uses double quotes
|
|
1154
|
-
const escapedCwd = safeCwd.replace(/"/g, '""');
|
|
1155
|
-
term.write(`cd /d "${escapedCwd}"\r`);
|
|
1156
|
-
} else {
|
|
1157
|
-
// Unix shells use single quotes
|
|
1158
|
-
const escapedCwd = safeCwd.replace(/'/g, "'\\''");
|
|
1159
|
-
term.write(`cd '${escapedCwd}'\r`);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
}, 300);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// Write collaboration config for agent panes via @spaces/teams
|
|
1166
|
-
if (isCollaborating && agentType !== 'shell') {
|
|
1167
|
-
try {
|
|
1168
|
-
const teams = require('@spaces/teams');
|
|
1169
|
-
teams.terminal.writeAgentConfig(agentType, safeCwd, env);
|
|
1170
|
-
console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
|
|
1171
|
-
} catch (e) {
|
|
1172
|
-
console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
if (agentType !== 'shell') {
|
|
1177
|
-
const command = agentType === 'custom' ? customCommand : agent.command;
|
|
1178
|
-
|
|
1179
|
-
if (command) {
|
|
1180
|
-
const delay = isSSH ? 800 : 300;
|
|
1181
|
-
|
|
1182
|
-
if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
|
|
1183
|
-
// Resume an existing session
|
|
1184
|
-
if (agentType === 'claude') {
|
|
1185
|
-
// Claude needs to be run from the correct project CWD
|
|
1186
|
-
const sessionCwd = findSessionCwd(agentSession, username);
|
|
1187
|
-
setTimeout(() => {
|
|
1188
|
-
if (session.exited) return;
|
|
1189
|
-
if (sessionCwd && sessionCwd !== safeCwd) {
|
|
1190
|
-
const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
|
|
1191
|
-
term.write(cdCmd + '\r');
|
|
1192
|
-
setTimeout(() => {
|
|
1193
|
-
if (!session.exited) {
|
|
1194
|
-
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1195
|
-
}
|
|
1196
|
-
}, 300);
|
|
1197
|
-
} else {
|
|
1198
|
-
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1199
|
-
}
|
|
1200
|
-
}, delay);
|
|
1201
|
-
} else {
|
|
1202
|
-
// Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
|
|
1203
|
-
setTimeout(() => {
|
|
1204
|
-
if (!session.exited) {
|
|
1205
|
-
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1206
|
-
}
|
|
1207
|
-
}, delay);
|
|
1208
|
-
}
|
|
1209
|
-
} else {
|
|
1210
|
-
// Start new session
|
|
1211
|
-
setTimeout(() => {
|
|
1212
|
-
if (!session.exited) {
|
|
1213
|
-
term.write(`${command}\r`);
|
|
1214
|
-
}
|
|
1215
|
-
}, delay);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// pty -> ws (and buffer)
|
|
1221
|
-
term.onData((data) => {
|
|
1222
|
-
session.lastOutputTime = Date.now();
|
|
1223
|
-
session.buffer.push(data);
|
|
1224
|
-
if (session.buffer.length > MAX_BUFFER_LINES) {
|
|
1225
|
-
session.buffer.shift();
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
1229
|
-
session.ws.send(JSON.stringify({ type: 'data', data }));
|
|
1230
|
-
}
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
term.onExit(({ exitCode }) => {
|
|
1234
|
-
session.exited = true;
|
|
1235
|
-
analyticsRecordSessionEnd(paneId);
|
|
1236
|
-
// Clean up hook state file
|
|
1237
|
-
try {
|
|
1238
|
-
const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
|
|
1239
|
-
if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
|
|
1240
|
-
} catch { /* ignore */ }
|
|
1241
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
1242
|
-
session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
|
1243
|
-
}
|
|
1244
|
-
setTimeout(() => {
|
|
1245
|
-
if (sessions.get(paneId) === session) {
|
|
1246
|
-
sessions.delete(paneId);
|
|
1247
|
-
}
|
|
1248
|
-
}, 120000);
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
// ws -> pty
|
|
1252
|
-
ws.on('message', (raw) => {
|
|
1253
|
-
try {
|
|
1254
|
-
const msg = JSON.parse(raw.toString());
|
|
1255
|
-
if (msg.type === 'data') {
|
|
1256
|
-
term.write(msg.data);
|
|
1257
|
-
} else if (msg.type === 'resize') {
|
|
1258
|
-
try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
|
|
1259
|
-
} else if (msg.type === 'collab-toggle') {
|
|
1260
|
-
handleCollabToggle(paneId, session);
|
|
1261
|
-
}
|
|
1262
|
-
} catch {
|
|
1263
|
-
term.write(raw.toString());
|
|
1264
|
-
}
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
ws.on('close', (code, reason) => {
|
|
1268
|
-
console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
|
|
1269
|
-
if (session.ws === ws) session.ws = null;
|
|
1270
|
-
});
|
|
1271
|
-
|
|
1272
|
-
ws.on('error', (err) => {
|
|
1273
|
-
console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
ws.send(JSON.stringify({ type: 'ready', paneId }));
|
|
1277
|
-
|
|
1278
|
-
// Confirm actual collaboration state so browser syncs with backend
|
|
1279
|
-
ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
|
|
1280
|
-
|
|
1281
|
-
// ─── Session ID detection for
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
fs.
|
|
1319
|
-
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
if (
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
// to
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
httpServer.
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
+
// Track whether the Next.js API is ready — avoids timeout spam during startup
|
|
18
|
+
let _apiReady = false;
|
|
19
|
+
function setApiReady() { _apiReady = true; }
|
|
20
|
+
function isApiReady() { return _apiReady; }
|
|
21
|
+
// Poll until the API responds, then mark ready
|
|
22
|
+
function waitForApi() {
|
|
23
|
+
const check = () => {
|
|
24
|
+
const req = http.get(`http://localhost:${API_PORT}/api/tier`, { timeout: 1000 }, (res) => {
|
|
25
|
+
res.resume(); // consume body to free socket
|
|
26
|
+
if (res.statusCode < 500) { setApiReady(); return; }
|
|
27
|
+
setTimeout(check, 2000);
|
|
28
|
+
});
|
|
29
|
+
req.on('error', () => setTimeout(check, 2000));
|
|
30
|
+
req.on('timeout', () => { req.destroy(); setTimeout(check, 2000); });
|
|
31
|
+
};
|
|
32
|
+
setTimeout(check, 1000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Terminal token verification ──────────────────────────
|
|
36
|
+
|
|
37
|
+
const SECRET_PATH = path.join(os.homedir(), '.spaces', 'terminal_secret');
|
|
38
|
+
|
|
39
|
+
function getTerminalSecret() {
|
|
40
|
+
if (fs.existsSync(SECRET_PATH)) {
|
|
41
|
+
return Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
42
|
+
}
|
|
43
|
+
const secret = crypto.randomBytes(32);
|
|
44
|
+
const dir = path.dirname(SECRET_PATH);
|
|
45
|
+
if (!fs.existsSync(dir)) {
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(SECRET_PATH, secret.toString('hex'), { mode: 0o600 });
|
|
49
|
+
return secret;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let _terminalSecret = null;
|
|
53
|
+
function terminalSecret() {
|
|
54
|
+
if (!_terminalSecret) {
|
|
55
|
+
_terminalSecret = getTerminalSecret();
|
|
56
|
+
}
|
|
57
|
+
return _terminalSecret;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function verifyTerminalToken(token) {
|
|
61
|
+
if (!token) return null;
|
|
62
|
+
const parts = token.split('.');
|
|
63
|
+
if (parts.length !== 2) return null;
|
|
64
|
+
|
|
65
|
+
const [payloadB64, sig] = parts;
|
|
66
|
+
const expectedSig = crypto.createHmac('sha256', terminalSecret())
|
|
67
|
+
.update(payloadB64)
|
|
68
|
+
.digest('base64url');
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
80
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return payload.sub || null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Session token verification (for self-contained auth) ──
|
|
90
|
+
|
|
91
|
+
const SESSION_SECRET_PATH = path.join(os.homedir(), '.spaces', 'session_secret');
|
|
92
|
+
|
|
93
|
+
function getSessionSecret() {
|
|
94
|
+
if (fs.existsSync(SESSION_SECRET_PATH)) {
|
|
95
|
+
return Buffer.from(fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let _sessionSecret = null;
|
|
101
|
+
function sessionSecret() {
|
|
102
|
+
if (!_sessionSecret) {
|
|
103
|
+
_sessionSecret = getSessionSecret();
|
|
104
|
+
}
|
|
105
|
+
return _sessionSecret;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function verifySessionToken(token) {
|
|
109
|
+
const secret = sessionSecret();
|
|
110
|
+
if (!token || !secret) return null;
|
|
111
|
+
const parts = token.split('.');
|
|
112
|
+
if (parts.length !== 2) return null;
|
|
113
|
+
|
|
114
|
+
const [payloadB64, sig] = parts;
|
|
115
|
+
const expectedSig = crypto.createHmac('sha256', secret)
|
|
116
|
+
.update(payloadB64)
|
|
117
|
+
.digest('base64url');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
129
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return { sub: payload.sub, role: payload.role || 'user' };
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Admin DB for shell user lookup ─────────────────────────
|
|
139
|
+
|
|
140
|
+
const ADMIN_DB_PATH = path.join(os.homedir(), '.spaces', 'admin.db');
|
|
141
|
+
let _adminDb = null;
|
|
142
|
+
|
|
143
|
+
function getAdminDb() {
|
|
144
|
+
if (_adminDb) return _adminDb;
|
|
145
|
+
if (!fs.existsSync(ADMIN_DB_PATH)) return null;
|
|
146
|
+
try {
|
|
147
|
+
const Database = require('better-sqlite3');
|
|
148
|
+
_adminDb = new Database(ADMIN_DB_PATH, { readonly: true });
|
|
149
|
+
return _adminDb;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lookupShellUser(appUsername) {
|
|
156
|
+
const db = getAdminDb();
|
|
157
|
+
if (!db) return appUsername;
|
|
158
|
+
try {
|
|
159
|
+
const row = db.prepare('SELECT shell_user FROM users WHERE username = ?').get(appUsername);
|
|
160
|
+
return row ? row.shell_user : appUsername;
|
|
161
|
+
} catch {
|
|
162
|
+
return appUsername;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Network DB for federation ───────────────────────────────
|
|
167
|
+
|
|
168
|
+
const NETWORK_DB_PATH = path.join(os.homedir(), '.spaces', 'network.db');
|
|
169
|
+
let _networkDb = null;
|
|
170
|
+
|
|
171
|
+
function getNetworkDb() {
|
|
172
|
+
if (_networkDb) return _networkDb;
|
|
173
|
+
if (!fs.existsSync(NETWORK_DB_PATH)) return null;
|
|
174
|
+
try {
|
|
175
|
+
const Database = require('better-sqlite3');
|
|
176
|
+
_networkDb = new Database(NETWORK_DB_PATH, { readonly: true });
|
|
177
|
+
return _networkDb;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateNetworkApiKey(rawKey) {
|
|
184
|
+
const db = getNetworkDb();
|
|
185
|
+
if (!db || !rawKey || !rawKey.startsWith('spk_')) return null;
|
|
186
|
+
try {
|
|
187
|
+
const keys = db.prepare('SELECT * FROM api_keys').all();
|
|
188
|
+
for (const key of keys) {
|
|
189
|
+
if (key.expires && new Date(key.expires) < new Date()) continue;
|
|
190
|
+
const [salt, hash] = key.key_hash.split(':');
|
|
191
|
+
if (!salt || !hash) continue;
|
|
192
|
+
const derived = crypto.scryptSync(rawKey, salt, 64).toString('hex');
|
|
193
|
+
try {
|
|
194
|
+
if (crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(derived, 'hex'))) {
|
|
195
|
+
return key;
|
|
196
|
+
}
|
|
197
|
+
} catch { continue; }
|
|
198
|
+
}
|
|
199
|
+
} catch { /* ignore */ }
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getNodeInfo(nodeId) {
|
|
204
|
+
const db = getNetworkDb();
|
|
205
|
+
if (!db) return null;
|
|
206
|
+
try {
|
|
207
|
+
return db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function decryptNodeApiKey(encrypted) {
|
|
214
|
+
try {
|
|
215
|
+
const key = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf-8').trim(), 'hex');
|
|
216
|
+
const [ivB64, tagB64, dataB64] = encrypted.split(':');
|
|
217
|
+
if (!ivB64 || !tagB64 || !dataB64) return null;
|
|
218
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
219
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
220
|
+
const data = Buffer.from(dataB64, 'base64');
|
|
221
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
222
|
+
decipher.setAuthTag(tag);
|
|
223
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Writable Admin DB for analytics ─────────────────────────
|
|
230
|
+
|
|
231
|
+
let _adminDbRW = null;
|
|
232
|
+
|
|
233
|
+
function getAdminDbRW() {
|
|
234
|
+
if (_adminDbRW) return _adminDbRW;
|
|
235
|
+
try {
|
|
236
|
+
const Database = require('better-sqlite3');
|
|
237
|
+
const dir = path.dirname(ADMIN_DB_PATH);
|
|
238
|
+
if (!fs.existsSync(dir)) {
|
|
239
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
240
|
+
}
|
|
241
|
+
const db = new Database(ADMIN_DB_PATH);
|
|
242
|
+
db.pragma('journal_mode = WAL');
|
|
243
|
+
db.pragma('busy_timeout = 5000');
|
|
244
|
+
db.exec(`
|
|
245
|
+
CREATE TABLE IF NOT EXISTS login_events (
|
|
246
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
247
|
+
username TEXT NOT NULL,
|
|
248
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
249
|
+
ip_address TEXT,
|
|
250
|
+
user_agent TEXT
|
|
251
|
+
);
|
|
252
|
+
CREATE TABLE IF NOT EXISTS terminal_sessions (
|
|
253
|
+
id TEXT PRIMARY KEY,
|
|
254
|
+
username TEXT NOT NULL,
|
|
255
|
+
agent_type TEXT NOT NULL DEFAULT 'shell',
|
|
256
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
257
|
+
ended_at TEXT,
|
|
258
|
+
duration_seconds INTEGER
|
|
259
|
+
);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_login_events_username ON login_events(username);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_login_events_timestamp ON login_events(timestamp);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_terminal_sessions_username ON terminal_sessions(username);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_terminal_sessions_started_at ON terminal_sessions(started_at);
|
|
264
|
+
`);
|
|
265
|
+
// Clean up stale sessions from previous crashes
|
|
266
|
+
db.prepare(`
|
|
267
|
+
UPDATE terminal_sessions
|
|
268
|
+
SET ended_at = datetime('now'),
|
|
269
|
+
duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
|
|
270
|
+
WHERE ended_at IS NULL
|
|
271
|
+
`).run();
|
|
272
|
+
_adminDbRW = db;
|
|
273
|
+
console.log('[Analytics] Writable admin DB connected, stale sessions cleaned up');
|
|
274
|
+
return db;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error('[Analytics] Failed to open writable admin DB:', err.message);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function analyticsRecordSessionStart(paneId, username, agentType) {
|
|
282
|
+
try {
|
|
283
|
+
const db = getAdminDbRW();
|
|
284
|
+
if (!db) return;
|
|
285
|
+
db.prepare(
|
|
286
|
+
'INSERT OR REPLACE INTO terminal_sessions (id, username, agent_type) VALUES (?, ?, ?)'
|
|
287
|
+
).run(paneId, username, agentType);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error('[Analytics] recordSessionStart error:', err.message);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function analyticsRecordSessionEnd(paneId) {
|
|
294
|
+
try {
|
|
295
|
+
const db = getAdminDbRW();
|
|
296
|
+
if (!db) return;
|
|
297
|
+
db.prepare(`
|
|
298
|
+
UPDATE terminal_sessions
|
|
299
|
+
SET ended_at = datetime('now'),
|
|
300
|
+
duration_seconds = CAST((julianday('now') - julianday(started_at)) * 86400 AS INTEGER)
|
|
301
|
+
WHERE id = ? AND ended_at IS NULL
|
|
302
|
+
`).run(paneId);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error('[Analytics] recordSessionEnd error:', err.message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Cookie parser ──────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function parseCookies(cookieHeader) {
|
|
311
|
+
const cookies = {};
|
|
312
|
+
if (!cookieHeader) return cookies;
|
|
313
|
+
cookieHeader.split(';').forEach(part => {
|
|
314
|
+
const [key, ...rest] = part.trim().split('=');
|
|
315
|
+
if (key) cookies[key] = rest.join('=');
|
|
316
|
+
});
|
|
317
|
+
return cookies;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── SSH service key path (used to spawn shells as other OS users) ──
|
|
321
|
+
|
|
322
|
+
const SERVICE_KEY = path.join(os.homedir(), '.spaces', 'service_key');
|
|
323
|
+
|
|
324
|
+
// Ensure the SSH service key exists, has correct permissions, and is authorized.
|
|
325
|
+
function ensureServiceKeyAtRuntime() {
|
|
326
|
+
const { spawnSync } = require('child_process');
|
|
327
|
+
const currentUser = os.userInfo().username;
|
|
328
|
+
const isWindows = process.platform === 'win32';
|
|
329
|
+
|
|
330
|
+
// Generate key if missing (all platforms)
|
|
331
|
+
if (!fs.existsSync(SERVICE_KEY)) {
|
|
332
|
+
const dir = path.dirname(SERVICE_KEY);
|
|
333
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
334
|
+
const result = spawnSync('ssh-keygen', [
|
|
335
|
+
'-t', 'ed25519', '-f', SERVICE_KEY, '-N', '', '-C', 'spaces-service-key',
|
|
336
|
+
], { stdio: 'pipe', timeout: 10000 });
|
|
337
|
+
if (result.status !== 0) {
|
|
338
|
+
console.error('[SSH] Failed to generate service key');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (isWindows) {
|
|
342
|
+
// Lock down permissions: only the process owner + SYSTEM
|
|
343
|
+
spawnSync('icacls', [SERVICE_KEY, '/inheritance:r',
|
|
344
|
+
'/remove', 'BUILTIN\\Administrators', '/remove', 'BUILTIN\\Users', '/remove', 'Everyone',
|
|
345
|
+
'/grant:r', currentUser + ':(F)',
|
|
346
|
+
'/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
|
|
347
|
+
} else {
|
|
348
|
+
spawnSync('chmod', ['600', SERVICE_KEY], { stdio: 'pipe', timeout: 5000 });
|
|
349
|
+
}
|
|
350
|
+
console.log('[SSH] Generated service key as ' + currentUser);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Always ensure the public key is authorized
|
|
354
|
+
if (!fs.existsSync(SERVICE_KEY + '.pub')) return;
|
|
355
|
+
const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
|
|
356
|
+
|
|
357
|
+
if (isWindows) {
|
|
358
|
+
// Authorize in administrators_authorized_keys (for admin shell users)
|
|
359
|
+
try {
|
|
360
|
+
const adminAuthKeys = path.join(process.env.ProgramData || 'C:\\ProgramData', 'ssh', 'administrators_authorized_keys');
|
|
361
|
+
const authDir = path.dirname(adminAuthKeys);
|
|
362
|
+
if (!fs.existsSync(authDir)) fs.mkdirSync(authDir, { recursive: true });
|
|
363
|
+
spawnSync('icacls', [adminAuthKeys, '/inheritance:r',
|
|
364
|
+
'/grant:r', 'SYSTEM:(F)', '/grant', 'Administrators:(R)'], { stdio: 'pipe', timeout: 5000 });
|
|
365
|
+
let existing = '';
|
|
366
|
+
try { existing = fs.readFileSync(adminAuthKeys, 'utf-8'); } catch {}
|
|
367
|
+
if (!existing.includes(pubKey)) {
|
|
368
|
+
fs.appendFileSync(adminAuthKeys, pubKey + String.fromCharCode(10));
|
|
369
|
+
console.log('[SSH] Authorized service key in administrators_authorized_keys');
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
console.error('[SSH] Could not authorize admin key (non-fatal):', e.message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Authorize in each shell user's ~/.ssh/authorized_keys (for non-admin users)
|
|
376
|
+
try {
|
|
377
|
+
const usersDir = path.dirname(os.homedir());
|
|
378
|
+
const skip = new Set(['Public', 'Default', 'Default User', 'All Users']);
|
|
379
|
+
const profiles = fs.readdirSync(usersDir)
|
|
380
|
+
.filter(name => !skip.has(name) && !name.startsWith('.'))
|
|
381
|
+
.filter(name => fs.existsSync(path.join(usersDir, name, '.claude')));
|
|
382
|
+
for (const username of profiles) {
|
|
383
|
+
try {
|
|
384
|
+
const sshDir = path.join(usersDir, username, '.ssh');
|
|
385
|
+
if (!fs.existsSync(sshDir)) fs.mkdirSync(sshDir, { recursive: true });
|
|
386
|
+
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
387
|
+
let existing = '';
|
|
388
|
+
try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
|
|
389
|
+
if (!existing.includes(pubKey)) {
|
|
390
|
+
fs.appendFileSync(authKeysPath, pubKey + String.fromCharCode(10));
|
|
391
|
+
spawnSync('icacls', [authKeysPath, '/inheritance:r',
|
|
392
|
+
'/grant:r', username + ':(F)',
|
|
393
|
+
'/grant', 'NT AUTHORITY\\SYSTEM:(F)'], { stdio: 'pipe', timeout: 5000 });
|
|
394
|
+
console.log('[SSH] Authorized service key for user ' + username);
|
|
395
|
+
}
|
|
396
|
+
} catch (e) {
|
|
397
|
+
console.error('[SSH] Could not authorize key for ' + username + ' (non-fatal):', e.message);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.error('[SSH] Could not scan user profiles (non-fatal):', e.message);
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
// Linux/macOS: authorize all shell users from admin DB as a fallback
|
|
405
|
+
// (AuthorizedKeysCommand is the primary mechanism, this is belt-and-suspenders)
|
|
406
|
+
const db = getAdminDb();
|
|
407
|
+
if (db) {
|
|
408
|
+
try {
|
|
409
|
+
const users = db.prepare('SELECT DISTINCT shell_user FROM users').all();
|
|
410
|
+
for (const row of users) {
|
|
411
|
+
const shellUser = row.shell_user;
|
|
412
|
+
try {
|
|
413
|
+
authorizeShellUser(shellUser, pubKey);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
console.error('[SSH] Could not authorize key for ' + shellUser + ' (non-fatal):', e.message);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch (e) {
|
|
419
|
+
console.error('[SSH] Could not query admin DB for shell users (non-fatal):', e.message);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Authorize the service key for a Linux/macOS shell user
|
|
426
|
+
function authorizeShellUser(shellUser, pubKey) {
|
|
427
|
+
const { spawnSync } = require('child_process');
|
|
428
|
+
|
|
429
|
+
// Resolve home directory
|
|
430
|
+
let userHome;
|
|
431
|
+
try {
|
|
432
|
+
const result = spawnSync('getent', ['passwd', shellUser], { encoding: 'utf-8', timeout: 5000 });
|
|
433
|
+
const fields = (result.stdout || '').split(':');
|
|
434
|
+
userHome = fields[5];
|
|
435
|
+
} catch {}
|
|
436
|
+
if (!userHome) {
|
|
437
|
+
userHome = process.platform === 'darwin' ? `/Users/${shellUser}` : `/home/${shellUser}`;
|
|
438
|
+
}
|
|
439
|
+
if (!fs.existsSync(userHome)) return;
|
|
440
|
+
|
|
441
|
+
const sshDir = path.join(userHome, '.ssh');
|
|
442
|
+
const authKeysPath = path.join(sshDir, 'authorized_keys');
|
|
443
|
+
|
|
444
|
+
// Check if already authorized
|
|
445
|
+
let existing = '';
|
|
446
|
+
try { existing = fs.readFileSync(authKeysPath, 'utf-8'); } catch {}
|
|
447
|
+
if (existing.includes(pubKey)) return;
|
|
448
|
+
|
|
449
|
+
// Create .ssh dir with correct ownership
|
|
450
|
+
if (!fs.existsSync(sshDir)) {
|
|
451
|
+
spawnSync('sudo', ['mkdir', '-p', sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
452
|
+
spawnSync('sudo', ['chmod', '700', sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
453
|
+
spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, sshDir], { stdio: 'pipe', timeout: 5000 });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Append key and fix permissions
|
|
457
|
+
const tmpFile = `/tmp/spaces-authkey-${shellUser}-${Date.now()}`;
|
|
458
|
+
fs.writeFileSync(tmpFile, existing + pubKey + '\n');
|
|
459
|
+
spawnSync('sudo', ['cp', tmpFile, authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
460
|
+
spawnSync('sudo', ['chmod', '600', authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
461
|
+
spawnSync('sudo', ['chown', `${shellUser}:${shellUser}`, authKeysPath], { stdio: 'pipe', timeout: 5000 });
|
|
462
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
463
|
+
|
|
464
|
+
console.log('[SSH] Authorized service key for user ' + shellUser);
|
|
465
|
+
}
|
|
466
|
+
try { ensureServiceKeyAtRuntime(); } catch (e) { console.error('[SSH] Key setup failed (non-fatal):', e.message); }
|
|
467
|
+
|
|
468
|
+
// Session store: keeps ptys alive across WebSocket reconnections
|
|
469
|
+
// Key: paneId, Value: { pty, ws (current WebSocket or null), buffer (rolling output), username }
|
|
470
|
+
const sessions = new Map();
|
|
471
|
+
|
|
472
|
+
const MAX_BUFFER_LINES = 500;
|
|
473
|
+
|
|
474
|
+
// ─── Agent definitions (mirrors src/lib/agents.ts) ────────
|
|
475
|
+
const AGENTS = {
|
|
476
|
+
shell: { command: '', resumeFlag: '', resumeStyle: '' },
|
|
477
|
+
claude: { command: 'claude', resumeFlag: '--resume', resumeStyle: 'flag' },
|
|
478
|
+
codex: { command: 'codex', resumeFlag: 'resume', resumeStyle: 'subcommand' },
|
|
479
|
+
gemini: { command: 'gemini', resumeFlag: '--resume', resumeStyle: 'flag' },
|
|
480
|
+
aider: { command: 'aider', resumeFlag: '', resumeStyle: '' },
|
|
481
|
+
custom: { command: '', resumeFlag: '', resumeStyle: '' },
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
485
|
+
|
|
486
|
+
// ─── Remove Cortex hooks from Claude Code config ─────────
|
|
487
|
+
function removeCortexHookConfig(cwd) {
|
|
488
|
+
try {
|
|
489
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
|
|
490
|
+
if (!fs.existsSync(settingsPath)) return;
|
|
491
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
492
|
+
let changed = false;
|
|
493
|
+
|
|
494
|
+
// Remove cortex hooks from UserPromptSubmit and Stop
|
|
495
|
+
if (settings.hooks?.UserPromptSubmit) {
|
|
496
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
|
|
497
|
+
(g) => !g.hooks?.some((h) => h.command?.includes('cortex-hook'))
|
|
498
|
+
);
|
|
499
|
+
if (settings.hooks.UserPromptSubmit.length === 0) delete settings.hooks.UserPromptSubmit;
|
|
500
|
+
changed = true;
|
|
501
|
+
}
|
|
502
|
+
if (settings.hooks?.Stop) {
|
|
503
|
+
settings.hooks.Stop = settings.hooks.Stop.filter(
|
|
504
|
+
(g) => !g.hooks?.some((h) => h.command?.includes('cortex-learn-hook'))
|
|
505
|
+
);
|
|
506
|
+
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
507
|
+
changed = true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Remove cortex MCP server
|
|
511
|
+
if (settings.mcpServers?.cortex) {
|
|
512
|
+
delete settings.mcpServers.cortex;
|
|
513
|
+
changed = true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (changed) {
|
|
517
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
518
|
+
console.log(`[Cortex] Removed hooks and MCP server from ${settingsPath}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Remove spaces-env.json
|
|
522
|
+
const envFile = path.join(cwd, '.claude', 'spaces-env.json');
|
|
523
|
+
if (fs.existsSync(envFile)) fs.unlinkSync(envFile);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
console.error(`[Cortex] Failed to remove hook config:`, err.message);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ─── Cortex Claude Code hook config ──────────────────────
|
|
530
|
+
// Write a UserPromptSubmit hook into .claude/settings.local.json
|
|
531
|
+
// so every prompt gets a RAG search before Claude sees it.
|
|
532
|
+
function writeCortexHookConfig(cwd) {
|
|
533
|
+
try {
|
|
534
|
+
const claudeDir = path.join(cwd, '.claude');
|
|
535
|
+
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
536
|
+
|
|
537
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
538
|
+
let settings = {};
|
|
539
|
+
if (fs.existsSync(settingsPath)) {
|
|
540
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); } catch {}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Resolve hook paths from @spaces/cortex addon or legacy bin/
|
|
544
|
+
let ragHook, learnHook;
|
|
545
|
+
try {
|
|
546
|
+
const cortexDir = path.dirname(require.resolve('@spaces/cortex'));
|
|
547
|
+
ragHook = path.join(cortexDir, 'hooks', 'cortex-hook.js');
|
|
548
|
+
learnHook = path.join(cortexDir, 'hooks', 'cortex-learn-hook.js');
|
|
549
|
+
} catch {
|
|
550
|
+
ragHook = path.resolve(__dirname, 'cortex-hook.js');
|
|
551
|
+
learnHook = path.resolve(__dirname, 'cortex-learn-hook.js');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Merge — don't clobber existing hooks for other events
|
|
555
|
+
if (!settings.hooks) settings.hooks = {};
|
|
556
|
+
|
|
557
|
+
// Bake env vars into hook commands so they're always available
|
|
558
|
+
// (Claude Code hook subprocesses may not inherit the PTY env)
|
|
559
|
+
const hookEnv = `SPACES_PORT=${API_PORT} SPACES_SESSION_SECRET="${process.env.SPACES_SESSION_SECRET || ''}"`;
|
|
560
|
+
|
|
561
|
+
// RAG search: runs on every prompt, injects relevant context
|
|
562
|
+
settings.hooks.UserPromptSubmit = [
|
|
563
|
+
{
|
|
564
|
+
hooks: [
|
|
565
|
+
{
|
|
566
|
+
type: 'command',
|
|
567
|
+
command: `${hookEnv} node "${ragHook}"`,
|
|
568
|
+
timeout: 5,
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
// Learn: runs after Claude finishes, ingests the exchange back into Cortex
|
|
575
|
+
settings.hooks.Stop = [
|
|
576
|
+
{
|
|
577
|
+
hooks: [
|
|
578
|
+
{
|
|
579
|
+
type: 'command',
|
|
580
|
+
command: `${hookEnv} node "${learnHook}"`,
|
|
581
|
+
timeout: 10,
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
},
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
// Register Cortex MCP server
|
|
588
|
+
const mcpServer = path.resolve(__dirname, 'cortex-mcp.js');
|
|
589
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
590
|
+
settings.mcpServers.cortex = {
|
|
591
|
+
command: 'node',
|
|
592
|
+
args: [mcpServer],
|
|
593
|
+
env: {
|
|
594
|
+
SPACES_URL: `http://localhost:${API_PORT}`,
|
|
595
|
+
SPACES_INTERNAL_TOKEN: (process.env.SPACES_SESSION_SECRET || '').slice(0, 16),
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
600
|
+
console.log(`[Cortex] Wrote Claude Code hooks (RAG + Learn) + MCP server to ${settingsPath}`);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
console.error(`[Cortex] Failed to write hook config:`, err.message);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─── Cortex context injection ────────────────────────────
|
|
607
|
+
// Fetch relevant knowledge from Cortex API and write a context file
|
|
608
|
+
// in the workspace before the agent launches.
|
|
609
|
+
async function injectCortexContext(cwd, workspaceId, ws) {
|
|
610
|
+
if (!isApiReady()) return 0;
|
|
611
|
+
if (SPACES_TIER !== 'team' && SPACES_TIER !== 'federation') return 0;
|
|
612
|
+
// Check if Cortex is actually enabled in user config
|
|
613
|
+
try {
|
|
614
|
+
const configPath = path.join(os.homedir(), '.spaces', 'config.json');
|
|
615
|
+
if (fs.existsSync(configPath)) {
|
|
616
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
617
|
+
if (!cfg.cortex?.enabled) return 0;
|
|
618
|
+
} else {
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
} catch { return 0; }
|
|
622
|
+
try {
|
|
623
|
+
const projectName = path.basename(cwd);
|
|
624
|
+
const query = encodeURIComponent(`${projectName} workspace context`);
|
|
625
|
+
const params = `q=${query}&limit=10${workspaceId ? `&workspace_id=${workspaceId}` : ''}`;
|
|
626
|
+
const url = `http://localhost:${API_PORT}/api/cortex/search?${params}`;
|
|
627
|
+
|
|
628
|
+
// Use internal auth bypass (x-spaces-internal header) to skip session middleware
|
|
629
|
+
const internalToken = (process.env.SPACES_SESSION_SECRET || '').slice(0, 16);
|
|
630
|
+
const options = {
|
|
631
|
+
timeout: 5000,
|
|
632
|
+
headers: {
|
|
633
|
+
'x-spaces-internal': internalToken,
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const body = await new Promise((resolve, reject) => {
|
|
638
|
+
const req = http.get(url, options, (res) => {
|
|
639
|
+
// Follow redirects (Next.js trailing-slash redirects)
|
|
640
|
+
if (res.statusCode === 308 || res.statusCode === 307 || res.statusCode === 301 || res.statusCode === 302) {
|
|
641
|
+
const redirectUrl = res.headers.location;
|
|
642
|
+
if (redirectUrl) {
|
|
643
|
+
const fullUrl = redirectUrl.startsWith('http') ? redirectUrl : `http://localhost:${API_PORT}${redirectUrl}`;
|
|
644
|
+
const req2 = http.get(fullUrl, options, (res2) => {
|
|
645
|
+
let data = '';
|
|
646
|
+
res2.on('data', (chunk) => { data += chunk; });
|
|
647
|
+
res2.on('end', () => {
|
|
648
|
+
if (res2.statusCode !== 200) {
|
|
649
|
+
reject(new Error(`Cortex API returned ${res2.statusCode}: ${data.slice(0, 200)}`));
|
|
650
|
+
} else {
|
|
651
|
+
resolve(data);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
req2.on('error', reject);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
let data = '';
|
|
660
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
661
|
+
res.on('end', () => {
|
|
662
|
+
if (res.statusCode !== 200) {
|
|
663
|
+
reject(new Error(`Cortex API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
664
|
+
} else {
|
|
665
|
+
resolve(data);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
req.on('error', reject);
|
|
670
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const parsed = JSON.parse(body);
|
|
674
|
+
const results = parsed.results;
|
|
675
|
+
if (!results || results.length === 0) {
|
|
676
|
+
console.log(`[Cortex] No knowledge found for "${projectName}"`);
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Format context (mirrors src/lib/cortex/retrieval/injection.ts)
|
|
681
|
+
const TYPE_LABELS = {
|
|
682
|
+
decision: 'Decision', pattern: 'Pattern', preference: 'Preference',
|
|
683
|
+
error_fix: 'Error Fix', context: 'Context', code_pattern: 'Code',
|
|
684
|
+
command: 'Command', conversation: 'Conversation', summary: 'Summary',
|
|
685
|
+
};
|
|
686
|
+
const lines = ['<cortex-context>', 'Relevant context from your workspace history:', ''];
|
|
687
|
+
let tokens = 20;
|
|
688
|
+
const included = [];
|
|
689
|
+
for (const unit of results) {
|
|
690
|
+
const label = TYPE_LABELS[unit.type] || unit.type;
|
|
691
|
+
const date = (unit.source_timestamp || '').slice(0, 10);
|
|
692
|
+
const confidence = (unit.confidence * 100).toFixed(0);
|
|
693
|
+
let entry = `[${label}]`;
|
|
694
|
+
if (date) entry += ` ${date}:`;
|
|
695
|
+
entry += ` ${unit.text}`;
|
|
696
|
+
if (unit.session_id) entry += `\nSource: session ${unit.session_id}, confidence: ${confidence}%`;
|
|
697
|
+
const entryTokens = Math.ceil(entry.length / 4);
|
|
698
|
+
if (tokens + entryTokens > 2000) break;
|
|
699
|
+
lines.push(entry, '');
|
|
700
|
+
tokens += entryTokens;
|
|
701
|
+
included.push({ type: unit.type, text: unit.text.slice(0, 80) });
|
|
702
|
+
}
|
|
703
|
+
lines.push('</cortex-context>');
|
|
704
|
+
|
|
705
|
+
// Write context file (readable artifact for any agent)
|
|
706
|
+
const spacesDir = path.join(cwd, '.spaces');
|
|
707
|
+
if (!fs.existsSync(spacesDir)) fs.mkdirSync(spacesDir, { recursive: true });
|
|
708
|
+
fs.writeFileSync(path.join(spacesDir, 'cortex-context.md'), lines.join('\n'), 'utf-8');
|
|
709
|
+
console.log(`[Cortex] Injected ${included.length} knowledge units for ${path.basename(cwd)}`);
|
|
710
|
+
|
|
711
|
+
// Notify client for injection badge
|
|
712
|
+
if (ws && ws.readyState === 1) {
|
|
713
|
+
ws.send(JSON.stringify({ type: 'cortex-injection', count: included.length, items: included }));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return included.length;
|
|
717
|
+
} catch (err) {
|
|
718
|
+
console.error(`[Cortex] Injection failed:`, err.message);
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ─── Git Bash detection (Windows) ────────────────────────
|
|
724
|
+
function findGitBash() {
|
|
725
|
+
const custom = process.env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
726
|
+
if (custom && fs.existsSync(custom)) return custom;
|
|
727
|
+
const localAppData = process.env.LOCALAPPDATA || '';
|
|
728
|
+
const candidates = [
|
|
729
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
730
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
731
|
+
path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
732
|
+
];
|
|
733
|
+
for (const p of candidates) {
|
|
734
|
+
if (p && fs.existsSync(p)) return p;
|
|
735
|
+
}
|
|
736
|
+
// Last resort: check if bash is on PATH via where command
|
|
737
|
+
try {
|
|
738
|
+
const result = require('child_process').execSync('where bash.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
|
|
739
|
+
const first = result.trim().split('\n')[0].trim();
|
|
740
|
+
if (first && first.toLowerCase().includes('git') && fs.existsSync(first)) return first;
|
|
741
|
+
} catch { /* not found */ }
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ─── SSH binary detection (Windows) ──────────────────────
|
|
746
|
+
function findSshBinary() {
|
|
747
|
+
if (process.platform !== 'win32') return '/usr/bin/ssh';
|
|
748
|
+
// Windows OpenSSH ships in System32
|
|
749
|
+
const sysSSH = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe');
|
|
750
|
+
if (fs.existsSync(sysSSH)) return sysSSH;
|
|
751
|
+
// Git for Windows also bundles ssh
|
|
752
|
+
const gitSSH = 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe';
|
|
753
|
+
if (fs.existsSync(gitSSH)) return gitSSH;
|
|
754
|
+
try {
|
|
755
|
+
const result = require('child_process').execSync('where ssh.exe 2>nul', { encoding: 'utf-8', timeout: 3000 });
|
|
756
|
+
const first = result.trim().split(String.fromCharCode(10))[0].trim();
|
|
757
|
+
if (first && fs.existsSync(first)) return first;
|
|
758
|
+
} catch {}
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ─── Origin validation ───────────────────────────────────
|
|
763
|
+
function isAllowedOrigin(origin, req) {
|
|
764
|
+
if (!origin) return false;
|
|
765
|
+
try {
|
|
766
|
+
const url = new URL(origin);
|
|
767
|
+
// Allow localhost/127.0.0.1 (any port)
|
|
768
|
+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
|
|
769
|
+
// Allow if origin matches the server's own Host header (same-origin requests)
|
|
770
|
+
const host = req && req.headers && req.headers.host;
|
|
771
|
+
if (host && url.host === host) return true;
|
|
772
|
+
// Allow configured hostname from env (e.g., spaces.example.com)
|
|
773
|
+
const allowed = process.env.SPACES_ALLOWED_ORIGINS;
|
|
774
|
+
if (allowed) {
|
|
775
|
+
return allowed.split(',').some(h => url.hostname === h.trim());
|
|
776
|
+
}
|
|
777
|
+
// In non-community modes, require explicit allowed origins
|
|
778
|
+
if (SPACES_TIER !== 'community') return false;
|
|
779
|
+
// Desktop/community: allow any origin
|
|
780
|
+
return true;
|
|
781
|
+
} catch {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ─── Live collab toggle handler ─────────────────────────
|
|
787
|
+
function handleCollabToggle(paneId, session) {
|
|
788
|
+
try {
|
|
789
|
+
const teams = require('@spaces/teams');
|
|
790
|
+
const config = teams.terminal.getCollabConfig(paneId, session.username);
|
|
791
|
+
|
|
792
|
+
if (config) {
|
|
793
|
+
// Enabling collaboration
|
|
794
|
+
session.isCollaborating = true;
|
|
795
|
+
session.workspaceId = config.workspaceId;
|
|
796
|
+
session.paneName = config.paneName;
|
|
797
|
+
|
|
798
|
+
const env = {
|
|
799
|
+
SPACES_PANE_ID: paneId,
|
|
800
|
+
SPACES_WORKSPACE_ID: config.workspaceId,
|
|
801
|
+
SPACES_PANE_NAME: config.paneName,
|
|
802
|
+
SPACES_USERNAME: session.username,
|
|
803
|
+
SPACES_API_URL: `http://localhost:${API_PORT}`,
|
|
804
|
+
SPACES_COLLABORATING: '1',
|
|
805
|
+
};
|
|
806
|
+
teams.terminal.writeAgentConfig(session.agentType, session.cwd, env);
|
|
807
|
+
|
|
808
|
+
// Nudge the agent so it knows collaboration is available
|
|
809
|
+
if (session.pty && !session.exited) {
|
|
810
|
+
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).';
|
|
811
|
+
session.pty.write(nudge);
|
|
812
|
+
setTimeout(() => { if (!session.exited) session.pty.write('\r'); }, 100);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
console.log(`[CollabToggle] Enabled for pane ${paneId.slice(0, 8)} (workspace ${config.workspaceId.slice(0, 8)})`);
|
|
816
|
+
} else {
|
|
817
|
+
// Disabling collaboration
|
|
818
|
+
teams.terminal.removeAgentConfig(session.agentType, session.cwd);
|
|
819
|
+
session.isCollaborating = false;
|
|
820
|
+
session.workspaceId = null;
|
|
821
|
+
|
|
822
|
+
console.log(`[CollabToggle] Disabled for pane ${paneId.slice(0, 8)}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Confirm to browser
|
|
826
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
827
|
+
session.ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating: !!config }));
|
|
828
|
+
}
|
|
829
|
+
} catch (e) {
|
|
830
|
+
console.error(`[CollabToggle] Error for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ─── Shared connection handler ──────────────────────────
|
|
835
|
+
function handleConnection(wss, ws, req) {
|
|
836
|
+
ws.isAlive = true;
|
|
837
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
838
|
+
|
|
839
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
840
|
+
const paneId = url.searchParams.get('paneId') || require('crypto').randomUUID();
|
|
841
|
+
const cwd = url.searchParams.get('cwd') || process.env.HOME || process.env.USERPROFILE || 'C:\\';
|
|
842
|
+
const agentType = url.searchParams.get('agentType') || 'shell';
|
|
843
|
+
const rawAgentSession = url.searchParams.get('agentSession') || '';
|
|
844
|
+
const agentSession = (rawAgentSession === 'new' || UUID_RE.test(rawAgentSession)) ? rawAgentSession : '';
|
|
845
|
+
const rawCustomCommand = url.searchParams.get('customCommand') || '';
|
|
846
|
+
// Sanitize: reject shell metacharacters that enable injection (;, |, &, $, `, etc.)
|
|
847
|
+
const customCommand = /[;&|`$(){}]/.test(rawCustomCommand) ? '' : rawCustomCommand;
|
|
848
|
+
const cols = parseInt(url.searchParams.get('cols') || '120', 10);
|
|
849
|
+
const rows = parseInt(url.searchParams.get('rows') || '30', 10);
|
|
850
|
+
|
|
851
|
+
// Authenticate: try session cookie first (self-contained auth), then terminal token + SSO header
|
|
852
|
+
let username = null;
|
|
853
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
854
|
+
const sessionToken = cookies['spaces-session'];
|
|
855
|
+
const sessionPayload = sessionToken ? verifySessionToken(sessionToken) : null;
|
|
856
|
+
|
|
857
|
+
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'}`);
|
|
858
|
+
|
|
859
|
+
if (sessionPayload) {
|
|
860
|
+
// Self-contained auth: session cookie is valid
|
|
861
|
+
username = sessionPayload.sub;
|
|
862
|
+
console.log(`[Auth] Authenticated via session cookie: ${username}`);
|
|
863
|
+
} else {
|
|
864
|
+
const terminalToken = url.searchParams.get('terminalToken') || '';
|
|
865
|
+
|
|
866
|
+
// Accept magic tokens from desktop/community tier, or from trusted local proxies (Docker/localhost)
|
|
867
|
+
const remoteIp = req.socket.remoteAddress || '';
|
|
868
|
+
const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1' || remoteIp.startsWith('172.') || remoteIp.startsWith('::ffff:172.');
|
|
869
|
+
if ((terminalToken === 'desktop-local' || terminalToken === 'session-auth') && (SPACES_TIER === 'desktop' || SPACES_TIER === 'community' || isLocal)) {
|
|
870
|
+
username = os.userInfo().username;
|
|
871
|
+
// When running as SYSTEM, resolve to the first real user from admin DB
|
|
872
|
+
if (process.platform === "win32" && username.toUpperCase() === "SYSTEM") {
|
|
873
|
+
const db = getAdminDb();
|
|
874
|
+
if (db) {
|
|
875
|
+
try {
|
|
876
|
+
const row = db.prepare("SELECT username FROM users LIMIT 1").get();
|
|
877
|
+
if (row) username = row.username;
|
|
878
|
+
} catch {}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
console.log(`[Auth] Authenticated via desktop token: ${username}`);
|
|
882
|
+
} else {
|
|
883
|
+
// Verify terminal token — if signed by this server's secret, trust it
|
|
884
|
+
const tokenUser = verifyTerminalToken(terminalToken);
|
|
885
|
+
if (tokenUser) {
|
|
886
|
+
// Use the user from the signed token — do NOT trust x-auth-user header
|
|
887
|
+
// as it can be spoofed by clients
|
|
888
|
+
username = tokenUser;
|
|
889
|
+
console.log(`[Auth] Authenticated via terminal token: ${username}`);
|
|
890
|
+
} else if (terminalToken) {
|
|
891
|
+
console.log(`[Auth] Terminal token FAILED: invalid or expired`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Network API key auth (for proxied connections from remote nodes)
|
|
897
|
+
if (!username) {
|
|
898
|
+
const apiKey = url.searchParams.get('apiKey');
|
|
899
|
+
if (apiKey) {
|
|
900
|
+
console.log(`[Auth] API key provided: ${apiKey.slice(0, 4)}*** (length=${apiKey.length})`);
|
|
901
|
+
const keyRecord = validateNetworkApiKey(apiKey);
|
|
902
|
+
if (keyRecord) {
|
|
903
|
+
console.log(`[Auth] API key validated: permissions=${keyRecord.permissions}, username=${keyRecord.username}`);
|
|
904
|
+
if (keyRecord.permissions === 'terminal' || keyRecord.permissions === 'admin') {
|
|
905
|
+
username = keyRecord.username || os.userInfo().username;
|
|
906
|
+
} else {
|
|
907
|
+
console.log(`[Auth] API key rejected: permissions="${keyRecord.permissions}" not terminal/admin`);
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
console.log(`[Auth] API key validation FAILED (no matching key in DB)`);
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
console.log(`[Auth] No apiKey param in WebSocket URL`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!username) {
|
|
918
|
+
console.log(`[Auth] REJECTED connection for pane ${paneId} — no auth method succeeded`);
|
|
919
|
+
ws.send(JSON.stringify({ type: 'error', data: 'Authentication required' }));
|
|
920
|
+
ws.close();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Proxy to remote node (federation tier only)
|
|
925
|
+
const nodeId = url.searchParams.get('nodeId');
|
|
926
|
+
if (nodeId) {
|
|
927
|
+
if (SPACES_TIER !== 'federation') {
|
|
928
|
+
ws.send(JSON.stringify({ type: 'error', data: 'Remote workspaces require the Federation tier' }));
|
|
929
|
+
ws.close();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
handleProxyConnection(ws, nodeId, { paneId, cwd, agentType, agentSession, customCommand, cols, rows });
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Check for existing session to reattach
|
|
937
|
+
const existing = sessions.get(paneId);
|
|
938
|
+
if (existing && existing.pty && !existing.exited) {
|
|
939
|
+
console.log(`[WS] Reattach pane=${paneId.slice(0,8)} buffer=${existing.buffer.length} chunks`);
|
|
940
|
+
existing.ws = ws;
|
|
941
|
+
|
|
942
|
+
// Replay buffered output so user sees context
|
|
943
|
+
for (const chunk of existing.buffer) {
|
|
944
|
+
ws.send(JSON.stringify({ type: 'data', data: chunk }));
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
try { existing.pty.resize(cols, rows); } catch { /* ignore */ }
|
|
948
|
+
|
|
949
|
+
ws.send(JSON.stringify({ type: 'ready', paneId, reattached: true }));
|
|
950
|
+
|
|
951
|
+
// Skip Cortex injection on reattach — context was already injected at spawn.
|
|
952
|
+
// The badge polls /api/cortex/status independently.
|
|
953
|
+
|
|
954
|
+
ws.on('message', (raw) => {
|
|
955
|
+
try {
|
|
956
|
+
const msg = JSON.parse(raw.toString());
|
|
957
|
+
if (msg.type === 'data') {
|
|
958
|
+
existing.pty.write(msg.data);
|
|
959
|
+
} else if (msg.type === 'resize') {
|
|
960
|
+
try { existing.pty.resize(msg.cols, msg.rows); } catch { /* ignore */ }
|
|
961
|
+
} else if (msg.type === 'collab-toggle') {
|
|
962
|
+
handleCollabToggle(paneId, existing);
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
existing.pty.write(raw.toString());
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
ws.on('close', (code, reason) => {
|
|
970
|
+
console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
|
|
971
|
+
if (existing.ws === ws) existing.ws = null;
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
ws.on('error', (err) => {
|
|
975
|
+
console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Create new pty session
|
|
982
|
+
const isWindows = process.platform === 'win32';
|
|
983
|
+
|
|
984
|
+
// Resolve the OS shell user for this app user
|
|
985
|
+
const shellUser = lookupShellUser(username);
|
|
986
|
+
const processUser = os.userInfo().username;
|
|
987
|
+
let shell, args;
|
|
988
|
+
const isSSH = shellUser !== processUser;
|
|
989
|
+
if (isSSH) {
|
|
990
|
+
// SSH to localhost as the mapped shell user using the service key
|
|
991
|
+
const sshBin = findSshBinary();
|
|
992
|
+
if (!sshBin) {
|
|
993
|
+
console.error(`[Spawn] SSH binary not found — cannot spawn as ${shellUser}`);
|
|
994
|
+
ws.send(JSON.stringify({ type: 'error', data: 'SSH not available. Install OpenSSH to enable multi-user terminals.' }));
|
|
995
|
+
ws.close();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
shell = sshBin;
|
|
999
|
+
args = [
|
|
1000
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
1001
|
+
'-o', `UserKnownHostsFile=${path.join(os.homedir(), '.spaces', 'known_hosts')}`,
|
|
1002
|
+
'-i', SERVICE_KEY,
|
|
1003
|
+
'-t',
|
|
1004
|
+
`${shellUser}@localhost`,
|
|
1005
|
+
];
|
|
1006
|
+
// Force IPv4 — localhost may resolve to ::1 (IPv6) which sshd can reject
|
|
1007
|
+
args.unshift('-4');
|
|
1008
|
+
|
|
1009
|
+
// On-demand SSH provisioning: ensure the shell user's authorized_keys is set up
|
|
1010
|
+
// before attempting the connection. This handles users added after service install.
|
|
1011
|
+
if (process.platform !== 'win32' && fs.existsSync(SERVICE_KEY + '.pub')) {
|
|
1012
|
+
try {
|
|
1013
|
+
const pubKey = fs.readFileSync(SERVICE_KEY + '.pub', 'utf-8').trim();
|
|
1014
|
+
authorizeShellUser(shellUser, pubKey);
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
console.error(`[SSH] On-demand provisioning for ${shellUser} failed (non-fatal):`, e.message);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
} else if (isWindows && agentType !== 'shell') {
|
|
1020
|
+
// Agents like Claude Code require bash on Windows — find git-bash
|
|
1021
|
+
shell = findGitBash();
|
|
1022
|
+
args = [];
|
|
1023
|
+
if (!shell) {
|
|
1024
|
+
shell = 'cmd.exe';
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
shell = isWindows ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
|
|
1028
|
+
args = [];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const env = { ...process.env };
|
|
1032
|
+
delete env.CLAUDECODE;
|
|
1033
|
+
// Enable prompt suggestions in spawned Claude Code sessions
|
|
1034
|
+
env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION = 'true';
|
|
1035
|
+
// Tell Claude Code where git-bash is so it doesn't fail the bash detection
|
|
1036
|
+
if (isWindows && shell && shell.endsWith('bash.exe') && !env.CLAUDE_CODE_GIT_BASH_PATH) {
|
|
1037
|
+
env.CLAUDE_CODE_GIT_BASH_PATH = shell;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Fall back to HOME if cwd doesn't exist (e.g. remote path from another node)
|
|
1041
|
+
let safeCwd = cwd;
|
|
1042
|
+
if (!fs.existsSync(safeCwd)) {
|
|
1043
|
+
safeCwd = os.homedir();
|
|
1044
|
+
console.log(`[Spawn] cwd "${cwd}" does not exist, falling back to "${safeCwd}"`);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Inject Spaces bus environment for agent communication
|
|
1048
|
+
env.SPACES_PANE_ID = paneId;
|
|
1049
|
+
env.SPACES_API_URL = `http://localhost:${API_PORT}`;
|
|
1050
|
+
|
|
1051
|
+
// Look up workspace collaboration config from @spaces/teams
|
|
1052
|
+
let isCollaborating = false;
|
|
1053
|
+
try {
|
|
1054
|
+
const teams = require('@spaces/teams');
|
|
1055
|
+
const config = teams.terminal.getCollabConfig(paneId, username);
|
|
1056
|
+
if (config) {
|
|
1057
|
+
env.SPACES_WORKSPACE_ID = config.workspaceId;
|
|
1058
|
+
env.SPACES_PANE_NAME = config.paneName;
|
|
1059
|
+
env.SPACES_USERNAME = username;
|
|
1060
|
+
isCollaborating = true;
|
|
1061
|
+
env.SPACES_COLLABORATING = '1';
|
|
1062
|
+
console.log(`[Collab] Enabled for pane ${paneId.slice(0, 8)} — workspace ${config.workspaceId}, name "${config.paneName}"`);
|
|
1063
|
+
}
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
console.error(`[Collab] Failed to check collaboration config for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
console.log(`[Spawn] user=${username} shell=${shell} args=${JSON.stringify(args)} cwd=${safeCwd} agentType=${agentType}`);
|
|
1069
|
+
|
|
1070
|
+
// Write Cortex RAG hook for Claude Code before spawning (only if Cortex is enabled)
|
|
1071
|
+
if (agentType === 'claude' && (SPACES_TIER === 'team' || SPACES_TIER === 'federation')) {
|
|
1072
|
+
try {
|
|
1073
|
+
const userHome = getUserHome(username);
|
|
1074
|
+
const configPath = path.join(userHome, '.spaces', 'config.json');
|
|
1075
|
+
let cortexEnabled = false;
|
|
1076
|
+
if (fs.existsSync(configPath)) {
|
|
1077
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1078
|
+
cortexEnabled = cfg.cortex?.enabled === true;
|
|
1079
|
+
}
|
|
1080
|
+
if (!cortexEnabled) {
|
|
1081
|
+
removeCortexHookConfig(safeCwd);
|
|
1082
|
+
} else {
|
|
1083
|
+
writeCortexHookConfig(safeCwd);
|
|
1084
|
+
// Resolve workspace ID: from collab config, or look up from pane DB
|
|
1085
|
+
let wsId = env.SPACES_WORKSPACE_ID || null;
|
|
1086
|
+
if (!wsId) {
|
|
1087
|
+
try {
|
|
1088
|
+
const Database = require('better-sqlite3');
|
|
1089
|
+
const spacesDb = new Database(path.join(getUserHome(username), '.spaces', 'spaces.db'), { readonly: true });
|
|
1090
|
+
const row = spacesDb.prepare('SELECT workspace_id FROM panes WHERE id = ?').get(paneId);
|
|
1091
|
+
if (row && row.workspace_id) wsId = String(row.workspace_id);
|
|
1092
|
+
spacesDb.close();
|
|
1093
|
+
} catch { /* non-fatal */ }
|
|
1094
|
+
}
|
|
1095
|
+
if (wsId) env.SPACES_WORKSPACE_ID = wsId;
|
|
1096
|
+
// Write workspace ID for hooks to read (they can't inherit PTY env)
|
|
1097
|
+
try {
|
|
1098
|
+
const envFile = path.join(safeCwd, '.claude', 'spaces-env.json');
|
|
1099
|
+
fs.writeFileSync(envFile, JSON.stringify({
|
|
1100
|
+
workspaceId: wsId,
|
|
1101
|
+
port: API_PORT,
|
|
1102
|
+
}), 'utf-8');
|
|
1103
|
+
} catch { /* non-fatal */ }
|
|
1104
|
+
}
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
console.error('[Cortex] Config check failed (non-fatal):', e.message);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
let term;
|
|
1111
|
+
try {
|
|
1112
|
+
term = pty.spawn(shell, args, {
|
|
1113
|
+
name: 'xterm-256color',
|
|
1114
|
+
cols,
|
|
1115
|
+
rows,
|
|
1116
|
+
cwd: safeCwd,
|
|
1117
|
+
env,
|
|
1118
|
+
});
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.error(`[Spawn Error] ${err.message} (cwd=${cwd}, shell=${shell})`);
|
|
1121
|
+
ws.send(JSON.stringify({ type: 'error', data: 'Failed to spawn terminal session' }));
|
|
1122
|
+
ws.close();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const session = {
|
|
1127
|
+
pty: term, ws, buffer: [], exited: false, username,
|
|
1128
|
+
agentType,
|
|
1129
|
+
cwd: safeCwd,
|
|
1130
|
+
paneName: env.SPACES_PANE_NAME || paneId,
|
|
1131
|
+
lastOutputTime: Date.now(),
|
|
1132
|
+
lastNudgeTime: 0,
|
|
1133
|
+
startedAt: Date.now(),
|
|
1134
|
+
workspaceId: env.SPACES_WORKSPACE_ID || null,
|
|
1135
|
+
isCollaborating,
|
|
1136
|
+
};
|
|
1137
|
+
sessions.set(paneId, session);
|
|
1138
|
+
analyticsRecordSessionStart(paneId, username, agentType);
|
|
1139
|
+
|
|
1140
|
+
// ─── Cortex context injection (async, non-blocking) ─────
|
|
1141
|
+
if (agentType !== 'shell') {
|
|
1142
|
+
injectCortexContext(safeCwd, env.SPACES_WORKSPACE_ID || null, ws).catch(() => {});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// ─── Inject cd for SSH sessions, then agent command ─────
|
|
1146
|
+
const agent = AGENTS[agentType] || AGENTS.shell;
|
|
1147
|
+
|
|
1148
|
+
// SSH sessions start in the remote user's home dir — cd to target cwd first
|
|
1149
|
+
if (isSSH) {
|
|
1150
|
+
setTimeout(() => {
|
|
1151
|
+
if (!session.exited) {
|
|
1152
|
+
if (isWindows) {
|
|
1153
|
+
// Windows cmd.exe uses double quotes
|
|
1154
|
+
const escapedCwd = safeCwd.replace(/"/g, '""');
|
|
1155
|
+
term.write(`cd /d "${escapedCwd}"\r`);
|
|
1156
|
+
} else {
|
|
1157
|
+
// Unix shells use single quotes
|
|
1158
|
+
const escapedCwd = safeCwd.replace(/'/g, "'\\''");
|
|
1159
|
+
term.write(`cd '${escapedCwd}'\r`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}, 300);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Write collaboration config for agent panes via @spaces/teams
|
|
1166
|
+
if (isCollaborating && agentType !== 'shell') {
|
|
1167
|
+
try {
|
|
1168
|
+
const teams = require('@spaces/teams');
|
|
1169
|
+
teams.terminal.writeAgentConfig(agentType, safeCwd, env);
|
|
1170
|
+
console.log(`[Collab] Wrote agent config for pane ${paneId.slice(0, 8)} (${agentType}) in ${safeCwd}`);
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
console.error(`[Collab] Failed to write agent config for pane ${paneId.slice(0, 8)}:`, e.message);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (agentType !== 'shell') {
|
|
1177
|
+
const command = agentType === 'custom' ? customCommand : agent.command;
|
|
1178
|
+
|
|
1179
|
+
if (command) {
|
|
1180
|
+
const delay = isSSH ? 800 : 300;
|
|
1181
|
+
|
|
1182
|
+
if (agentSession && agentSession !== 'new' && agent.resumeFlag) {
|
|
1183
|
+
// Resume an existing session
|
|
1184
|
+
if (agentType === 'claude') {
|
|
1185
|
+
// Claude needs to be run from the correct project CWD
|
|
1186
|
+
const sessionCwd = findSessionCwd(agentSession, username);
|
|
1187
|
+
setTimeout(() => {
|
|
1188
|
+
if (session.exited) return;
|
|
1189
|
+
if (sessionCwd && sessionCwd !== safeCwd) {
|
|
1190
|
+
const cdCmd = isWindows ? `cd /d "${sessionCwd}"` : `cd "${sessionCwd}"`;
|
|
1191
|
+
term.write(cdCmd + '\r');
|
|
1192
|
+
setTimeout(() => {
|
|
1193
|
+
if (!session.exited) {
|
|
1194
|
+
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1195
|
+
}
|
|
1196
|
+
}, 300);
|
|
1197
|
+
} else {
|
|
1198
|
+
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1199
|
+
}
|
|
1200
|
+
}, delay);
|
|
1201
|
+
} else {
|
|
1202
|
+
// Generic resume: works for both subcommand (codex resume <id>) and flag (gemini --resume <id>)
|
|
1203
|
+
setTimeout(() => {
|
|
1204
|
+
if (!session.exited) {
|
|
1205
|
+
term.write(`${command} ${agent.resumeFlag} ${agentSession}\r`);
|
|
1206
|
+
}
|
|
1207
|
+
}, delay);
|
|
1208
|
+
}
|
|
1209
|
+
} else {
|
|
1210
|
+
// Start new session
|
|
1211
|
+
setTimeout(() => {
|
|
1212
|
+
if (!session.exited) {
|
|
1213
|
+
term.write(`${command}\r`);
|
|
1214
|
+
}
|
|
1215
|
+
}, delay);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// pty -> ws (and buffer)
|
|
1221
|
+
term.onData((data) => {
|
|
1222
|
+
session.lastOutputTime = Date.now();
|
|
1223
|
+
session.buffer.push(data);
|
|
1224
|
+
if (session.buffer.length > MAX_BUFFER_LINES) {
|
|
1225
|
+
session.buffer.shift();
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
1229
|
+
session.ws.send(JSON.stringify({ type: 'data', data }));
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
term.onExit(({ exitCode }) => {
|
|
1234
|
+
session.exited = true;
|
|
1235
|
+
analyticsRecordSessionEnd(paneId);
|
|
1236
|
+
// Clean up hook state file
|
|
1237
|
+
try {
|
|
1238
|
+
const hookStateFile = path.join(os.homedir(), '.spaces', 'hook-state', `${paneId}.json`);
|
|
1239
|
+
if (fs.existsSync(hookStateFile)) fs.unlinkSync(hookStateFile);
|
|
1240
|
+
} catch { /* ignore */ }
|
|
1241
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
1242
|
+
session.ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
|
1243
|
+
}
|
|
1244
|
+
setTimeout(() => {
|
|
1245
|
+
if (sessions.get(paneId) === session) {
|
|
1246
|
+
sessions.delete(paneId);
|
|
1247
|
+
}
|
|
1248
|
+
}, 120000);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// ws -> pty
|
|
1252
|
+
ws.on('message', (raw) => {
|
|
1253
|
+
try {
|
|
1254
|
+
const msg = JSON.parse(raw.toString());
|
|
1255
|
+
if (msg.type === 'data') {
|
|
1256
|
+
term.write(msg.data);
|
|
1257
|
+
} else if (msg.type === 'resize') {
|
|
1258
|
+
try { term.resize(msg.cols, msg.rows); } catch { /* ignore */ }
|
|
1259
|
+
} else if (msg.type === 'collab-toggle') {
|
|
1260
|
+
handleCollabToggle(paneId, session);
|
|
1261
|
+
}
|
|
1262
|
+
} catch {
|
|
1263
|
+
term.write(raw.toString());
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
ws.on('close', (code, reason) => {
|
|
1268
|
+
console.log(`[WS] Close pane=${paneId.slice(0,8)} code=${code} reason=${reason || 'none'}`);
|
|
1269
|
+
if (session.ws === ws) session.ws = null;
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
ws.on('error', (err) => {
|
|
1273
|
+
console.log(`[WS] Error pane=${paneId.slice(0,8)} err=${err.message}`);
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
ws.send(JSON.stringify({ type: 'ready', paneId }));
|
|
1277
|
+
|
|
1278
|
+
// Confirm actual collaboration state so browser syncs with backend
|
|
1279
|
+
ws.send(JSON.stringify({ type: 'collab-updated', isCollaborating }));
|
|
1280
|
+
|
|
1281
|
+
// ─── Session ID detection for Claude sessions ────────────
|
|
1282
|
+
// Always run detection for Claude panes — even when resuming, because
|
|
1283
|
+
// the resume may fail (stale/expired session) and Claude will start fresh,
|
|
1284
|
+
// creating a new session ID that we need to capture.
|
|
1285
|
+
if (agentType === 'claude') {
|
|
1286
|
+
detectNewClaudeSession(paneId, cwd, ws, session, username);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ─── Claude-specific helpers ──────────────────────────────
|
|
1291
|
+
|
|
1292
|
+
function getUserHome(username) {
|
|
1293
|
+
const shellUser = lookupShellUser(username);
|
|
1294
|
+
if (shellUser === os.userInfo().username) return os.homedir();
|
|
1295
|
+
if (process.platform === 'win32') {
|
|
1296
|
+
// On Windows, user profiles live under the Users directory
|
|
1297
|
+
const usersDir = path.dirname(os.homedir());
|
|
1298
|
+
const userHome = path.join(usersDir, shellUser);
|
|
1299
|
+
if (fs.existsSync(userHome)) return userHome;
|
|
1300
|
+
return os.homedir();
|
|
1301
|
+
}
|
|
1302
|
+
return `/home/${shellUser}`;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
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$/;
|
|
1306
|
+
|
|
1307
|
+
function findSessionCwd(sessionId, username) {
|
|
1308
|
+
const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
|
|
1309
|
+
try {
|
|
1310
|
+
if (!fs.existsSync(claudeProjectsDir)) return null;
|
|
1311
|
+
const fileName = `${sessionId}.jsonl`;
|
|
1312
|
+
|
|
1313
|
+
for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
|
|
1314
|
+
if (!projDir.isDirectory()) continue;
|
|
1315
|
+
const filePath = path.join(claudeProjectsDir, projDir.name, fileName);
|
|
1316
|
+
if (fs.existsSync(filePath)) {
|
|
1317
|
+
// Try to find cwd in the jsonl first few lines
|
|
1318
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1319
|
+
const buf = Buffer.alloc(4096);
|
|
1320
|
+
const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
|
|
1321
|
+
fs.closeSync(fd);
|
|
1322
|
+
|
|
1323
|
+
const chunk = buf.toString('utf-8', 0, bytesRead);
|
|
1324
|
+
const lines = chunk.split('\n');
|
|
1325
|
+
for (const line of lines) {
|
|
1326
|
+
if (!line.trim()) continue;
|
|
1327
|
+
try {
|
|
1328
|
+
const entry = JSON.parse(line);
|
|
1329
|
+
if (entry.cwd) {
|
|
1330
|
+
console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${entry.cwd}`);
|
|
1331
|
+
return entry.cwd;
|
|
1332
|
+
}
|
|
1333
|
+
} catch { /* skip */ }
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Fallback: derive CWD from the project directory name
|
|
1337
|
+
// Claude encodes paths as e.g. "-home-user-projects-myapp"
|
|
1338
|
+
const derivedPath = '/' + projDir.name.replace(/^-/, '').replace(/-/g, '/');
|
|
1339
|
+
if (fs.existsSync(derivedPath)) {
|
|
1340
|
+
console.log(`[Session CWD] ${sessionId.slice(0, 8)}: ${derivedPath} (derived from dir name)`);
|
|
1341
|
+
return derivedPath;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
console.error(`[Session CWD] Error looking up ${sessionId}:`, err.message);
|
|
1347
|
+
}
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function detectNewClaudeSession(paneId, cwd, ws, session, username) {
|
|
1352
|
+
const claudeProjectsDir = path.join(getUserHome(username), '.claude', 'projects');
|
|
1353
|
+
|
|
1354
|
+
const knownSessionIds = new Set();
|
|
1355
|
+
try {
|
|
1356
|
+
if (!fs.existsSync(claudeProjectsDir)) { /* will be created */ }
|
|
1357
|
+
else {
|
|
1358
|
+
for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
|
|
1359
|
+
if (!projDir.isDirectory()) continue;
|
|
1360
|
+
const projPath = path.join(claudeProjectsDir, projDir.name);
|
|
1361
|
+
try {
|
|
1362
|
+
for (const item of fs.readdirSync(projPath)) {
|
|
1363
|
+
const m = item.match(UUID_JSONL_RE);
|
|
1364
|
+
if (m) knownSessionIds.add(m[1]);
|
|
1365
|
+
}
|
|
1366
|
+
const indexPath = path.join(projPath, 'sessions-index.json');
|
|
1367
|
+
if (fs.existsSync(indexPath)) {
|
|
1368
|
+
try {
|
|
1369
|
+
const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
1370
|
+
if (data.entries) {
|
|
1371
|
+
for (const entry of data.entries) knownSessionIds.add(entry.sessionId);
|
|
1372
|
+
}
|
|
1373
|
+
} catch { /* ignore */ }
|
|
1374
|
+
}
|
|
1375
|
+
} catch { /* ignore */ }
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} catch { /* ignore */ }
|
|
1379
|
+
|
|
1380
|
+
console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): snapshot ${knownSessionIds.size} existing sessions`);
|
|
1381
|
+
|
|
1382
|
+
let attempts = 0;
|
|
1383
|
+
const maxAttempts = 45;
|
|
1384
|
+
const interval = setInterval(() => {
|
|
1385
|
+
attempts++;
|
|
1386
|
+
if (attempts > maxAttempts || session.exited) {
|
|
1387
|
+
clearInterval(interval);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
try {
|
|
1392
|
+
if (!fs.existsSync(claudeProjectsDir)) return;
|
|
1393
|
+
|
|
1394
|
+
for (const projDir of fs.readdirSync(claudeProjectsDir, { withFileTypes: true })) {
|
|
1395
|
+
if (!projDir.isDirectory()) continue;
|
|
1396
|
+
const projPath = path.join(claudeProjectsDir, projDir.name);
|
|
1397
|
+
try {
|
|
1398
|
+
for (const item of fs.readdirSync(projPath)) {
|
|
1399
|
+
const m = item.match(UUID_JSONL_RE);
|
|
1400
|
+
if (m && !knownSessionIds.has(m[1])) {
|
|
1401
|
+
const newSessionId = m[1];
|
|
1402
|
+
clearInterval(interval);
|
|
1403
|
+
console.log(`[Session Detect] Pane ${paneId.slice(0, 8)} (${username}): detected session ${newSessionId}`);
|
|
1404
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
1405
|
+
session.ws.send(JSON.stringify({
|
|
1406
|
+
type: 'session-detected',
|
|
1407
|
+
sessionId: newSessionId,
|
|
1408
|
+
paneId,
|
|
1409
|
+
}));
|
|
1410
|
+
}
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
} catch { /* ignore */ }
|
|
1415
|
+
}
|
|
1416
|
+
} catch { /* ignore */ }
|
|
1417
|
+
}, 2000);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ─── Proxy: forward connection to remote node ──────────
|
|
1421
|
+
|
|
1422
|
+
async function handleProxyConnection(clientWs, nodeId, opts) {
|
|
1423
|
+
const { paneId, cwd, agentType, agentSession, customCommand, cols, rows } = opts;
|
|
1424
|
+
|
|
1425
|
+
const node = getNodeInfo(nodeId);
|
|
1426
|
+
if (!node) {
|
|
1427
|
+
clientWs.send(JSON.stringify({ type: 'error', data: `Node ${nodeId} not found` }));
|
|
1428
|
+
clientWs.close();
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const apiKey = decryptNodeApiKey(node.api_key_encrypted);
|
|
1433
|
+
if (!apiKey) {
|
|
1434
|
+
clientWs.send(JSON.stringify({ type: 'error', data: 'Cannot decrypt API key for remote node' }));
|
|
1435
|
+
clientWs.close();
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Get the remote WebSocket URL via the terminal token endpoint
|
|
1440
|
+
// Only skip TLS verification if explicitly opted in (e.g., self-signed certs)
|
|
1441
|
+
const skipTls = process.env.SPACES_SKIP_TLS_VERIFY === '1';
|
|
1442
|
+
const prevTls = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
1443
|
+
if (skipTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
1444
|
+
let remoteWsUrl;
|
|
1445
|
+
try {
|
|
1446
|
+
const tokenUrl = `${node.url}/api/network/terminal/token/`;
|
|
1447
|
+
const res = await fetch(tokenUrl, {
|
|
1448
|
+
method: 'POST',
|
|
1449
|
+
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
1450
|
+
signal: AbortSignal.timeout(10000),
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
if (!res.ok) {
|
|
1454
|
+
let detail = '';
|
|
1455
|
+
try { const body = await res.json(); detail = body.error || JSON.stringify(body); } catch { detail = await res.text().catch(() => `HTTP ${res.status}`); }
|
|
1456
|
+
clientWs.send(JSON.stringify({ type: 'error', data: `Remote terminal auth failed: ${detail}` }));
|
|
1457
|
+
clientWs.close();
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const data = await res.json();
|
|
1462
|
+
remoteWsUrl = data.wsUrl;
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
clientWs.send(JSON.stringify({ type: 'error', data: `Cannot reach remote node: ${err.message}` }));
|
|
1465
|
+
clientWs.close();
|
|
1466
|
+
return;
|
|
1467
|
+
} finally {
|
|
1468
|
+
if (prevTls === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
1469
|
+
else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTls;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Connect to remote terminal server using the API key directly.
|
|
1473
|
+
// The terminal token approach fails because the proxied WebSocket has no
|
|
1474
|
+
// x-auth-user header, so the remote server can't match the token's username
|
|
1475
|
+
// to the request. API key auth on the WebSocket is the reliable path.
|
|
1476
|
+
const WebSocket = require('ws');
|
|
1477
|
+
const remoteParams = new URLSearchParams({
|
|
1478
|
+
paneId,
|
|
1479
|
+
cwd,
|
|
1480
|
+
agentType,
|
|
1481
|
+
cols: String(cols),
|
|
1482
|
+
rows: String(rows),
|
|
1483
|
+
apiKey: apiKey,
|
|
1484
|
+
});
|
|
1485
|
+
if (agentSession) remoteParams.set('agentSession', agentSession);
|
|
1486
|
+
// Never forward customCommand to remote nodes — too dangerous
|
|
1487
|
+
|
|
1488
|
+
// Upgrade ws:// to wss:// if the node uses https
|
|
1489
|
+
let wsUrl = remoteWsUrl;
|
|
1490
|
+
if (node.url.startsWith('https://') && wsUrl.startsWith('ws://')) {
|
|
1491
|
+
wsUrl = 'wss://' + wsUrl.slice(5);
|
|
1492
|
+
}
|
|
1493
|
+
const remoteUrl = `${wsUrl}?${remoteParams}`;
|
|
1494
|
+
console.log(`[Proxy] Connecting to remote node ${nodeId.slice(0, 8)}`);
|
|
1495
|
+
|
|
1496
|
+
const remoteWs = new WebSocket(remoteUrl, { rejectUnauthorized: !skipTls ? undefined : false });
|
|
1497
|
+
|
|
1498
|
+
remoteWs.on('open', () => {
|
|
1499
|
+
console.log(`[Proxy] Connected to remote node ${nodeId.slice(0, 8)} for pane ${paneId.slice(0, 8)}`);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
// Pipe data bidirectionally
|
|
1503
|
+
let firstMsg = true;
|
|
1504
|
+
remoteWs.on('message', (data) => {
|
|
1505
|
+
const str = data.toString();
|
|
1506
|
+
if (firstMsg) {
|
|
1507
|
+
console.log(`[Proxy] First message from remote for pane ${paneId.slice(0, 8)}: ${str.slice(0, 200)}`);
|
|
1508
|
+
firstMsg = false;
|
|
1509
|
+
}
|
|
1510
|
+
if (clientWs.readyState === 1) {
|
|
1511
|
+
clientWs.send(str);
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
clientWs.on('message', (data) => {
|
|
1516
|
+
if (remoteWs.readyState === 1) {
|
|
1517
|
+
remoteWs.send(data.toString());
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// Handle closes
|
|
1522
|
+
remoteWs.on('close', () => {
|
|
1523
|
+
console.log(`[Proxy] Remote connection closed for pane ${paneId.slice(0, 8)}`);
|
|
1524
|
+
if (clientWs.readyState === 1) {
|
|
1525
|
+
clientWs.send(JSON.stringify({ type: 'exit', exitCode: -1, reason: 'Remote connection closed' }));
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
remoteWs.on('error', (err) => {
|
|
1530
|
+
console.error(`[Proxy] Remote error for pane ${paneId.slice(0, 8)}:`, err.message);
|
|
1531
|
+
if (clientWs.readyState === 1) {
|
|
1532
|
+
clientWs.send(JSON.stringify({ type: 'error', data: `Remote error: ${err.message}` }));
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
clientWs.on('close', () => {
|
|
1537
|
+
console.log(`[Proxy] Client disconnected for pane ${paneId.slice(0, 8)}`);
|
|
1538
|
+
if (remoteWs.readyState === 1) {
|
|
1539
|
+
remoteWs.close();
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// ─── Shared WSS setup (attach handlers to any WebSocketServer) ──
|
|
1545
|
+
|
|
1546
|
+
function setupWss(wss) {
|
|
1547
|
+
const pingInterval = setInterval(() => {
|
|
1548
|
+
wss.clients.forEach((ws) => {
|
|
1549
|
+
if (ws.isAlive === false) return ws.terminate();
|
|
1550
|
+
ws.isAlive = false;
|
|
1551
|
+
ws.ping();
|
|
1552
|
+
});
|
|
1553
|
+
}, 30000);
|
|
1554
|
+
|
|
1555
|
+
wss.on('connection', (ws, req) => handleConnection(wss, ws, req));
|
|
1556
|
+
|
|
1557
|
+
wss.on('error', (err) => {
|
|
1558
|
+
console.error('[Terminal Server] Error:', err.message);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Initialize analytics DB
|
|
1562
|
+
getAdminDbRW();
|
|
1563
|
+
|
|
1564
|
+
return pingInterval;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function startMdnsIfNeeded(httpPort) {
|
|
1568
|
+
if (SPACES_TIER === 'federation') {
|
|
1569
|
+
try {
|
|
1570
|
+
const { startMdns } = require('./mdns-service');
|
|
1571
|
+
startMdns(httpPort || PORT);
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
console.log('[mDNS] Discovery not available:', err.message);
|
|
1574
|
+
}
|
|
1575
|
+
} else {
|
|
1576
|
+
console.log(`[mDNS] Skipped (tier=${SPACES_TIER}, requires federation)`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// ─── Poll-based idle nudge for agent collaboration ───────
|
|
1581
|
+
|
|
1582
|
+
function startMessageWatcher(apiPort) {
|
|
1583
|
+
try {
|
|
1584
|
+
const teams = require('@spaces/teams');
|
|
1585
|
+
teams.terminal.startMessageWatcher(apiPort, sessions);
|
|
1586
|
+
} catch { /* @spaces/teams not installed — no message watcher */ }
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ─── Attached mode: mount on an existing HTTP server ─────
|
|
1590
|
+
|
|
1591
|
+
function createTerminalServer(httpServer) {
|
|
1592
|
+
// In attached mode, the API is served by the parent HTTP server, not on PORT (3458).
|
|
1593
|
+
if (httpServer.listening) {
|
|
1594
|
+
API_PORT = httpServer.address().port;
|
|
1595
|
+
waitForApi();
|
|
1596
|
+
} else {
|
|
1597
|
+
httpServer.on('listening', () => { API_PORT = httpServer.address().port; waitForApi(); });
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1601
|
+
setupWss(wss);
|
|
1602
|
+
|
|
1603
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
1604
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1605
|
+
if (url.pathname === '/ws' || url.pathname.endsWith('/ws')) {
|
|
1606
|
+
// Verify origin for browser clients
|
|
1607
|
+
const origin = req.headers.origin;
|
|
1608
|
+
if (origin && !isAllowedOrigin(origin, req)) {
|
|
1609
|
+
socket.destroy();
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1613
|
+
wss.emit('connection', ws, req);
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
// Non-/ws upgrades are left for other listeners (e.g. HMR proxy)
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
// Start mDNS and message watcher once we know the HTTP port
|
|
1620
|
+
if (httpServer.listening) {
|
|
1621
|
+
startMdnsIfNeeded(httpServer.address().port);
|
|
1622
|
+
startMessageWatcher(httpServer.address().port);
|
|
1623
|
+
} else {
|
|
1624
|
+
httpServer.on('listening', () => {
|
|
1625
|
+
startMdnsIfNeeded(httpServer.address().port);
|
|
1626
|
+
startMessageWatcher(httpServer.address().port);
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
return wss;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ─── Standalone mode (run directly) ──────────────────────
|
|
1633
|
+
|
|
1634
|
+
if (require.main === module) {
|
|
1635
|
+
const wss = new WebSocketServer({
|
|
1636
|
+
port: PORT,
|
|
1637
|
+
verifyClient: ({ req }) => {
|
|
1638
|
+
const origin = req.headers.origin;
|
|
1639
|
+
if (!origin) return true;
|
|
1640
|
+
return isAllowedOrigin(origin, req);
|
|
1641
|
+
},
|
|
1642
|
+
});
|
|
1643
|
+
setupWss(wss);
|
|
1644
|
+
startMdnsIfNeeded();
|
|
1645
|
+
startMessageWatcher(PORT);
|
|
1646
|
+
console.log(`Terminal WebSocket server running on ws://localhost:${PORT}`);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
module.exports = { createTerminalServer };
|