upfynai-code 3.0.4 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -92
- package/bin/cli.js +191 -0
- package/dist/client/assets/AppContent-M14Au3SB.js +542 -0
- package/{client/dist/assets/BrowserPanel-0TLEl-IC.js → dist/client/assets/BrowserPanel-TFKm2NDJ.js} +2 -2
- package/dist/client/assets/DashboardPanel-C88HjsCh.js +1 -0
- package/dist/client/assets/FileTree-DvO1xnDE.js +1 -0
- package/{client/dist/assets/GitPanel-C_xFM-N2.js → dist/client/assets/GitPanel-D-slVlyy.js} +2 -2
- package/dist/client/assets/LoginModal-Chi4SYcr.js +21 -0
- package/{client/dist/assets/MarkdownPreview-CESjI261.js → dist/client/assets/MarkdownPreview-CuIix2u9.js} +1 -1
- package/dist/client/assets/MermaidBlock-Dq9uFv82.js +2 -0
- package/dist/client/assets/Onboarding-QYXx24dX.js +1 -0
- package/{client/dist/assets/PreviewPanel-CqCa92Tf.js → dist/client/assets/PreviewPanel-Dd8q-jo0.js} +1 -1
- package/dist/client/assets/SetupForm-CrspaUva.js +1 -0
- package/dist/client/assets/WorkflowsPanel-DIlYAdhB.js +1 -0
- package/dist/client/assets/index-CnNNzw9A.css +1 -0
- package/{client/dist/assets/index-HaY-3pK1.js → dist/client/assets/index-rUkK9FDP.js} +26 -26
- package/{client/dist/assets/vendor-codemirror-D2ALgpaX.js → dist/client/assets/vendor-codemirror-jc6nyJQg.js} +1 -1
- package/{client/dist/assets/vendor-diff-DNQpbhrT.js → dist/client/assets/vendor-diff-THJmAcEI.js} +1 -1
- package/{client/dist/assets/vendor-icons-GyYE35HP.js → dist/client/assets/vendor-icons-CfjIpdrD.js} +145 -155
- package/{client/dist/assets/vendor-markdown-CimbIo6Y.js → dist/client/assets/vendor-markdown-Cdm6NEGf.js} +1 -1
- package/dist/client/assets/vendor-mermaid-DTPaBx-U.js +2559 -0
- package/{client/dist/assets/vendor-react-96lCPsRK.js → dist/client/assets/vendor-react-wFkb6mSf.js} +1 -1
- package/{client/dist/assets/vendor-syntax-LS_Nt30I.js → dist/client/assets/vendor-syntax-C_UZR7tc.js} +1 -1
- package/dist/client/favicon.png +0 -0
- package/dist/client/icons/icon-128x128.png +0 -0
- package/dist/client/icons/icon-144x144.png +0 -0
- package/dist/client/icons/icon-152x152.png +0 -0
- package/dist/client/icons/icon-192x192.png +0 -0
- package/dist/client/icons/icon-384x384.png +0 -0
- package/dist/client/icons/icon-512x512.png +0 -0
- package/dist/client/icons/icon-72x72.png +0 -0
- package/dist/client/icons/icon-96x96.png +0 -0
- package/{client/dist → dist/client}/index.html +37 -36
- package/dist/client/logo-128.png +0 -0
- package/dist/client/logo-256.png +0 -0
- package/dist/client/logo-32.png +0 -0
- package/dist/client/logo-512.png +0 -0
- package/dist/client/logo-64.png +0 -0
- package/dist/client/logo.png +0 -0
- package/{client/dist → dist/client}/manifest.json +12 -12
- package/{client/dist → dist/client}/mcp-docs.html +1 -1
- package/{client/dist → dist/client}/sw.js +2 -2
- package/package.json +56 -105
- package/scripts/postinstall.js +9 -0
- package/scripts/prepublish.js +77 -0
- package/src/animation.js +228 -0
- package/src/auth.js +142 -0
- package/src/config.js +40 -0
- package/src/connect.js +416 -0
- package/src/launch.js +81 -0
- package/src/mcp.js +57 -0
- package/src/permissions.js +140 -0
- package/src/persistent-shell.js +261 -0
- package/src/server.js +54 -0
- package/client/dist/assets/AppContent-CwrTP6TW.js +0 -545
- package/client/dist/assets/CanvasFullScreen-D1GWQsGL.js +0 -1
- package/client/dist/assets/CanvasWorkspace-D7ORj358.js +0 -163
- package/client/dist/assets/DashboardPanel-BV7ybUDe.js +0 -1
- package/client/dist/assets/FileTree-5qfhBqdE.js +0 -1
- package/client/dist/assets/LoginModal-CImJHRjX.js +0 -13
- package/client/dist/assets/MermaidBlock-BFM21cwe.js +0 -2
- package/client/dist/assets/Onboarding-B3cteLu2.js +0 -1
- package/client/dist/assets/SetupForm-P6dsYgHO.js +0 -1
- package/client/dist/assets/WorkflowsPanel-CBoN80kc.js +0 -1
- package/client/dist/assets/index-46kkVu2i.css +0 -1
- package/client/dist/assets/pdf-CE_K4jFx.js +0 -12
- package/client/dist/assets/vendor-canvas-BZV40eAE.css +0 -1
- package/client/dist/assets/vendor-canvas-DvHJ_Pn2.js +0 -49
- package/client/dist/assets/vendor-mermaid-DucWyDEe.js +0 -2556
- package/client/dist/favicon.png +0 -0
- package/client/dist/icons/icon-128x128.png +0 -0
- package/client/dist/icons/icon-144x144.png +0 -0
- package/client/dist/icons/icon-152x152.png +0 -0
- package/client/dist/icons/icon-192x192.png +0 -0
- package/client/dist/icons/icon-384x384.png +0 -0
- package/client/dist/icons/icon-512x512.png +0 -0
- package/client/dist/icons/icon-72x72.png +0 -0
- package/client/dist/icons/icon-96x96.png +0 -0
- package/client/dist/logo-128.png +0 -0
- package/client/dist/logo-256.png +0 -0
- package/client/dist/logo-32.png +0 -0
- package/client/dist/logo-512.png +0 -0
- package/client/dist/logo-64.png +0 -0
- package/commands/upfynai-connect.md +0 -59
- package/commands/upfynai-disconnect.md +0 -31
- package/commands/upfynai-doctor.md +0 -99
- package/commands/upfynai-export.md +0 -49
- package/commands/upfynai-local.md +0 -82
- package/commands/upfynai-status.md +0 -75
- package/commands/upfynai-stop.md +0 -49
- package/commands/upfynai-uninstall.md +0 -58
- package/commands/upfynai.md +0 -69
- package/scripts/build-client.js +0 -17
- package/scripts/fix-node-pty.js +0 -67
- package/scripts/install-commands.js +0 -78
- package/server/agent-loop.js +0 -242
- package/server/auto-compact.js +0 -99
- package/server/browser.js +0 -131
- package/server/claude-sdk.js +0 -797
- package/server/cli-ui.js +0 -798
- package/server/cli.js +0 -751
- package/server/constants/config.js +0 -31
- package/server/cursor-cli.js +0 -270
- package/server/database/auth.db +0 -0
- package/server/database/db.js +0 -1547
- package/server/database/init.sql +0 -70
- package/server/index.js +0 -3813
- package/server/load-env.js +0 -26
- package/server/mcp-server.js +0 -621
- package/server/middleware/auth.js +0 -184
- package/server/middleware/relayHelpers.js +0 -44
- package/server/middleware/sandboxRouter.js +0 -174
- package/server/openai-codex.js +0 -403
- package/server/openrouter.js +0 -137
- package/server/projects.js +0 -1807
- package/server/provider-factory.js +0 -174
- package/server/relay-client.js +0 -390
- package/server/routes/agent.js +0 -1234
- package/server/routes/auth.js +0 -559
- package/server/routes/browser.js +0 -419
- package/server/routes/canvas.js +0 -53
- package/server/routes/cli-auth.js +0 -263
- package/server/routes/codex.js +0 -396
- package/server/routes/commands.js +0 -707
- package/server/routes/composio.js +0 -176
- package/server/routes/cursor.js +0 -770
- package/server/routes/dashboard.js +0 -295
- package/server/routes/git.js +0 -1208
- package/server/routes/keys.js +0 -34
- package/server/routes/mcp-utils.js +0 -48
- package/server/routes/mcp.js +0 -661
- package/server/routes/payments.js +0 -227
- package/server/routes/projects.js +0 -754
- package/server/routes/sessions.js +0 -146
- package/server/routes/settings.js +0 -261
- package/server/routes/taskmaster.js +0 -1928
- package/server/routes/user.js +0 -106
- package/server/routes/vapi-chat.js +0 -624
- package/server/routes/voice.js +0 -235
- package/server/routes/webhooks.js +0 -166
- package/server/routes/workflows.js +0 -312
- package/server/sandbox.js +0 -120
- package/server/services/browser-ai.js +0 -154
- package/server/services/composio.js +0 -204
- package/server/services/sessionRegistry.js +0 -139
- package/server/services/whisperService.js +0 -84
- package/server/services/workflowScheduler.js +0 -211
- package/server/tests/relay-flow.test.js +0 -570
- package/server/tests/sessions.test.js +0 -259
- package/server/utils/commandParser.js +0 -303
- package/server/utils/email.js +0 -66
- package/server/utils/gitConfig.js +0 -24
- package/server/utils/mcp-detector.js +0 -198
- package/server/utils/taskmaster-websocket.js +0 -129
- package/shared/integrationCatalog.d.ts +0 -12
- package/shared/integrationCatalog.js +0 -172
- package/shared/modelConstants.js +0 -96
- /package/{shared → dist}/agents/claude.js +0 -0
- /package/{shared → dist}/agents/codex.js +0 -0
- /package/{shared → dist}/agents/cursor.js +0 -0
- /package/{shared → dist}/agents/detect.js +0 -0
- /package/{shared → dist}/agents/exec.js +0 -0
- /package/{shared → dist}/agents/files.js +0 -0
- /package/{shared → dist}/agents/git.js +0 -0
- /package/{shared → dist}/agents/gitagent.js +0 -0
- /package/{shared → dist}/agents/index.js +0 -0
- /package/{shared → dist}/agents/shell.js +0 -0
- /package/{shared → dist}/agents/utils.js +0 -0
- /package/{client/dist → dist/client}/api-docs.html +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- /package/{client/dist → dist/client}/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- /package/{client/dist → dist/client}/assets/vendor-i18n-DCFGyhQR.js +0 -0
- /package/{client/dist → dist/client}/assets/vendor-xterm-CZq1hqo1.js +0 -0
- /package/{client/dist → dist/client}/assets/vendor-xterm-qxJ8_QYu.css +0 -0
- /package/{client/dist → dist/client}/clear-cache.html +0 -0
- /package/{client/dist → dist/client}/convert-icons.md +0 -0
- /package/{client/dist → dist/client}/favicon.svg +0 -0
- /package/{client/dist → dist/client}/generate-icons.js +0 -0
- /package/{client/dist → dist/client}/icons/claude-ai-icon.svg +0 -0
- /package/{client/dist → dist/client}/icons/codex-white.svg +0 -0
- /package/{client/dist → dist/client}/icons/codex.svg +0 -0
- /package/{client/dist → dist/client}/icons/cursor-white.svg +0 -0
- /package/{client/dist → dist/client}/icons/cursor.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-128x128.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-144x144.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-152x152.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-192x192.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-384x384.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-512x512.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-72x72.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-96x96.svg +0 -0
- /package/{client/dist → dist/client}/icons/icon-template.svg +0 -0
- /package/{client/dist → dist/client}/logo.svg +0 -0
- /package/{client/dist → dist/client}/offline.html +0 -0
- /package/{client/dist → dist/client}/screenshots/cli-selection.png +0 -0
- /package/{client/dist → dist/client}/screenshots/desktop-main.png +0 -0
- /package/{client/dist → dist/client}/screenshots/mobile-chat.png +0 -0
- /package/{client/dist → dist/client}/screenshots/tools-modal.png +0 -0
- /package/{shared → dist}/gitagent/index.js +0 -0
- /package/{shared → dist}/gitagent/parser.js +0 -0
- /package/{shared → dist}/gitagent/prompt-builder.js +0 -0
package/server/index.js
DELETED
|
@@ -1,3813 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Load environment variables before other imports execute
|
|
3
|
-
import './load-env.js';
|
|
4
|
-
|
|
5
|
-
// Strip Claude Code session markers so spawned CLI processes don't fail with
|
|
6
|
-
// "cannot be launched inside another Claude Code session" errors.
|
|
7
|
-
delete process.env.CLAUDECODE;
|
|
8
|
-
delete process.env.CLAUDE_CODE;
|
|
9
|
-
import crypto from 'crypto';
|
|
10
|
-
import fs from 'fs';
|
|
11
|
-
import path from 'path';
|
|
12
|
-
import jwt from 'jsonwebtoken';
|
|
13
|
-
import { fileURLToPath } from 'url';
|
|
14
|
-
import { dirname } from 'path';
|
|
15
|
-
|
|
16
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
-
const __dirname = dirname(__filename);
|
|
18
|
-
|
|
19
|
-
// Read version from package.json at startup
|
|
20
|
-
const SERVER_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
21
|
-
|
|
22
|
-
// ANSI color codes for terminal output
|
|
23
|
-
const colors = {
|
|
24
|
-
reset: '\x1b[0m',
|
|
25
|
-
bright: '\x1b[1m',
|
|
26
|
-
cyan: '\x1b[36m',
|
|
27
|
-
green: '\x1b[32m',
|
|
28
|
-
yellow: '\x1b[33m',
|
|
29
|
-
blue: '\x1b[34m',
|
|
30
|
-
dim: '\x1b[2m',
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const c = {
|
|
34
|
-
info: (text) => `${colors.cyan}${text}${colors.reset}`,
|
|
35
|
-
ok: (text) => `${colors.green}${text}${colors.reset}`,
|
|
36
|
-
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
|
|
37
|
-
tip: (text) => `${colors.blue}${text}${colors.reset}`,
|
|
38
|
-
bright: (text) => `${colors.bright}${text}${colors.reset}`,
|
|
39
|
-
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// PORT read from env (no log)
|
|
43
|
-
|
|
44
|
-
import express from 'express';
|
|
45
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
46
|
-
import os from 'os';
|
|
47
|
-
import http from 'http';
|
|
48
|
-
import cors from 'cors';
|
|
49
|
-
import cookieParser from 'cookie-parser';
|
|
50
|
-
import { promises as fsPromises } from 'fs';
|
|
51
|
-
import { spawn } from 'child_process';
|
|
52
|
-
// node-pty: conditionally imported (not available on Vercel serverless)
|
|
53
|
-
let pty = null;
|
|
54
|
-
try {
|
|
55
|
-
pty = (await import('node-pty')).default;
|
|
56
|
-
} catch (e) {
|
|
57
|
-
console.warn('[WARN] node-pty not available. Shell tab requires relay connection.');
|
|
58
|
-
}
|
|
59
|
-
// Node 22+ has built-in fetch — no need for node-fetch
|
|
60
|
-
import mime from 'mime-types';
|
|
61
|
-
|
|
62
|
-
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, setCloudUserId } from './projects.js';
|
|
63
|
-
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, loadGitagentContext, clearGitagentCache, extractTokenBudget } from './claude-sdk.js';
|
|
64
|
-
import { buildSystemPromptAppendix } from '../shared/gitagent/prompt-builder.js';
|
|
65
|
-
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
|
66
|
-
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
|
67
|
-
import { queryOpenRouter, OPENROUTER_MODELS } from './openrouter.js';
|
|
68
|
-
import { createMcpServer, mountMcpServer } from './mcp-server.js';
|
|
69
|
-
import gitRoutes from './routes/git.js';
|
|
70
|
-
import authRoutes from './routes/auth.js';
|
|
71
|
-
import mcpRoutes from './routes/mcp.js';
|
|
72
|
-
import cursorRoutes from './routes/cursor.js';
|
|
73
|
-
import taskmasterRoutes from './routes/taskmaster.js';
|
|
74
|
-
import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|
75
|
-
import commandsRoutes from './routes/commands.js';
|
|
76
|
-
import settingsRoutes from './routes/settings.js';
|
|
77
|
-
import agentRoutes from './routes/agent.js';
|
|
78
|
-
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
|
79
|
-
import cliAuthRoutes from './routes/cli-auth.js';
|
|
80
|
-
import userRoutes from './routes/user.js';
|
|
81
|
-
import codexRoutes from './routes/codex.js';
|
|
82
|
-
import paymentRoutes from './routes/payments.js';
|
|
83
|
-
import webhookRoutes from './routes/webhooks.js';
|
|
84
|
-
import workflowRoutes from './routes/workflows.js';
|
|
85
|
-
import voiceRoutes from './routes/voice.js';
|
|
86
|
-
import dashboardRoutes from './routes/dashboard.js';
|
|
87
|
-
import keysRoutes from './routes/keys.js';
|
|
88
|
-
import assistantRoutes from './routes/vapi-chat.js';
|
|
89
|
-
import canvasRoutes from './routes/canvas.js';
|
|
90
|
-
import composioRoutes from './routes/composio.js';
|
|
91
|
-
import browserRoutes from './routes/browser.js';
|
|
92
|
-
import sessionRoutes from './routes/sessions.js';
|
|
93
|
-
import { handleSandboxWebSocketCommand } from './middleware/sandboxRouter.js';
|
|
94
|
-
import { createRelayMiddleware } from './middleware/relayHelpers.js';
|
|
95
|
-
import { initScheduler } from './services/workflowScheduler.js';
|
|
96
|
-
import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb, fileVersionDb, sessionUsageDb, connectionDb, projectDb, voiceCallDb } from './database/db.js';
|
|
97
|
-
import { validateApiKey, authenticateToken, authenticateWebSocket, JWT_SECRET } from './middleware/auth.js';
|
|
98
|
-
import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
|
|
99
|
-
import { sandboxClient } from './sandbox.js';
|
|
100
|
-
import { execSync } from 'child_process';
|
|
101
|
-
|
|
102
|
-
// ─── Project ownership middleware (user isolation) ──────────────────────────────
|
|
103
|
-
// Ensures the requesting user owns the project before allowing access.
|
|
104
|
-
// In cloud mode: checks user_projects table. In local mode: always passes (single user).
|
|
105
|
-
const IS_CLOUD_ENV = !!(process.env.TURSO_DATABASE_URL && !process.env.IS_LOCAL_SERVER);
|
|
106
|
-
|
|
107
|
-
function authorizeProject(req, res, next) {
|
|
108
|
-
const projectName = req.params.projectName;
|
|
109
|
-
if (!projectName) return next();
|
|
110
|
-
|
|
111
|
-
// Local mode — single user, no isolation needed
|
|
112
|
-
if (!IS_CLOUD_ENV) return next();
|
|
113
|
-
|
|
114
|
-
const userId = req.user?.id || req.user?.userId;
|
|
115
|
-
if (!userId) return res.status(401).json({ error: 'Authentication required' });
|
|
116
|
-
|
|
117
|
-
// Check user_projects table for ownership
|
|
118
|
-
projectDb.getAll(userId).then(projects => {
|
|
119
|
-
const owns = projects.some(p => p.project_name === projectName || p.original_path === projectName);
|
|
120
|
-
if (!owns) {
|
|
121
|
-
return res.status(403).json({ error: 'Access denied — you do not own this project' });
|
|
122
|
-
}
|
|
123
|
-
next();
|
|
124
|
-
}).catch(() => {
|
|
125
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// File system watchers for provider project/session folders
|
|
130
|
-
const PROVIDER_WATCH_PATHS = [
|
|
131
|
-
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
|
132
|
-
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
|
133
|
-
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
|
|
134
|
-
];
|
|
135
|
-
const WATCHER_IGNORED_PATTERNS = [
|
|
136
|
-
'**/node_modules/**',
|
|
137
|
-
'**/.git/**',
|
|
138
|
-
'**/dist/**',
|
|
139
|
-
'**/build/**',
|
|
140
|
-
'**/*.tmp',
|
|
141
|
-
'**/*.swp',
|
|
142
|
-
'**/.DS_Store'
|
|
143
|
-
];
|
|
144
|
-
const WATCHER_DEBOUNCE_MS = 300;
|
|
145
|
-
let projectsWatchers = [];
|
|
146
|
-
let projectsWatcherDebounceTimer = null;
|
|
147
|
-
const connectedClients = new Set();
|
|
148
|
-
let isGetProjectsRunning = false; // Flag to prevent reentrant calls
|
|
149
|
-
|
|
150
|
-
// Relay connections: Maps userId → { ws, capabilities, user }
|
|
151
|
-
// Connects user's local machine to the hosted server
|
|
152
|
-
const relayConnections = new Map();
|
|
153
|
-
// Pending relay requests: Maps requestId → { resolve, reject, timeout }
|
|
154
|
-
const pendingRelayRequests = new Map();
|
|
155
|
-
// Relay session tracking: Maps sessionId → { userId, requestId } (for abort forwarding)
|
|
156
|
-
const relaySessionRequests = new Map();
|
|
157
|
-
// Sandbox cleanup timers: Maps userId → timeout (grace period before sandbox destroy)
|
|
158
|
-
const sandboxCleanupTimers = new Map();
|
|
159
|
-
const SANDBOX_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
|
|
160
|
-
|
|
161
|
-
// ─── Relay Security ──────────────────────────────────────────────────────────
|
|
162
|
-
|
|
163
|
-
// Allowed relay actions — only these can be sent to user's machine.
|
|
164
|
-
// Anything not on this list is rejected to prevent arbitrary command execution.
|
|
165
|
-
const ALLOWED_RELAY_ACTIONS = new Set([
|
|
166
|
-
'claude-query', 'codex-query', 'cursor-query',
|
|
167
|
-
'claude-task-query', // Sub-agent: read-only research (opencode pattern)
|
|
168
|
-
'exec', 'shell-command', 'shell-session-start', // Shell execution & interactive terminal
|
|
169
|
-
'file-read', 'file-write', 'file-tree',
|
|
170
|
-
'browse-dirs', 'validate-path', 'create-folder',
|
|
171
|
-
'git-operation', 'detect-agents',
|
|
172
|
-
'gitagent-detect', 'gitagent-parse', // Gitagent directory structure detection
|
|
173
|
-
]);
|
|
174
|
-
|
|
175
|
-
// Dangerous shell patterns — block injection attempts in shell-command payloads.
|
|
176
|
-
// These patterns catch common shell injection techniques: pipes, chaining,
|
|
177
|
-
// backtick execution, subshells, redirection to sensitive files, etc.
|
|
178
|
-
const DANGEROUS_SHELL_PATTERNS = [
|
|
179
|
-
/;\s*(rm|del|format|mkfs|dd)\b/i, // destructive chained commands
|
|
180
|
-
/\|\s*(bash|sh|cmd|powershell|nc|ncat|curl\s.*\|)/i, // pipe to shell/reverse-shell
|
|
181
|
-
/`[^`]*`/, // backtick command substitution
|
|
182
|
-
/\$\([^)]*\)/, // $() command substitution
|
|
183
|
-
/>\s*\/etc\//, // redirect to system files
|
|
184
|
-
/>\s*C:\\Windows\\/i, // redirect to Windows system
|
|
185
|
-
/&&\s*(rm|del|format|shutdown|reboot)\b/i, // chain destructive commands
|
|
186
|
-
/\|\|\s*(rm|del|format)\b/i, // fallback destructive
|
|
187
|
-
/;\s*(curl|wget|nc)\s.*\s*\|/i, // download-and-execute
|
|
188
|
-
/eval\s*\(/, // eval injection
|
|
189
|
-
/\bsudo\b/, // privilege escalation
|
|
190
|
-
/\bchmod\s+[0-7]*[67][0-7]*\s/, // making files world-writable
|
|
191
|
-
];
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Extract real client IP from request (respects proxy headers on Railway/Vercel).
|
|
195
|
-
* Railway sets x-forwarded-for; we trust the first IP in the chain.
|
|
196
|
-
*/
|
|
197
|
-
function extractClientIp(request) {
|
|
198
|
-
const forwarded = request?.headers?.['x-forwarded-for'];
|
|
199
|
-
if (forwarded) {
|
|
200
|
-
// x-forwarded-for may be comma-separated; first is the real client
|
|
201
|
-
return forwarded.split(',')[0].trim();
|
|
202
|
-
}
|
|
203
|
-
return request?.socket?.remoteAddress || request?.connection?.remoteAddress || 'unknown';
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Validate a relay command payload for safety.
|
|
208
|
-
* Returns { valid: true } or { valid: false, reason: string }.
|
|
209
|
-
*/
|
|
210
|
-
function validateRelayPayload(action, payload) {
|
|
211
|
-
if (!ALLOWED_RELAY_ACTIONS.has(action)) {
|
|
212
|
-
return { valid: false, reason: `Unknown relay action: ${action}` };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// For shell-command, validate the command string
|
|
216
|
-
if (action === 'shell-command' && payload?.command) {
|
|
217
|
-
const cmd = payload.command;
|
|
218
|
-
|
|
219
|
-
// Must be a string
|
|
220
|
-
if (typeof cmd !== 'string') {
|
|
221
|
-
return { valid: false, reason: 'Command must be a string' };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Max command length (prevent buffer overflow attempts)
|
|
225
|
-
if (cmd.length > 10000) {
|
|
226
|
-
return { valid: false, reason: 'Command too long' };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Only allow git commands and known safe commands via shell-command
|
|
230
|
-
// This is the key security gate: shell-command is only used for git operations
|
|
231
|
-
// and system commands like mkdir, rm (for discard). Block everything else.
|
|
232
|
-
const allowedPrefixes = ['git ', 'mkdir ', 'rm ', 'wmic ', 'dir ', 'claude ', 'codex ', 'npm '];
|
|
233
|
-
const isAllowedCommand = allowedPrefixes.some(prefix => cmd.trimStart().startsWith(prefix));
|
|
234
|
-
if (!isAllowedCommand) {
|
|
235
|
-
return { valid: false, reason: `Shell command not allowed: ${cmd.substring(0, 50)}` };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Check for dangerous patterns even in allowed commands
|
|
239
|
-
for (const pattern of DANGEROUS_SHELL_PATTERNS) {
|
|
240
|
-
if (pattern.test(cmd)) {
|
|
241
|
-
return { valid: false, reason: 'Dangerous command pattern detected' };
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// For file operations, validate paths
|
|
247
|
-
if (['file-read', 'file-write', 'file-tree', 'create-folder'].includes(action)) {
|
|
248
|
-
const filePath = payload?.filePath || payload?.dirPath || payload?.folderPath;
|
|
249
|
-
if (filePath && typeof filePath === 'string') {
|
|
250
|
-
// Block path traversal attempts
|
|
251
|
-
if (filePath.includes('..') && (filePath.includes('/etc/') || filePath.includes('/proc/') || filePath.includes('\\Windows\\System32'))) {
|
|
252
|
-
return { valid: false, reason: 'Path traversal blocked' };
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return { valid: true };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Session-tab locking: Maps sessionId → WebSocket connection
|
|
261
|
-
// Prevents the same session from being active in multiple tabs
|
|
262
|
-
const sessionLocks = new Map();
|
|
263
|
-
|
|
264
|
-
// Session-to-user ownership map — tracks which userId owns each sessionId
|
|
265
|
-
// Used by session REST routes to enforce user isolation
|
|
266
|
-
const sessionOwners = new Map();
|
|
267
|
-
|
|
268
|
-
// Session busy detection (opencode pattern: IsSessionBusy)
|
|
269
|
-
// Maps sessionId → true when a query is being processed
|
|
270
|
-
const busySessions = new Map();
|
|
271
|
-
|
|
272
|
-
function markSessionBusy(sessionId) {
|
|
273
|
-
if (!sessionId) return true;
|
|
274
|
-
if (busySessions.has(sessionId)) return false; // already busy
|
|
275
|
-
busySessions.set(sessionId, true);
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function markSessionFree(sessionId) {
|
|
280
|
-
if (sessionId) busySessions.delete(sessionId);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function acquireSessionLock(sessionId, ws) {
|
|
284
|
-
if (!sessionId) return true; // New sessions don't need locks yet
|
|
285
|
-
const existingWs = sessionLocks.get(sessionId);
|
|
286
|
-
if (existingWs && existingWs !== ws && existingWs.readyState === 1) {
|
|
287
|
-
return false; // Another tab has this session locked
|
|
288
|
-
}
|
|
289
|
-
sessionLocks.set(sessionId, ws);
|
|
290
|
-
return true;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function releaseSessionLock(sessionId, ws) {
|
|
294
|
-
const lockedWs = sessionLocks.get(sessionId);
|
|
295
|
-
if (lockedWs === ws) {
|
|
296
|
-
sessionLocks.delete(sessionId);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function releaseAllLocksForWs(ws) {
|
|
301
|
-
for (const [sessionId, lockedWs] of sessionLocks.entries()) {
|
|
302
|
-
if (lockedWs === ws) {
|
|
303
|
-
sessionLocks.delete(sessionId);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Session subscribers: Maps sessionId → Set<WebSocket>
|
|
309
|
-
// Allows multiple tabs to READ output from an active session
|
|
310
|
-
const sessionSubscribers = new Map();
|
|
311
|
-
|
|
312
|
-
function subscribeToSession(sessionId, ws) {
|
|
313
|
-
if (!sessionId) return;
|
|
314
|
-
if (!sessionSubscribers.has(sessionId)) {
|
|
315
|
-
sessionSubscribers.set(sessionId, new Set());
|
|
316
|
-
}
|
|
317
|
-
sessionSubscribers.get(sessionId).add(ws);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function unsubscribeFromSession(sessionId, ws) {
|
|
321
|
-
const subs = sessionSubscribers.get(sessionId);
|
|
322
|
-
if (subs) {
|
|
323
|
-
subs.delete(ws);
|
|
324
|
-
if (subs.size === 0) sessionSubscribers.delete(sessionId);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function unsubscribeAllForWs(ws) {
|
|
329
|
-
for (const [sessionId, subs] of sessionSubscribers.entries()) {
|
|
330
|
-
subs.delete(ws);
|
|
331
|
-
if (subs.size === 0) sessionSubscribers.delete(sessionId);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function broadcastToSessionSubscribers(sessionId, message) {
|
|
336
|
-
const subs = sessionSubscribers.get(sessionId);
|
|
337
|
-
if (!subs) return;
|
|
338
|
-
const payload = typeof message === 'string' ? message : JSON.stringify(message);
|
|
339
|
-
for (const ws of subs) {
|
|
340
|
-
if (ws.readyState === 1) {
|
|
341
|
-
ws.send(payload);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Broadcast progress to all connected WebSocket clients
|
|
347
|
-
function broadcastProgress(progress) {
|
|
348
|
-
const message = JSON.stringify({
|
|
349
|
-
type: 'loading_progress',
|
|
350
|
-
...progress
|
|
351
|
-
});
|
|
352
|
-
connectedClients.forEach(client => {
|
|
353
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
354
|
-
client.send(message);
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Setup file system watchers for Claude, Cursor, and Codex project/session folders
|
|
360
|
-
async function setupProjectsWatcher() {
|
|
361
|
-
const chokidar = (await import('chokidar')).default;
|
|
362
|
-
|
|
363
|
-
if (projectsWatcherDebounceTimer) {
|
|
364
|
-
clearTimeout(projectsWatcherDebounceTimer);
|
|
365
|
-
projectsWatcherDebounceTimer = null;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
await Promise.all(
|
|
369
|
-
projectsWatchers.map(async (watcher) => {
|
|
370
|
-
try {
|
|
371
|
-
await watcher.close();
|
|
372
|
-
} catch (error) {
|
|
373
|
-
// watcher close error handled silently
|
|
374
|
-
}
|
|
375
|
-
})
|
|
376
|
-
);
|
|
377
|
-
projectsWatchers = [];
|
|
378
|
-
|
|
379
|
-
const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
|
|
380
|
-
if (projectsWatcherDebounceTimer) {
|
|
381
|
-
clearTimeout(projectsWatcherDebounceTimer);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
projectsWatcherDebounceTimer = setTimeout(async () => {
|
|
385
|
-
// Prevent reentrant calls
|
|
386
|
-
if (isGetProjectsRunning) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
try {
|
|
391
|
-
isGetProjectsRunning = true;
|
|
392
|
-
|
|
393
|
-
// Clear project directory cache when files change
|
|
394
|
-
clearProjectDirectoryCache();
|
|
395
|
-
|
|
396
|
-
// Get updated projects list
|
|
397
|
-
const updatedProjects = await getProjects(broadcastProgress);
|
|
398
|
-
|
|
399
|
-
// Notify connected clients about project changes (per-user filtered)
|
|
400
|
-
const basePayload = {
|
|
401
|
-
type: 'projects_updated',
|
|
402
|
-
timestamp: new Date().toISOString(),
|
|
403
|
-
changeType: eventType,
|
|
404
|
-
changedFile: path.relative(rootPath, filePath),
|
|
405
|
-
watchProvider: provider
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
// Build per-user project lists, then send to all their clients
|
|
409
|
-
const userProjectsCache = new Map();
|
|
410
|
-
for (const client of connectedClients) {
|
|
411
|
-
if (client.readyState !== WebSocket.OPEN) continue;
|
|
412
|
-
const uid = client._wsUser?.userId;
|
|
413
|
-
if (!uid) {
|
|
414
|
-
// No auth — local mode, send all projects
|
|
415
|
-
client.send(JSON.stringify({ ...basePayload, projects: updatedProjects }));
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
try {
|
|
419
|
-
if (!userProjectsCache.has(uid)) {
|
|
420
|
-
userProjectsCache.set(uid, await getProjects(null, uid));
|
|
421
|
-
}
|
|
422
|
-
client.send(JSON.stringify({ ...basePayload, projects: userProjectsCache.get(uid) }));
|
|
423
|
-
} catch { /* ignore per-user fetch errors */ }
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
} catch (error) {
|
|
427
|
-
// project change error handled silently
|
|
428
|
-
} finally {
|
|
429
|
-
isGetProjectsRunning = false;
|
|
430
|
-
}
|
|
431
|
-
}, WATCHER_DEBOUNCE_MS);
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
|
435
|
-
try {
|
|
436
|
-
// chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
|
|
437
|
-
// Ensure provider folders exist before creating the watcher so watching stays active.
|
|
438
|
-
await fsPromises.mkdir(rootPath, { recursive: true });
|
|
439
|
-
|
|
440
|
-
// Initialize chokidar watcher with optimized settings
|
|
441
|
-
const watcher = chokidar.watch(rootPath, {
|
|
442
|
-
ignored: WATCHER_IGNORED_PATTERNS,
|
|
443
|
-
persistent: true,
|
|
444
|
-
ignoreInitial: true, // Don't fire events for existing files on startup
|
|
445
|
-
followSymlinks: false,
|
|
446
|
-
depth: 10, // Reasonable depth limit
|
|
447
|
-
awaitWriteFinish: {
|
|
448
|
-
stabilityThreshold: 100, // Wait 100ms for file to stabilize
|
|
449
|
-
pollInterval: 50
|
|
450
|
-
}
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// Set up event listeners
|
|
454
|
-
watcher
|
|
455
|
-
.on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
|
|
456
|
-
.on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
|
|
457
|
-
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
|
|
458
|
-
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
|
|
459
|
-
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
|
|
460
|
-
.on('error', (error) => {
|
|
461
|
-
// provider watcher error handled silently
|
|
462
|
-
})
|
|
463
|
-
.on('ready', () => {
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
projectsWatchers.push(watcher);
|
|
467
|
-
} catch (error) {
|
|
468
|
-
// provider watcher setup error handled silently
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (projectsWatchers.length === 0) {
|
|
473
|
-
// no provider watchers available
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const app = express();
|
|
479
|
-
|
|
480
|
-
// On Vercel serverless, we don't need an HTTP server or WebSocket server
|
|
481
|
-
const server = process.env.VERCEL ? null : http.createServer(app);
|
|
482
|
-
|
|
483
|
-
const ptySessionsMap = new Map();
|
|
484
|
-
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
485
|
-
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
|
486
|
-
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
|
487
|
-
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
|
488
|
-
|
|
489
|
-
function stripAnsiSequences(value = '') {
|
|
490
|
-
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function normalizeDetectedUrl(url) {
|
|
494
|
-
if (!url || typeof url !== 'string') return null;
|
|
495
|
-
|
|
496
|
-
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
|
497
|
-
if (!cleaned) return null;
|
|
498
|
-
|
|
499
|
-
try {
|
|
500
|
-
const parsed = new URL(cleaned);
|
|
501
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
502
|
-
return null;
|
|
503
|
-
}
|
|
504
|
-
return parsed.toString();
|
|
505
|
-
} catch {
|
|
506
|
-
return null;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function extractUrlsFromText(value = '') {
|
|
511
|
-
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
|
512
|
-
|
|
513
|
-
// Handle wrapped terminal URLs split across lines by terminal width.
|
|
514
|
-
const wrappedMatches = [];
|
|
515
|
-
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
|
516
|
-
const lines = value.split(/\r?\n/);
|
|
517
|
-
for (let i = 0; i < lines.length; i++) {
|
|
518
|
-
const line = lines[i].trim();
|
|
519
|
-
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
|
520
|
-
if (!startMatch) continue;
|
|
521
|
-
|
|
522
|
-
let combined = startMatch[0];
|
|
523
|
-
let j = i + 1;
|
|
524
|
-
while (j < lines.length) {
|
|
525
|
-
const continuation = lines[j].trim();
|
|
526
|
-
if (!continuation) break;
|
|
527
|
-
if (!continuationRegex.test(continuation)) break;
|
|
528
|
-
combined += continuation;
|
|
529
|
-
j++;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function shouldAutoOpenUrlFromOutput(value = '') {
|
|
539
|
-
const normalized = value.toLowerCase();
|
|
540
|
-
return (
|
|
541
|
-
normalized.includes('browser didn\'t open') ||
|
|
542
|
-
normalized.includes('open this url') ||
|
|
543
|
-
normalized.includes('continue in your browser') ||
|
|
544
|
-
normalized.includes('press enter to open') ||
|
|
545
|
-
normalized.includes('open_url:')
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Single WebSocket server that handles both paths (skip on Vercel serverless)
|
|
550
|
-
let wss = null;
|
|
551
|
-
if (server) {
|
|
552
|
-
wss = new WebSocketServer({
|
|
553
|
-
server,
|
|
554
|
-
verifyClient: (info, done) => {
|
|
555
|
-
const reqUrl = info.req.url || '';
|
|
556
|
-
const clientIp = info.req.headers['x-forwarded-for']?.split(',')[0]?.trim() || info.req.socket?.remoteAddress || 'unknown';
|
|
557
|
-
// Timeout the entire verifyClient to prevent hung upgrades
|
|
558
|
-
const authTimeout = setTimeout(() => {
|
|
559
|
-
console.warn(`[WS] Auth TIMEOUT for ${reqUrl} from ${clientIp}`);
|
|
560
|
-
done(false, 504, 'Auth timeout');
|
|
561
|
-
}, 15000);
|
|
562
|
-
authenticateWebSocket(info.req).then(user => {
|
|
563
|
-
clearTimeout(authTimeout);
|
|
564
|
-
if (!user) {
|
|
565
|
-
console.warn(`[WS] Auth REJECTED for ${reqUrl} from ${clientIp} — no valid token`);
|
|
566
|
-
done(false, 401, 'Unauthorized');
|
|
567
|
-
return;
|
|
568
|
-
}
|
|
569
|
-
info.req.user = user;
|
|
570
|
-
done(true);
|
|
571
|
-
}).catch(err => {
|
|
572
|
-
clearTimeout(authTimeout);
|
|
573
|
-
console.error(`[WS] Auth ERROR for ${reqUrl} from ${clientIp}:`, err.message);
|
|
574
|
-
done(false, 500, 'Auth error');
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Make WebSocket server available to routes
|
|
581
|
-
app.locals.wss = wss;
|
|
582
|
-
|
|
583
|
-
// Security headers — protect against common web attacks
|
|
584
|
-
app.use((req, res, next) => {
|
|
585
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
586
|
-
// No iframe embedding — app is served directly via Vercel reverse proxy
|
|
587
|
-
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
588
|
-
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
589
|
-
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
590
|
-
if (process.env.NODE_ENV === 'production') {
|
|
591
|
-
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
592
|
-
}
|
|
593
|
-
next();
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
// CORS: require explicit CORS_ORIGINS in production, restrict to same-origin otherwise
|
|
597
|
-
const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|
598
|
-
? process.env.CORS_ORIGINS.split(',').map(o => o.trim())
|
|
599
|
-
: (process.env.NODE_ENV === 'production' ? ['https://cli.upfyn.com'] : true);
|
|
600
|
-
app.use(cors({ origin: CORS_ORIGINS, credentials: true }));
|
|
601
|
-
app.use(cookieParser());
|
|
602
|
-
app.use(express.json({
|
|
603
|
-
limit: '50mb',
|
|
604
|
-
type: (req) => {
|
|
605
|
-
// Skip multipart/form-data requests (for file uploads like images)
|
|
606
|
-
const contentType = req.headers['content-type'] || '';
|
|
607
|
-
if (contentType.includes('multipart/form-data')) {
|
|
608
|
-
return false;
|
|
609
|
-
}
|
|
610
|
-
return contentType.includes('json');
|
|
611
|
-
}
|
|
612
|
-
}));
|
|
613
|
-
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
614
|
-
|
|
615
|
-
// Rate limiting for auth endpoints — prevent brute force attacks
|
|
616
|
-
const authRateLimitMap = new Map();
|
|
617
|
-
const AUTH_RATE_WINDOW = 15 * 60 * 1000; // 15 minutes
|
|
618
|
-
const AUTH_RATE_MAX = 10; // max attempts per window
|
|
619
|
-
|
|
620
|
-
function authRateLimit(req, res, next) {
|
|
621
|
-
const key = req.ip || req.connection.remoteAddress || 'unknown';
|
|
622
|
-
const now = Date.now();
|
|
623
|
-
const entry = authRateLimitMap.get(key);
|
|
624
|
-
|
|
625
|
-
if (entry) {
|
|
626
|
-
// Clean expired entries
|
|
627
|
-
if (now - entry.windowStart > AUTH_RATE_WINDOW) {
|
|
628
|
-
authRateLimitMap.set(key, { windowStart: now, count: 1 });
|
|
629
|
-
return next();
|
|
630
|
-
}
|
|
631
|
-
entry.count++;
|
|
632
|
-
if (entry.count > AUTH_RATE_MAX) {
|
|
633
|
-
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
|
|
634
|
-
}
|
|
635
|
-
} else {
|
|
636
|
-
authRateLimitMap.set(key, { windowStart: now, count: 1 });
|
|
637
|
-
}
|
|
638
|
-
next();
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Clean up stale rate limit entries every 30 minutes
|
|
642
|
-
setInterval(() => {
|
|
643
|
-
const now = Date.now();
|
|
644
|
-
for (const [key, entry] of authRateLimitMap) {
|
|
645
|
-
if (now - entry.windowStart > AUTH_RATE_WINDOW) {
|
|
646
|
-
authRateLimitMap.delete(key);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
}, 30 * 60 * 1000);
|
|
650
|
-
|
|
651
|
-
app.locals.authRateLimit = authRateLimit;
|
|
652
|
-
|
|
653
|
-
// Vercel serverless: lazy DB initialization on first request
|
|
654
|
-
let dbInitialized = false;
|
|
655
|
-
if (process.env.VERCEL) {
|
|
656
|
-
app.use(async (req, res, next) => {
|
|
657
|
-
if (!dbInitialized) {
|
|
658
|
-
try {
|
|
659
|
-
await initializeDatabase();
|
|
660
|
-
dbInitialized = true;
|
|
661
|
-
} catch (err) {
|
|
662
|
-
// DB init error handled silently
|
|
663
|
-
return res.status(500).json({ error: 'Service temporarily unavailable' });
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
next();
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Public health check endpoint (no authentication required)
|
|
671
|
-
app.get('/health', (req, res) => {
|
|
672
|
-
res.json({
|
|
673
|
-
status: 'ok',
|
|
674
|
-
version: SERVER_VERSION,
|
|
675
|
-
timestamp: new Date().toISOString()
|
|
676
|
-
});
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
// Optional API key validation (if configured)
|
|
680
|
-
app.use('/api', validateApiKey);
|
|
681
|
-
|
|
682
|
-
// Authentication routes (public)
|
|
683
|
-
app.use('/api/auth', authRoutes);
|
|
684
|
-
|
|
685
|
-
// Relay middleware — injects req.isCloud, req.hasRelay(), req.sendRelay(), req.requireRelay()
|
|
686
|
-
// Must be created after hasActiveRelay/sendRelayCommand are defined (they're hoisted functions)
|
|
687
|
-
const relayMiddleware = createRelayMiddleware(hasActiveRelay, sendRelayCommand, withRetry);
|
|
688
|
-
|
|
689
|
-
// Projects API Routes (protected + relay-aware)
|
|
690
|
-
app.use('/api/projects', authenticateToken, relayMiddleware, projectsRoutes);
|
|
691
|
-
|
|
692
|
-
// Git API Routes (protected + relay-aware)
|
|
693
|
-
app.use('/api/git', authenticateToken, relayMiddleware, gitRoutes);
|
|
694
|
-
|
|
695
|
-
// MCP API Routes (protected + relay-aware)
|
|
696
|
-
app.use('/api/mcp', authenticateToken, relayMiddleware, mcpRoutes);
|
|
697
|
-
|
|
698
|
-
// Cursor API Routes (protected + relay-aware)
|
|
699
|
-
app.use('/api/cursor', authenticateToken, relayMiddleware, cursorRoutes);
|
|
700
|
-
|
|
701
|
-
// TaskMaster API Routes (protected)
|
|
702
|
-
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
|
|
703
|
-
|
|
704
|
-
// MCP utilities
|
|
705
|
-
app.use('/api/mcp-utils', authenticateToken, relayMiddleware, mcpUtilsRoutes);
|
|
706
|
-
|
|
707
|
-
// Upfyn-Code MCP Server — exposes app capabilities to any MCP client (ChatGPT, Claude Desktop, Cursor, etc.)
|
|
708
|
-
const mcpDeps = {
|
|
709
|
-
getProjects,
|
|
710
|
-
getSessions,
|
|
711
|
-
getSessionMessages,
|
|
712
|
-
queryClaudeSDK,
|
|
713
|
-
abortClaudeSDKSession,
|
|
714
|
-
getActiveClaudeSDKSessions,
|
|
715
|
-
connectedClients,
|
|
716
|
-
};
|
|
717
|
-
const mcpAppServer = createMcpServer(mcpDeps);
|
|
718
|
-
const mcpServerFactory = () => createMcpServer(mcpDeps);
|
|
719
|
-
mountMcpServer(app, mcpAppServer, mcpServerFactory).catch(err => console.error('[MCP] Failed to mount:', err.message));
|
|
720
|
-
|
|
721
|
-
// Commands API Routes (protected + relay-aware)
|
|
722
|
-
app.use('/api/commands', authenticateToken, relayMiddleware, commandsRoutes);
|
|
723
|
-
|
|
724
|
-
// Settings API Routes (protected)
|
|
725
|
-
app.use('/api/settings', authenticateToken, settingsRoutes);
|
|
726
|
-
|
|
727
|
-
// CLI Authentication API Routes (protected)
|
|
728
|
-
app.use('/api/cli', authenticateToken, cliAuthRoutes);
|
|
729
|
-
|
|
730
|
-
// User API Routes (protected)
|
|
731
|
-
app.use('/api/user', authenticateToken, userRoutes);
|
|
732
|
-
|
|
733
|
-
// Codex API Routes (protected + relay-aware)
|
|
734
|
-
app.use('/api/codex', authenticateToken, relayMiddleware, codexRoutes);
|
|
735
|
-
|
|
736
|
-
// Payment & Subscription Routes (protected)
|
|
737
|
-
app.use('/api/payments', authenticateToken, paymentRoutes);
|
|
738
|
-
app.use('/api/webhooks', authenticateToken, webhookRoutes);
|
|
739
|
-
app.use('/api/workflows', authenticateToken, workflowRoutes);
|
|
740
|
-
app.use('/api/voice', authenticateToken, voiceRoutes);
|
|
741
|
-
app.use('/api/dashboard', authenticateToken, dashboardRoutes);
|
|
742
|
-
// Inject sessionOwners map so session routes can filter by userId
|
|
743
|
-
app.use('/api/sessions', authenticateToken, (req, _res, next) => {
|
|
744
|
-
req.sessionOwners = sessionOwners;
|
|
745
|
-
next();
|
|
746
|
-
}, sessionRoutes);
|
|
747
|
-
app.use('/api/keys', authenticateToken, keysRoutes);
|
|
748
|
-
app.use('/api/assistant', assistantRoutes);
|
|
749
|
-
app.use('/api/canvas', authenticateToken, canvasRoutes);
|
|
750
|
-
app.use('/api/composio', authenticateToken, composioRoutes);
|
|
751
|
-
app.use('/api/browser', authenticateToken, browserRoutes);
|
|
752
|
-
app.use('/api/vapi', assistantRoutes); // Alias: VAPI dashboard webhook points to /api/vapi/webhook
|
|
753
|
-
|
|
754
|
-
// Voice call history endpoints (per-user isolated)
|
|
755
|
-
app.get('/api/voice/calls', authenticateToken, async (req, res) => {
|
|
756
|
-
try {
|
|
757
|
-
const userId = req.user.id || req.user.userId;
|
|
758
|
-
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
|
|
759
|
-
const offset = parseInt(req.query.offset) || 0;
|
|
760
|
-
const calls = await voiceCallDb.getByUser(userId, limit, offset);
|
|
761
|
-
const stats = await voiceCallDb.getUserStats(userId);
|
|
762
|
-
res.json({ calls, stats });
|
|
763
|
-
} catch {
|
|
764
|
-
res.status(500).json({ error: 'Failed to fetch voice call history' });
|
|
765
|
-
}
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
// Agent API Routes (uses API key authentication)
|
|
769
|
-
app.use('/api/agent', agentRoutes);
|
|
770
|
-
|
|
771
|
-
// Relay token management routes
|
|
772
|
-
app.get('/api/relay/tokens', authenticateToken, async (req, res) => {
|
|
773
|
-
try {
|
|
774
|
-
const tokens = await relayTokensDb.getTokens(req.user.id);
|
|
775
|
-
res.json(tokens.map(t => ({ ...t, token: t.token.slice(0, 10) + '...' }))); // mask tokens
|
|
776
|
-
} catch (err) {
|
|
777
|
-
res.status(500).json({ error: 'Failed to fetch relay tokens' });
|
|
778
|
-
}
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
app.post('/api/relay/tokens', authenticateToken, async (req, res) => {
|
|
782
|
-
try {
|
|
783
|
-
const name = req.body.name || 'default';
|
|
784
|
-
const result = await relayTokensDb.createToken(req.user.id, name);
|
|
785
|
-
res.json(result); // returns full token only on creation
|
|
786
|
-
} catch (err) {
|
|
787
|
-
res.status(500).json({ error: 'Failed to create relay token' });
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
app.delete('/api/relay/tokens/:id', authenticateToken, async (req, res) => {
|
|
792
|
-
try {
|
|
793
|
-
await relayTokensDb.deleteToken(req.user.id, req.params.id);
|
|
794
|
-
res.json({ success: true });
|
|
795
|
-
} catch (err) {
|
|
796
|
-
res.status(500).json({ error: 'Failed to delete relay token' });
|
|
797
|
-
}
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
app.get('/api/relay/status', authenticateToken, async (req, res) => {
|
|
801
|
-
// In local mode, always connected — SDK runs directly on this machine
|
|
802
|
-
if (IS_LOCAL) {
|
|
803
|
-
return res.json({ connected: true, local: true, connectedAt: Date.now() });
|
|
804
|
-
}
|
|
805
|
-
const relay = relayConnections.get(Number(req.user.id));
|
|
806
|
-
const connected = !!(relay && relay.ws.readyState === 1);
|
|
807
|
-
|
|
808
|
-
// Ensure relay CWD is registered as a project (handles post-deploy filesystem wipe)
|
|
809
|
-
if (connected && relay.cwd) {
|
|
810
|
-
try { await addProjectManually(relay.cwd, null, req.user.id); } catch { /* already exists */ }
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// Check if sandbox is alive (even if relay is disconnected — grace period)
|
|
814
|
-
let sandboxActive = false;
|
|
815
|
-
try {
|
|
816
|
-
const sbStatus = await sandboxClient.getStatus(req.user.id).catch(() => null);
|
|
817
|
-
sandboxActive = !!(sbStatus?.running);
|
|
818
|
-
} catch { /* ignore */ }
|
|
819
|
-
|
|
820
|
-
// Get last connection info from DB for reconnection context
|
|
821
|
-
let lastConnection = null;
|
|
822
|
-
if (!connected) {
|
|
823
|
-
try {
|
|
824
|
-
const active = await connectionDb.getActive(String(req.user.id));
|
|
825
|
-
if (active.length === 0) {
|
|
826
|
-
// Check recent disconnections
|
|
827
|
-
const all = await connectionDb.getAllActive();
|
|
828
|
-
lastConnection = all.find(c => c.user_id === String(req.user.id)) || null;
|
|
829
|
-
}
|
|
830
|
-
} catch { /* ignore */ }
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
res.json({
|
|
834
|
-
connected,
|
|
835
|
-
connectedAt: relay?.connectedAt || null,
|
|
836
|
-
cwd: connected ? relay.cwd : null,
|
|
837
|
-
machine: connected ? relay.machine : null,
|
|
838
|
-
platform: connected ? relay.platform : null,
|
|
839
|
-
version: connected ? relay.version : null,
|
|
840
|
-
sandboxActive,
|
|
841
|
-
lastConnection: lastConnection ? {
|
|
842
|
-
cwd: lastConnection.last_cwd,
|
|
843
|
-
machine: lastConnection.last_machine,
|
|
844
|
-
platform: lastConnection.last_platform,
|
|
845
|
-
disconnectedAt: lastConnection.disconnected_at,
|
|
846
|
-
} : null,
|
|
847
|
-
// Mask the IP — only show last octet for user verification
|
|
848
|
-
clientIp: connected && relay.clientIp ? relay.clientIp.replace(/(\d+\.\d+\.\d+\.)(\d+)/, '$1***') : null,
|
|
849
|
-
});
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
// POST /api/relay/disconnect — user-initiated disconnect (saves state, destroys sandbox)
|
|
853
|
-
app.post('/api/relay/disconnect', authenticateToken, async (req, res) => {
|
|
854
|
-
const userId = Number(req.user.id);
|
|
855
|
-
const relay = relayConnections.get(userId);
|
|
856
|
-
if (!relay || relay.ws.readyState !== 1) {
|
|
857
|
-
return res.json({ success: false, error: 'No active relay connection' });
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Cancel any pending sandbox grace period
|
|
861
|
-
const pending = sandboxCleanupTimers.get(userId);
|
|
862
|
-
if (pending) { clearTimeout(pending); sandboxCleanupTimers.delete(userId); }
|
|
863
|
-
|
|
864
|
-
// Save sandbox state to connection record before destroying
|
|
865
|
-
try {
|
|
866
|
-
const sbStatus = await sandboxClient.getStatus(userId).catch(() => null);
|
|
867
|
-
await connectionDb.disconnect(String(userId), 'relay');
|
|
868
|
-
} catch { /* non-critical */ }
|
|
869
|
-
|
|
870
|
-
// Destroy sandbox immediately (user-initiated = no grace period)
|
|
871
|
-
try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
|
|
872
|
-
|
|
873
|
-
// Close the relay WebSocket
|
|
874
|
-
try {
|
|
875
|
-
relay.ws.send(JSON.stringify({ type: 'server-disconnect', reason: 'User disconnected from web UI' }));
|
|
876
|
-
relay.ws.close();
|
|
877
|
-
} catch { /* may already be closing */ }
|
|
878
|
-
|
|
879
|
-
res.json({ success: true, message: 'Relay disconnected' });
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
// ─── Sandbox API endpoints ──────────────────────────────────────────────────
|
|
883
|
-
|
|
884
|
-
// Initialize sandbox for user
|
|
885
|
-
app.post('/api/sandbox/init', authenticateToken, async (req, res) => {
|
|
886
|
-
try {
|
|
887
|
-
const result = await sandboxClient.initSandbox(req.user.id);
|
|
888
|
-
res.json(result);
|
|
889
|
-
} catch (err) {
|
|
890
|
-
res.status(503).json({ error: err.message });
|
|
891
|
-
}
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
// Get sandbox status
|
|
895
|
-
app.get('/api/sandbox/status', authenticateToken, async (req, res) => {
|
|
896
|
-
try {
|
|
897
|
-
const result = await sandboxClient.getStatus(req.user.id);
|
|
898
|
-
res.json(result);
|
|
899
|
-
} catch (err) {
|
|
900
|
-
res.status(503).json({ error: err.message });
|
|
901
|
-
}
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
// ─── System update endpoint ─────────────────────────────────────────────────
|
|
905
|
-
// Runs npm update on the user's machine. Only available in self-hosted mode.
|
|
906
|
-
// Platform users (cli.upfyn.com) don't see update notifications — the server auto-deploys.
|
|
907
|
-
app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
908
|
-
try {
|
|
909
|
-
if (IS_LOCAL) {
|
|
910
|
-
// Self-hosted: run locally
|
|
911
|
-
const { execSync } = await import('child_process');
|
|
912
|
-
const output = execSync('npm install -g @upfynai-code/app@latest', { encoding: 'utf8', timeout: 120000 });
|
|
913
|
-
res.json({ output, exitCode: 0 });
|
|
914
|
-
} else {
|
|
915
|
-
// Platform mode: server is managed by Railway, no user action needed
|
|
916
|
-
res.status(400).json({ error: 'Server updates are automatic on the platform. No action needed.' });
|
|
917
|
-
}
|
|
918
|
-
} catch (err) {
|
|
919
|
-
res.status(500).json({ error: err.message || 'Update failed' });
|
|
920
|
-
}
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
// Execute command in sandbox
|
|
924
|
-
app.post('/api/sandbox/exec', authenticateToken, async (req, res) => {
|
|
925
|
-
try {
|
|
926
|
-
const { command, cwd, timeout } = req.body;
|
|
927
|
-
// Get user's BYOK keys for sandbox env
|
|
928
|
-
const userKeys = {};
|
|
929
|
-
try {
|
|
930
|
-
const anthropicKey = await credentialsDb.getCredentialByType(req.user.id, 'anthropic_key');
|
|
931
|
-
if (anthropicKey) userKeys.anthropic_key = anthropicKey.credential_value;
|
|
932
|
-
const openaiKey = await credentialsDb.getCredentialByType(req.user.id, 'openai_key');
|
|
933
|
-
if (openaiKey) userKeys.openai_key = openaiKey.credential_value;
|
|
934
|
-
} catch { /* no keys */ }
|
|
935
|
-
const result = await sandboxClient.exec(req.user.id, command, { cwd, timeout, userKeys });
|
|
936
|
-
res.json(result);
|
|
937
|
-
} catch (err) {
|
|
938
|
-
res.status(500).json({ error: err.message });
|
|
939
|
-
}
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
// Read file from sandbox
|
|
943
|
-
app.post('/api/sandbox/file/read', authenticateToken, async (req, res) => {
|
|
944
|
-
try {
|
|
945
|
-
const result = await sandboxClient.readFile(req.user.id, req.body.filePath);
|
|
946
|
-
res.json(result);
|
|
947
|
-
} catch (err) {
|
|
948
|
-
const status = err.message?.includes('Access denied') ? 403
|
|
949
|
-
: err.message?.includes('ENOENT') || err.message?.includes('404') ? 404 : 500;
|
|
950
|
-
res.status(status).json({ error: err.message });
|
|
951
|
-
}
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
// Write file to sandbox
|
|
955
|
-
app.post('/api/sandbox/file/write', authenticateToken, async (req, res) => {
|
|
956
|
-
try {
|
|
957
|
-
const result = await sandboxClient.writeFile(req.user.id, req.body.filePath, req.body.content);
|
|
958
|
-
res.json(result);
|
|
959
|
-
} catch (err) {
|
|
960
|
-
res.status(500).json({ error: err.message });
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
|
|
964
|
-
// File tree from sandbox
|
|
965
|
-
app.post('/api/sandbox/file/tree', authenticateToken, async (req, res) => {
|
|
966
|
-
try {
|
|
967
|
-
const result = await sandboxClient.getFileTree(req.user.id, req.body.dirPath, req.body.depth);
|
|
968
|
-
res.json(result);
|
|
969
|
-
} catch (err) {
|
|
970
|
-
res.status(500).json({ error: err.message });
|
|
971
|
-
}
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
// Git operation in sandbox
|
|
975
|
-
app.post('/api/sandbox/git', authenticateToken, async (req, res) => {
|
|
976
|
-
try {
|
|
977
|
-
const result = await sandboxClient.gitOperation(req.user.id, req.body.gitCommand, req.body.cwd);
|
|
978
|
-
res.json(result);
|
|
979
|
-
} catch (err) {
|
|
980
|
-
res.status(500).json({ error: err.message });
|
|
981
|
-
}
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
// Destroy sandbox
|
|
985
|
-
app.delete('/api/sandbox', authenticateToken, async (req, res) => {
|
|
986
|
-
try {
|
|
987
|
-
const result = await sandboxClient.destroySandbox(req.user.id);
|
|
988
|
-
res.json(result);
|
|
989
|
-
} catch (err) {
|
|
990
|
-
res.status(500).json({ error: err.message });
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* Detect installed AI CLI agents on the local machine (server-side).
|
|
996
|
-
* Used in self-hosted/local mode where no relay is needed.
|
|
997
|
-
*/
|
|
998
|
-
let cachedLocalAgents = null;
|
|
999
|
-
let localAgentsCacheTime = 0;
|
|
1000
|
-
function detectLocalAgents() {
|
|
1001
|
-
// Cache for 60 seconds
|
|
1002
|
-
if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
|
|
1003
|
-
return cachedLocalAgents;
|
|
1004
|
-
}
|
|
1005
|
-
const isWindows = process.platform === 'win32';
|
|
1006
|
-
const whichCmd = isWindows ? 'where' : 'which';
|
|
1007
|
-
const agents = [
|
|
1008
|
-
{ name: 'claude', binary: 'claude', label: 'Claude Code' },
|
|
1009
|
-
{ name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
|
|
1010
|
-
{ name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
|
|
1011
|
-
];
|
|
1012
|
-
const detected = {};
|
|
1013
|
-
for (const agent of agents) {
|
|
1014
|
-
try {
|
|
1015
|
-
const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
|
|
1016
|
-
detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
|
|
1017
|
-
} catch {
|
|
1018
|
-
detected[agent.name] = { installed: false, label: agent.label };
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
cachedLocalAgents = detected;
|
|
1022
|
-
localAgentsCacheTime = Date.now();
|
|
1023
|
-
return detected;
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
// Connection status — alias at path the frontend expects
|
|
1027
|
-
app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
|
|
1028
|
-
const relay = relayConnections.get(Number(req.user.id));
|
|
1029
|
-
const connected = !!(relay && relay.ws.readyState === 1);
|
|
1030
|
-
|
|
1031
|
-
// In local mode, always "connected" — SDK runs directly on this machine
|
|
1032
|
-
if (IS_LOCAL) {
|
|
1033
|
-
const agents = detectLocalAgents();
|
|
1034
|
-
return res.json({
|
|
1035
|
-
connected: true,
|
|
1036
|
-
local: true,
|
|
1037
|
-
connectedAt: Date.now(),
|
|
1038
|
-
agents,
|
|
1039
|
-
machine: {
|
|
1040
|
-
hostname: os.hostname(),
|
|
1041
|
-
platform: process.platform,
|
|
1042
|
-
cwd: process.cwd(),
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// Check sandbox service availability when no relay
|
|
1048
|
-
let sandboxAvailable = false;
|
|
1049
|
-
let sandboxInfo = null;
|
|
1050
|
-
if (!connected) {
|
|
1051
|
-
try {
|
|
1052
|
-
sandboxAvailable = await sandboxClient.isAvailable();
|
|
1053
|
-
if (sandboxAvailable) {
|
|
1054
|
-
sandboxInfo = await sandboxClient.getStatus(req.user.id);
|
|
1055
|
-
}
|
|
1056
|
-
} catch { /* sandbox service unreachable */ }
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
res.json({
|
|
1060
|
-
connected,
|
|
1061
|
-
local: false,
|
|
1062
|
-
connectedAt: relay?.connectedAt || null,
|
|
1063
|
-
agents: connected ? (relay.agents || null) : null,
|
|
1064
|
-
machine: connected ? {
|
|
1065
|
-
hostname: relay.machine,
|
|
1066
|
-
platform: relay.platform,
|
|
1067
|
-
cwd: relay.cwd,
|
|
1068
|
-
version: relay.version,
|
|
1069
|
-
} : null,
|
|
1070
|
-
sandbox: {
|
|
1071
|
-
available: sandboxAvailable,
|
|
1072
|
-
active: sandboxInfo?.exists || false,
|
|
1073
|
-
diskUsage: sandboxInfo?.exists ? {
|
|
1074
|
-
usedMB: sandboxInfo.usedMB,
|
|
1075
|
-
maxMB: sandboxInfo.maxMB,
|
|
1076
|
-
} : null,
|
|
1077
|
-
},
|
|
1078
|
-
});
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
// Serve public files (like api-docs.html)
|
|
1082
|
-
app.use(express.static(path.join(__dirname, '../client/public')));
|
|
1083
|
-
|
|
1084
|
-
// Static files served after API routes at /app/ prefix
|
|
1085
|
-
// Assets are built with Vite base: '/app/' so all URLs are /app/assets/...
|
|
1086
|
-
app.use('/app', express.static(path.join(__dirname, '../client/dist'), {
|
|
1087
|
-
index: false, // Don't serve index.html directly — catch-all injects runtime config
|
|
1088
|
-
setHeaders: (res, filePath) => {
|
|
1089
|
-
if (filePath.endsWith('.html')) {
|
|
1090
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1091
|
-
res.setHeader('Pragma', 'no-cache');
|
|
1092
|
-
res.setHeader('Expires', '0');
|
|
1093
|
-
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
|
1094
|
-
// Cache static assets for 1 year (they have hashed names)
|
|
1095
|
-
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
}));
|
|
1099
|
-
|
|
1100
|
-
// API Routes (protected)
|
|
1101
|
-
// /api/config endpoint removed - no longer needed
|
|
1102
|
-
// Frontend now uses window.location for WebSocket URLs
|
|
1103
|
-
|
|
1104
|
-
// System update endpoint — REMOVED for security (shell command execution risk)
|
|
1105
|
-
// Use `uc update` from CLI instead
|
|
1106
|
-
|
|
1107
|
-
app.get('/api/projects', authenticateToken, async (req, res) => {
|
|
1108
|
-
try {
|
|
1109
|
-
const projects = await getProjects(broadcastProgress, req.user.id);
|
|
1110
|
-
res.json(projects);
|
|
1111
|
-
} catch (error) {
|
|
1112
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1113
|
-
}
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
app.get('/api/projects/:projectName/sessions', authenticateToken, authorizeProject, async (req, res) => {
|
|
1117
|
-
try {
|
|
1118
|
-
const { limit = 5, offset = 0 } = req.query;
|
|
1119
|
-
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
|
1120
|
-
res.json(result);
|
|
1121
|
-
} catch (error) {
|
|
1122
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1123
|
-
}
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
// Get messages for a specific session
|
|
1127
|
-
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, authorizeProject, async (req, res) => {
|
|
1128
|
-
try {
|
|
1129
|
-
const { projectName, sessionId } = req.params;
|
|
1130
|
-
const { limit, offset } = req.query;
|
|
1131
|
-
|
|
1132
|
-
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
|
1133
|
-
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
|
1134
|
-
|
|
1135
|
-
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
|
1136
|
-
|
|
1137
|
-
if (Array.isArray(result)) {
|
|
1138
|
-
res.json({ messages: result });
|
|
1139
|
-
} else {
|
|
1140
|
-
res.json(result);
|
|
1141
|
-
}
|
|
1142
|
-
} catch (error) {
|
|
1143
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1144
|
-
}
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
// Rename project endpoint
|
|
1148
|
-
app.put('/api/projects/:projectName/rename', authenticateToken, authorizeProject, async (req, res) => {
|
|
1149
|
-
try {
|
|
1150
|
-
const { displayName } = req.body;
|
|
1151
|
-
await renameProject(req.params.projectName, displayName, req.user.id);
|
|
1152
|
-
res.json({ success: true });
|
|
1153
|
-
} catch (error) {
|
|
1154
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1155
|
-
}
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
// Delete session endpoint
|
|
1159
|
-
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, authorizeProject, async (req, res) => {
|
|
1160
|
-
try {
|
|
1161
|
-
const { projectName, sessionId } = req.params;
|
|
1162
|
-
await deleteSession(projectName, sessionId);
|
|
1163
|
-
res.json({ success: true });
|
|
1164
|
-
} catch (error) {
|
|
1165
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1166
|
-
}
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
// Delete project endpoint (force=true to delete with sessions)
|
|
1170
|
-
app.delete('/api/projects/:projectName', authenticateToken, authorizeProject, async (req, res) => {
|
|
1171
|
-
try {
|
|
1172
|
-
const { projectName } = req.params;
|
|
1173
|
-
const force = req.query.force === 'true';
|
|
1174
|
-
await deleteProject(projectName, force, req.user.id);
|
|
1175
|
-
res.json({ success: true });
|
|
1176
|
-
} catch (error) {
|
|
1177
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1178
|
-
}
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
// Read CLAUDE.md for a project
|
|
1182
|
-
app.get('/api/projects/:projectName/claude-md', authenticateToken, authorizeProject, async (req, res) => {
|
|
1183
|
-
try {
|
|
1184
|
-
const projectDir = await extractProjectDirectory(req.params.projectName);
|
|
1185
|
-
if (!projectDir) {
|
|
1186
|
-
return res.json({ content: null, path: null, error: 'Could not resolve project path' });
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
|
|
1190
|
-
|
|
1191
|
-
if (IS_CLOUD_ENV) {
|
|
1192
|
-
const userId = req.user?.id || req.user?.userId;
|
|
1193
|
-
if (!hasActiveRelay(Number(userId))) {
|
|
1194
|
-
return res.json({ content: null, path: claudeMdPath, error: 'No machine connected' });
|
|
1195
|
-
}
|
|
1196
|
-
try {
|
|
1197
|
-
const result = await sendRelayCommand(Number(userId), 'file-read', { filePath: claudeMdPath }, null, 10000);
|
|
1198
|
-
return res.json({ content: result?.data?.content || null, path: claudeMdPath });
|
|
1199
|
-
} catch {
|
|
1200
|
-
return res.json({ content: null, path: claudeMdPath });
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
// Local mode
|
|
1205
|
-
try {
|
|
1206
|
-
const content = await fsPromises.readFile(claudeMdPath, 'utf8');
|
|
1207
|
-
res.json({ content, path: claudeMdPath });
|
|
1208
|
-
} catch {
|
|
1209
|
-
res.json({ content: null, path: claudeMdPath });
|
|
1210
|
-
}
|
|
1211
|
-
} catch {
|
|
1212
|
-
res.json({ content: null, path: null });
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
// Create project endpoint
|
|
1217
|
-
app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
|
1218
|
-
try {
|
|
1219
|
-
const { path: projectPath } = req.body;
|
|
1220
|
-
|
|
1221
|
-
if (!projectPath || !projectPath.trim()) {
|
|
1222
|
-
return res.status(400).json({ error: 'Project path is required' });
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
const project = await addProjectManually(projectPath.trim(), null, req.user.id);
|
|
1226
|
-
res.json({ success: true, project });
|
|
1227
|
-
} catch (error) {
|
|
1228
|
-
// project creation error handled silently
|
|
1229
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1230
|
-
}
|
|
1231
|
-
});
|
|
1232
|
-
|
|
1233
|
-
// ── Gitagent API endpoints ──────────────────────────────────────────
|
|
1234
|
-
|
|
1235
|
-
// Get parsed gitagent definition + system prompt preview for a project
|
|
1236
|
-
app.get('/api/projects/:projectName/gitagent', authenticateToken, authorizeProject, async (req, res) => {
|
|
1237
|
-
try {
|
|
1238
|
-
const projectDir = await extractProjectDirectory(req.params.projectName);
|
|
1239
|
-
if (!projectDir) return res.status(404).json({ error: 'Project not found' });
|
|
1240
|
-
|
|
1241
|
-
const ctx = await loadGitagentContext(projectDir);
|
|
1242
|
-
if (!ctx) return res.json({ detected: false });
|
|
1243
|
-
|
|
1244
|
-
res.json({
|
|
1245
|
-
detected: true,
|
|
1246
|
-
definition: ctx.definition,
|
|
1247
|
-
systemPromptPreview: ctx.systemPromptAppendix,
|
|
1248
|
-
model: ctx.model,
|
|
1249
|
-
allowedTools: ctx.allowedTools,
|
|
1250
|
-
runtime: ctx.runtime,
|
|
1251
|
-
});
|
|
1252
|
-
} catch {
|
|
1253
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1254
|
-
}
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
// Clear cache and re-parse gitagent for a project
|
|
1258
|
-
app.post('/api/projects/:projectName/gitagent/refresh', authenticateToken, authorizeProject, async (req, res) => {
|
|
1259
|
-
try {
|
|
1260
|
-
const projectDir = await extractProjectDirectory(req.params.projectName);
|
|
1261
|
-
if (!projectDir) return res.status(404).json({ error: 'Project not found' });
|
|
1262
|
-
|
|
1263
|
-
clearGitagentCache(projectDir);
|
|
1264
|
-
const ctx = await loadGitagentContext(projectDir);
|
|
1265
|
-
if (!ctx) return res.json({ detected: false });
|
|
1266
|
-
|
|
1267
|
-
res.json({
|
|
1268
|
-
detected: true,
|
|
1269
|
-
definition: ctx.definition,
|
|
1270
|
-
systemPromptPreview: ctx.systemPromptAppendix,
|
|
1271
|
-
model: ctx.model,
|
|
1272
|
-
allowedTools: ctx.allowedTools,
|
|
1273
|
-
runtime: ctx.runtime,
|
|
1274
|
-
});
|
|
1275
|
-
} catch {
|
|
1276
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1277
|
-
}
|
|
1278
|
-
});
|
|
1279
|
-
|
|
1280
|
-
const expandWorkspacePath = (inputPath) => {
|
|
1281
|
-
if (!inputPath) return inputPath;
|
|
1282
|
-
if (inputPath === '~') {
|
|
1283
|
-
return WORKSPACES_ROOT;
|
|
1284
|
-
}
|
|
1285
|
-
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
|
1286
|
-
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
|
|
1287
|
-
}
|
|
1288
|
-
return inputPath;
|
|
1289
|
-
};
|
|
1290
|
-
|
|
1291
|
-
// Browse filesystem endpoint for project suggestions
|
|
1292
|
-
// When relay is connected, proxies to user's local machine; otherwise uses server filesystem
|
|
1293
|
-
app.get('/api/browse-filesystem', authenticateToken, relayMiddleware, async (req, res) => {
|
|
1294
|
-
try {
|
|
1295
|
-
const { path: dirPath } = req.query;
|
|
1296
|
-
|
|
1297
|
-
// Cloud mode: always use relay
|
|
1298
|
-
if (req.isCloud) {
|
|
1299
|
-
if (!req.hasRelay()) {
|
|
1300
|
-
return res.status(503).json({
|
|
1301
|
-
error: 'Machine not connected',
|
|
1302
|
-
message: 'Run "uc connect" on your local machine to use this feature.',
|
|
1303
|
-
code: 'RELAY_NOT_CONNECTED'
|
|
1304
|
-
});
|
|
1305
|
-
}
|
|
1306
|
-
try {
|
|
1307
|
-
const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
|
|
1308
|
-
return res.json(result);
|
|
1309
|
-
} catch (err) {
|
|
1310
|
-
return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// Local mode: if relay is connected, prefer it
|
|
1315
|
-
if (req.hasRelay()) {
|
|
1316
|
-
try {
|
|
1317
|
-
const result = await req.sendRelay('browse-dirs', { dirPath: dirPath || '~' }, 15000);
|
|
1318
|
-
return res.json(result);
|
|
1319
|
-
} catch (err) {
|
|
1320
|
-
return res.status(500).json({ error: err.message || 'Failed to browse filesystem via relay' });
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Fallback: browse server filesystem (local/self-hosted mode)
|
|
1325
|
-
const defaultRoot = WORKSPACES_ROOT || os.homedir();
|
|
1326
|
-
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
|
1327
|
-
|
|
1328
|
-
// Resolve and normalize the path
|
|
1329
|
-
targetPath = path.resolve(targetPath);
|
|
1330
|
-
|
|
1331
|
-
// Security check - ensure path is within allowed workspace root
|
|
1332
|
-
const validation = await validateWorkspacePath(targetPath);
|
|
1333
|
-
if (!validation.valid) {
|
|
1334
|
-
return res.status(403).json({ error: validation.error });
|
|
1335
|
-
}
|
|
1336
|
-
const resolvedPath = validation.resolvedPath || targetPath;
|
|
1337
|
-
|
|
1338
|
-
// Security check - ensure path is accessible
|
|
1339
|
-
try {
|
|
1340
|
-
await fs.promises.access(resolvedPath);
|
|
1341
|
-
const stats = await fs.promises.stat(resolvedPath);
|
|
1342
|
-
|
|
1343
|
-
if (!stats.isDirectory()) {
|
|
1344
|
-
return res.status(400).json({ error: 'Path is not a directory' });
|
|
1345
|
-
}
|
|
1346
|
-
} catch (err) {
|
|
1347
|
-
return res.status(404).json({ error: 'Directory not accessible' });
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
// Use existing getFileTree function with shallow depth (only direct children)
|
|
1351
|
-
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
|
1352
|
-
|
|
1353
|
-
// Filter only directories and format for suggestions
|
|
1354
|
-
const directories = fileTree
|
|
1355
|
-
.filter(item => item.type === 'directory')
|
|
1356
|
-
.map(item => ({
|
|
1357
|
-
path: item.path,
|
|
1358
|
-
name: item.name,
|
|
1359
|
-
type: 'directory'
|
|
1360
|
-
}))
|
|
1361
|
-
.sort((a, b) => {
|
|
1362
|
-
const aHidden = a.name.startsWith('.');
|
|
1363
|
-
const bHidden = b.name.startsWith('.');
|
|
1364
|
-
if (aHidden && !bHidden) return 1;
|
|
1365
|
-
if (!aHidden && bHidden) return -1;
|
|
1366
|
-
return a.name.localeCompare(b.name);
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
// Add common directories if browsing home directory
|
|
1370
|
-
const suggestions = [];
|
|
1371
|
-
let resolvedWorkspaceRoot = defaultRoot;
|
|
1372
|
-
try {
|
|
1373
|
-
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
|
|
1374
|
-
} catch (error) {
|
|
1375
|
-
// Use default root as-is if realpath fails
|
|
1376
|
-
}
|
|
1377
|
-
if (resolvedPath === resolvedWorkspaceRoot) {
|
|
1378
|
-
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
|
1379
|
-
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
|
1380
|
-
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
|
1381
|
-
|
|
1382
|
-
suggestions.push(...existingCommon, ...otherDirs);
|
|
1383
|
-
} else {
|
|
1384
|
-
suggestions.push(...directories);
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
res.json({
|
|
1388
|
-
path: resolvedPath,
|
|
1389
|
-
suggestions: suggestions
|
|
1390
|
-
});
|
|
1391
|
-
|
|
1392
|
-
} catch (error) {
|
|
1393
|
-
// filesystem browse error handled silently
|
|
1394
|
-
res.status(500).json({ error: 'Failed to browse filesystem' });
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
|
|
1398
|
-
app.post('/api/create-folder', authenticateToken, relayMiddleware, async (req, res) => {
|
|
1399
|
-
try {
|
|
1400
|
-
const { path: folderPath } = req.body;
|
|
1401
|
-
if (!folderPath) {
|
|
1402
|
-
return res.status(400).json({ error: 'Path is required' });
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// Cloud mode: create folder via relay on user's machine
|
|
1406
|
-
if (req.isCloud) {
|
|
1407
|
-
if (!req.requireRelay()) return;
|
|
1408
|
-
try {
|
|
1409
|
-
const result = await req.sendRelay('create-folder', { folderPath }, 15000);
|
|
1410
|
-
return res.json(result);
|
|
1411
|
-
} catch (err) {
|
|
1412
|
-
return res.status(500).json({ error: err.message || 'Failed to create folder via relay' });
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// Local mode
|
|
1417
|
-
const expandedPath = expandWorkspacePath(folderPath);
|
|
1418
|
-
const resolvedInput = path.resolve(expandedPath);
|
|
1419
|
-
const validation = await validateWorkspacePath(resolvedInput);
|
|
1420
|
-
if (!validation.valid) {
|
|
1421
|
-
return res.status(403).json({ error: validation.error });
|
|
1422
|
-
}
|
|
1423
|
-
const targetPath = validation.resolvedPath || resolvedInput;
|
|
1424
|
-
const parentDir = path.dirname(targetPath);
|
|
1425
|
-
try {
|
|
1426
|
-
await fs.promises.access(parentDir);
|
|
1427
|
-
} catch (err) {
|
|
1428
|
-
return res.status(404).json({ error: 'Parent directory does not exist' });
|
|
1429
|
-
}
|
|
1430
|
-
try {
|
|
1431
|
-
await fs.promises.access(targetPath);
|
|
1432
|
-
return res.status(409).json({ error: 'Folder already exists' });
|
|
1433
|
-
} catch (err) {
|
|
1434
|
-
// Folder doesn't exist, which is what we want
|
|
1435
|
-
}
|
|
1436
|
-
try {
|
|
1437
|
-
await fs.promises.mkdir(targetPath, { recursive: false });
|
|
1438
|
-
res.json({ success: true, path: targetPath });
|
|
1439
|
-
} catch (mkdirError) {
|
|
1440
|
-
if (mkdirError.code === 'EEXIST') {
|
|
1441
|
-
return res.status(409).json({ error: 'Folder already exists' });
|
|
1442
|
-
}
|
|
1443
|
-
throw mkdirError;
|
|
1444
|
-
}
|
|
1445
|
-
} catch (error) {
|
|
1446
|
-
// folder creation error handled silently
|
|
1447
|
-
res.status(500).json({ error: 'Failed to create folder' });
|
|
1448
|
-
}
|
|
1449
|
-
});
|
|
1450
|
-
|
|
1451
|
-
// Read file content endpoint
|
|
1452
|
-
app.get('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
|
|
1453
|
-
try {
|
|
1454
|
-
const { projectName } = req.params;
|
|
1455
|
-
const { filePath } = req.query;
|
|
1456
|
-
|
|
1457
|
-
if (!filePath) {
|
|
1458
|
-
return res.status(400).json({ error: 'Invalid file path' });
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1462
|
-
if (!projectRoot) {
|
|
1463
|
-
return res.status(404).json({ error: 'Project not found' });
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// Cloud mode: read file via relay on user's machine
|
|
1467
|
-
if (req.isCloud) {
|
|
1468
|
-
if (!req.requireRelay()) return;
|
|
1469
|
-
try {
|
|
1470
|
-
// Construct full path: project root + relative file path
|
|
1471
|
-
const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
1472
|
-
? filePath
|
|
1473
|
-
: `${projectRoot}/${filePath}`;
|
|
1474
|
-
const result = await req.sendRelay('file-read', { filePath: fullPath }, 15000);
|
|
1475
|
-
return res.json({ content: result.content, path: fullPath });
|
|
1476
|
-
} catch (err) {
|
|
1477
|
-
if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
|
|
1478
|
-
return res.status(404).json({ error: 'File not found' });
|
|
1479
|
-
}
|
|
1480
|
-
return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// Local mode
|
|
1485
|
-
const resolved = path.resolve(projectRoot, filePath);
|
|
1486
|
-
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
1487
|
-
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
|
|
1488
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
const content = await fsPromises.readFile(resolved, 'utf8');
|
|
1492
|
-
res.json({ content, path: resolved });
|
|
1493
|
-
} catch (error) {
|
|
1494
|
-
if (error.code === 'ENOENT') {
|
|
1495
|
-
res.status(404).json({ error: 'File not found' });
|
|
1496
|
-
} else if (error.code === 'EACCES') {
|
|
1497
|
-
res.status(403).json({ error: 'Permission denied' });
|
|
1498
|
-
} else {
|
|
1499
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
});
|
|
1503
|
-
|
|
1504
|
-
// Serve binary file content endpoint (for images, etc.)
|
|
1505
|
-
app.get('/api/projects/:projectName/files/content', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
|
|
1506
|
-
try {
|
|
1507
|
-
const { projectName } = req.params;
|
|
1508
|
-
const { path: filePath } = req.query;
|
|
1509
|
-
|
|
1510
|
-
if (!filePath) {
|
|
1511
|
-
return res.status(400).json({ error: 'Invalid file path' });
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1515
|
-
if (!projectRoot) {
|
|
1516
|
-
return res.status(404).json({ error: 'Project not found' });
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// Cloud mode: read binary file via relay (base64 encoded)
|
|
1520
|
-
if (req.isCloud) {
|
|
1521
|
-
if (!req.requireRelay()) return;
|
|
1522
|
-
try {
|
|
1523
|
-
const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
1524
|
-
? filePath
|
|
1525
|
-
: `${projectRoot}/${filePath}`;
|
|
1526
|
-
const result = await req.sendRelay('file-read', { filePath: fullPath, encoding: 'base64' }, 30000);
|
|
1527
|
-
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
|
|
1528
|
-
res.setHeader('Content-Type', mimeType);
|
|
1529
|
-
return res.send(Buffer.from(result.content, 'base64'));
|
|
1530
|
-
} catch (err) {
|
|
1531
|
-
if (err.message?.includes('ENOENT') || err.message?.includes('not found')) {
|
|
1532
|
-
return res.status(404).json({ error: 'File not found' });
|
|
1533
|
-
}
|
|
1534
|
-
return res.status(500).json({ error: err.message || 'Failed to read file via relay' });
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// Local mode
|
|
1539
|
-
const resolved = path.resolve(projectRoot, filePath);
|
|
1540
|
-
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
1541
|
-
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
|
|
1542
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
try {
|
|
1546
|
-
await fsPromises.access(resolved);
|
|
1547
|
-
} catch (error) {
|
|
1548
|
-
return res.status(404).json({ error: 'File not found' });
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
const mimeType = mime.lookup(resolved) || 'application/octet-stream';
|
|
1552
|
-
res.setHeader('Content-Type', mimeType);
|
|
1553
|
-
|
|
1554
|
-
const fileStream = fs.createReadStream(resolved);
|
|
1555
|
-
fileStream.pipe(res);
|
|
1556
|
-
|
|
1557
|
-
fileStream.on('error', (error) => {
|
|
1558
|
-
if (!res.headersSent) {
|
|
1559
|
-
res.status(500).json({ error: 'Error reading file' });
|
|
1560
|
-
}
|
|
1561
|
-
});
|
|
1562
|
-
|
|
1563
|
-
} catch (error) {
|
|
1564
|
-
if (!res.headersSent) {
|
|
1565
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
// Save file content endpoint
|
|
1571
|
-
app.put('/api/projects/:projectName/file', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
|
|
1572
|
-
try {
|
|
1573
|
-
const { projectName } = req.params;
|
|
1574
|
-
const { filePath, content } = req.body;
|
|
1575
|
-
|
|
1576
|
-
if (!filePath) {
|
|
1577
|
-
return res.status(400).json({ error: 'Invalid file path' });
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
if (content === undefined) {
|
|
1581
|
-
return res.status(400).json({ error: 'Content is required' });
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
|
|
1585
|
-
if (!projectRoot) {
|
|
1586
|
-
return res.status(404).json({ error: 'Project not found' });
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
// Cloud mode: write file via relay on user's machine
|
|
1590
|
-
if (req.isCloud) {
|
|
1591
|
-
if (!req.requireRelay()) return;
|
|
1592
|
-
try {
|
|
1593
|
-
const fullPath = filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
|
|
1594
|
-
? filePath
|
|
1595
|
-
: `${projectRoot}/${filePath}`;
|
|
1596
|
-
const result = await req.sendRelay('file-write', { filePath: fullPath, content }, 15000);
|
|
1597
|
-
// Track file version in Turso
|
|
1598
|
-
try { await fileVersionDb.save(req.user.userId || req.user.id, projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
|
|
1599
|
-
return res.json({ success: true, path: fullPath, message: 'File saved successfully' });
|
|
1600
|
-
} catch (err) {
|
|
1601
|
-
return res.status(500).json({ error: err.message || 'Failed to write file via relay' });
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
// Local mode
|
|
1606
|
-
const resolved = path.resolve(projectRoot, filePath);
|
|
1607
|
-
const normalizedRoot = path.resolve(projectRoot) + path.sep;
|
|
1608
|
-
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectRoot)) {
|
|
1609
|
-
return res.status(403).json({ error: 'Access denied' });
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
await fsPromises.writeFile(resolved, content, 'utf8');
|
|
1613
|
-
// Track file version
|
|
1614
|
-
try { await fileVersionDb.save(req.user?.userId || req.user?.id || 'local', projectName, filePath, content, 'edit'); } catch { /* non-critical */ }
|
|
1615
|
-
|
|
1616
|
-
res.json({
|
|
1617
|
-
success: true,
|
|
1618
|
-
path: resolved,
|
|
1619
|
-
message: 'File saved successfully'
|
|
1620
|
-
});
|
|
1621
|
-
} catch (error) {
|
|
1622
|
-
if (error.code === 'ENOENT') {
|
|
1623
|
-
res.status(404).json({ error: 'File or directory not found' });
|
|
1624
|
-
} else if (error.code === 'EACCES') {
|
|
1625
|
-
res.status(403).json({ error: 'Permission denied' });
|
|
1626
|
-
} else {
|
|
1627
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
// File version history endpoint
|
|
1633
|
-
app.get('/api/projects/:projectName/files/versions', authenticateToken, authorizeProject, async (req, res) => {
|
|
1634
|
-
try {
|
|
1635
|
-
const { projectName } = req.params;
|
|
1636
|
-
const { path: filePath, limit } = req.query;
|
|
1637
|
-
if (filePath) {
|
|
1638
|
-
const versions = await fileVersionDb.getVersions(projectName, filePath, parseInt(limit) || 20);
|
|
1639
|
-
return res.json({ versions });
|
|
1640
|
-
}
|
|
1641
|
-
// No path — return all files with versions in this project
|
|
1642
|
-
const files = await fileVersionDb.getSessionFiles(projectName);
|
|
1643
|
-
res.json({ files });
|
|
1644
|
-
} catch (error) {
|
|
1645
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1646
|
-
}
|
|
1647
|
-
});
|
|
1648
|
-
|
|
1649
|
-
// Usage stats endpoint
|
|
1650
|
-
app.get('/api/usage', authenticateToken, async (req, res) => {
|
|
1651
|
-
try {
|
|
1652
|
-
const userId = req.user.userId || req.user.id;
|
|
1653
|
-
const days = parseInt(req.query.days) || 30;
|
|
1654
|
-
const usage = await sessionUsageDb.getUserUsage(userId, days);
|
|
1655
|
-
const sessions = await sessionUsageDb.getUserSessions(userId, parseInt(req.query.limit) || 20);
|
|
1656
|
-
res.json({ usage, sessions });
|
|
1657
|
-
} catch (error) {
|
|
1658
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1659
|
-
}
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
app.get('/api/projects/:projectName/files', authenticateToken, authorizeProject, relayMiddleware, async (req, res) => {
|
|
1663
|
-
try {
|
|
1664
|
-
let actualPath;
|
|
1665
|
-
try {
|
|
1666
|
-
actualPath = await extractProjectDirectory(req.params.projectName);
|
|
1667
|
-
} catch (error) {
|
|
1668
|
-
actualPath = req.params.projectName.replace(/-/g, '/');
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
// Cloud mode: get file tree via relay on user's machine
|
|
1672
|
-
if (req.isCloud) {
|
|
1673
|
-
if (!req.requireRelay()) return;
|
|
1674
|
-
try {
|
|
1675
|
-
// In cloud mode, actualPath comes from extractProjectDirectory (which stores the original
|
|
1676
|
-
// Windows/Unix path from when the project was added via relay CWD). If that failed and
|
|
1677
|
-
// fell back to dash-replacement, use the relay connection's CWD as a better fallback.
|
|
1678
|
-
const relay = relayConnections.get(Number(req.user?.id));
|
|
1679
|
-
const relayCwd = relay?.cwd;
|
|
1680
|
-
const dirPath = actualPath && actualPath !== req.params.projectName.replace(/-/g, '/')
|
|
1681
|
-
? actualPath
|
|
1682
|
-
: (relayCwd || actualPath);
|
|
1683
|
-
const result = await req.sendRelay('file-tree', { dirPath, maxDepth: 10 }, 30000);
|
|
1684
|
-
return res.json(result.files || result);
|
|
1685
|
-
} catch (err) {
|
|
1686
|
-
return res.status(500).json({ error: err.message || 'Failed to get file tree via relay' });
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
// Local mode
|
|
1691
|
-
try {
|
|
1692
|
-
await fsPromises.access(actualPath);
|
|
1693
|
-
} catch (e) {
|
|
1694
|
-
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
const files = await getFileTree(actualPath, 10, 0, true);
|
|
1698
|
-
res.json(files);
|
|
1699
|
-
} catch (error) {
|
|
1700
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
1701
|
-
}
|
|
1702
|
-
});
|
|
1703
|
-
|
|
1704
|
-
// WebSocket connection handler that routes based on URL path (skip on Vercel)
|
|
1705
|
-
if (wss) wss.on('connection', (ws, request) => {
|
|
1706
|
-
const url = request.url;
|
|
1707
|
-
// Client connected
|
|
1708
|
-
|
|
1709
|
-
// Parse URL to get pathname without query parameters
|
|
1710
|
-
const urlObj = new URL(url, 'http://localhost');
|
|
1711
|
-
const pathname = urlObj.pathname;
|
|
1712
|
-
|
|
1713
|
-
if (pathname === '/shell') {
|
|
1714
|
-
handleShellConnection(ws, request);
|
|
1715
|
-
} else if (pathname === '/ws') {
|
|
1716
|
-
handleChatConnection(ws, request);
|
|
1717
|
-
} else if (pathname === '/relay') {
|
|
1718
|
-
handleRelayConnection(ws, urlObj.searchParams.get('token'), request);
|
|
1719
|
-
} else {
|
|
1720
|
-
// unknown WebSocket path
|
|
1721
|
-
ws.close();
|
|
1722
|
-
}
|
|
1723
|
-
});
|
|
1724
|
-
|
|
1725
|
-
/**
|
|
1726
|
-
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
|
1727
|
-
*/
|
|
1728
|
-
class WebSocketWriter {
|
|
1729
|
-
constructor(ws) {
|
|
1730
|
-
this.ws = ws;
|
|
1731
|
-
this.sessionId = null;
|
|
1732
|
-
this.isWebSocketWriter = true; // Marker for transport detection
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
send(data) {
|
|
1736
|
-
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
|
1737
|
-
// Providers send raw objects, we stringify for WebSocket
|
|
1738
|
-
this.ws.send(JSON.stringify(data));
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
setSessionId(sessionId) {
|
|
1743
|
-
this.sessionId = sessionId;
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
getSessionId() {
|
|
1747
|
-
return this.sessionId;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
/**
|
|
1752
|
-
* Look up a user's stored API key for a given provider.
|
|
1753
|
-
* Falls back to server env vars if user has none stored.
|
|
1754
|
-
* @param {number} userId
|
|
1755
|
-
* @param {string} providerType - e.g. 'anthropic_key', 'openai_key', 'openrouter_key', 'google_key'
|
|
1756
|
-
* @returns {Promise<string|null>}
|
|
1757
|
-
*/
|
|
1758
|
-
async function getUserProviderKey(userId, providerType) {
|
|
1759
|
-
if (!userId) return null;
|
|
1760
|
-
try {
|
|
1761
|
-
const creds = await credentialsDb.getCredentials(userId, providerType);
|
|
1762
|
-
const active = creds.find(c => c.is_active);
|
|
1763
|
-
return active?.credential_value || null;
|
|
1764
|
-
} catch { return null; }
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
/**
|
|
1768
|
-
* Temporarily set environment variable for an AI SDK call, then restore.
|
|
1769
|
-
* @param {string} envKey - e.g. 'ANTHROPIC_API_KEY'
|
|
1770
|
-
* @param {string|null} userKey - user's BYOK key, null to skip
|
|
1771
|
-
* @param {Function} fn - async function to execute with the key set
|
|
1772
|
-
*/
|
|
1773
|
-
async function withUserApiKey(envKey, userKey, fn) {
|
|
1774
|
-
if (!userKey) return fn();
|
|
1775
|
-
const prev = process.env[envKey];
|
|
1776
|
-
process.env[envKey] = userKey;
|
|
1777
|
-
try {
|
|
1778
|
-
return await fn();
|
|
1779
|
-
} finally {
|
|
1780
|
-
if (prev !== undefined) process.env[envKey] = prev;
|
|
1781
|
-
else delete process.env[envKey];
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// Handle chat WebSocket connections
|
|
1786
|
-
function handleChatConnection(ws, request) {
|
|
1787
|
-
// chat WebSocket connected
|
|
1788
|
-
const wsUser = request?.user || null;
|
|
1789
|
-
|
|
1790
|
-
// Add to connected clients for project updates + tag with user for permission routing
|
|
1791
|
-
ws._wsUser = wsUser;
|
|
1792
|
-
connectedClients.add(ws);
|
|
1793
|
-
|
|
1794
|
-
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
|
1795
|
-
const writer = new WebSocketWriter(ws);
|
|
1796
|
-
|
|
1797
|
-
// Track which sessions this WebSocket has locked
|
|
1798
|
-
const lockedSessionsForThisWs = new Set();
|
|
1799
|
-
|
|
1800
|
-
// Track usage per-query for sessionUsageDb
|
|
1801
|
-
let lastTokenBudget = null;
|
|
1802
|
-
let lastCompletedSessionId = null;
|
|
1803
|
-
|
|
1804
|
-
// Wrap the original writer.send to capture session-created events for auto-locking
|
|
1805
|
-
const originalSend = writer.send.bind(writer);
|
|
1806
|
-
writer.send = (data) => {
|
|
1807
|
-
// When a new session is created, auto-lock it to this WebSocket + track ownership
|
|
1808
|
-
if (data.type === 'session-created' && data.sessionId) {
|
|
1809
|
-
sessionLocks.set(data.sessionId, ws);
|
|
1810
|
-
lockedSessionsForThisWs.add(data.sessionId);
|
|
1811
|
-
if (wsUser?.userId) sessionOwners.set(data.sessionId, wsUser.userId);
|
|
1812
|
-
}
|
|
1813
|
-
// Capture token usage for tracking
|
|
1814
|
-
if (data.type === 'token-budget' && data.data) {
|
|
1815
|
-
lastTokenBudget = data.data;
|
|
1816
|
-
if (data.sessionId) lastCompletedSessionId = data.sessionId;
|
|
1817
|
-
}
|
|
1818
|
-
originalSend(data);
|
|
1819
|
-
};
|
|
1820
|
-
|
|
1821
|
-
ws.on('message', async (message) => {
|
|
1822
|
-
try {
|
|
1823
|
-
const data = JSON.parse(message);
|
|
1824
|
-
|
|
1825
|
-
// Handle session lock request (tab claiming a session)
|
|
1826
|
-
if (data.type === 'lock-session') {
|
|
1827
|
-
const sid = data.sessionId;
|
|
1828
|
-
if (!sid) return;
|
|
1829
|
-
if (acquireSessionLock(sid, ws)) {
|
|
1830
|
-
lockedSessionsForThisWs.add(sid);
|
|
1831
|
-
writer.send({ type: 'session-locked', sessionId: sid, success: true });
|
|
1832
|
-
// session locked to tab
|
|
1833
|
-
} else {
|
|
1834
|
-
writer.send({ type: 'session-locked', sessionId: sid, success: false,
|
|
1835
|
-
error: 'Session is already open in another tab' });
|
|
1836
|
-
// session lock denied
|
|
1837
|
-
}
|
|
1838
|
-
return;
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
// Handle session unlock request
|
|
1842
|
-
if (data.type === 'unlock-session') {
|
|
1843
|
-
const sid = data.sessionId;
|
|
1844
|
-
if (sid) {
|
|
1845
|
-
releaseSessionLock(sid, ws);
|
|
1846
|
-
lockedSessionsForThisWs.delete(sid);
|
|
1847
|
-
writer.send({ type: 'session-unlocked', sessionId: sid });
|
|
1848
|
-
// session unlocked
|
|
1849
|
-
}
|
|
1850
|
-
return;
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
if (data.type === 'shell-exec') {
|
|
1854
|
-
const { command, cwd } = data;
|
|
1855
|
-
if (!command) {
|
|
1856
|
-
writer.send({ type: 'shell-exec-output', output: 'No command provided.', exitCode: 1, status: 'error' });
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
if (hasActiveRelay(wsUser?.userId)) {
|
|
1861
|
-
try {
|
|
1862
|
-
const result = await sendRelayCommand(Number(wsUser.userId), 'exec', { command, cwd }, null, 30000);
|
|
1863
|
-
const output = result?.data?.stdout || result?.data?.output || result?.data?.stderr || '';
|
|
1864
|
-
writer.send({ type: 'shell-exec-output', output, exitCode: result?.data?.exitCode ?? 0, status: 'complete' });
|
|
1865
|
-
} catch (err) {
|
|
1866
|
-
writer.send({ type: 'shell-exec-output', output: err.message || 'Command execution failed.', exitCode: 1, status: 'error' });
|
|
1867
|
-
}
|
|
1868
|
-
} else {
|
|
1869
|
-
writer.send({ type: 'shell-exec-output', output: 'No machine connected. Use `uc connect` to bridge your local machine.', exitCode: 1, status: 'error' });
|
|
1870
|
-
}
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
if (data.type === 'claude-command') {
|
|
1875
|
-
const sid = data.options?.sessionId;
|
|
1876
|
-
|
|
1877
|
-
// Session-tab locking: check if this session is locked by another tab
|
|
1878
|
-
if (sid && !acquireSessionLock(sid, ws)) {
|
|
1879
|
-
writer.send({
|
|
1880
|
-
type: 'session-lock-denied',
|
|
1881
|
-
sessionId: sid,
|
|
1882
|
-
error: 'This session is already active in another tab. Close it there first.'
|
|
1883
|
-
});
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
if (sid) lockedSessionsForThisWs.add(sid);
|
|
1887
|
-
|
|
1888
|
-
// Session busy detection (opencode pattern: prevent duplicate requests)
|
|
1889
|
-
if (sid && !markSessionBusy(sid)) {
|
|
1890
|
-
writer.send({
|
|
1891
|
-
type: 'session-busy',
|
|
1892
|
-
sessionId: sid,
|
|
1893
|
-
error: 'This session is already processing a request. Wait for it to finish or abort it first.'
|
|
1894
|
-
});
|
|
1895
|
-
return;
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
try {
|
|
1899
|
-
// Check if user has active relay → route to local machine
|
|
1900
|
-
if (hasActiveRelay(wsUser?.userId)) {
|
|
1901
|
-
// Lightweight gitagent check — 1.5s timeout, non-blocking on failure
|
|
1902
|
-
if (data.options?.cwd) {
|
|
1903
|
-
try {
|
|
1904
|
-
const gaResult = await sendRelayCommand(Number(wsUser.userId), 'gitagent-parse', { projectPath: data.options.cwd }, null, 1500);
|
|
1905
|
-
if (gaResult?.data?.definition) {
|
|
1906
|
-
const appendix = buildSystemPromptAppendix(gaResult.data.definition);
|
|
1907
|
-
data.command = `<gitagent-context>\n${appendix}\n</gitagent-context>\n\n${data.command}`;
|
|
1908
|
-
}
|
|
1909
|
-
} catch { /* not gitagent or timed out — proceed without delay */ }
|
|
1910
|
-
}
|
|
1911
|
-
await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
|
|
1912
|
-
response: 'claude-response',
|
|
1913
|
-
complete: 'claude-complete',
|
|
1914
|
-
error: 'claude-error'
|
|
1915
|
-
});
|
|
1916
|
-
} else {
|
|
1917
|
-
// No relay — try sandbox fallback, then server-side SDK
|
|
1918
|
-
try {
|
|
1919
|
-
const sandboxHandled = wsUser?.userId
|
|
1920
|
-
? await handleSandboxWebSocketCommand(wsUser.userId, 'claude-command', { ...data, sessionId: data.options?.sessionId }, writer)
|
|
1921
|
-
: false;
|
|
1922
|
-
|
|
1923
|
-
if (!sandboxHandled) {
|
|
1924
|
-
// Fall back to server-side SDK
|
|
1925
|
-
const userAnthropicKey = wsUser?.userId
|
|
1926
|
-
? await getUserProviderKey(wsUser.userId, 'anthropic_key')
|
|
1927
|
-
: null;
|
|
1928
|
-
|
|
1929
|
-
await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
|
|
1930
|
-
queryClaudeSDK(data.command, data.options, writer)
|
|
1931
|
-
);
|
|
1932
|
-
}
|
|
1933
|
-
} catch {
|
|
1934
|
-
// SDK fallback failed — send claude-error (not generic error)
|
|
1935
|
-
// so the frontend stops the spinner instead of hanging forever
|
|
1936
|
-
writer.send({
|
|
1937
|
-
type: 'claude-error',
|
|
1938
|
-
error: 'No machine connected and server-side AI is unavailable. Please connect with "uc connect" and try again.',
|
|
1939
|
-
sessionId: sid
|
|
1940
|
-
});
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
} finally {
|
|
1944
|
-
markSessionFree(sid);
|
|
1945
|
-
// Track session usage in Turso
|
|
1946
|
-
if (wsUser?.userId && lastTokenBudget) {
|
|
1947
|
-
const usageSessionId = lastCompletedSessionId || sid || 'unknown';
|
|
1948
|
-
try {
|
|
1949
|
-
await sessionUsageDb.upsert(wsUser.userId, usageSessionId, {
|
|
1950
|
-
provider: 'claude',
|
|
1951
|
-
promptTokens: lastTokenBudget.used || 0,
|
|
1952
|
-
completionTokens: 0,
|
|
1953
|
-
costCents: 0,
|
|
1954
|
-
model: data.options?.model || null,
|
|
1955
|
-
});
|
|
1956
|
-
} catch { /* non-critical */ }
|
|
1957
|
-
lastTokenBudget = null;
|
|
1958
|
-
lastCompletedSessionId = null;
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
} else if (data.type === 'cursor-command') {
|
|
1962
|
-
// Check if user has active relay → route to local machine
|
|
1963
|
-
if (hasActiveRelay(wsUser?.userId)) {
|
|
1964
|
-
await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
|
|
1965
|
-
response: 'cursor-response',
|
|
1966
|
-
complete: 'cursor-complete',
|
|
1967
|
-
error: 'cursor-error'
|
|
1968
|
-
});
|
|
1969
|
-
} else {
|
|
1970
|
-
await spawnCursor(data.command, data.options, writer);
|
|
1971
|
-
}
|
|
1972
|
-
} else if (data.type === 'codex-command') {
|
|
1973
|
-
// Check if user has active relay → route to local machine
|
|
1974
|
-
if (hasActiveRelay(wsUser?.userId)) {
|
|
1975
|
-
await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
|
|
1976
|
-
response: 'codex-response',
|
|
1977
|
-
complete: 'codex-complete',
|
|
1978
|
-
error: 'codex-error'
|
|
1979
|
-
});
|
|
1980
|
-
} else {
|
|
1981
|
-
const userOpenaiKey = wsUser?.userId
|
|
1982
|
-
? await getUserProviderKey(wsUser.userId, 'openai_key')
|
|
1983
|
-
: null;
|
|
1984
|
-
|
|
1985
|
-
await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
|
|
1986
|
-
queryCodex(data.command, data.options, writer)
|
|
1987
|
-
);
|
|
1988
|
-
}
|
|
1989
|
-
} else if (data.type === 'openrouter-command') {
|
|
1990
|
-
// BYOK: OpenRouter requires user's own API key
|
|
1991
|
-
const userOrKey = wsUser?.userId
|
|
1992
|
-
? await getUserProviderKey(wsUser.userId, 'openrouter_key')
|
|
1993
|
-
: null;
|
|
1994
|
-
|
|
1995
|
-
await queryOpenRouter(data.command, {
|
|
1996
|
-
...data.options,
|
|
1997
|
-
apiKey: userOrKey,
|
|
1998
|
-
}, writer);
|
|
1999
|
-
} else if (data.type === 'cursor-resume') {
|
|
2000
|
-
// Backward compatibility: treat as cursor-command with resume and no prompt
|
|
2001
|
-
// cursor resume session
|
|
2002
|
-
await spawnCursor('', {
|
|
2003
|
-
sessionId: data.sessionId,
|
|
2004
|
-
resume: true,
|
|
2005
|
-
cwd: data.options?.cwd
|
|
2006
|
-
}, writer);
|
|
2007
|
-
} else if (data.type === 'abort-session') {
|
|
2008
|
-
// abort session request
|
|
2009
|
-
const provider = data.provider || 'claude';
|
|
2010
|
-
let success;
|
|
2011
|
-
|
|
2012
|
-
// Check if this is a relay session first (opencode pattern: cancel propagation)
|
|
2013
|
-
const relayInfo = relaySessionRequests.get(data.sessionId);
|
|
2014
|
-
if (relayInfo) {
|
|
2015
|
-
const relay = relayConnections.get(relayInfo.userId);
|
|
2016
|
-
if (relay && relay.ws.readyState === 1) {
|
|
2017
|
-
// Forward abort to CLI via relay WebSocket
|
|
2018
|
-
relay.ws.send(JSON.stringify({
|
|
2019
|
-
type: 'relay-abort',
|
|
2020
|
-
requestId: relayInfo.requestId,
|
|
2021
|
-
}));
|
|
2022
|
-
// Clean up pending relay request
|
|
2023
|
-
const pending = pendingRelayRequests.get(relayInfo.requestId);
|
|
2024
|
-
if (pending) {
|
|
2025
|
-
clearTimeout(pending.timeout);
|
|
2026
|
-
pending.resolve({ exitCode: -1, aborted: true });
|
|
2027
|
-
pendingRelayRequests.delete(relayInfo.requestId);
|
|
2028
|
-
}
|
|
2029
|
-
relaySessionRequests.delete(data.sessionId);
|
|
2030
|
-
success = true;
|
|
2031
|
-
} else {
|
|
2032
|
-
success = false;
|
|
2033
|
-
}
|
|
2034
|
-
} else if (provider === 'cursor') {
|
|
2035
|
-
success = abortCursorSession(data.sessionId);
|
|
2036
|
-
} else if (provider === 'codex') {
|
|
2037
|
-
success = abortCodexSession(data.sessionId);
|
|
2038
|
-
} else {
|
|
2039
|
-
// Use Claude Agents SDK
|
|
2040
|
-
success = await abortClaudeSDKSession(data.sessionId);
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
writer.send({
|
|
2044
|
-
type: 'session-aborted',
|
|
2045
|
-
sessionId: data.sessionId,
|
|
2046
|
-
provider,
|
|
2047
|
-
success
|
|
2048
|
-
});
|
|
2049
|
-
} else if (data.type === 'claude-permission-response') {
|
|
2050
|
-
// Relay UI approval decisions back into the SDK control flow.
|
|
2051
|
-
// This does not persist permissions; it only resolves the in-flight request,
|
|
2052
|
-
// introduced so the SDK can resume once the user clicks Allow/Deny.
|
|
2053
|
-
if (data.requestId) {
|
|
2054
|
-
resolveToolApproval(data.requestId, {
|
|
2055
|
-
allow: Boolean(data.allow),
|
|
2056
|
-
updatedInput: data.updatedInput,
|
|
2057
|
-
message: data.message,
|
|
2058
|
-
rememberEntry: data.rememberEntry
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
} else if (data.type === 'relay-permission-response') {
|
|
2062
|
-
// Forward permission response from browser → CLI relay (opencode pattern)
|
|
2063
|
-
if (wsUser?.userId && hasActiveRelay(wsUser.userId)) {
|
|
2064
|
-
const relay = relayConnections.get(Number(wsUser.userId));
|
|
2065
|
-
if (relay?.ws?.readyState === 1) {
|
|
2066
|
-
relay.ws.send(JSON.stringify({
|
|
2067
|
-
type: 'relay-permission-response',
|
|
2068
|
-
permissionId: data.permissionId,
|
|
2069
|
-
approved: data.approved,
|
|
2070
|
-
}));
|
|
2071
|
-
}
|
|
2072
|
-
}
|
|
2073
|
-
} else if (data.type === 'cursor-abort') {
|
|
2074
|
-
// abort cursor session
|
|
2075
|
-
const success = abortCursorSession(data.sessionId);
|
|
2076
|
-
writer.send({
|
|
2077
|
-
type: 'session-aborted',
|
|
2078
|
-
sessionId: data.sessionId,
|
|
2079
|
-
provider: 'cursor',
|
|
2080
|
-
success
|
|
2081
|
-
});
|
|
2082
|
-
} else if (data.type === 'check-session-status') {
|
|
2083
|
-
// Check if a specific session is currently processing
|
|
2084
|
-
const provider = data.provider || 'claude';
|
|
2085
|
-
const sessionId = data.sessionId;
|
|
2086
|
-
let isActive;
|
|
2087
|
-
|
|
2088
|
-
if (provider === 'cursor') {
|
|
2089
|
-
isActive = isCursorSessionActive(sessionId);
|
|
2090
|
-
} else if (provider === 'codex') {
|
|
2091
|
-
isActive = isCodexSessionActive(sessionId);
|
|
2092
|
-
} else {
|
|
2093
|
-
// Use Claude Agents SDK
|
|
2094
|
-
isActive = isClaudeSDKSessionActive(sessionId);
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
writer.send({
|
|
2098
|
-
type: 'session-status',
|
|
2099
|
-
sessionId,
|
|
2100
|
-
provider,
|
|
2101
|
-
isProcessing: isActive
|
|
2102
|
-
});
|
|
2103
|
-
} else if (data.type === 'get-active-sessions') {
|
|
2104
|
-
// Get all currently active sessions
|
|
2105
|
-
const activeSessions = {
|
|
2106
|
-
claude: getActiveClaudeSDKSessions(),
|
|
2107
|
-
cursor: getActiveCursorSessions(),
|
|
2108
|
-
codex: getActiveCodexSessions()
|
|
2109
|
-
};
|
|
2110
|
-
writer.send({
|
|
2111
|
-
type: 'active-sessions',
|
|
2112
|
-
sessions: activeSessions
|
|
2113
|
-
});
|
|
2114
|
-
} else if (data.type === 'subscribe-session') {
|
|
2115
|
-
// Multi-tab read access: subscribe to session output without locking
|
|
2116
|
-
const sid = data.sessionId;
|
|
2117
|
-
if (sid) {
|
|
2118
|
-
subscribeToSession(sid, ws);
|
|
2119
|
-
writer.send({ type: 'session-subscribed', sessionId: sid });
|
|
2120
|
-
}
|
|
2121
|
-
} else if (data.type === 'unsubscribe-session') {
|
|
2122
|
-
const sid = data.sessionId;
|
|
2123
|
-
if (sid) {
|
|
2124
|
-
unsubscribeFromSession(sid, ws);
|
|
2125
|
-
writer.send({ type: 'session-unsubscribed', sessionId: sid });
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
} catch (error) {
|
|
2129
|
-
// chat WebSocket error
|
|
2130
|
-
writer.send({
|
|
2131
|
-
type: 'error',
|
|
2132
|
-
error: 'An unexpected error occurred'
|
|
2133
|
-
});
|
|
2134
|
-
}
|
|
2135
|
-
});
|
|
2136
|
-
|
|
2137
|
-
ws.on('close', () => {
|
|
2138
|
-
// Chat client disconnected
|
|
2139
|
-
// Release all session locks held by this WebSocket
|
|
2140
|
-
releaseAllLocksForWs(ws);
|
|
2141
|
-
unsubscribeAllForWs(ws);
|
|
2142
|
-
// Remove from connected clients
|
|
2143
|
-
connectedClients.delete(ws);
|
|
2144
|
-
});
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
// Handle relay WebSocket connections (local machine ↔ server bridge)
|
|
2148
|
-
async function handleRelayConnection(ws, token, request) {
|
|
2149
|
-
if (!token) {
|
|
2150
|
-
ws.send(JSON.stringify({ type: 'error', error: 'Relay token required. Use ?token=upfyn_xxx' }));
|
|
2151
|
-
ws.close();
|
|
2152
|
-
return;
|
|
2153
|
-
}
|
|
2154
|
-
|
|
2155
|
-
// Extract and pin client IP for this relay session
|
|
2156
|
-
const clientIp = extractClientIp(request);
|
|
2157
|
-
|
|
2158
|
-
const tokenData = await relayTokensDb.validateToken(token, clientIp);
|
|
2159
|
-
if (!tokenData) {
|
|
2160
|
-
ws.send(JSON.stringify({ type: 'error', error: 'Invalid or expired relay token' }));
|
|
2161
|
-
ws.close();
|
|
2162
|
-
return;
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
const userId = Number(tokenData.user_id);
|
|
2166
|
-
const username = tokenData.username;
|
|
2167
|
-
|
|
2168
|
-
// Reject if another relay is already connected for this user from a DIFFERENT IP
|
|
2169
|
-
// This prevents session hijacking — only the original machine can hold the relay
|
|
2170
|
-
const existingRelay = relayConnections.get(userId);
|
|
2171
|
-
if (existingRelay && existingRelay.ws.readyState === 1 && existingRelay.clientIp !== clientIp) {
|
|
2172
|
-
ws.send(JSON.stringify({
|
|
2173
|
-
type: 'error',
|
|
2174
|
-
error: 'Another machine is already connected for this account. Disconnect it first or use a different token.'
|
|
2175
|
-
}));
|
|
2176
|
-
ws.close();
|
|
2177
|
-
return;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// Extract optional headers from relay handshake
|
|
2181
|
-
const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
|
|
2182
|
-
const relayVersion = request?.headers?.['x-upfyn-version'] || null;
|
|
2183
|
-
const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
|
|
2184
|
-
const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
|
|
2185
|
-
const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
|
|
2186
|
-
|
|
2187
|
-
// Store relay connection with IP pinning — all commands must originate from authenticated user
|
|
2188
|
-
// API key is held per-user in the relay connection, NOT in process.env
|
|
2189
|
-
relayConnections.set(userId, {
|
|
2190
|
-
ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
|
|
2191
|
-
version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
|
|
2192
|
-
agents: null, // populated when client sends agent-capabilities
|
|
2193
|
-
lastPong: Date.now(),
|
|
2194
|
-
clientIp, // Pinned IP — used for security validation
|
|
2195
|
-
tokenId: tokenData.id, // Token ID used for this connection
|
|
2196
|
-
});
|
|
2197
|
-
|
|
2198
|
-
ws.send(JSON.stringify({
|
|
2199
|
-
type: 'relay-connected',
|
|
2200
|
-
message: `Connected as ${username}. Your local machine is now bridged to the server.`
|
|
2201
|
-
}));
|
|
2202
|
-
|
|
2203
|
-
// Auto-add relay CWD as a project so the user lands with a ready workspace
|
|
2204
|
-
if (relayCwd) {
|
|
2205
|
-
try {
|
|
2206
|
-
await addProjectManually(relayCwd, null, userId);
|
|
2207
|
-
} catch { /* project may already exist, ignore */ }
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
// Cancel any pending sandbox cleanup (user reconnected within grace period)
|
|
2211
|
-
const pendingCleanup = sandboxCleanupTimers.get(userId);
|
|
2212
|
-
if (pendingCleanup) {
|
|
2213
|
-
clearTimeout(pendingCleanup);
|
|
2214
|
-
sandboxCleanupTimers.delete(userId);
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
// Initialize sandbox for user isolation (reuses existing if still alive)
|
|
2218
|
-
let sandboxActive = false;
|
|
2219
|
-
try {
|
|
2220
|
-
// Check if sandbox is still running from previous session
|
|
2221
|
-
const status = await sandboxClient.getStatus(userId).catch(() => null);
|
|
2222
|
-
if (status?.running) {
|
|
2223
|
-
sandboxActive = true;
|
|
2224
|
-
} else {
|
|
2225
|
-
await sandboxClient.initSandbox(userId, { projectName: relayCwd });
|
|
2226
|
-
sandboxActive = true;
|
|
2227
|
-
}
|
|
2228
|
-
} catch { /* sandbox is optional — don't block relay */ }
|
|
2229
|
-
|
|
2230
|
-
// Track active connection in Turso (with last cwd/machine/platform for smooth reconnection)
|
|
2231
|
-
try { await connectionDb.connect(String(userId), relayCwd, 'relay', sandboxActive ? String(userId) : null, { cwd: relayCwd, machine: relayMachine, platform: relayPlatform }); } catch { /* non-critical */ }
|
|
2232
|
-
|
|
2233
|
-
// Broadcast relay status to this user's browser clients only
|
|
2234
|
-
for (const client of connectedClients) {
|
|
2235
|
-
try {
|
|
2236
|
-
if (client.readyState === 1 && client._wsUser?.userId === userId) {
|
|
2237
|
-
client.send(JSON.stringify({
|
|
2238
|
-
type: 'relay-status',
|
|
2239
|
-
userId,
|
|
2240
|
-
connected: true,
|
|
2241
|
-
cwd: relayCwd,
|
|
2242
|
-
machine: relayMachine,
|
|
2243
|
-
platform: relayPlatform,
|
|
2244
|
-
sandboxActive,
|
|
2245
|
-
}));
|
|
2246
|
-
}
|
|
2247
|
-
} catch (e) { /* ignore */ }
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
// Native WebSocket pong handler — Railway proxy recognizes protocol-level
|
|
2251
|
-
// ping/pong as keepalive, preventing idle connection termination
|
|
2252
|
-
ws.on('pong', () => {
|
|
2253
|
-
const relay = relayConnections.get(userId);
|
|
2254
|
-
if (relay && relay.ws === ws) relay.lastPong = Date.now();
|
|
2255
|
-
});
|
|
2256
|
-
|
|
2257
|
-
ws.on('message', async (message) => {
|
|
2258
|
-
try {
|
|
2259
|
-
const data = JSON.parse(message);
|
|
2260
|
-
|
|
2261
|
-
// Relay response from local machine → resolve pending request
|
|
2262
|
-
if (data.type === 'relay-response' && data.requestId) {
|
|
2263
|
-
const pending = pendingRelayRequests.get(data.requestId);
|
|
2264
|
-
if (pending) {
|
|
2265
|
-
clearTimeout(pending.timeout);
|
|
2266
|
-
pendingRelayRequests.delete(data.requestId);
|
|
2267
|
-
// If CLI sent an error, reject; otherwise unwrap data.data
|
|
2268
|
-
if (data.error) {
|
|
2269
|
-
pending.reject(new Error(data.error));
|
|
2270
|
-
} else {
|
|
2271
|
-
pending.resolve(data.data || data);
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
return;
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
// Relay stream chunk from local machine → forward to browser WebSocket
|
|
2278
|
-
if (data.type === 'relay-stream' && data.requestId) {
|
|
2279
|
-
const pending = pendingRelayRequests.get(data.requestId);
|
|
2280
|
-
if (pending && pending.onStream) {
|
|
2281
|
-
pending.onStream(data.data);
|
|
2282
|
-
}
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
// Relay complete signal
|
|
2287
|
-
if (data.type === 'relay-complete' && data.requestId) {
|
|
2288
|
-
const pending = pendingRelayRequests.get(data.requestId);
|
|
2289
|
-
if (pending) {
|
|
2290
|
-
clearTimeout(pending.timeout);
|
|
2291
|
-
pendingRelayRequests.delete(data.requestId);
|
|
2292
|
-
pending.resolve(data);
|
|
2293
|
-
}
|
|
2294
|
-
return;
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
// Permission request from CLI → forward to browser (opencode pattern)
|
|
2298
|
-
if (data.type === 'relay-permission-request') {
|
|
2299
|
-
for (const client of connectedClients) {
|
|
2300
|
-
try {
|
|
2301
|
-
if (client.readyState === 1 && client._wsUser?.userId === userId) {
|
|
2302
|
-
client.send(JSON.stringify({
|
|
2303
|
-
type: 'relay-permission-request',
|
|
2304
|
-
...data,
|
|
2305
|
-
}));
|
|
2306
|
-
}
|
|
2307
|
-
} catch { /* ignore */ }
|
|
2308
|
-
}
|
|
2309
|
-
return;
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
// Agent capabilities report from relay client
|
|
2313
|
-
if (data.type === 'agent-capabilities') {
|
|
2314
|
-
const relay = relayConnections.get(userId);
|
|
2315
|
-
if (relay) {
|
|
2316
|
-
relay.agents = data.agents || {};
|
|
2317
|
-
relay.machine = data.machine || relay.machine;
|
|
2318
|
-
}
|
|
2319
|
-
// Broadcast agent info to this user's browser clients only
|
|
2320
|
-
for (const client of connectedClients) {
|
|
2321
|
-
try {
|
|
2322
|
-
if (client.readyState === 1 && client._wsUser?.userId === userId) {
|
|
2323
|
-
client.send(JSON.stringify({
|
|
2324
|
-
type: 'relay-agents',
|
|
2325
|
-
userId,
|
|
2326
|
-
agents: data.agents || {},
|
|
2327
|
-
machine: data.machine || {}
|
|
2328
|
-
}));
|
|
2329
|
-
}
|
|
2330
|
-
} catch (e) { /* ignore */ }
|
|
2331
|
-
}
|
|
2332
|
-
return;
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
// Relay shell output from CLI → forward to browser shell WebSocket
|
|
2336
|
-
if (data.type === 'relay-shell-output' && data.shellSessionId) {
|
|
2337
|
-
const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
|
|
2338
|
-
if (browserWs?.readyState === 1) {
|
|
2339
|
-
browserWs.send(JSON.stringify({ type: 'output', data: data.data }));
|
|
2340
|
-
}
|
|
2341
|
-
return;
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
// Relay shell exited on CLI
|
|
2345
|
-
if (data.type === 'relay-shell-exited' && data.shellSessionId) {
|
|
2346
|
-
const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
|
|
2347
|
-
if (browserWs?.readyState === 1) {
|
|
2348
|
-
browserWs.send(JSON.stringify({
|
|
2349
|
-
type: 'output',
|
|
2350
|
-
data: `\r\n\x1b[33mProcess exited with code ${data.exitCode ?? 0}\x1b[0m\r\n`
|
|
2351
|
-
}));
|
|
2352
|
-
}
|
|
2353
|
-
relayShellBrowserSockets.delete(data.shellSessionId);
|
|
2354
|
-
return;
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
// Relay shell auth URL detected
|
|
2358
|
-
if (data.type === 'relay-shell-auth-url' && data.shellSessionId) {
|
|
2359
|
-
const browserWs = relayShellBrowserSockets.get(data.shellSessionId);
|
|
2360
|
-
if (browserWs?.readyState === 1) {
|
|
2361
|
-
browserWs.send(JSON.stringify({ type: 'auth_url', url: data.url, autoOpen: data.autoOpen }));
|
|
2362
|
-
}
|
|
2363
|
-
return;
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// Relay init: CLI sends working directory after connect
|
|
2367
|
-
// Auto-register CWD as default project so user lands with a ready workspace
|
|
2368
|
-
if (data.type === 'relay-init') {
|
|
2369
|
-
const relay = relayConnections.get(userId);
|
|
2370
|
-
if (relay) {
|
|
2371
|
-
relay.cwd = data.cwd || relay.cwd;
|
|
2372
|
-
relay.platform = data.platform || relay.platform;
|
|
2373
|
-
relay.machine = data.hostname || relay.machine;
|
|
2374
|
-
}
|
|
2375
|
-
// Auto-add CWD as project (pass userId for cloud DB storage)
|
|
2376
|
-
if (data.cwd) {
|
|
2377
|
-
try {
|
|
2378
|
-
await addProjectManually(data.cwd, null, userId);
|
|
2379
|
-
} catch { /* already exists */ }
|
|
2380
|
-
}
|
|
2381
|
-
// Broadcast to browser so it auto-selects this project
|
|
2382
|
-
for (const client of connectedClients) {
|
|
2383
|
-
try {
|
|
2384
|
-
if (client.readyState === 1) {
|
|
2385
|
-
client.send(JSON.stringify({
|
|
2386
|
-
type: 'relay-init',
|
|
2387
|
-
userId,
|
|
2388
|
-
cwd: data.cwd,
|
|
2389
|
-
dirName: data.dirName,
|
|
2390
|
-
platform: data.platform,
|
|
2391
|
-
}));
|
|
2392
|
-
}
|
|
2393
|
-
} catch { /* ignore */ }
|
|
2394
|
-
}
|
|
2395
|
-
return;
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
// Heartbeat
|
|
2399
|
-
if (data.type === 'ping') {
|
|
2400
|
-
const relay = relayConnections.get(userId);
|
|
2401
|
-
if (relay) relay.lastPong = Date.now();
|
|
2402
|
-
ws.send(JSON.stringify({ type: 'pong' }));
|
|
2403
|
-
return;
|
|
2404
|
-
}
|
|
2405
|
-
} catch (e) {
|
|
2406
|
-
// relay message processing error
|
|
2407
|
-
}
|
|
2408
|
-
});
|
|
2409
|
-
|
|
2410
|
-
// Server-side heartbeat: ping relay client every 25s, terminate if no pong in 60s
|
|
2411
|
-
const relayHeartbeat = setInterval(() => {
|
|
2412
|
-
const relay = relayConnections.get(userId);
|
|
2413
|
-
if (!relay || relay.ws !== ws) {
|
|
2414
|
-
clearInterval(relayHeartbeat);
|
|
2415
|
-
return;
|
|
2416
|
-
}
|
|
2417
|
-
// If no ping received from client in 60s, consider connection stale
|
|
2418
|
-
if (Date.now() - relay.lastPong > 60000) {
|
|
2419
|
-
clearInterval(relayHeartbeat);
|
|
2420
|
-
ws.terminate();
|
|
2421
|
-
return;
|
|
2422
|
-
}
|
|
2423
|
-
// Send native WebSocket ping frame — recognized by Railway proxy as keepalive
|
|
2424
|
-
try { ws.ping(); } catch { /* ignore */ }
|
|
2425
|
-
// Also send JSON-level ping to keep application-layer alive
|
|
2426
|
-
try {
|
|
2427
|
-
if (ws.readyState === 1) {
|
|
2428
|
-
ws.send(JSON.stringify({ type: 'server-ping' }));
|
|
2429
|
-
}
|
|
2430
|
-
} catch { /* ignore */ }
|
|
2431
|
-
}, 25000);
|
|
2432
|
-
|
|
2433
|
-
ws.on('close', async () => {
|
|
2434
|
-
clearInterval(relayHeartbeat);
|
|
2435
|
-
|
|
2436
|
-
// Guard: only delete if THIS ws is still the active relay for this user.
|
|
2437
|
-
// Prevents race condition where CLI reconnects quickly and the old close
|
|
2438
|
-
// handler fires after the new connection is stored, wiping it out.
|
|
2439
|
-
const currentRelay = relayConnections.get(userId);
|
|
2440
|
-
if (!currentRelay || currentRelay.ws !== ws) {
|
|
2441
|
-
// New connection already replaced this one — skip all cleanup
|
|
2442
|
-
return;
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
relayConnections.delete(userId);
|
|
2446
|
-
|
|
2447
|
-
// Clean up pending requests for this user (relay is truly gone)
|
|
2448
|
-
for (const [reqId, pending] of pendingRelayRequests) {
|
|
2449
|
-
if (pending.userId === userId) {
|
|
2450
|
-
clearTimeout(pending.timeout);
|
|
2451
|
-
pending.reject(new Error('Relay disconnected'));
|
|
2452
|
-
pendingRelayRequests.delete(reqId);
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
// Track disconnection in Turso (but keep sandbox alive for grace period)
|
|
2457
|
-
try { await connectionDb.disconnect(String(userId), 'relay'); } catch { /* non-critical */ }
|
|
2458
|
-
|
|
2459
|
-
// Schedule sandbox cleanup after grace period (5 min)
|
|
2460
|
-
// If user reconnects within this window, the timer is cancelled and sandbox reused
|
|
2461
|
-
const cleanupTimer = setTimeout(async () => {
|
|
2462
|
-
sandboxCleanupTimers.delete(userId);
|
|
2463
|
-
// Only destroy if user hasn't reconnected
|
|
2464
|
-
if (!relayConnections.has(userId)) {
|
|
2465
|
-
try { await sandboxClient.destroySandbox(userId); } catch { /* best-effort */ }
|
|
2466
|
-
}
|
|
2467
|
-
}, SANDBOX_GRACE_PERIOD_MS);
|
|
2468
|
-
sandboxCleanupTimers.set(userId, cleanupTimer);
|
|
2469
|
-
|
|
2470
|
-
// Broadcast relay disconnect to this user's browser clients only
|
|
2471
|
-
// Note: sandboxActive stays true during grace period (sandbox is still alive)
|
|
2472
|
-
for (const client of connectedClients) {
|
|
2473
|
-
try {
|
|
2474
|
-
if (client.readyState === 1 && client._wsUser?.userId === userId) {
|
|
2475
|
-
client.send(JSON.stringify({ type: 'relay-status', userId, connected: false, sandboxActive: true }));
|
|
2476
|
-
}
|
|
2477
|
-
} catch (e) { /* ignore */ }
|
|
2478
|
-
}
|
|
2479
|
-
});
|
|
2480
|
-
|
|
2481
|
-
ws.on('error', () => {
|
|
2482
|
-
clearInterval(relayHeartbeat);
|
|
2483
|
-
});
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
/**
|
|
2487
|
-
* Retry helper with exponential backoff (opencode pattern)
|
|
2488
|
-
* Only retries on transient errors (timeout, disconnection). Not for validation/security.
|
|
2489
|
-
*/
|
|
2490
|
-
async function withRetry(fn, { maxRetries = 3, baseDelayMs = 2000, jitter = 0.2 } = {}) {
|
|
2491
|
-
let lastError;
|
|
2492
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2493
|
-
try {
|
|
2494
|
-
return await fn();
|
|
2495
|
-
} catch (err) {
|
|
2496
|
-
lastError = err;
|
|
2497
|
-
const msg = err.message || '';
|
|
2498
|
-
const isTransient = msg.includes('timed out') || msg.includes('disconnected');
|
|
2499
|
-
if (!isTransient || attempt === maxRetries) throw err;
|
|
2500
|
-
const delay = baseDelayMs * Math.pow(2, attempt) * (1 + (Math.random() * jitter * 2 - jitter));
|
|
2501
|
-
await new Promise(r => setTimeout(r, delay));
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
throw lastError;
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
/**
|
|
2508
|
-
* Send a command to a user's relay and wait for response
|
|
2509
|
-
* @param {number} userId - User ID
|
|
2510
|
-
* @param {string} action - Action type (claude-query, shell-command, file-read, etc.)
|
|
2511
|
-
* @param {object} payload - Action payload
|
|
2512
|
-
* @param {function} onStream - Optional callback for streaming chunks
|
|
2513
|
-
* @param {number} timeoutMs - Timeout in milliseconds (default 5 min)
|
|
2514
|
-
* @returns {Promise<object>} Relay response
|
|
2515
|
-
*/
|
|
2516
|
-
function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs = 300000, externalRequestId = null) {
|
|
2517
|
-
return new Promise((resolve, reject) => {
|
|
2518
|
-
const relay = relayConnections.get(userId);
|
|
2519
|
-
if (!relay || relay.ws.readyState !== 1) {
|
|
2520
|
-
reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
|
|
2521
|
-
return;
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
// Security: validate action is in allowlist and payload is safe
|
|
2525
|
-
const validation = validateRelayPayload(action, payload);
|
|
2526
|
-
if (!validation.valid) {
|
|
2527
|
-
reject(new Error(`Relay command blocked: ${validation.reason}`));
|
|
2528
|
-
return;
|
|
2529
|
-
}
|
|
2530
|
-
|
|
2531
|
-
// Use external requestId if provided (for abort tracking), else generate
|
|
2532
|
-
const requestId = externalRequestId || crypto.randomUUID();
|
|
2533
|
-
const timeout = setTimeout(() => {
|
|
2534
|
-
pendingRelayRequests.delete(requestId);
|
|
2535
|
-
reject(new Error('Relay request timed out'));
|
|
2536
|
-
}, timeoutMs);
|
|
2537
|
-
|
|
2538
|
-
pendingRelayRequests.set(requestId, { resolve, reject, timeout, userId, onStream });
|
|
2539
|
-
|
|
2540
|
-
relay.ws.send(JSON.stringify({
|
|
2541
|
-
type: 'relay-command',
|
|
2542
|
-
requestId,
|
|
2543
|
-
action,
|
|
2544
|
-
...payload
|
|
2545
|
-
}));
|
|
2546
|
-
});
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
/**
|
|
2550
|
-
* Check if a user has an active relay connection
|
|
2551
|
-
*/
|
|
2552
|
-
function hasActiveRelay(userId) {
|
|
2553
|
-
if (!userId) return false;
|
|
2554
|
-
const relay = relayConnections.get(Number(userId));
|
|
2555
|
-
return relay && relay.ws.readyState === 1;
|
|
2556
|
-
}
|
|
2557
|
-
|
|
2558
|
-
/**
|
|
2559
|
-
* Route a chat command through the user's relay connection to their local machine.
|
|
2560
|
-
* Translates relay-stream/relay-complete events into the format the frontend expects.
|
|
2561
|
-
*
|
|
2562
|
-
* @param {number} userId - User ID
|
|
2563
|
-
* @param {string} action - Relay action (claude-query, codex-query, cursor-query)
|
|
2564
|
-
* @param {object} data - Original command data from the browser
|
|
2565
|
-
* @param {object} writer - WebSocket writer to send events to browser
|
|
2566
|
-
* @param {object} eventMap - Maps relay stream data types to chat event types
|
|
2567
|
-
*/
|
|
2568
|
-
async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
|
|
2569
|
-
const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2570
|
-
|
|
2571
|
-
// Generate requestId upfront so we can track it for abort (opencode pattern)
|
|
2572
|
-
const requestId = crypto.randomUUID();
|
|
2573
|
-
|
|
2574
|
-
// Track relay session → requestId mapping for abort forwarding
|
|
2575
|
-
relaySessionRequests.set(sessionId, { userId: Number(userId), requestId });
|
|
2576
|
-
|
|
2577
|
-
// Send session-created so the frontend can track this query
|
|
2578
|
-
writer.send({ type: 'session-created', sessionId });
|
|
2579
|
-
|
|
2580
|
-
// Determine event types from the provider
|
|
2581
|
-
const responseType = eventMap.response || 'claude-response';
|
|
2582
|
-
const completeType = eventMap.complete || 'claude-complete';
|
|
2583
|
-
const errorType = eventMap.error || 'claude-error';
|
|
2584
|
-
|
|
2585
|
-
let fullContent = '';
|
|
2586
|
-
let capturedCliSessionId = null;
|
|
2587
|
-
let usedSDKFormat = false;
|
|
2588
|
-
|
|
2589
|
-
try {
|
|
2590
|
-
const result = await sendRelayCommand(
|
|
2591
|
-
Number(userId),
|
|
2592
|
-
action,
|
|
2593
|
-
{
|
|
2594
|
-
command: data.command,
|
|
2595
|
-
options: data.options || {}
|
|
2596
|
-
},
|
|
2597
|
-
// onStream callback — translates relay events to chat events
|
|
2598
|
-
(streamData) => {
|
|
2599
|
-
// ── NEW: Handle SDK messages from upgraded CLI agent ──
|
|
2600
|
-
// The CLI now uses @anthropic-ai/claude-agent-sdk query() which
|
|
2601
|
-
// sends rich typed messages (tool_use, tool_result, text, system, result).
|
|
2602
|
-
// Forward them directly — same format as queryClaudeSDK() in claude-sdk.js.
|
|
2603
|
-
if (streamData.type === 'claude-sdk-message') {
|
|
2604
|
-
const sdkMessage = streamData.data;
|
|
2605
|
-
if (!sdkMessage) return;
|
|
2606
|
-
|
|
2607
|
-
// Capture session ID from SDK messages
|
|
2608
|
-
if (sdkMessage.session_id && !capturedCliSessionId) {
|
|
2609
|
-
capturedCliSessionId = sdkMessage.session_id;
|
|
2610
|
-
|
|
2611
|
-
// Send session-created with the real CLI session ID
|
|
2612
|
-
writer.send({
|
|
2613
|
-
type: 'relay-session-id',
|
|
2614
|
-
sessionId,
|
|
2615
|
-
cliSessionId: capturedCliSessionId,
|
|
2616
|
-
model: sdkMessage.model,
|
|
2617
|
-
cwd: sdkMessage.cwd,
|
|
2618
|
-
});
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
// Transform parent_tool_use_id for subagent grouping (match queryClaudeSDK format)
|
|
2622
|
-
const transformedMessage = sdkMessage.parent_tool_use_id
|
|
2623
|
-
? { ...sdkMessage, parentToolUseId: sdkMessage.parent_tool_use_id }
|
|
2624
|
-
: sdkMessage;
|
|
2625
|
-
|
|
2626
|
-
// Forward SDK message — SAME format as queryClaudeSDK()
|
|
2627
|
-
writer.send({
|
|
2628
|
-
type: responseType,
|
|
2629
|
-
data: transformedMessage,
|
|
2630
|
-
sessionId: capturedCliSessionId || sessionId || null,
|
|
2631
|
-
});
|
|
2632
|
-
|
|
2633
|
-
// Extract and send token budget from result messages
|
|
2634
|
-
if (sdkMessage.type === 'result' && sdkMessage.modelUsage) {
|
|
2635
|
-
const tokenBudget = extractTokenBudget(sdkMessage);
|
|
2636
|
-
if (tokenBudget) {
|
|
2637
|
-
writer.send({
|
|
2638
|
-
type: 'token-budget',
|
|
2639
|
-
data: tokenBudget,
|
|
2640
|
-
sessionId: capturedCliSessionId || sessionId || null,
|
|
2641
|
-
});
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
// Track that SDK format was used (skip content_block_stop on completion)
|
|
2646
|
-
usedSDKFormat = true;
|
|
2647
|
-
if (sdkMessage.type === 'assistant' || sdkMessage.type === 'result') {
|
|
2648
|
-
fullContent += '1'; // Mark that we got content
|
|
2649
|
-
}
|
|
2650
|
-
|
|
2651
|
-
return;
|
|
2652
|
-
}
|
|
2653
|
-
|
|
2654
|
-
// ── LEGACY: Handle old CLI format (backward compat with pre-SDK CLI versions) ──
|
|
2655
|
-
// Capture CLI-reported session ID for resume support
|
|
2656
|
-
// Forward immediately so frontend can use it for --continue
|
|
2657
|
-
if (streamData.type === 'claude-system' && streamData.sessionId) {
|
|
2658
|
-
capturedCliSessionId = streamData.sessionId;
|
|
2659
|
-
writer.send({
|
|
2660
|
-
type: 'relay-session-id',
|
|
2661
|
-
sessionId,
|
|
2662
|
-
cliSessionId: capturedCliSessionId,
|
|
2663
|
-
model: streamData.model,
|
|
2664
|
-
cwd: streamData.cwd,
|
|
2665
|
-
});
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
|
-
if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
|
|
2669
|
-
const chunk = streamData.content || '';
|
|
2670
|
-
if (chunk) {
|
|
2671
|
-
fullContent += chunk;
|
|
2672
|
-
// Send in content_block_delta format — matches what useChatRealtimeHandlers expects
|
|
2673
|
-
writer.send({
|
|
2674
|
-
type: responseType,
|
|
2675
|
-
data: { type: 'content_block_delta', delta: { text: chunk } },
|
|
2676
|
-
sessionId
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
} else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
|
|
2680
|
-
const errChunk = streamData.content || '';
|
|
2681
|
-
if (errChunk) {
|
|
2682
|
-
writer.send({
|
|
2683
|
-
type: responseType,
|
|
2684
|
-
data: { type: 'content_block_delta', delta: { text: errChunk } },
|
|
2685
|
-
sessionId
|
|
2686
|
-
});
|
|
2687
|
-
}
|
|
2688
|
-
} else if (streamData.type === 'claude-result') {
|
|
2689
|
-
// Result event from stream-json — capture session ID
|
|
2690
|
-
if (streamData.sessionId) capturedCliSessionId = streamData.sessionId;
|
|
2691
|
-
}
|
|
2692
|
-
},
|
|
2693
|
-
600000, // 10 minute timeout for AI queries
|
|
2694
|
-
requestId // Pass pre-generated requestId for abort tracking
|
|
2695
|
-
);
|
|
2696
|
-
|
|
2697
|
-
// Clean up relay session tracking
|
|
2698
|
-
relaySessionRequests.delete(sessionId);
|
|
2699
|
-
|
|
2700
|
-
// Finalize any open streaming message before sending complete
|
|
2701
|
-
// (only for legacy text-streaming mode — SDK messages are self-contained)
|
|
2702
|
-
if (fullContent && !usedSDKFormat) {
|
|
2703
|
-
writer.send({ type: responseType, data: { type: 'content_block_stop' }, sessionId });
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
// Send completion event
|
|
2707
|
-
writer.send({
|
|
2708
|
-
type: completeType,
|
|
2709
|
-
sessionId,
|
|
2710
|
-
cliSessionId: capturedCliSessionId || null,
|
|
2711
|
-
exitCode: result?.exitCode ?? 0,
|
|
2712
|
-
isNewSession: !data.options?.sessionId,
|
|
2713
|
-
viaRelay: true
|
|
2714
|
-
});
|
|
2715
|
-
} catch (error) {
|
|
2716
|
-
relaySessionRequests.delete(sessionId);
|
|
2717
|
-
const isRelayLost = error.message?.includes('Relay disconnected') || error.message?.includes('No relay connection') || error.message?.includes('Relay request timed out');
|
|
2718
|
-
writer.send({
|
|
2719
|
-
type: errorType,
|
|
2720
|
-
error: isRelayLost
|
|
2721
|
-
? 'Your machine disconnected. Please reconnect with "uc connect" and try again.'
|
|
2722
|
-
: 'An error occurred while processing your request',
|
|
2723
|
-
sessionId,
|
|
2724
|
-
relayDisconnected: isRelayLost
|
|
2725
|
-
});
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
|
-
|
|
2729
|
-
// Handle shell WebSocket connections
|
|
2730
|
-
/**
|
|
2731
|
-
* Bridge a browser shell WebSocket to the user's local machine via relay.
|
|
2732
|
-
* Creates a relay shell session on the CLI and forwards I/O bidirectionally.
|
|
2733
|
-
*/
|
|
2734
|
-
function handleRelayShell(browserWs, userId) {
|
|
2735
|
-
const relay = relayConnections.get(userId);
|
|
2736
|
-
if (!relay || relay.ws.readyState !== 1) {
|
|
2737
|
-
browserWs.send(JSON.stringify({ type: 'output', data: '\r\n\x1b[31m[Error] Relay connection lost. Reconnect with "uc connect".\x1b[0m\r\n' }));
|
|
2738
|
-
browserWs.close();
|
|
2739
|
-
return;
|
|
2740
|
-
}
|
|
2741
|
-
|
|
2742
|
-
const shellSessionId = `relay-shell-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2743
|
-
|
|
2744
|
-
browserWs.send(JSON.stringify({ type: 'output', data: '\x1b[36m[Connected to local machine via relay]\x1b[0m\r\n' }));
|
|
2745
|
-
|
|
2746
|
-
// Forward browser shell messages to relay CLI
|
|
2747
|
-
browserWs.on('message', async (message) => {
|
|
2748
|
-
try {
|
|
2749
|
-
const data = JSON.parse(message);
|
|
2750
|
-
|
|
2751
|
-
if (data.type === 'init') {
|
|
2752
|
-
// Auto-register project path so it appears in sidebar
|
|
2753
|
-
if (data.projectPath) {
|
|
2754
|
-
try { await addProjectManually(data.projectPath, null, userId); } catch { /* exists */ }
|
|
2755
|
-
// Notify browser clients — if this is a Claude/Cursor/Codex session,
|
|
2756
|
-
// tell the frontend to auto-select this project and open a new chat
|
|
2757
|
-
const isAgentSession = data.provider && data.provider !== 'plain-shell';
|
|
2758
|
-
for (const client of connectedClients) {
|
|
2759
|
-
try {
|
|
2760
|
-
if (client.readyState === 1 && client._wsUser?.userId === userId) {
|
|
2761
|
-
if (isAgentSession) {
|
|
2762
|
-
client.send(JSON.stringify({
|
|
2763
|
-
type: 'shell-project-selected',
|
|
2764
|
-
projectPath: data.projectPath,
|
|
2765
|
-
provider: data.provider,
|
|
2766
|
-
}));
|
|
2767
|
-
} else {
|
|
2768
|
-
client.send(JSON.stringify({ type: 'projects_updated' }));
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
} catch { /* ignore */ }
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2774
|
-
// Start relay shell session on CLI
|
|
2775
|
-
const relayConn = relayConnections.get(userId);
|
|
2776
|
-
if (relayConn?.ws?.readyState === 1) {
|
|
2777
|
-
relayConn.ws.send(JSON.stringify({
|
|
2778
|
-
type: 'relay-command',
|
|
2779
|
-
requestId: shellSessionId,
|
|
2780
|
-
action: 'shell-session-start',
|
|
2781
|
-
projectPath: data.projectPath,
|
|
2782
|
-
cols: data.cols || 80,
|
|
2783
|
-
rows: data.rows || 24,
|
|
2784
|
-
shellType: data.shellType,
|
|
2785
|
-
initialCommand: data.initialCommand,
|
|
2786
|
-
provider: data.provider,
|
|
2787
|
-
sessionId: data.sessionId,
|
|
2788
|
-
hasSession: data.hasSession,
|
|
2789
|
-
isPlainShell: data.isPlainShell,
|
|
2790
|
-
}));
|
|
2791
|
-
}
|
|
2792
|
-
} else if (data.type === 'input') {
|
|
2793
|
-
// Forward keystroke to CLI shell
|
|
2794
|
-
const relayConn = relayConnections.get(userId);
|
|
2795
|
-
if (relayConn?.ws?.readyState === 1) {
|
|
2796
|
-
relayConn.ws.send(JSON.stringify({
|
|
2797
|
-
type: 'relay-shell-input',
|
|
2798
|
-
shellSessionId,
|
|
2799
|
-
data: data.data,
|
|
2800
|
-
}));
|
|
2801
|
-
}
|
|
2802
|
-
} else if (data.type === 'resize') {
|
|
2803
|
-
// Forward resize to CLI shell
|
|
2804
|
-
const relayConn = relayConnections.get(userId);
|
|
2805
|
-
if (relayConn?.ws?.readyState === 1) {
|
|
2806
|
-
relayConn.ws.send(JSON.stringify({
|
|
2807
|
-
type: 'relay-shell-resize',
|
|
2808
|
-
shellSessionId,
|
|
2809
|
-
cols: data.cols,
|
|
2810
|
-
rows: data.rows,
|
|
2811
|
-
}));
|
|
2812
|
-
}
|
|
2813
|
-
}
|
|
2814
|
-
} catch { /* ignore parse errors */ }
|
|
2815
|
-
});
|
|
2816
|
-
|
|
2817
|
-
// Listen for relay shell output → forward to browser
|
|
2818
|
-
// We tag this browser WS so the relay message handler can find it
|
|
2819
|
-
browserWs._relayShellSessionId = shellSessionId;
|
|
2820
|
-
browserWs._relayShellUserId = userId;
|
|
2821
|
-
|
|
2822
|
-
// Register this shell WS for output routing
|
|
2823
|
-
if (!relayShellBrowserSockets) {
|
|
2824
|
-
// Will be initialized below
|
|
2825
|
-
}
|
|
2826
|
-
relayShellBrowserSockets.set(shellSessionId, browserWs);
|
|
2827
|
-
|
|
2828
|
-
browserWs.on('close', () => {
|
|
2829
|
-
relayShellBrowserSockets.delete(shellSessionId);
|
|
2830
|
-
// Tell CLI to kill the shell session
|
|
2831
|
-
const relayConn = relayConnections.get(userId);
|
|
2832
|
-
if (relayConn?.ws?.readyState === 1) {
|
|
2833
|
-
relayConn.ws.send(JSON.stringify({
|
|
2834
|
-
type: 'relay-shell-kill',
|
|
2835
|
-
shellSessionId,
|
|
2836
|
-
}));
|
|
2837
|
-
}
|
|
2838
|
-
});
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
// Maps shellSessionId → browser WebSocket (for routing relay shell output)
|
|
2842
|
-
const relayShellBrowserSockets = new Map();
|
|
2843
|
-
|
|
2844
|
-
function handleShellConnection(ws, request) {
|
|
2845
|
-
const shellUserId = request?.user?.id || request?.user?.userId || null;
|
|
2846
|
-
|
|
2847
|
-
// ── Relay mode: bridge shell WebSocket to user's local machine ─────────
|
|
2848
|
-
if (shellUserId && hasActiveRelay(Number(shellUserId))) {
|
|
2849
|
-
handleRelayShell(ws, Number(shellUserId));
|
|
2850
|
-
return;
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
if (!pty) {
|
|
2854
|
-
ws.send(JSON.stringify({ type: 'output', data: '\r\n[Shell unavailable] node-pty not installed. Use relay connection for shell access.\r\n' }));
|
|
2855
|
-
ws.close();
|
|
2856
|
-
return;
|
|
2857
|
-
}
|
|
2858
|
-
// Shell client connected
|
|
2859
|
-
let shellProcess = null;
|
|
2860
|
-
let ptySessionKey = null;
|
|
2861
|
-
let urlDetectionBuffer = '';
|
|
2862
|
-
const announcedAuthUrls = new Set();
|
|
2863
|
-
|
|
2864
|
-
ws.on('message', async (message) => {
|
|
2865
|
-
try {
|
|
2866
|
-
const data = JSON.parse(message);
|
|
2867
|
-
// Shell message received
|
|
2868
|
-
|
|
2869
|
-
if (data.type === 'init') {
|
|
2870
|
-
const projectPath = data.projectPath || process.cwd();
|
|
2871
|
-
const sessionId = data.sessionId;
|
|
2872
|
-
const hasSession = data.hasSession;
|
|
2873
|
-
const provider = data.provider || 'claude';
|
|
2874
|
-
const initialCommand = data.initialCommand;
|
|
2875
|
-
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
2876
|
-
const shellType = data.shellType || null;
|
|
2877
|
-
urlDetectionBuffer = '';
|
|
2878
|
-
announcedAuthUrls.clear();
|
|
2879
|
-
|
|
2880
|
-
// Login commands (Claude/Cursor auth) should never reuse cached sessions
|
|
2881
|
-
const isLoginCommand = initialCommand && (
|
|
2882
|
-
initialCommand.includes('setup-token') ||
|
|
2883
|
-
initialCommand.includes('cursor-agent login') ||
|
|
2884
|
-
initialCommand.includes('auth login')
|
|
2885
|
-
);
|
|
2886
|
-
|
|
2887
|
-
// Include command hash in session key so different commands get separate sessions
|
|
2888
|
-
const commandSuffix = isPlainShell && initialCommand
|
|
2889
|
-
? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
|
|
2890
|
-
: '';
|
|
2891
|
-
ptySessionKey = `${shellUserId || 'anon'}_${projectPath}_${sessionId || 'default'}${commandSuffix}`;
|
|
2892
|
-
|
|
2893
|
-
// Kill any existing login session before starting fresh
|
|
2894
|
-
if (isLoginCommand) {
|
|
2895
|
-
const oldSession = ptySessionsMap.get(ptySessionKey);
|
|
2896
|
-
if (oldSession) {
|
|
2897
|
-
// cleaning up existing session
|
|
2898
|
-
if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);
|
|
2899
|
-
if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();
|
|
2900
|
-
ptySessionsMap.delete(ptySessionKey);
|
|
2901
|
-
}
|
|
2902
|
-
}
|
|
2903
|
-
|
|
2904
|
-
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
|
|
2905
|
-
if (existingSession) {
|
|
2906
|
-
// reconnecting to existing PTY session
|
|
2907
|
-
shellProcess = existingSession.pty;
|
|
2908
|
-
|
|
2909
|
-
clearTimeout(existingSession.timeoutId);
|
|
2910
|
-
|
|
2911
|
-
ws.send(JSON.stringify({
|
|
2912
|
-
type: 'output',
|
|
2913
|
-
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
|
2914
|
-
}));
|
|
2915
|
-
|
|
2916
|
-
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
2917
|
-
// sending buffered messages
|
|
2918
|
-
existingSession.buffer.forEach(bufferedData => {
|
|
2919
|
-
ws.send(JSON.stringify({
|
|
2920
|
-
type: 'output',
|
|
2921
|
-
data: bufferedData
|
|
2922
|
-
}));
|
|
2923
|
-
});
|
|
2924
|
-
}
|
|
2925
|
-
|
|
2926
|
-
existingSession.ws = ws;
|
|
2927
|
-
|
|
2928
|
-
return;
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
// shell start path logged silently
|
|
2932
|
-
// shell session started
|
|
2933
|
-
// provider info logged silently
|
|
2934
|
-
if (initialCommand) {
|
|
2935
|
-
// initial command logged silently
|
|
2936
|
-
}
|
|
2937
|
-
|
|
2938
|
-
// First send a welcome message
|
|
2939
|
-
let welcomeMsg;
|
|
2940
|
-
if (isPlainShell) {
|
|
2941
|
-
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
|
2942
|
-
} else {
|
|
2943
|
-
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
|
2944
|
-
welcomeMsg = hasSession ?
|
|
2945
|
-
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
|
2946
|
-
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
ws.send(JSON.stringify({
|
|
2950
|
-
type: 'output',
|
|
2951
|
-
data: welcomeMsg
|
|
2952
|
-
}));
|
|
2953
|
-
|
|
2954
|
-
try {
|
|
2955
|
-
// Prepare the shell command adapted to the platform and provider
|
|
2956
|
-
let shellCommand;
|
|
2957
|
-
if (isPlainShell) {
|
|
2958
|
-
// Plain shell mode - run initial command or open interactive shell
|
|
2959
|
-
const usesPowerShell = !shellType || shellType === 'powershell';
|
|
2960
|
-
if (initialCommand) {
|
|
2961
|
-
if (usesPowerShell) {
|
|
2962
|
-
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
|
2963
|
-
} else {
|
|
2964
|
-
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
|
2965
|
-
}
|
|
2966
|
-
} else {
|
|
2967
|
-
// Interactive shell tab — spawn shell directly (no command wrapper)
|
|
2968
|
-
shellCommand = null;
|
|
2969
|
-
}
|
|
2970
|
-
} else if (provider === 'cursor') {
|
|
2971
|
-
// Use cursor-agent command
|
|
2972
|
-
if (os.platform() === 'win32') {
|
|
2973
|
-
if (hasSession && sessionId) {
|
|
2974
|
-
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
|
|
2975
|
-
} else {
|
|
2976
|
-
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
|
|
2977
|
-
}
|
|
2978
|
-
} else {
|
|
2979
|
-
if (hasSession && sessionId) {
|
|
2980
|
-
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
|
|
2981
|
-
} else {
|
|
2982
|
-
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
|
-
} else {
|
|
2986
|
-
// Use claude command (default) or initialCommand if provided
|
|
2987
|
-
const command = initialCommand || 'claude';
|
|
2988
|
-
if (os.platform() === 'win32') {
|
|
2989
|
-
if (hasSession && sessionId) {
|
|
2990
|
-
// Try to resume session, but with fallback to new session if it fails
|
|
2991
|
-
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
2992
|
-
} else {
|
|
2993
|
-
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
2994
|
-
}
|
|
2995
|
-
} else {
|
|
2996
|
-
if (hasSession && sessionId) {
|
|
2997
|
-
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
|
2998
|
-
} else {
|
|
2999
|
-
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
// shell command logged silently
|
|
3005
|
-
|
|
3006
|
-
// Use appropriate shell based on platform and requested shellType
|
|
3007
|
-
const shellMap = {
|
|
3008
|
-
'powershell': { cmd: 'powershell.exe', args: ['-Command'] },
|
|
3009
|
-
'cmd': { cmd: 'cmd.exe', args: ['/c'] },
|
|
3010
|
-
'bash': { cmd: 'bash', args: ['-c'] },
|
|
3011
|
-
};
|
|
3012
|
-
const defaultShell = os.platform() === 'win32'
|
|
3013
|
-
? { cmd: 'powershell.exe', args: ['-Command'] }
|
|
3014
|
-
: { cmd: 'bash', args: ['-c'] };
|
|
3015
|
-
const selectedShell = (shellType && shellMap[shellType]) || defaultShell;
|
|
3016
|
-
const shell = selectedShell.cmd;
|
|
3017
|
-
// If shellCommand is null, spawn an interactive shell with no args
|
|
3018
|
-
const shellArgs = shellCommand ? [...selectedShell.args, shellCommand] : [];
|
|
3019
|
-
|
|
3020
|
-
// Use terminal dimensions from client if provided, otherwise use defaults
|
|
3021
|
-
const termCols = data.cols || 80;
|
|
3022
|
-
const termRows = data.rows || 24;
|
|
3023
|
-
// terminal dimensions logged silently
|
|
3024
|
-
|
|
3025
|
-
shellProcess = pty.spawn(shell, shellArgs, {
|
|
3026
|
-
name: 'xterm-256color',
|
|
3027
|
-
cols: termCols,
|
|
3028
|
-
rows: termRows,
|
|
3029
|
-
cwd: shellCommand ? os.homedir() : projectPath,
|
|
3030
|
-
env: {
|
|
3031
|
-
...process.env,
|
|
3032
|
-
TERM: 'xterm-256color',
|
|
3033
|
-
COLORTERM: 'truecolor',
|
|
3034
|
-
FORCE_COLOR: '3'
|
|
3035
|
-
}
|
|
3036
|
-
});
|
|
3037
|
-
|
|
3038
|
-
// shell process started
|
|
3039
|
-
|
|
3040
|
-
ptySessionsMap.set(ptySessionKey, {
|
|
3041
|
-
pty: shellProcess,
|
|
3042
|
-
ws: ws,
|
|
3043
|
-
buffer: [],
|
|
3044
|
-
timeoutId: null,
|
|
3045
|
-
projectPath,
|
|
3046
|
-
sessionId
|
|
3047
|
-
});
|
|
3048
|
-
|
|
3049
|
-
// Handle data output
|
|
3050
|
-
shellProcess.onData((data) => {
|
|
3051
|
-
const session = ptySessionsMap.get(ptySessionKey);
|
|
3052
|
-
if (!session) return;
|
|
3053
|
-
|
|
3054
|
-
if (session.buffer.length < 5000) {
|
|
3055
|
-
session.buffer.push(data);
|
|
3056
|
-
} else {
|
|
3057
|
-
session.buffer.shift();
|
|
3058
|
-
session.buffer.push(data);
|
|
3059
|
-
}
|
|
3060
|
-
|
|
3061
|
-
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
3062
|
-
let outputData = data;
|
|
3063
|
-
|
|
3064
|
-
const cleanChunk = stripAnsiSequences(data);
|
|
3065
|
-
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
|
3066
|
-
|
|
3067
|
-
outputData = outputData.replace(
|
|
3068
|
-
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
|
3069
|
-
'[INFO] Opening in browser: $1'
|
|
3070
|
-
);
|
|
3071
|
-
|
|
3072
|
-
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
|
3073
|
-
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
|
3074
|
-
if (!normalizedUrl) return;
|
|
3075
|
-
|
|
3076
|
-
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
|
3077
|
-
if (isNewUrl) {
|
|
3078
|
-
announcedAuthUrls.add(normalizedUrl);
|
|
3079
|
-
session.ws.send(JSON.stringify({
|
|
3080
|
-
type: 'auth_url',
|
|
3081
|
-
url: normalizedUrl,
|
|
3082
|
-
autoOpen
|
|
3083
|
-
}));
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
};
|
|
3087
|
-
|
|
3088
|
-
const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
|
|
3089
|
-
.map((url) => normalizeDetectedUrl(url))
|
|
3090
|
-
.filter(Boolean);
|
|
3091
|
-
|
|
3092
|
-
// Prefer the most complete URL if shorter prefix variants are also present.
|
|
3093
|
-
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
|
|
3094
|
-
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
|
3095
|
-
);
|
|
3096
|
-
|
|
3097
|
-
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
|
3098
|
-
|
|
3099
|
-
if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
|
|
3100
|
-
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
|
3101
|
-
current.length > longest.length ? current : longest
|
|
3102
|
-
);
|
|
3103
|
-
emitAuthUrl(bestUrl, true);
|
|
3104
|
-
}
|
|
3105
|
-
|
|
3106
|
-
// Send regular output
|
|
3107
|
-
session.ws.send(JSON.stringify({
|
|
3108
|
-
type: 'output',
|
|
3109
|
-
data: outputData
|
|
3110
|
-
}));
|
|
3111
|
-
}
|
|
3112
|
-
});
|
|
3113
|
-
|
|
3114
|
-
// Handle process exit
|
|
3115
|
-
shellProcess.onExit((exitCode) => {
|
|
3116
|
-
// shell process exited
|
|
3117
|
-
const session = ptySessionsMap.get(ptySessionKey);
|
|
3118
|
-
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
3119
|
-
session.ws.send(JSON.stringify({
|
|
3120
|
-
type: 'output',
|
|
3121
|
-
data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
|
|
3122
|
-
}));
|
|
3123
|
-
}
|
|
3124
|
-
if (session && session.timeoutId) {
|
|
3125
|
-
clearTimeout(session.timeoutId);
|
|
3126
|
-
}
|
|
3127
|
-
ptySessionsMap.delete(ptySessionKey);
|
|
3128
|
-
shellProcess = null;
|
|
3129
|
-
});
|
|
3130
|
-
|
|
3131
|
-
} catch (spawnError) {
|
|
3132
|
-
// process spawn error handled silently
|
|
3133
|
-
ws.send(JSON.stringify({
|
|
3134
|
-
type: 'output',
|
|
3135
|
-
data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n`
|
|
3136
|
-
}));
|
|
3137
|
-
}
|
|
3138
|
-
|
|
3139
|
-
} else if (data.type === 'input') {
|
|
3140
|
-
// Send input to shell process
|
|
3141
|
-
if (shellProcess && shellProcess.write) {
|
|
3142
|
-
try {
|
|
3143
|
-
shellProcess.write(data.data);
|
|
3144
|
-
} catch (error) {
|
|
3145
|
-
// shell write error handled silently
|
|
3146
|
-
}
|
|
3147
|
-
} else {
|
|
3148
|
-
// no active shell process
|
|
3149
|
-
}
|
|
3150
|
-
} else if (data.type === 'resize') {
|
|
3151
|
-
// Handle terminal resize
|
|
3152
|
-
if (shellProcess && shellProcess.resize) {
|
|
3153
|
-
// terminal resize handled
|
|
3154
|
-
shellProcess.resize(data.cols, data.rows);
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
|
-
} catch (error) {
|
|
3158
|
-
// shell WebSocket error
|
|
3159
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
3160
|
-
ws.send(JSON.stringify({
|
|
3161
|
-
type: 'output',
|
|
3162
|
-
data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n`
|
|
3163
|
-
}));
|
|
3164
|
-
}
|
|
3165
|
-
}
|
|
3166
|
-
});
|
|
3167
|
-
|
|
3168
|
-
ws.on('close', () => {
|
|
3169
|
-
// shell client disconnected
|
|
3170
|
-
|
|
3171
|
-
if (ptySessionKey) {
|
|
3172
|
-
const session = ptySessionsMap.get(ptySessionKey);
|
|
3173
|
-
if (session) {
|
|
3174
|
-
// PTY session kept alive
|
|
3175
|
-
session.ws = null;
|
|
3176
|
-
|
|
3177
|
-
session.timeoutId = setTimeout(() => {
|
|
3178
|
-
// PTY session timeout
|
|
3179
|
-
if (session.pty && session.pty.kill) {
|
|
3180
|
-
session.pty.kill();
|
|
3181
|
-
}
|
|
3182
|
-
ptySessionsMap.delete(ptySessionKey);
|
|
3183
|
-
}, PTY_SESSION_TIMEOUT);
|
|
3184
|
-
}
|
|
3185
|
-
}
|
|
3186
|
-
});
|
|
3187
|
-
|
|
3188
|
-
ws.on('error', (error) => {
|
|
3189
|
-
// shell error
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
|
-
// Audio transcription endpoint
|
|
3193
|
-
// Priority: 1) Self-hosted Whisper (WHISPER_URL), 2) BYOK OpenAI key, 3) Server OpenAI key
|
|
3194
|
-
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|
3195
|
-
try {
|
|
3196
|
-
const multer = (await import('multer')).default;
|
|
3197
|
-
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
|
3198
|
-
|
|
3199
|
-
upload.single('audio')(req, res, async (err) => {
|
|
3200
|
-
if (err) {
|
|
3201
|
-
return res.status(400).json({ error: 'Failed to process audio file' });
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
if (!req.file) {
|
|
3205
|
-
return res.status(400).json({ error: 'No audio file provided' });
|
|
3206
|
-
}
|
|
3207
|
-
|
|
3208
|
-
const mode = req.body?.mode || 'default';
|
|
3209
|
-
let transcribedText = '';
|
|
3210
|
-
|
|
3211
|
-
try {
|
|
3212
|
-
const FormData = (await import('form-data')).default;
|
|
3213
|
-
|
|
3214
|
-
// --- Strategy 1: Self-hosted Whisper (no API key needed) ---
|
|
3215
|
-
const whisperUrl = process.env.WHISPER_URL;
|
|
3216
|
-
if (whisperUrl) {
|
|
3217
|
-
try {
|
|
3218
|
-
const formData = new FormData();
|
|
3219
|
-
formData.append('file', req.file.buffer, {
|
|
3220
|
-
filename: req.file.originalname || 'audio.webm',
|
|
3221
|
-
contentType: req.file.mimetype || 'audio/webm'
|
|
3222
|
-
});
|
|
3223
|
-
formData.append('model', 'whisper-1');
|
|
3224
|
-
formData.append('response_format', 'json');
|
|
3225
|
-
formData.append('language', 'en');
|
|
3226
|
-
|
|
3227
|
-
const endpoint = whisperUrl.replace(/\/+$/, '') + '/v1/audio/transcriptions';
|
|
3228
|
-
const response = await fetch(endpoint, {
|
|
3229
|
-
method: 'POST',
|
|
3230
|
-
headers: formData.getHeaders(),
|
|
3231
|
-
body: formData
|
|
3232
|
-
});
|
|
3233
|
-
|
|
3234
|
-
if (response.ok) {
|
|
3235
|
-
const data = await response.json();
|
|
3236
|
-
transcribedText = data.text || '';
|
|
3237
|
-
}
|
|
3238
|
-
} catch {
|
|
3239
|
-
// Self-hosted whisper failed, try next strategy
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
|
|
3243
|
-
// --- Strategy 2: OpenAI Whisper API (BYOK or server key) ---
|
|
3244
|
-
if (!transcribedText) {
|
|
3245
|
-
const userOpenaiKey = req.user?.id
|
|
3246
|
-
? await getUserProviderKey(req.user.id, 'openai_key')
|
|
3247
|
-
: null;
|
|
3248
|
-
const apiKey = userOpenaiKey || process.env.OPENAI_API_KEY;
|
|
3249
|
-
|
|
3250
|
-
if (apiKey) {
|
|
3251
|
-
const formData = new FormData();
|
|
3252
|
-
formData.append('file', req.file.buffer, {
|
|
3253
|
-
filename: req.file.originalname || 'audio.webm',
|
|
3254
|
-
contentType: req.file.mimetype || 'audio/webm'
|
|
3255
|
-
});
|
|
3256
|
-
formData.append('model', 'whisper-1');
|
|
3257
|
-
formData.append('response_format', 'json');
|
|
3258
|
-
formData.append('language', 'en');
|
|
3259
|
-
|
|
3260
|
-
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
3261
|
-
method: 'POST',
|
|
3262
|
-
headers: {
|
|
3263
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
3264
|
-
...formData.getHeaders()
|
|
3265
|
-
},
|
|
3266
|
-
body: formData
|
|
3267
|
-
});
|
|
3268
|
-
|
|
3269
|
-
if (response.ok) {
|
|
3270
|
-
const data = await response.json();
|
|
3271
|
-
transcribedText = data.text || '';
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
|
-
}
|
|
3275
|
-
|
|
3276
|
-
// No STT engine worked
|
|
3277
|
-
if (!transcribedText) {
|
|
3278
|
-
return res.status(500).json({ error: 'Speech recognition is temporarily unavailable. Please try again.' });
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
|
-
// Default mode — return raw transcription
|
|
3282
|
-
if (mode === 'default') {
|
|
3283
|
-
return res.json({ text: transcribedText });
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
|
-
// Enhancement modes (prompt, vibe, architect) — use OpenAI GPT if available
|
|
3287
|
-
try {
|
|
3288
|
-
const userOpenaiKey = req.user?.id
|
|
3289
|
-
? await getUserProviderKey(req.user.id, 'openai_key')
|
|
3290
|
-
: null;
|
|
3291
|
-
const enhanceKey = userOpenaiKey || process.env.OPENAI_API_KEY;
|
|
3292
|
-
|
|
3293
|
-
if (enhanceKey) {
|
|
3294
|
-
const OpenAI = (await import('openai')).default;
|
|
3295
|
-
const openai = new OpenAI({ apiKey: enhanceKey });
|
|
3296
|
-
|
|
3297
|
-
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
|
3298
|
-
|
|
3299
|
-
switch (mode) {
|
|
3300
|
-
case 'prompt':
|
|
3301
|
-
systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';
|
|
3302
|
-
prompt = `Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.\n\nYour enhanced prompt should:\n1. Be specific and unambiguous\n2. Include relevant context and constraints\n3. Specify the desired output format\n4. Use clear, actionable language\n\nRough instruction: "${transcribedText}"\n\nEnhanced prompt:`;
|
|
3303
|
-
break;
|
|
3304
|
-
case 'vibe':
|
|
3305
|
-
case 'instructions':
|
|
3306
|
-
case 'architect':
|
|
3307
|
-
systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';
|
|
3308
|
-
temperature = 0.5;
|
|
3309
|
-
prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.\n\nIMPORTANT RULES:\n- Format as clear, step-by-step instructions\n- Add reasonable implementation details based on common patterns\n- Only include details directly related to what was asked\n- Do NOT add features or functionality not mentioned\n- Keep the original intent and scope intact\n\nIdea: "${transcribedText}"\n\nAgent instructions:`;
|
|
3310
|
-
break;
|
|
3311
|
-
}
|
|
3312
|
-
|
|
3313
|
-
if (prompt) {
|
|
3314
|
-
const completion = await openai.chat.completions.create({
|
|
3315
|
-
model: 'gpt-4o-mini',
|
|
3316
|
-
messages: [
|
|
3317
|
-
{ role: 'system', content: systemMessage },
|
|
3318
|
-
{ role: 'user', content: prompt }
|
|
3319
|
-
],
|
|
3320
|
-
temperature,
|
|
3321
|
-
max_tokens: maxTokens
|
|
3322
|
-
});
|
|
3323
|
-
transcribedText = completion.choices[0]?.message?.content || transcribedText;
|
|
3324
|
-
}
|
|
3325
|
-
}
|
|
3326
|
-
// If no OpenAI key for enhancement, just return raw transcription
|
|
3327
|
-
} catch {
|
|
3328
|
-
// Enhancement failed silently — return raw transcription
|
|
3329
|
-
}
|
|
3330
|
-
|
|
3331
|
-
res.json({ text: transcribedText });
|
|
3332
|
-
|
|
3333
|
-
} catch {
|
|
3334
|
-
res.status(500).json({ error: 'Transcription failed. Please try again.' });
|
|
3335
|
-
}
|
|
3336
|
-
});
|
|
3337
|
-
} catch {
|
|
3338
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
3339
|
-
}
|
|
3340
|
-
});
|
|
3341
|
-
|
|
3342
|
-
// Image upload endpoint
|
|
3343
|
-
app.post('/api/projects/:projectName/upload-images', authenticateToken, authorizeProject, async (req, res) => {
|
|
3344
|
-
try {
|
|
3345
|
-
const multer = (await import('multer')).default;
|
|
3346
|
-
const path = (await import('path')).default;
|
|
3347
|
-
const fs = (await import('fs')).promises;
|
|
3348
|
-
const os = (await import('os')).default;
|
|
3349
|
-
|
|
3350
|
-
// Configure multer for image uploads
|
|
3351
|
-
const storage = multer.diskStorage({
|
|
3352
|
-
destination: async (req, file, cb) => {
|
|
3353
|
-
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
|
|
3354
|
-
await fs.mkdir(uploadDir, { recursive: true });
|
|
3355
|
-
cb(null, uploadDir);
|
|
3356
|
-
},
|
|
3357
|
-
filename: (req, file, cb) => {
|
|
3358
|
-
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
3359
|
-
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
3360
|
-
cb(null, uniqueSuffix + '-' + sanitizedName);
|
|
3361
|
-
}
|
|
3362
|
-
});
|
|
3363
|
-
|
|
3364
|
-
const fileFilter = (req, file, cb) => {
|
|
3365
|
-
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
3366
|
-
if (allowedMimes.includes(file.mimetype)) {
|
|
3367
|
-
cb(null, true);
|
|
3368
|
-
} else {
|
|
3369
|
-
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
|
3370
|
-
}
|
|
3371
|
-
};
|
|
3372
|
-
|
|
3373
|
-
const upload = multer({
|
|
3374
|
-
storage,
|
|
3375
|
-
fileFilter,
|
|
3376
|
-
limits: {
|
|
3377
|
-
fileSize: 5 * 1024 * 1024, // 5MB
|
|
3378
|
-
files: 5
|
|
3379
|
-
}
|
|
3380
|
-
});
|
|
3381
|
-
|
|
3382
|
-
// Handle multipart form data
|
|
3383
|
-
upload.array('images', 5)(req, res, async (err) => {
|
|
3384
|
-
if (err) {
|
|
3385
|
-
const uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.code === 'LIMIT_FILE_COUNT' ? 'Too many files (max 5)' : 'Invalid file upload';
|
|
3386
|
-
return res.status(400).json({ error: uploadError });
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
|
-
if (!req.files || req.files.length === 0) {
|
|
3390
|
-
return res.status(400).json({ error: 'No image files provided' });
|
|
3391
|
-
}
|
|
3392
|
-
|
|
3393
|
-
try {
|
|
3394
|
-
// Process uploaded images
|
|
3395
|
-
const processedImages = await Promise.all(
|
|
3396
|
-
req.files.map(async (file) => {
|
|
3397
|
-
// Read file and convert to base64
|
|
3398
|
-
const buffer = await fs.readFile(file.path);
|
|
3399
|
-
const base64 = buffer.toString('base64');
|
|
3400
|
-
const mimeType = file.mimetype;
|
|
3401
|
-
|
|
3402
|
-
// Clean up temp file immediately
|
|
3403
|
-
await fs.unlink(file.path);
|
|
3404
|
-
|
|
3405
|
-
return {
|
|
3406
|
-
name: file.originalname,
|
|
3407
|
-
data: `data:${mimeType};base64,${base64}`,
|
|
3408
|
-
size: file.size,
|
|
3409
|
-
mimeType: mimeType
|
|
3410
|
-
};
|
|
3411
|
-
})
|
|
3412
|
-
);
|
|
3413
|
-
|
|
3414
|
-
res.json({ images: processedImages });
|
|
3415
|
-
} catch (error) {
|
|
3416
|
-
// image processing error handled silently
|
|
3417
|
-
// Clean up any remaining files
|
|
3418
|
-
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));
|
|
3419
|
-
res.status(500).json({ error: 'Failed to process images' });
|
|
3420
|
-
}
|
|
3421
|
-
});
|
|
3422
|
-
} catch (error) {
|
|
3423
|
-
// image upload error handled silently
|
|
3424
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
3425
|
-
}
|
|
3426
|
-
});
|
|
3427
|
-
|
|
3428
|
-
// Get token usage for a specific session
|
|
3429
|
-
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, authorizeProject, async (req, res) => {
|
|
3430
|
-
try {
|
|
3431
|
-
const { projectName, sessionId } = req.params;
|
|
3432
|
-
const { provider = 'claude' } = req.query;
|
|
3433
|
-
const homeDir = os.homedir();
|
|
3434
|
-
|
|
3435
|
-
// Allow only safe characters in sessionId
|
|
3436
|
-
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
|
3437
|
-
if (!safeSessionId) {
|
|
3438
|
-
return res.status(400).json({ error: 'Invalid sessionId' });
|
|
3439
|
-
}
|
|
3440
|
-
|
|
3441
|
-
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
|
3442
|
-
if (provider === 'cursor') {
|
|
3443
|
-
return res.json({
|
|
3444
|
-
used: 0,
|
|
3445
|
-
total: 0,
|
|
3446
|
-
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
|
3447
|
-
unsupported: true,
|
|
3448
|
-
message: 'Token usage tracking not available for Cursor sessions'
|
|
3449
|
-
});
|
|
3450
|
-
}
|
|
3451
|
-
|
|
3452
|
-
// Handle Codex sessions
|
|
3453
|
-
if (provider === 'codex') {
|
|
3454
|
-
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
3455
|
-
|
|
3456
|
-
// Find the session file by searching for the session ID
|
|
3457
|
-
const findSessionFile = async (dir) => {
|
|
3458
|
-
try {
|
|
3459
|
-
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
3460
|
-
for (const entry of entries) {
|
|
3461
|
-
const fullPath = path.join(dir, entry.name);
|
|
3462
|
-
if (entry.isDirectory()) {
|
|
3463
|
-
const found = await findSessionFile(fullPath);
|
|
3464
|
-
if (found) return found;
|
|
3465
|
-
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
3466
|
-
return fullPath;
|
|
3467
|
-
}
|
|
3468
|
-
}
|
|
3469
|
-
} catch (error) {
|
|
3470
|
-
// Skip directories we can't read
|
|
3471
|
-
}
|
|
3472
|
-
return null;
|
|
3473
|
-
};
|
|
3474
|
-
|
|
3475
|
-
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
|
3476
|
-
|
|
3477
|
-
if (!sessionFilePath) {
|
|
3478
|
-
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
3479
|
-
}
|
|
3480
|
-
|
|
3481
|
-
// Read and parse the Codex JSONL file
|
|
3482
|
-
let fileContent;
|
|
3483
|
-
try {
|
|
3484
|
-
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
|
3485
|
-
} catch (error) {
|
|
3486
|
-
if (error.code === 'ENOENT') {
|
|
3487
|
-
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
|
3488
|
-
}
|
|
3489
|
-
throw error;
|
|
3490
|
-
}
|
|
3491
|
-
const lines = fileContent.trim().split('\n');
|
|
3492
|
-
let totalTokens = 0;
|
|
3493
|
-
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
3494
|
-
|
|
3495
|
-
// Find the latest token_count event with info (scan from end)
|
|
3496
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3497
|
-
try {
|
|
3498
|
-
const entry = JSON.parse(lines[i]);
|
|
3499
|
-
|
|
3500
|
-
// Codex stores token info in event_msg with type: "token_count"
|
|
3501
|
-
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
3502
|
-
const tokenInfo = entry.payload.info;
|
|
3503
|
-
if (tokenInfo.total_token_usage) {
|
|
3504
|
-
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
|
3505
|
-
}
|
|
3506
|
-
if (tokenInfo.model_context_window) {
|
|
3507
|
-
contextWindow = tokenInfo.model_context_window;
|
|
3508
|
-
}
|
|
3509
|
-
break; // Stop after finding the latest token count
|
|
3510
|
-
}
|
|
3511
|
-
} catch (parseError) {
|
|
3512
|
-
// Skip lines that can't be parsed
|
|
3513
|
-
continue;
|
|
3514
|
-
}
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
|
-
return res.json({
|
|
3518
|
-
used: totalTokens,
|
|
3519
|
-
total: contextWindow
|
|
3520
|
-
});
|
|
3521
|
-
}
|
|
3522
|
-
|
|
3523
|
-
// Handle Claude sessions (default)
|
|
3524
|
-
// Extract actual project path
|
|
3525
|
-
let projectPath;
|
|
3526
|
-
try {
|
|
3527
|
-
projectPath = await extractProjectDirectory(projectName);
|
|
3528
|
-
} catch (error) {
|
|
3529
|
-
// project dir extraction error handled silently
|
|
3530
|
-
return res.status(500).json({ error: 'Failed to determine project path' });
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
// Construct the JSONL file path
|
|
3534
|
-
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
|
3535
|
-
// The encoding replaces /, spaces, ~, and _ with -
|
|
3536
|
-
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
|
3537
|
-
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
3538
|
-
|
|
3539
|
-
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
|
3540
|
-
|
|
3541
|
-
// Constrain to projectDir
|
|
3542
|
-
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
|
3543
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
3544
|
-
return res.status(400).json({ error: 'Invalid path' });
|
|
3545
|
-
}
|
|
3546
|
-
|
|
3547
|
-
// Read and parse the JSONL file
|
|
3548
|
-
let fileContent;
|
|
3549
|
-
try {
|
|
3550
|
-
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
|
3551
|
-
} catch (error) {
|
|
3552
|
-
if (error.code === 'ENOENT') {
|
|
3553
|
-
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
|
3554
|
-
}
|
|
3555
|
-
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
3556
|
-
}
|
|
3557
|
-
const lines = fileContent.trim().split('\n');
|
|
3558
|
-
|
|
3559
|
-
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
3560
|
-
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
3561
|
-
let inputTokens = 0;
|
|
3562
|
-
let cacheCreationTokens = 0;
|
|
3563
|
-
let cacheReadTokens = 0;
|
|
3564
|
-
|
|
3565
|
-
// Find the latest assistant message with usage data (scan from end)
|
|
3566
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3567
|
-
try {
|
|
3568
|
-
const entry = JSON.parse(lines[i]);
|
|
3569
|
-
|
|
3570
|
-
// Only count assistant messages which have usage data
|
|
3571
|
-
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
3572
|
-
const usage = entry.message.usage;
|
|
3573
|
-
|
|
3574
|
-
// Use token counts from latest assistant message only
|
|
3575
|
-
inputTokens = usage.input_tokens || 0;
|
|
3576
|
-
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
3577
|
-
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
3578
|
-
|
|
3579
|
-
break; // Stop after finding the latest assistant message
|
|
3580
|
-
}
|
|
3581
|
-
} catch (parseError) {
|
|
3582
|
-
// Skip lines that can't be parsed
|
|
3583
|
-
continue;
|
|
3584
|
-
}
|
|
3585
|
-
}
|
|
3586
|
-
|
|
3587
|
-
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
|
3588
|
-
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
3589
|
-
|
|
3590
|
-
res.json({
|
|
3591
|
-
used: totalUsed,
|
|
3592
|
-
total: contextWindow,
|
|
3593
|
-
breakdown: {
|
|
3594
|
-
input: inputTokens,
|
|
3595
|
-
cacheCreation: cacheCreationTokens,
|
|
3596
|
-
cacheRead: cacheReadTokens
|
|
3597
|
-
}
|
|
3598
|
-
});
|
|
3599
|
-
} catch (error) {
|
|
3600
|
-
// token usage read error
|
|
3601
|
-
res.status(500).json({ error: 'Failed to read session token usage' });
|
|
3602
|
-
}
|
|
3603
|
-
});
|
|
3604
|
-
|
|
3605
|
-
// Root redirect → /app/
|
|
3606
|
-
app.get('/', (req, res) => res.redirect('/app/'));
|
|
3607
|
-
|
|
3608
|
-
// Serve React app for /app/* routes (SPA catch-all)
|
|
3609
|
-
app.get('/app/*', (req, res) => {
|
|
3610
|
-
// Skip requests for static assets (files with extensions) — already handled by express.static
|
|
3611
|
-
if (path.extname(req.path)) {
|
|
3612
|
-
return res.status(404).send('Not found');
|
|
3613
|
-
}
|
|
3614
|
-
|
|
3615
|
-
// If a JWT token is in the query param and no session cookie exists,
|
|
3616
|
-
// set the cookie now so the client-side AuthContext can authenticate on subsequent API calls.
|
|
3617
|
-
if (req.query?.token && !req.cookies?.session) {
|
|
3618
|
-
try {
|
|
3619
|
-
const decoded = jwt.verify(req.query.token, JWT_SECRET);
|
|
3620
|
-
if (decoded?.userId) {
|
|
3621
|
-
const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
|
|
3622
|
-
const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
|
|
3623
|
-
res.cookie('session', req.query.token, {
|
|
3624
|
-
httpOnly: true,
|
|
3625
|
-
secure: isSecure,
|
|
3626
|
-
sameSite: isSecure ? 'lax' : 'strict',
|
|
3627
|
-
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
3628
|
-
path: '/',
|
|
3629
|
-
...(isSecure && cookieDomain ? { domain: cookieDomain } : {}),
|
|
3630
|
-
});
|
|
3631
|
-
}
|
|
3632
|
-
} catch (e) {
|
|
3633
|
-
// Invalid token — just serve the page without setting cookie
|
|
3634
|
-
}
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
const indexPath = path.join(__dirname, '../client/dist/index.html');
|
|
3638
|
-
|
|
3639
|
-
if (fs.existsSync(indexPath)) {
|
|
3640
|
-
// Set no-cache headers for HTML to prevent service worker issues
|
|
3641
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
3642
|
-
res.setHeader('Pragma', 'no-cache');
|
|
3643
|
-
res.setHeader('Expires', '0');
|
|
3644
|
-
|
|
3645
|
-
// Inject runtime config so the frontend knows the server mode and base path
|
|
3646
|
-
let html = fs.readFileSync(indexPath, 'utf8');
|
|
3647
|
-
const runtimeConfig = JSON.stringify({
|
|
3648
|
-
isPlatform: IS_PLATFORM,
|
|
3649
|
-
isLocal: IS_LOCAL,
|
|
3650
|
-
basename: '/app',
|
|
3651
|
-
wsUrl: process.env.APP_WS_URL || '',
|
|
3652
|
-
});
|
|
3653
|
-
html = html.replace('</head>', `<script>window.__UPFYN_CONFIG__=${runtimeConfig}</script>\n</head>`);
|
|
3654
|
-
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
3655
|
-
res.send(html);
|
|
3656
|
-
} else {
|
|
3657
|
-
// In development, redirect to Vite dev server
|
|
3658
|
-
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}/app/`);
|
|
3659
|
-
}
|
|
3660
|
-
});
|
|
3661
|
-
|
|
3662
|
-
// Helper function to convert permissions to rwx format
|
|
3663
|
-
function permToRwx(perm) {
|
|
3664
|
-
const r = perm & 4 ? 'r' : '-';
|
|
3665
|
-
const w = perm & 2 ? 'w' : '-';
|
|
3666
|
-
const x = perm & 1 ? 'x' : '-';
|
|
3667
|
-
return r + w + x;
|
|
3668
|
-
}
|
|
3669
|
-
|
|
3670
|
-
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
|
3671
|
-
// Using fsPromises from import
|
|
3672
|
-
const items = [];
|
|
3673
|
-
|
|
3674
|
-
try {
|
|
3675
|
-
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
|
3676
|
-
|
|
3677
|
-
for (const entry of entries) {
|
|
3678
|
-
// Debug: log all entries including hidden files
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
// Skip heavy build directories and VCS directories
|
|
3682
|
-
if (entry.name === 'node_modules' ||
|
|
3683
|
-
entry.name === 'dist' ||
|
|
3684
|
-
entry.name === 'build' ||
|
|
3685
|
-
entry.name === '.git' ||
|
|
3686
|
-
entry.name === '.svn' ||
|
|
3687
|
-
entry.name === '.hg') continue;
|
|
3688
|
-
|
|
3689
|
-
const itemPath = path.join(dirPath, entry.name);
|
|
3690
|
-
const item = {
|
|
3691
|
-
name: entry.name,
|
|
3692
|
-
path: itemPath,
|
|
3693
|
-
type: entry.isDirectory() ? 'directory' : 'file'
|
|
3694
|
-
};
|
|
3695
|
-
|
|
3696
|
-
// Get file stats for additional metadata
|
|
3697
|
-
try {
|
|
3698
|
-
const stats = await fsPromises.stat(itemPath);
|
|
3699
|
-
item.size = stats.size;
|
|
3700
|
-
item.modified = stats.mtime.toISOString();
|
|
3701
|
-
|
|
3702
|
-
// Convert permissions to rwx format
|
|
3703
|
-
const mode = stats.mode;
|
|
3704
|
-
const ownerPerm = (mode >> 6) & 7;
|
|
3705
|
-
const groupPerm = (mode >> 3) & 7;
|
|
3706
|
-
const otherPerm = mode & 7;
|
|
3707
|
-
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
|
3708
|
-
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
|
3709
|
-
} catch (statError) {
|
|
3710
|
-
// If stat fails, provide default values
|
|
3711
|
-
item.size = 0;
|
|
3712
|
-
item.modified = null;
|
|
3713
|
-
item.permissions = '000';
|
|
3714
|
-
item.permissionsRwx = '---------';
|
|
3715
|
-
}
|
|
3716
|
-
|
|
3717
|
-
if (entry.isDirectory() && currentDepth < maxDepth) {
|
|
3718
|
-
// Recursively get subdirectories but limit depth
|
|
3719
|
-
try {
|
|
3720
|
-
// Check if we can access the directory before trying to read it
|
|
3721
|
-
await fsPromises.access(item.path, fs.constants.R_OK);
|
|
3722
|
-
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
|
3723
|
-
} catch (e) {
|
|
3724
|
-
// Silently skip directories we can't access (permission denied, etc.)
|
|
3725
|
-
item.children = [];
|
|
3726
|
-
}
|
|
3727
|
-
}
|
|
3728
|
-
|
|
3729
|
-
items.push(item);
|
|
3730
|
-
}
|
|
3731
|
-
} catch (error) {
|
|
3732
|
-
// Only log non-permission errors to avoid spam
|
|
3733
|
-
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
|
3734
|
-
// directory read error handled silently
|
|
3735
|
-
}
|
|
3736
|
-
}
|
|
3737
|
-
|
|
3738
|
-
return items.sort((a, b) => {
|
|
3739
|
-
if (a.type !== b.type) {
|
|
3740
|
-
return a.type === 'directory' ? -1 : 1;
|
|
3741
|
-
}
|
|
3742
|
-
return a.name.localeCompare(b.name);
|
|
3743
|
-
});
|
|
3744
|
-
}
|
|
3745
|
-
|
|
3746
|
-
const PORT = process.env.PORT || 3001;
|
|
3747
|
-
|
|
3748
|
-
// Initialize database and start server
|
|
3749
|
-
async function startServer() {
|
|
3750
|
-
try {
|
|
3751
|
-
// Initialize authentication database
|
|
3752
|
-
await initializeDatabase();
|
|
3753
|
-
|
|
3754
|
-
// In local mode, ensure a default user exists (no signup needed)
|
|
3755
|
-
if (IS_LOCAL) {
|
|
3756
|
-
const hasUsers = await userDb.hasUsers();
|
|
3757
|
-
if (!hasUsers) {
|
|
3758
|
-
const localUsername = os.userInfo().username || 'local';
|
|
3759
|
-
const dummyHash = crypto.randomBytes(32).toString('hex');
|
|
3760
|
-
await userDb.createUser(localUsername, dummyHash);
|
|
3761
|
-
console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
|
|
3762
|
-
}
|
|
3763
|
-
console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
|
|
3764
|
-
}
|
|
3765
|
-
|
|
3766
|
-
// Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
|
|
3767
|
-
const distIndexPath = path.join(__dirname, '../client/dist/index.html');
|
|
3768
|
-
const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
|
|
3769
|
-
|
|
3770
|
-
// Log Claude implementation mode
|
|
3771
|
-
console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);
|
|
3772
|
-
console.log(`${c.info('[INFO]')} Running in ${c.bright(isProduction ? 'PRODUCTION' : 'DEVELOPMENT')} mode`);
|
|
3773
|
-
|
|
3774
|
-
if (!isProduction) {
|
|
3775
|
-
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
|
3776
|
-
}
|
|
3777
|
-
|
|
3778
|
-
server.listen(PORT, '0.0.0.0', async () => {
|
|
3779
|
-
const appInstallPath = path.join(__dirname, '..');
|
|
3780
|
-
|
|
3781
|
-
console.log('');
|
|
3782
|
-
console.log(c.dim('═'.repeat(63)));
|
|
3783
|
-
console.log(` ${c.bright('Upfyn-Code Server - Ready')}`);
|
|
3784
|
-
console.log(c.dim('═'.repeat(63)));
|
|
3785
|
-
console.log('');
|
|
3786
|
-
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
|
3787
|
-
console.log(`${c.info('[INFO]')} MCP Server: ${c.bright('http://0.0.0.0:' + PORT + '/mcp')}`);
|
|
3788
|
-
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
|
3789
|
-
console.log(`${c.tip('[TIP]')} Run "uc status" for full configuration details`);
|
|
3790
|
-
|
|
3791
|
-
// Start workflow cron scheduler
|
|
3792
|
-
initScheduler().catch(err => console.warn('[Scheduler]', err.message));
|
|
3793
|
-
console.log('');
|
|
3794
|
-
|
|
3795
|
-
// Start watching the projects folder for changes (skip on Vercel)
|
|
3796
|
-
if (!process.env.VERCEL) {
|
|
3797
|
-
await setupProjectsWatcher();
|
|
3798
|
-
}
|
|
3799
|
-
});
|
|
3800
|
-
} catch (error) {
|
|
3801
|
-
console.error('[ERROR] Failed to start server:', error);
|
|
3802
|
-
process.exit(1);
|
|
3803
|
-
}
|
|
3804
|
-
}
|
|
3805
|
-
|
|
3806
|
-
// Only start server when not running on Vercel (Vercel uses the exported app)
|
|
3807
|
-
if (!process.env.VERCEL) {
|
|
3808
|
-
startServer();
|
|
3809
|
-
}
|
|
3810
|
-
|
|
3811
|
-
// Export for Vercel serverless and testing
|
|
3812
|
-
export default app;
|
|
3813
|
-
export { app, server, relayConnections, sendRelayCommand };
|