#!/usr/bin/env node /** * VRCloud IDE * Backend: Express (REST file API) + WebSocket (terminal via node-pty). * * Env: * PORT port HTTP/HTTPS (default 1337) * HOST bind address (default 0.0.0.0) * HTTPS true = https://IP:PORT (default). false = HTTP biasa. * TLS_CERT sertifikat PEM (opsional; kalau kosong, self-signed otomatis) * TLS_KEY kunci privat PEM (wajib bila TLS_CERT diisi) * WORKSPACE folder kerja (default: cwd) * AUTH_USER username login * AUTH_PASS password login * AUTH_SECRET secret penandatangan cookie * SHELL_BIN shell terminal (default: bash) */ "use strict"; const path = require("path"); const fs = require("fs"); const http = require("http"); const https = require("https"); const crypto = require("crypto"); const os = require("os"); const { spawn, spawnSync, execFileSync } = require("child_process"); const express = require("express"); const mime = require("mime-types"); const WebSocket = require("ws"); const SessionStore = require("./lib/session-store"); const TerminalManager = require("./lib/terminal-manager"); const RealtimeHub = require("./lib/realtime-hub"); const { ensureTlsMaterial } = require("./lib/tls-cert"); const AiChat = require("./lib/ai-chat"); const BgProcs = require("./lib/bg-procs"); const Checkpoints = require("./lib/checkpoints"); const SysMetrics = require("./lib/sys-metrics"); const BrowserAutomation = require("./lib/browser-automation"); const BrowserSecrets = require("./lib/browser-secrets"); const GitScm = require("./lib/git-scm"); const WorkspaceEvents = require("./lib/workspace-events"); const WorkspaceIndex = require("./lib/workspace-index"); const ContextEngine = require("./lib/context-engine"); const LspManager = require("./lib/lsp-manager"); const MemoryStore = require("./lib/memory-store"); const VerificationEngine = require("./lib/verification-engine"); const createMemoryTools = require("./lib/memory-tools"); const createLspTools = require("./lib/lsp-tools"); const createImageTools = require("./lib/image-tools"); const { createWebSearchTools } = require("./lib/web-search"); const createStructuralTools = require("./lib/structural-tools"); let aiChat = null; // diisi di bagian AI Agent; dipakai lebih awal oleh route hook let browser = null; // otomasi browser (CDP) untuk tool agent // Muat .env tanpa dependency tambahan (tidak perlu paket dotenv). try { fs.readFileSync(path.join(__dirname, ".env"), "utf8").split(/\r?\n/).forEach((line) => { const value = line.trim(); if (!value || value[0] === "#") return; const index = value.indexOf("="); if (index < 1) return; const key = value.slice(0, index).trim(); let parsed = value.slice(index + 1).trim(); if ((parsed[0] === '"' && parsed[parsed.length - 1] === '"') || (parsed[0] === "'" && parsed[parsed.length - 1] === "'")) parsed = parsed.slice(1, -1); if (process.env[key] == null) process.env[key] = parsed; }); } catch (e) {} const IS_WINDOWS = process.platform === "win32"; const PORT = parseInt(process.env.PORT || "1337", 10); const HOST = process.env.HOST || "0.0.0.0"; const WORKSPACE = path.resolve(process.env.WORKSPACE || process.cwd()); // Default shell: bash di Unix, PowerShell di Windows. Bisa dioverride SHELL_BIN. const SHELL = process.env.SHELL_BIN || (IS_WINDOWS ? "powershell.exe" : "bash"); const AUTH_USER = process.env.AUTH_USER || ""; const AUTH_PASS = process.env.AUTH_PASS || ""; const AUTH_SECRET = process.env.AUTH_SECRET || ""; function envFlag(name, fallback) { const v = process.env[name]; if (v == null || String(v).trim() === "") return fallback; return /^(1|true|yes|on)$/i.test(String(v)); } // https://IP:PORT di port yang sama. HTTPS=false mengembalikan HTTP biasa. const USE_HTTPS = envFlag("HTTPS", true); const COOKIE_SECURE = USE_HTTPS || String(process.env.COOKIE_SECURE || "").toLowerCase() === "true"; const SESSION_MAX_AGE = Math.max(300, parseInt(process.env.SESSION_MAX_AGE || "604800", 10)); const COOKIE_NAME = "vrcloud_session"; if (!AUTH_USER || !AUTH_PASS || AUTH_SECRET.length < 32) { console.error("VRCloud IDE membutuhkan AUTH_USER, AUTH_PASS, dan AUTH_SECRET (>=32 karakter) di .env"); process.exit(1); } // Prompt bash bergaya Kali. Hanya relevan untuk shell bash di Unix. const RCFILE = path.join(__dirname, ".c9rc"); if (!IS_WINDOWS && path.basename(SHELL).toLowerCase() === "bash") { try { fs.writeFileSync(RCFILE, '[ -f /etc/profile ] && . /etc/profile 2>/dev/null\n' + '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" 2>/dev/null\n' + "VRCLOUD_PROMPT_SYMBOL='$'\n" + '[ "$(id -u)" -eq 0 ] && VRCLOUD_PROMPT_SYMBOL=\'#\'\n' + "export PS1='\\[\\e[0;36m\\]┌──(\\[\\e[1;34m\\]\\u㉿\\h\\[\\e[0;36m\\])-[\\[\\e[1;37m\\]\\w\\[\\e[0;36m\\]]\\n└─\\[\\e[1;34m\\]${VRCLOUD_PROMPT_SYMBOL}\\[\\e[0m\\] '\n"); } catch (e) {} } const app = express(); // Parser JSON dilewati untuk /api/upload: body-nya biner mentah (file .json yang // di-upload jangan ikut di-parse, kalau tidak writeFileSync menerima objek). const jsonParser = express.json({ limit: "50mb" }); app.use((req, res, next) => (req.path === "/api/upload" ? next() : jsonParser(req, res, next))); app.use(express.urlencoded({ extended: false, limit: "16kb" })); function base64url(value) { return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function unbase64url(value) { const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/"); return Buffer.from(normalized + "=".repeat((4 - normalized.length % 4) % 4), "base64").toString("utf8"); } function signature(value) { return base64url(crypto.createHmac("sha256", AUTH_SECRET).update(value).digest()); } function issueSession(username) { const payload = base64url(JSON.stringify({ username, exp: Date.now() + SESSION_MAX_AGE * 1000 })); return payload + "." + signature(payload); } function parseCookies(header) { const result = {}; String(header || "").split(";").forEach((part) => { const index = part.indexOf("="); if (index < 1) return; result[part.slice(0, index).trim()] = part.slice(index + 1).trim(); }); return result; } function safeEqual(value, expected) { const a = crypto.createHash("sha256").update(String(value)).digest(); const b = crypto.createHash("sha256").update(String(expected)).digest(); return crypto.timingSafeEqual(a, b); } function authenticated(req) { try { const token = parseCookies(req.headers.cookie)[COOKIE_NAME]; if (!token) return false; const parts = token.split("."); if (parts.length !== 2 || !safeEqual(parts[1], signature(parts[0]))) return false; const payload = JSON.parse(unbase64url(parts[0])); return payload.username === AUTH_USER && Number(payload.exp) > Date.now(); } catch (e) { return false; } } function sessionCookie(value, maxAge) { return COOKIE_NAME + "=" + value + "; Path=/; HttpOnly; SameSite=Strict; Max-Age=" + maxAge + (COOKIE_SECURE ? "; Secure" : ""); } const loginAttempts = new Map(); app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "no-referrer"); next(); }); app.get("/login", (req, res) => { if (authenticated(req)) return res.redirect("/"); res.setHeader("Cache-Control", "no-store"); res.sendFile(path.join(__dirname, "public", "login.html")); }); app.post("/login", (req, res) => { const ip = req.socket.remoteAddress || "unknown"; const now = Date.now(); // Buang catatan IP lama agar Map tidak tumbuh tanpa batas. for (const [k, v] of loginAttempts) if (now - v.first > 15 * 60 * 1000 && v.blockedUntil < now) loginAttempts.delete(k); let attempt = loginAttempts.get(ip) || { count: 0, first: now, blockedUntil: 0 }; if (now - attempt.first > 15 * 60 * 1000) attempt = { count: 0, first: now, blockedUntil: 0 }; if (attempt.blockedUntil > now) return res.redirect(303, "/login?error=locked"); const valid = safeEqual(req.body.username || "", AUTH_USER) && safeEqual(req.body.password || "", AUTH_PASS); if (!valid) { attempt.count++; if (attempt.count >= 8) attempt.blockedUntil = now + 5 * 60 * 1000; loginAttempts.set(ip, attempt); return res.redirect(303, "/login?error=1"); } loginAttempts.delete(ip); res.setHeader("Set-Cookie", sessionCookie(issueSession(AUTH_USER), SESSION_MAX_AGE)); res.redirect(303, "/"); }); app.post("/logout", (req, res) => { res.setHeader("Set-Cookie", sessionCookie("", 0)); res.redirect(303, "/login"); }); // Hook agent (beforeShellExecution) memanggil endpoint ini dari mesin yang sama // tanpa cookie login; diamankan dengan token acak per proses (data/hook.json). const HOOK_TOKEN = crypto.randomBytes(24).toString("hex"); app.post("/api/ai/hook/shell", (req, res) => { const tok = String(req.get("x-vrcloud-hook") || ""); if (!tok || tok.length !== HOOK_TOKEN.length || !crypto.timingSafeEqual(Buffer.from(tok), Buffer.from(HOOK_TOKEN))) { return res.status(401).json({ error: "token hook tidak valid" }); } if (!aiChat) return res.json({ permission: "allow" }); aiChat.reviewShell(req.body || {}).then( (r) => res.json(r), (e) => res.json({ permission: "deny", agent_message: "Safety guard error: " + ((e && e.message) || String(e)) }) ); }); app.use((req, res, next) => { if (authenticated(req)) return next(); if (req.path.startsWith("/api/")) return res.status(401).json({ error: "Login diperlukan" }); res.redirect(302, "/login"); }); // ---- Amankan path agar tidak keluar dari WORKSPACE ------------------------ function safe(rel) { const p = path.resolve(WORKSPACE, "." + path.sep + (rel || "")); const workspacePrefix = WORKSPACE.endsWith(path.sep) ? WORKSPACE : WORKSPACE + path.sep; if (p !== WORKSPACE && !p.startsWith(workspacePrefix)) { throw new Error("Path di luar workspace"); } return p; } // Seperti safe(), tetapi menolak root workspace (untuk hapus/rename/salin). function safeNonRoot(rel) { const p = safe(rel); if (p === WORKSPACE) throw new Error("Root workspace tidak boleh dipilih"); return p; } // Tujuan tidak boleh sama dengan / berada di dalam sumber (salin folder ke dirinya sendiri). function assertNotInside(from, to) { if (to === from || to.startsWith(from + path.sep)) throw new Error("Tujuan berada di dalam sumber"); } function rel(abs) { return path.relative(WORKSPACE, abs).split(path.sep).join("/"); } // Cari nama unik kalau tujuan sudah ada: "nama copy", "nama copy 2", ... function uniquePath(p) { if (!fs.existsSync(p)) return p; const dir = path.dirname(p); const ext = path.extname(p); const base = path.basename(p, ext); let i = 1, np; do { np = path.join(dir, base + " copy" + (i > 1 ? " " + i : "") + ext); i++; } while (fs.existsSync(np)); return np; } function rmPath(p) { const st = fs.lstatSync(p); if (st.isDirectory()) fs.rmSync(p, { recursive: true, force: true }); else fs.unlinkSync(p); } // Salin file/folder rekursif tanpa bergantung pada `cp` (dipakai di Windows). function copyRecursive(src, dest) { const st = fs.lstatSync(src); if (st.isSymbolicLink()) { try { fs.symlinkSync(fs.readlinkSync(src), dest); return; } catch (e) { /* fallback: salin target */ } } if (st.isDirectory()) { fs.mkdirSync(dest, { recursive: true }); fs.readdirSync(src).forEach((name) => copyRecursive(path.join(src, name), path.join(dest, name))); } else { fs.copyFileSync(src, dest); } } // Salin lintas platform: `cp -a` di Unix, implementasi Node di Windows. function copyPath(from, to) { if (IS_WINDOWS) copyRecursive(from, to); else execFileSync("cp", ["-a", from, to]); } function archivePaths(values) { if (!Array.isArray(values) || values.length < 1 || values.length > 500) { throw new Error("Pilih 1 sampai 500 file/folder"); } const unique = Array.from(new Set(values.map((value) => { const normalized = rel(safe(String(value || ""))); if (!normalized) throw new Error("Root workspace tidak dapat dipilih sebagai item arsip"); return normalized; }))); return unique.filter((item) => !unique.some((parent) => { if (parent === item || !item.startsWith(parent + "/")) return false; try { return fs.statSync(safe(parent)).isDirectory(); } catch (e) { return false; } })); } function archiveExtension(format) { if (format === "zip") return ".zip"; if (format === "tar.gz") return ".tar.gz"; throw new Error("Format arsip tidak didukung"); } function createArchive(paths, format, output) { const parents = paths.map((item) => path.posix.dirname(item)); const sameParent = parents.every((parent) => parent === parents[0]); const cwd = sameParent ? safe(parents[0] === "." ? "" : parents[0]) : WORKSPACE; const entries = sameParent ? paths.map((item) => path.posix.basename(item)) : paths; const opts = { cwd, timeout: 10 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 }; if (format === "zip") { if (IS_WINDOWS) { // bsdtar (tar.exe bawaan Windows 10+) menulis zip via auto-format (-a). execFileSync("tar", ["-a", "-c", "-f", output, "-C", cwd, "--"].concat(entries), opts); } else { execFileSync("zip", ["-q", "-r", output, "--"].concat(entries), opts); } } else { execFileSync("tar", ["-czf", output, "-C", cwd, "--"].concat(entries), opts); } } function validateArchiveEntries(file, type) { // Windows: bsdtar dapat membaca daftar isi zip maupun tar dengan -tf. const command = (type === "zip" && !IS_WINDOWS) ? "unzip" : "tar"; const args = (type === "zip" && !IS_WINDOWS) ? ["-Z1", file] : ["-tf", file]; const output = execFileSync(command, args, { encoding: "utf8", timeout: 60 * 1000, maxBuffer: 20 * 1024 * 1024, }); output.split(/\r?\n/).filter(Boolean).forEach((entry) => { const clean = entry.replace(/\\/g, "/"); const normalized = path.posix.normalize(clean); if (clean[0] === "/" || normalized === ".." || normalized.startsWith("../") || /^[a-zA-Z]:\//.test(clean) || clean.includes("\0")) { throw new Error("Arsip mengandung path tidak aman: " + entry); } }); } const store = new SessionStore(path.join(__dirname, "data", "session.json")); // ---- Static: frontend + vendor (Ace, xterm) ------------------------------- app.use(express.static(path.join(__dirname, "public"))); app.use("/vendor/ace", express.static(path.join(__dirname, "node_modules/ace-builds/src-min-noconflict"))); app.use("/vendor/xterm", express.static(path.join(__dirname, "node_modules/xterm"))); app.use("/vendor/xterm-fit", express.static(path.join(__dirname, "node_modules/xterm-addon-fit/lib"))); // Preview: sajikan file workspace apa adanya (untuk pratinjau HTML dll) app.use("/preview", express.static(WORKSPACE)); // ---- API dasar ------------------------------------------------------------ // Spesifikasi server (platform, runtime, tool yang tersedia) — untuk panel // "Server" di setelan AI dan diagnosa. Dihitung sekali saat start. const hasBin = SysMetrics.hasBin; // satu implementasi, dipakai juga oleh lib/sys-metrics.js const SERVER_SPEC = (() => { const nodeVer = process.versions.node.split(".").map(Number); const nodeOk = nodeVer[0] > 22 || (nodeVer[0] === 22 && nodeVer[1] >= 13); const git = hasBin("git", ["--version"]); const tmux = !IS_WINDOWS && hasBin("tmux", ["-V"]); const tarBin = IS_WINDOWS ? hasBin("tar", ["--version"]) : (hasBin("zip", ["-v"]) && hasBin("tar", ["--version"])); const rg = hasBin("rg", ["--version"]); const grep = hasBin("grep", ["--version"]); let osName = process.platform; try { if (IS_WINDOWS) { const build = parseInt(String(os.release()).split(".")[2] || "0", 10); osName = (build >= 22000 ? "Windows 11" : "Windows 10") + " (build " + os.release().split(".")[2] + ")"; } else if (process.platform === "linux") { const rel = fs.readFileSync("/etc/os-release", "utf8"); const m = /^PRETTY_NAME="?([^"\n]+)"?/m.exec(rel); osName = m ? m[1] : "Linux " + os.release(); } else if (process.platform === "darwin") osName = "macOS " + os.release(); } catch (e) {} return { platform: process.platform, os: osName, arch: process.arch, node: process.version, nodeOk, nodeMin: "22.13", shell: SHELL, git, tmux, rg, grep, archives: tarBin, terminals: IS_WINDOWS ? "conpty" : (tmux ? "tmux" : "pty-direct"), persistentTerminals: !IS_WINDOWS && tmux, hostname: os.hostname(), cpus: os.cpus().length, memoryGb: Math.round(os.totalmem() / 1073741824 * 10) / 10, }; })(); if (!SERVER_SPEC.nodeOk) console.warn("[spec] Node.js " + process.version + " lebih lama dari minimum 22.13: agent AI (@cursor/sdk) mungkin gagal dimuat."); if (!IS_WINDOWS && !SERVER_SPEC.tmux) console.warn("[spec] tmux tidak ditemukan: terminal tidak akan persisten melewati restart."); // Metrik live (CPU/RAM/GPU) untuk indikator di menubar. const sysMetrics = new SysMetrics(); app.get("/api/info", (req, res) => res.json({ workspace: WORKSPACE, name: path.basename(WORKSPACE), product: "VRCloud IDE", collaboration: true, version: (() => { try { return require("./package.json").version; } catch (e) { return ""; } })(), spec: Object.assign({}, SERVER_SPEC, { checkpoints: !!(checkpoints && checkpoints.available), aiEnabled: !!(aiChat && aiChat.status().enabled), shellGuard: !!(aiChat && aiChat.getGuard().enabled), gpu: sysMetrics.info(), browser: browser ? browser.info() : { available: false }, }), })); app.get("/api/metrics", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(sysMetrics.snapshot()); }); // Self-update dari dalam IDE (tombol ⟳ Update di menubar): cek commit remote & jalankan // updater terpisah yang me-restart service tanpa perlu SSH. const Updater = require("./lib/updater"); const updater = new Updater({ appDir: __dirname, dataDir: path.join(__dirname, "data") }); updater.clearStaleLock(); app.get("/api/update/status", (req, res) => { res.setHeader("Cache-Control", "no-store"); updater.status({ force: String(req.query.force || "") === "1" }).then((s) => res.json(s), (e) => res.status(500).json({ error: e.message })); }); app.post("/api/update/run", (req, res) => { updater.start("web").then((r) => res.json(r), (e) => res.status(400).json({ error: e.message })); }); // Log proses update (sesi terakhir) untuk panel live di IDE. app.get("/api/update/log", (req, res) => { res.setHeader("Cache-Control", "no-store"); try { res.json(Object.assign(updater.logView(), { pid: process.pid, commit: updater.local().short })); } catch (e) { res.status(500).json({ error: e.message }); } }); // Runner "Jalankan" per ekstensi sesuai interpreter/compiler yang terpasang di server. // ?refresh=1 mendeteksi ulang (setelah memasang bahasa baru). const Runners = require("./lib/runners"); // Remote desktop (mirip VNC) dari IDE: status/kemampuan, pemasangan desktop virtual (Linux, root). const DesktopRemote = require("./lib/desktop-remote"); const desktop = new DesktopRemote({ dataDir: path.join(__dirname, "data"), log: (m) => console.warn(m) }); app.get("/api/desktop/status", (req, res) => { res.setHeader("Cache-Control", "no-store"); try { res.json(desktop.status()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post("/api/desktop/setup", (req, res) => { try { res.json(desktop.startSetup()); } catch (e) { res.status(400).json({ error: e.message }); } }); app.get("/api/desktop/setup", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(desktop.setup); }); const desktopWss = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 }); desktopWss.on("connection", (ws) => desktop.attach(ws)); app.get("/api/runners", (req, res) => { res.setHeader("Cache-Control", "no-store"); try { const refresh = String(req.query.refresh || "") === "1"; const ext = String(req.query.ext || ""); res.json(ext ? Runners.probe(ext, refresh) : Runners.detect(refresh)); } catch (e) { res.status(500).json({ error: e.message }); } }); // Status git workspace: ringkas (status bar, cache 4 dtk) atau detail (panel Source Control). const gitScm = new GitScm({ workspace: WORKSPACE, git: SERVER_SPEC.git }); let gitCache = { at: 0, value: null, pending: null }; function invalidateGit() { gitCache = { at: 0, value: null, pending: null }; } function gitErr(e) { return (e && e.message) || "git gagal"; } app.get("/api/git/status", (req, res) => { res.setHeader("Cache-Control", "no-store"); const detail = String(req.query.detail || "") === "1"; if (!detail) { const now = Date.now(); if (gitCache.value && now - gitCache.at < 4000) return res.json(gitCache.value); if (!gitCache.pending) { gitCache.pending = gitScm.summary().then((v) => { gitCache = { at: Date.now(), value: v, pending: null }; return v; }, (e) => { gitCache.pending = null; return { repo: false, error: gitErr(e) }; }); } return gitCache.pending.then((v) => res.json(v)); } gitScm.status().then((v) => res.json(v), (e) => res.status(400).json({ repo: false, error: gitErr(e) })); }); app.get("/api/git/diff", (req, res) => { res.setHeader("Cache-Control", "no-store"); gitScm.diff(req.query.path, String(req.query.staged || "") === "1").then( (diff) => res.json({ diff }), (e) => res.status(400).json({ error: gitErr(e) }) ); }); function gitMutate(fn) { return (req, res) => { fn(req.body || {}).then((v) => { invalidateGit(); if (v && v.changed && v.changed.length) filesChanged(v.changed, "git"); res.json(v); }, (e) => res.status(400).json({ error: gitErr(e) })); }; } app.post("/api/git/stage", gitMutate((b) => gitScm.stage(b.paths, !!b.all))); app.post("/api/git/unstage", gitMutate((b) => gitScm.unstage(b.paths, !!b.all))); app.post("/api/git/discard", gitMutate((b) => gitScm.discard(b.paths))); app.post("/api/git/commit", gitMutate((b) => gitScm.commit(b.message))); app.post("/api/git/checkout", gitMutate((b) => gitScm.checkout(b.branch, !!b.create))); app.post("/api/git/init", gitMutate(() => gitScm.init())); app.get("/api/git/log", (req, res) => { res.setHeader("Cache-Control", "no-store"); gitScm.log(req.query.n).then((log) => res.json({ log }), (e) => res.status(400).json({ error: gitErr(e) })); }); app.get("/api/git/branches", (req, res) => { res.setHeader("Cache-Control", "no-store"); gitScm.branches().then((branches) => res.json({ branches }), (e) => res.status(400).json({ error: gitErr(e) })); }); app.get("/api/list", (req, res) => { try { const dir = safe(req.query.path || ""); const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => { // Tautan kompatibilitas `.cursor` -> `.vrcloud-agent` di root workspace tidak ditampilkan (isinya sama). return !(dir === WORKSPACE && d.name === ".cursor" && d.isSymbolicLink()); }).map((d) => { let isDir = d.isDirectory(); if (d.isSymbolicLink()) { try { isDir = fs.statSync(path.join(dir, d.name)).isDirectory(); } catch (e) {} } return { name: d.name, path: rel(path.join(dir, d.name)), dir: isDir }; }).sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1)); res.json(entries); } catch (e) { res.status(400).json({ error: e.message }); } }); app.get("/api/read", (req, res) => { try { const f = safe(req.query.path); const st = fs.statSync(f); if (st.isDirectory()) return res.status(400).json({ error: "Itu folder" }); if (st.size > 5 * 1024 * 1024) return res.status(413).json({ error: "File terlalu besar (>5MB)" }); const buf = fs.readFileSync(f); const isBinary = buf.includes(0); res.json({ path: req.query.path, binary: isBinary, mime: mime.lookup(f) || "text/plain", content: isBinary ? "" : buf.toString("utf8") }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Simpan file selalu lewat WebSocket "doc-save" (realtime-hub) agar revisi dokumen // bersama ikut naik; tidak ada rute HTTP terpisah supaya logikanya satu. app.post("/api/create", (req, res) => { try { const f = safe(req.body.path); if (req.body.dir) fs.mkdirSync(f, { recursive: true }); else { fs.mkdirSync(path.dirname(f), { recursive: true }); if (!fs.existsSync(f)) fs.writeFileSync(f, ""); } res.json({ ok: true, path: rel(f) }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post("/api/rename", (req, res) => { try { const from = safeNonRoot(req.body.from); let to = safeNonRoot(req.body.to); // Nama tidak berubah: jangan dibuat "nama copy". Perubahan huruf besar/kecil saja // di Windows (a.txt -> A.txt) juga bukan tabrakan nama. if (to === from) return res.json({ ok: true, path: rel(from) }); const caseOnly = IS_WINDOWS && to.toLowerCase() === from.toLowerCase(); if (!caseOnly) { assertNotInside(from, to); to = uniquePath(to); } fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); res.json({ ok: true, path: rel(to) }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post("/api/delete", (req, res) => { try { rmPath(safeNonRoot(req.body.path)); const removed = String(req.body.path || ""); store.change((s) => { Object.keys(s.docs).forEach((p) => { if (p === removed || p.startsWith(removed + "/")) delete s.docs[p]; }); }); res.json({ ok: true }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Copy (rekursif). to = folder tujuan; nama dasal dipertahankan, dibuat unik. app.post("/api/copy", (req, res) => { try { const from = safeNonRoot(req.body.from); const destDir = safe(req.body.to || ""); assertNotInside(from, destDir); let to = path.join(destDir, path.basename(from)); to = uniquePath(to); fs.mkdirSync(path.dirname(to), { recursive: true }); copyPath(from, to); res.json({ ok: true, path: rel(to) }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Duplicate: salin di folder yang sama dengan nama "... copy". app.post("/api/duplicate", (req, res) => { try { const from = safeNonRoot(req.body.path); const to = uniquePath(path.join(path.dirname(from), path.basename(from))); copyPath(from, to); res.json({ ok: true, path: rel(to) }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Buat arsip permanen di workspace dari satu atau banyak selection. app.post("/api/archive", (req, res) => { try { const paths = archivePaths(req.body.paths); const format = String(req.body.format || ""); const ext = archiveExtension(format); const dest = safe(req.body.dest || ""); let name = path.basename(String(req.body.name || "archive")); if (!name.toLowerCase().endsWith(ext)) name += ext; const output = uniquePath(path.join(dest, name)); createArchive(paths, format, output); res.json({ ok: true, path: rel(output) }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Download selection langsung sebagai ZIP atau TAR.GZ tanpa meninggalkan file. app.post("/api/archive-download", (req, res) => { let temporary = ""; try { const paths = archivePaths(req.body.paths); const format = String(req.body.format || ""); const ext = archiveExtension(format); const name = path.basename(String(req.body.name || "selection")) + ext; temporary = path.join(os.tmpdir(), "vrcloud-" + crypto.randomBytes(12).toString("hex") + ext); createArchive(paths, format, temporary); res.download(temporary, name, () => { try { fs.unlinkSync(temporary); } catch (e) {} }); } catch (e) { if (temporary) { try { fs.unlinkSync(temporary); } catch (ignore) {} } res.status(400).json({ error: e.message }); } }); // Extract ZIP, TAR, TAR.GZ, atau TGZ ke folder unik di sebelah arsip. app.post("/api/extract", (req, res) => { try { const source = safe(req.body.path); const lower = source.toLowerCase(); const type = lower.endsWith(".zip") ? "zip" : (lower.endsWith(".tar") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) ? "tar" : ""; if (!type) throw new Error("Format extract didukung: .zip, .tar, .tar.gz, .tgz"); validateArchiveEntries(source, type); let base = path.basename(source).replace(/\.(tar\.gz|tgz|tar|zip)$/i, "") || "extracted"; const output = uniquePath(path.join(path.dirname(source), base)); fs.mkdirSync(output, { recursive: true }); if (type === "zip" && !IS_WINDOWS) { execFileSync("unzip", ["-q", source, "-d", output], { timeout: 10 * 60 * 1000 }); } else if (IS_WINDOWS) { // bsdtar mengekstrak zip maupun tar; flag ownership Unix tidak dipakai. execFileSync("tar", ["-xf", source, "-C", output], { timeout: 10 * 60 * 1000 }); } else { execFileSync("tar", ["-xf", source, "-C", output, "--no-same-owner", "--no-same-permissions"], { timeout: 10 * 60 * 1000, }); } res.json({ ok: true, path: rel(output) }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Upload (drag & drop). Body = biner file mentah. Header X-Filename = path relatif tujuan. app.post("/api/upload", express.raw({ type: () => true, limit: "500mb" }), (req, res) => { try { const dest = req.query.path || ""; let name = req.header("X-Filename"); if (!name) throw new Error("X-Filename kosong"); try { name = decodeURIComponent(name); } catch (e) { /* nama lama tanpa encode */ } const f = safe(path.join(dest, name)); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, req.body); res.json({ ok: true, path: rel(f) }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Download: file -> langsung; folder -> tar.gz streaming. app.get("/api/download", (req, res) => { try { const f = safe(req.query.path); const st = fs.statSync(f); if (st.isDirectory()) { const base = path.basename(f); res.setHeader("Content-Type", "application/gzip"); res.setHeader("Content-Disposition", `attachment; filename="${base}.tar.gz"`); const tar = spawn("tar", ["-czf", "-", "-C", path.dirname(f), base]); tar.stdout.pipe(res); tar.stderr.on("data", () => {}); tar.on("error", () => { try { res.status(500).end(); } catch (e) {} }); // Browser membatalkan download: hentikan tar agar tidak menggantung dengan buffer penuh. res.on("close", () => { if (tar.exitCode == null) { try { tar.kill(); } catch (e) {} } }); } else { res.download(f, path.basename(f)); } } catch (e) { res.status(400).json({ error: e.message }); } }); // ---- Pencarian ala VS Code: opsi case/word/regex, include/exclude glob, rg > grep > Node ---- function globRe(glob) { let g = String(glob || "").trim().replace(/\\/g, "/").replace(/^\.\//, ""); if (!g) return null; if (g.indexOf("/") === -1 && g.indexOf("**") !== 0) g = "**/" + g; let re = "^"; for (let i = 0; i < g.length; i++) { const ch = g[i]; if (ch === "*") { if (g[i + 1] === "*") { i++; if (g[i + 1] === "/") { i++; re += "(?:.*/)?"; } else re += ".*"; } else re += "[^/]*"; } else if (ch === "?") re += "[^/]"; else if (/[.+^$()|[\]\\{}]/.test(ch)) re += "\\" + ch; else re += ch; } // Nama folder polos (mis. "dist") juga cocok dengan seluruh isinya. return new RegExp(re + "(?:/.*)?$", IS_WINDOWS ? "i" : ""); } function parseGlobs(s) { return String(s || "").split(",").map((x) => x.trim()).filter(Boolean); } function searchOpts(q) { const on = (v) => v === "1" || v === "true"; return { q: String(q.q || ""), regex: on(q.regex), matchCase: on(q.case), word: on(q.word), include: parseGlobs(q.include), exclude: parseGlobs(q.exclude), }; } function searchRegex(o, global) { let src = o.regex ? o.q : o.q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); if (o.word) src = "\\b(?:" + src + ")\\b"; return new RegExp(src, (o.matchCase ? "" : "i") + (global ? "g" : "")); } function pathAllowed(relPath, o) { const p = relPath.replace(/\\/g, "/"); if (o.exclude.length && o.exclude.some((g) => { const r = globRe(g); return r && r.test(p); })) return false; if (o.include.length && !o.include.some((g) => { const r = globRe(g); return r && r.test(p); })) return false; return true; } // Fallback pencarian murni Node bila rg/grep tidak tersedia. function jsSearch(dir, o, maxTotal) { const results = []; let re; try { re = searchRegex(o, false); } catch (e) { throw new Error("Regex tidak valid: " + e.message); } const stack = [dir]; while (stack.length && results.length < maxTotal) { const current = stack.pop(); let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { continue; } for (const d of entries) { if (results.length >= maxTotal) break; const full = path.join(current, d.name); let isDir = d.isDirectory(); if (d.isSymbolicLink()) { try { isDir = fs.statSync(full).isDirectory(); } catch (e) { continue; } } if (isDir) { if (SKIP_DIRS.has(d.name)) continue; stack.push(full); continue; } const relp = rel(full); if (!pathAllowed(relp, o)) continue; let st; try { st = fs.statSync(full); } catch (e) { continue; } if (!st.isFile() || st.size > 2 * 1024 * 1024) continue; let buf; try { buf = fs.readFileSync(full); } catch (e) { continue; } if (buf.includes(0)) continue; const lines = buf.toString("utf8").split(/\r?\n/); for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { results.push({ path: relp, line: i + 1, text: lines[i].slice(0, 400) }); if (results.length >= maxTotal) break; } } } } return results; } // GET /api/search?q=&path=®ex=1&case=1&word=1&include=a,b&exclude=c app.get("/api/search", (req, res) => { try { const o = searchOpts(req.query); if (!o.q) return res.json([]); const dir = safe(req.query.path || ""); const maxTotal = 2000; try { searchRegex(o, false); } catch (e) { return res.status(400).json({ error: "Regex tidak valid: " + e.message }); } let bin = null, args = null; if (SERVER_SPEC.rg) { bin = "rg"; args = ["--line-number", "--no-heading", "--color", "never", "--max-count", "200", "--max-columns", "400", "--max-filesize", "2M"]; if (!o.matchCase) args.push("-i"); if (!o.regex) args.push("-F"); if (o.word) args.push("-w"); o.include.forEach((g) => args.push("--glob", g.indexOf("/") === -1 && g.indexOf("**") !== 0 ? "**/" + g : g)); o.exclude.forEach((g) => args.push("--glob", "!" + (g.indexOf("/") === -1 && g.indexOf("**") !== 0 ? "**/" + g : g))); args.push("-e", o.q, dir); } else if (SERVER_SPEC.grep) { bin = "grep"; args = ["-rIn", "--exclude-dir=node_modules", "--exclude-dir=.git"]; if (!o.matchCase) args.push("-i"); if (!o.regex) args.push("-F"); else args.push("-E"); if (o.word) args.push("-w"); o.include.forEach((g) => args.push("--include=" + g.split("/").pop())); o.exclude.forEach((g) => { args.push("--exclude=" + g.split("/").pop()); args.push("--exclude-dir=" + g.split("/").pop()); }); args.push("--", o.q, dir); } if (!bin) return res.json(jsSearch(dir, o, maxTotal)); const child = spawn(bin, args, { windowsHide: true }); let buf = ""; child.stdout.on("data", (d) => { buf += d.toString(); }); child.stderr.on("data", () => {}); child.on("close", () => { const results = []; for (const raw of buf.split(/\r?\n/)) { const line = raw.replace(/\r$/, ""); if (!line) continue; // ::. Drive Windows (C:\) ikut di path, bukan pemisah kolom. const m = line.match(/^((?:[A-Za-z]:[\\/])?.+?):(\d+):(.*)$/); if (!m) continue; const relp = rel(m[1]); if (bin === "grep" && !pathAllowed(relp, o)) continue; // grep tidak paham glob berjalur results.push({ path: relp, line: parseInt(m[2], 10), text: m[3].slice(0, 400) }); if (results.length >= maxTotal) break; } res.json(results); }); child.on("error", (e) => res.status(400).json({ error: e.message })); } catch (e) { res.status(400).json({ error: e.message }); } }); // POST /api/replace { q, replace, regex, case, word, files: [{ path, lines?: [n] }] } // Mengganti di file yang disebut (hasil pencarian klien); lines membatasi ke baris tertentu. app.post("/api/replace", (req, res) => { try { const b = req.body || {}; const o = searchOpts({ q: b.q, regex: b.regex ? "1" : "0", case: b.case ? "1" : "0", word: b.word ? "1" : "0" }); if (!o.q) return res.status(400).json({ error: "Teks pencarian kosong" }); let re; try { re = searchRegex(o, true); } catch (e) { return res.status(400).json({ error: "Regex tidak valid: " + e.message }); } const rep = String(b.replace == null ? "" : b.replace); const repl = o.regex ? rep : rep.replace(/\$/g, "$$$$"); // literal: '$' tidak jadi referensi grup const files = Array.isArray(b.files) ? b.files.slice(0, 500) : []; let replaced = 0; const changed = []; files.forEach((f) => { const relp = String((f && f.path) || ""); if (!relp) return; const abs = safe(relp); let text; try { text = fs.readFileSync(abs, "utf8"); } catch (e) { return; } const eol = text.indexOf("\r\n") !== -1 ? "\r\n" : "\n"; const lines = text.split(/\r?\n/); const only = Array.isArray(f.lines) && f.lines.length ? new Set(f.lines.map(Number)) : null; let n = 0; for (let i = 0; i < lines.length; i++) { if (only && !only.has(i + 1)) continue; re.lastIndex = 0; if (!re.test(lines[i])) continue; re.lastIndex = 0; const before = lines[i]; lines[i] = before.replace(re, repl); re.lastIndex = 0; n += (before.match(re) || []).length; } if (n) { fs.writeFileSync(abs, lines.join(eol), "utf8"); replaced += n; changed.push(relp); } }); if (changed.length) filesChanged(changed, "search"); res.json({ replaced, files: changed.length, changed }); } catch (e) { res.status(400).json({ error: e.message }); } }); // ---- AI Agent (Cursor SDK / Anthropic Claude) ----------------------------- // Checkpoint workspace (shadow git di data/) untuk tombol "Kembalikan". const checkpoints = new Checkpoints({ workspace: WORKSPACE, gitDir: path.join(__dirname, "data", "checkpoints.git") }); const workspaceEvents = new WorkspaceEvents({ workspace: WORKSPACE }); const workspaceIndex = new WorkspaceIndex({ workspace: WORKSPACE, dataDir: path.join(__dirname, "data", "context"), events: workspaceEvents, }); const memoryStore = new MemoryStore({ workspace: WORKSPACE, dataDir: path.join(__dirname, "data", "context"), events: workspaceEvents, }); const lspManager = new LspManager({ workspace: WORKSPACE, index: workspaceIndex, events: workspaceEvents, }); const contextEngine = new ContextEngine({ workspace: WORKSPACE, index: workspaceIndex, events: workspaceEvents, lspManager, memoryStore, }); const memoryTools = createMemoryTools(memoryStore); const structuralTools = createStructuralTools({ workspace: WORKSPACE }); const lspTools = createLspTools(lspManager); // diagnostics(path): pemeriksaan instan setelah edit // generate_image (Grok Imagine). Kredensial diambil saat dipakai: provider dan sesi login bisa // berubah kapan saja. AiChat menyaring tool ini bila model aktif tidak bisa membuat gambar. const imageTools = createImageTools({ workspace: WORKSPACE, getAuth: () => (aiChat ? aiChat.imageAuth() : null) }); // web_search: pencarian web lintas provider (Tavily/Brave bila ada kunci, DuckDuckGo tanpa kunci). const webSearchTools = createWebSearchTools({ getKey: () => (aiChat ? aiChat.searchKey : "") }); // Hub realtime dibuat belakangan; panggil lewat fungsi agar terikat saat dipakai. let hubRef = null; function filesChanged(paths, source) { const changes = Array.isArray(paths) ? paths : []; workspaceEvents.publish(source || "agent", changes, { wildcard: !changes.length }); if (hubRef) { try { hubRef.filesChangedOnDisk(changes, source || "agent", { workspaceEvent: false }); } catch (e) {} } } const verificationEngine = new VerificationEngine({ workspace: WORKSPACE, index: workspaceIndex, shell: process.env.AI_VERIFY_SHELL || "", timeoutMs: Math.max(1000, parseInt(process.env.AI_VERIFY_TIMEOUT_MS || "120000", 10) || 120000), maxOutput: Math.max(4096, parseInt(process.env.AI_VERIFY_MAX_OUTPUT || "65536", 10) || 65536), onChanged: (paths) => filesChanged(paths, "verifier"), }); // Brankas rahasia browser (kata sandi/token) — agent hanya tahu namanya. const browserSecrets = new BrowserSecrets({ file: path.join(__dirname, "data", "browser-secrets.json"), secret: AUTH_SECRET }); // Browser headless (Chrome/Edge/Chromium via CDP) sebagai tool agent: navigate, tabs, // click, type, select, upload, extract, screenshot, download, pdf, emulate, console, // network, evaluate. Langkah + frame live + tab disiarkan ke IDE lewat hub. browser = new BrowserAutomation({ dataDir: path.join(__dirname, "data"), workspace: WORKSPACE, secrets: browserSecrets, onStep: (step) => { if (hubRef) { try { hubRef.broadcast({ type: "browser-step", step }); } catch (e) {} } }, onEvent: (type, payload) => { if (hubRef) { try { hubRef.browserEvent(type, payload); } catch (e) {} } }, }); aiChat = new AiChat({ workspace: WORKSPACE, configFile: path.join(__dirname, "data", "ai-config.json"), storeFile: path.join(__dirname, "data", "ai-sessions.json"), grokAuthFile: path.join(__dirname, "data", "grok-session.json"), authSecret: AUTH_SECRET, checkpoints, browser, contextEngine, workspaceEvents, // Tool agent: otomasi browser headless + kelola/kendalikan remote desktop server. customTools: () => Object.assign({}, browser.tools(), desktop.tools(), memoryTools, structuralTools, lspTools, imageTools, webSearchTools), desktopStatus: () => desktop.status(), // untuk chip "Computer use" (tersedia/tidak) & prompt agent desktopShot: (callId) => desktop.shotFor(callId), // screenshot computer use untuk kartu chat (Cursor SDK) onFsChange: (paths) => filesChanged(paths, "agent"), // File yang terbuka di editor (tab aktif + tab lain) untuk konteks prompt agent. openFiles: () => { const st = store.state || {}; const open = []; let active = ""; const walk = (node) => { if (!node) return; if (node.type === "leaf") { (node.tabs || []).forEach((t) => { if (!t || t.kind !== "file" || !t.path) return; if (open.indexOf(t.path) === -1) open.push(t.path); if (node._id === st.activeLeafId && t.id === node.active) active = t.path; }); } else (node.children || []).forEach(walk); }; walk(st.layout); return { active, open }; }, // Event sesi (run mulai/selesai, daftar berubah) & cermin shell agent → semua browser. onNotify: (ev) => { if (hubRef) { try { hubRef.aiEvent(ev); } catch (e) {} } }, onShell: (p) => { if (hubRef) { try { hubRef.agentShell(p); } catch (e) {} } }, }); aiChat.setVerifier(verificationEngine); // Hook pengaman perintah untuk provider Cursor. lib/hooks/shell-guard.js didaftarkan di // .vrcloud-agent/hooks.json (dibaca SDK lewat tautan .cursor) dan menghubungi server ini // memakai token acak per proses yang ditulis ke data/hook.json. Provider Anthropic/Grok // tidak lewat sini: tool shell mereka memanggil aiChat.reviewShell langsung (beforeShell). // Pola yang dicocokkan dan sakelar on/off diatur di panel AI (aiChat.getGuard), jadi hook // selalu terpasang dan keputusannya diambil di server. function installShellGuardHook() { try { const dataDir = path.join(__dirname, "data"); fs.mkdirSync(dataDir, { recursive: true }); const cfgFile = path.join(dataDir, "hook.json"); fs.writeFileSync(cfgFile, JSON.stringify({ host: "127.0.0.1", port: PORT, https: USE_HTTPS, token: HOOK_TOKEN }, null, 2) + "\n", "utf8"); if (!IS_WINDOWS) { try { fs.chmodSync(cfgFile, 0o600); } catch (e) {} } const dir = path.join(WORKSPACE, ".vrcloud-agent"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "hooks.json"); let cfg = {}; if (fs.existsSync(file)) { try { cfg = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch (e) { cfg = {}; } } if (!cfg || typeof cfg !== "object") cfg = {}; if (!cfg.version) cfg.version = 1; if (!cfg.hooks || typeof cfg.hooks !== "object") cfg.hooks = {}; // Garis miring biasa: Node menerimanya di Windows, dan tidak ada backslash ganda di JSON. const script = path.join(__dirname, "lib", "hooks", "shell-guard.js").replace(/\\/g, "/"); const entry = { command: "node \"" + script + "\"", timeout: 900 }; const list = Array.isArray(cfg.hooks.beforeShellExecution) ? cfg.hooks.beforeShellExecution : []; const rest = list.filter((h) => !(h && typeof h.command === "string" && /shell-guard\.js/.test(h.command))); cfg.hooks.beforeShellExecution = [entry].concat(rest); const next = JSON.stringify(cfg, null, 2) + "\n"; let cur = ""; try { cur = fs.readFileSync(file, "utf8"); } catch (e) {} if (cur !== next) fs.writeFileSync(file, next, "utf8"); } catch (e) { console.warn("[agent] hook pengaman perintah tidak terpasang: " + e.message); } } app.get("/api/ai/status", (req, res) => { const st = aiChat.status(); st.checkpoints = checkpoints.status(); st.guard = aiChat.getGuard(); const manifest = workspaceIndex.manifest(); st.index = { revision: manifest.revision, updatedAt: manifest.updatedAt, files: Object.keys(manifest.files || {}).length, error: workspaceIndex.error ? workspaceIndex.error.message : "", }; st.lsp = lspManager.status(); st.structuredMemory = memoryStore.status(); st.verification = verificationEngine.status(); res.json(st); }); app.get("/api/ai/index", (req, res) => { res.setHeader("Cache-Control", "no-store"); const manifest = workspaceIndex.manifest(); const files = Object.values(manifest.files || {}); const languages = {}; let tests = 0; files.forEach((entry) => { const language = entry.language || "plaintext"; languages[language] = (languages[language] || 0) + 1; if (entry.isTest) tests++; }); res.json({ version: manifest.version, workspaceHash: manifest.workspaceHash, revision: manifest.revision, updatedAt: manifest.updatedAt, files: files.length, tests, languages, error: workspaceIndex.error ? workspaceIndex.error.message : "", }); }); app.get("/api/ai/verification", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(verificationEngine.status(req.query.runId)); }); // Persetujuan perintah berisiko dari UI. app.post("/api/ai/approve", (req, res) => { const id = String((req.body && req.body.id) || ""); const ok = aiChat.decideApproval(id, !!(req.body && req.body.allow), "user"); res.json({ ok }); }); // Screenshot langkah browser agent (data/browser-shots/.jpg) untuk kartu di chat. app.get("/api/ai/browser/shot/:n", (req, res) => { const abs = browser && browser.shotPath(req.params.n); if (!abs || !fs.existsSync(abs)) return res.status(404).json({ error: "Screenshot tidak ditemukan" }); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); res.type("image/jpeg").sendFile(abs); }); // Screenshot computer use (data/desktop-shots/.jpg). Kartu chat memuat URL ini, bukan byte dari event Cursor. app.get("/api/ai/desktop/shot/:name", (req, res) => { const abs = desktop && desktop.shotFile(req.params.name); if (!abs) return res.status(404).json({ error: "Screenshot tidak ditemukan" }); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); res.type("image/jpeg").sendFile(abs); }); // Status browser otomasi + langkah terakhir (untuk panel/status). app.get("/api/ai/browser", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(Object.assign({}, browser ? browser.info() : { available: false }, { steps: browser ? browser.steps.slice(-30) : [] })); }); app.post("/api/ai/browser/close", (req, res) => { if (!browser) return res.json({ ok: true }); browser.close().then(() => res.json({ ok: true }), (e) => res.status(500).json({ error: e.message })); }); // Ekspor langkah browser → skrip Playwright. steps dari klien (riwayat chat) supaya tetap bisa setelah server restart. app.post("/api/ai/browser/script", (req, res) => { try { const b = req.body || {}; const steps = Array.isArray(b.steps) && b.steps.length ? b.steps.map((s) => ({ action: String(s.action || ""), url: s.url || "", title: s.title || "", args: s.args && typeof s.args === "object" ? s.args : {} })) : (browser ? browser.steps : []); if (!steps.length) return res.status(400).json({ error: "Tidak ada langkah browser untuk diekspor" }); const rel = String(b.path || ("tests/browser-" + new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19) + ".spec.js")).replace(/\\/g, "/").replace(/^\/+/, ""); if (!/\.(spec|test)\.[cm]?js$/.test(rel)) return res.status(400).json({ error: "Nama file harus berakhiran .spec.js / .test.js" }); const abs = safe(rel); const script = BrowserAutomation.toPlaywright(steps, { file: rel, title: b.title }); if (b.preview) return res.json({ path: rel, script, steps: steps.length }); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, script, "utf8"); filesChanged([rel], "export"); res.json({ ok: true, path: rel, steps: steps.length }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Unduh Chrome for Testing ke data/browser/ bila tidak ada browser terpasang (progres via hub: browser-install). app.post("/api/ai/browser/install", (req, res) => { if (!browser) return res.status(400).json({ error: "Browser otomasi tidak aktif" }); browser.installChromium().catch(() => {}); res.json({ ok: true, install: browser.install }); }); // Pengguna selesai membantu (login/CAPTCHA) → tool browser_request_user kembali ke agent. app.post("/api/ai/browser/handoff-done", (req, res) => { res.json({ ok: !!(browser && browser.finishHandoff("done")) }); }); // Brankas rahasia browser: hanya nama yang keluar dari server. app.get("/api/ai/browser/secrets", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(browserSecrets.describe()); }); app.post("/api/ai/browser/secrets", (req, res) => { try { browserSecrets.set(req.body && req.body.name, req.body && req.body.value, req.body && req.body.note); res.json({ ok: true, items: browserSecrets.describe() }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.delete("/api/ai/browser/secrets/:name", (req, res) => { res.json({ ok: browserSecrets.remove(req.params.name), items: browserSecrets.describe() }); }); // Gambar tempel yang tersimpan di riwayat percakapan (data/ai-images). app.get("/api/ai/image/:file", (req, res) => { const abs = aiChat.imagePath(req.params.file); if (!abs || !fs.existsSync(abs)) return res.status(404).json({ error: "Gambar tidak ditemukan" }); res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); res.sendFile(abs); }); // Pengaman perintah berbahaya: aktif/nonaktif + daftar pola (dibaca klien lewat /api/ai/status → guard). app.post("/api/ai/guard", (req, res) => { try { res.json(aiChat.setGuard(req.body || {})); } catch (e) { res.status(400).json({ error: e.message }); } }); // Kembalikan workspace / satu file ke checkpoint. // rewind=true + sessionId (tombol "Kembalikan ke sebelum pesan ini"): percakapan juga // dimundurkan — pesan itu dan semua chat di bawahnya dihapus. Diperiksa SEBELUM file // dipulihkan, supaya tidak pernah terjadi file kembali tetapi chat tetap. app.post("/api/ai/restore", (req, res) => { const hash = String((req.body && req.body.hash) || ""); const p = req.body && req.body.path ? String(req.body.path) : ""; const rewindSession = !p && req.body && req.body.rewind && req.body.sessionId ? String(req.body.sessionId) : ""; // Pesan mana yang ditekan: urutan pesan pengguna + teksnya (hash checkpoint bisa dipakai bersama). const rewindHint = { ordinal: req.body && req.body.ordinal, text: req.body && typeof req.body.text === "string" ? req.body.text : undefined }; if (!/^[0-9a-f]{7,64}$/i.test(hash)) return res.status(400).json({ error: "hash tidak valid" }); if (rewindSession) { try { aiChat.assertCanRewind(rewindSession, hash, rewindHint); } catch (e) { return res.status(e.status || 400).json({ error: e.message }); } } const op = p ? checkpoints.restoreFile(hash, p) : checkpoints.restoreAll(hash); op.then((r) => { filesChanged(r.changed, "restore"); if (!rewindSession) return res.json(r); return aiChat.rewind(rewindSession, hash, rewindHint).then( (rewind) => res.json(Object.assign({}, r, { rewind })), (e) => res.status(e.status || 400).json(Object.assign({}, r, { error: "File sudah dipulihkan, tetapi chat gagal dimundurkan: " + e.message }))); }, (e) => res.status(400).json({ error: (e && e.stderr) ? String(e.stderr).trim() || e.message : e.message })); }); // Diff satu file antara dua checkpoint (kartu "Tinjau perubahan"). app.get("/api/ai/diff", (req, res) => { const from = String(req.query.from || ""), to = String(req.query.to || ""), p = String(req.query.path || ""); if (!/^[0-9a-f]{7,64}$/i.test(from) || !/^[0-9a-f]{7,64}$/i.test(to)) return res.status(400).json({ error: "hash tidak valid" }); checkpoints.diffFile(from, to, p).then( (diff) => res.json({ diff }), (e) => res.status(400).json({ error: (e && e.stderr) ? String(e.stderr).trim() || e.message : e.message }) ); }); // ---- Folder agent: /.vrcloud-agent (rules, skills, memory.md, hooks.json) ---- // Cursor SDK membaca `.cursor/` dari cwd; agar rules/skills/hooks tetap terbaca, `.cursor` // dibuat sebagai tautan (symlink / junction di Windows) ke `.vrcloud-agent`. Instalasi lama // yang masih punya folder `.cursor` sungguhan dimigrasikan (rename) sekali. function ensureAgentDir() { const agentDir = path.join(WORKSPACE, ".vrcloud-agent"); const legacy = path.join(WORKSPACE, ".cursor"); try { const lst = (p) => { try { return fs.lstatSync(p); } catch (e) { return null; } }; const a = lst(agentDir), c = lst(legacy); if (!a && c && c.isDirectory() && !c.isSymbolicLink()) { fs.renameSync(legacy, agentDir); console.log("[agent] .cursor dipindahkan menjadi .vrcloud-agent"); } if (!lst(agentDir)) fs.mkdirSync(agentDir, { recursive: true }); const c2 = lst(legacy); if (!c2) { let err = null; try { fs.symlinkSync(IS_WINDOWS ? agentDir : ".vrcloud-agent", legacy, IS_WINDOWS ? "junction" : "dir"); } catch (e) { err = e; // Windows: fs.symlinkSync junction kadang gagal (EISDIR); mklink /J tidak butuh hak admin. if (IS_WINDOWS) { const r = spawnSync("cmd", ["/c", "mklink", "/J", legacy, agentDir], { encoding: "utf8", windowsHide: true }); if (r.status === 0) err = null; } } if (err) console.warn("[agent] tautan .cursor -> .vrcloud-agent tidak bisa dibuat (" + err.message.split("\n")[0] + "); rules/skills untuk provider Cursor disisipkan lewat prompt. Filesystem tanpa symlink (mis. exFAT)?"); else console.log("[agent] tautan kompatibilitas .cursor -> .vrcloud-agent dibuat"); } else if (c2.isDirectory() && !c2.isSymbolicLink() && lst(agentDir)) { console.warn("[agent] .cursor dan .vrcloud-agent sama-sama ada; Cursor SDK membaca .cursor, IDE memakai .vrcloud-agent."); } } catch (e) { console.warn("[agent] ensureAgentDir: " + e.message); } } ensureAgentDir(); // Setelah ensureAgentDir: hook membuat .vrcloud-agent bila belum ada, dan itu akan // membatalkan migrasi folder .cursor lama yang hanya berjalan saat .vrcloud-agent belum ada. installShellGuardHook(); // ---- Rules agent: AGENTS.md + .vrcloud-agent/rules/*.mdc --------------------------- const RULES_DIR = path.join(WORKSPACE, ".vrcloud-agent", "rules"); const RULE_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.(mdc|md)$/; function ruleEntry(relPath, abs) { let text = ""; try { text = fs.readFileSync(abs, "utf8"); } catch (e) { return null; } const fm = parseFrontmatter(text); const isAgents = relPath === "AGENTS.md"; return { path: relPath, name: isAgents ? "AGENTS.md" : path.basename(relPath), description: isAgents ? "Instruksi proyek untuk agent (selalu dimuat)" : (fm.meta.description || ""), globs: fm.meta.globs || "", alwaysApply: isAgents ? true : String(fm.meta.alwaysApply || "").toLowerCase() === "true", size: text.length, updatedAt: fs.statSync(abs).mtimeMs, }; } function listRules() { const out = []; const agents = path.join(WORKSPACE, "AGENTS.md"); if (fs.existsSync(agents)) { const e = ruleEntry("AGENTS.md", agents); if (e) out.push(e); } if (fs.existsSync(RULES_DIR)) { fs.readdirSync(RULES_DIR).filter((f) => RULE_FILE_RE.test(f)).sort().forEach((f) => { const e = ruleEntry(".vrcloud-agent/rules/" + f, path.join(RULES_DIR, f)); if (e) out.push(e); }); } return out; } function ruleAbs(relPath) { relPath = String(relPath || "").replace(/\\/g, "/"); if (relPath === "AGENTS.md") return path.join(WORKSPACE, "AGENTS.md"); const m = /^\.vrcloud-agent\/rules\/([^/]+)$/.exec(relPath); if (m && RULE_FILE_RE.test(m[1])) return path.join(RULES_DIR, m[1]); return null; } app.get("/api/ai/rules", (req, res) => res.json({ rules: listRules() })); app.get("/api/ai/rules/file", (req, res) => { const abs = ruleAbs(req.query.path); if (!abs) return res.status(400).json({ error: "Path rule tidak valid" }); if (!fs.existsSync(abs)) return res.json({ path: String(req.query.path), content: "", exists: false }); const text = fs.readFileSync(abs, "utf8"); const fm = parseFrontmatter(text); res.json({ path: String(req.query.path), exists: true, content: fm.body, description: fm.meta.description || "", globs: fm.meta.globs || "", alwaysApply: String(fm.meta.alwaysApply || "").toLowerCase() === "true", raw: text }); }); app.post("/api/ai/rules", (req, res) => { try { const b = req.body || {}; let relPath = String(b.path || "").replace(/\\/g, "/").trim(); if (!relPath && b.name) { let n = String(b.name).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); if (!n) return res.status(400).json({ error: "Nama rule tidak valid" }); if (!/\.(mdc|md)$/.test(n)) n += ".mdc"; relPath = ".vrcloud-agent/rules/" + n; } const abs = ruleAbs(relPath); if (!abs) return res.status(400).json({ error: "Path rule tidak valid (AGENTS.md atau .vrcloud-agent/rules/.mdc)" }); const content = String(b.content || "").replace(/\r\n/g, "\n"); let text = content; if (relPath !== "AGENTS.md") { const fmLines = ["---"]; if (b.description) fmLines.push("description: " + yamlStr(b.description)); if (b.globs) fmLines.push("globs: " + String(b.globs).trim()); fmLines.push("alwaysApply: " + (b.alwaysApply ? "true" : "false"), "---", ""); text = fmLines.join("\n") + content.trim() + "\n"; } fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, text, "utf8"); filesChanged([relPath], "rules"); res.json(ruleEntry(relPath, abs)); } catch (e) { res.status(400).json({ error: e.message }); } }); app.delete("/api/ai/rules", (req, res) => { const abs = ruleAbs(req.query.path); if (!abs) return res.status(400).json({ error: "Path rule tidak valid" }); try { if (fs.existsSync(abs)) fs.unlinkSync(abs); } catch (e) { return res.status(400).json({ error: e.message }); } filesChanged([String(req.query.path)], "rules"); res.json({ ok: true }); }); // ---- Memori proyek agent: .vrcloud-agent/memory.md (dimuat ke prompt setiap run; agent boleh memperbarui) ---- const MEMORY_FILE = path.join(WORKSPACE, ".vrcloud-agent", "memory.md"); const MEMORY_MAX = 64 * 1024; app.get("/api/ai/memory", (req, res) => { res.setHeader("Cache-Control", "no-store"); try { if (!fs.existsSync(MEMORY_FILE)) return res.json({ exists: false, content: "", size: 0, path: ".vrcloud-agent/memory.md" }); const text = fs.readFileSync(MEMORY_FILE, "utf8"); res.json({ exists: true, content: text, size: text.length, updatedAt: fs.statSync(MEMORY_FILE).mtimeMs, path: ".vrcloud-agent/memory.md" }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post("/api/ai/memory", (req, res) => { try { const content = String((req.body && req.body.content) || "").replace(/\r\n/g, "\n"); if (content.length > MEMORY_MAX) return res.status(400).json({ error: "Memori terlalu besar (maks 64 KB)" }); fs.mkdirSync(path.dirname(MEMORY_FILE), { recursive: true }); if (content.trim()) fs.writeFileSync(MEMORY_FILE, content, "utf8"); else if (fs.existsSync(MEMORY_FILE)) fs.unlinkSync(MEMORY_FILE); filesChanged([".vrcloud-agent/memory.md"], "memory"); res.json({ ok: true, exists: !!content.trim(), size: content.length }); } catch (e) { res.status(400).json({ error: e.message }); } }); // Structured memory lives beside the workspace index. The legacy markdown endpoint // above remains unchanged for manual, free-form project notes. app.get("/api/ai/memory/items", (req, res) => { res.setHeader("Cache-Control", "no-store"); const status = req.query.status ? String(req.query.status).split(",").map((value) => value.trim()).filter(Boolean) : undefined; memoryStore.search(req.query.q || req.query.query || "", { kind: req.query.kind, status, includeStale: /^(1|true|yes)$/i.test(String(req.query.includeStale || "")), limit: req.query.limit, }).then( (items) => res.json({ items, count: items.length, status: memoryStore.status() }), (e) => res.status(400).json({ error: e.message }) ); }); app.post("/api/ai/memory/items", (req, res) => { memoryStore.upsert(req.body || {}).then( (item) => res.json({ item, status: memoryStore.status() }), (e) => res.status(400).json({ error: e.message }) ); }); app.delete("/api/ai/memory/items/:id", (req, res) => { memoryStore.forget(req.params.id).then( (item) => item ? res.json({ item, status: memoryStore.status() }) : res.status(404).json({ error: "Memori tidak ditemukan" }), (e) => res.status(400).json({ error: e.message }) ); }); app.post("/api/ai/memory/items/:id/revalidate", (req, res) => { memoryStore.revalidate(req.params.id, req.body || {}).then( (item) => item ? res.json({ item, status: memoryStore.status() }) : res.status(404).json({ error: "Memori tidak ditemukan" }), (e) => res.status(400).json({ error: e.message }) ); }); app.get("/api/ai/lsp", (req, res) => { res.setHeader("Cache-Control", "no-store"); res.json(lspManager.status()); }); app.post("/api/ai/lsp/:action", (req, res) => { const action = String(req.params.action || "").toLowerCase(); const language = String((req.body && (req.body.language || req.body.path)) || ""); if (["start", "stop", "restart"].indexOf(action) === -1) return res.status(400).json({ error: "Aksi LSP tidak valid" }); if (action !== "stop" && !language) return res.status(400).json({ error: "Bahasa atau path wajib diisi" }); Promise.resolve(lspManager[action](language || undefined)).then( () => res.json(lspManager.status()), (e) => res.status(400).json({ error: e.message }) ); }); app.post("/api/ai/session/rename", (req, res) => { const r = aiChat.renameSession(String((req.body && req.body.id) || ""), req.body && req.body.title); if (!r) return res.status(404).json({ error: "Percakapan tidak ditemukan" }); res.json(r); }); // Keputusan review perubahan agent (terima/tolak per file) — disimpan di pesan AI. app.post("/api/ai/review", (req, res) => { const b = req.body || {}; const r = aiChat.setReview(String(b.id || b.sessionId || ""), String(b.after || ""), b.review); if (!r) return res.status(404).json({ error: "Pesan tidak ditemukan" }); res.json({ ok: true, review: r }); }); // ---- Skills agent: /.vrcloud-agent/skills//SKILL.md -------------- const SKILLS_DIR = path.join(WORKSPACE, ".vrcloud-agent", "skills"); const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; function parseFrontmatter(text) { const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text); if (!m) return { meta: {}, body: text }; const meta = {}; let key = null, buf = []; const flush = () => { if (key) meta[key] = buf.join(" ").trim().replace(/^["']|["']$/g, ""); key = null; buf = []; }; m[1].split(/\r?\n/).forEach((line) => { const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); if (kv && !/^\s/.test(line)) { flush(); key = kv[1]; if (kv[2] && kv[2] !== ">-" && kv[2] !== ">" && kv[2] !== "|") buf.push(kv[2]); } else if (key) buf.push(line.trim()); }); flush(); return { meta, body: m[2] || "" }; } function readSkill(name) { if (!SKILL_NAME_RE.test(name)) return null; const file = path.join(SKILLS_DIR, name, "SKILL.md"); if (!fs.existsSync(file)) return null; const text = fs.readFileSync(file, "utf8"); const fm = parseFrontmatter(text); const extras = fs.readdirSync(path.join(SKILLS_DIR, name)).filter((f) => f !== "SKILL.md"); return { name, description: fm.meta.description || "", content: fm.body, autoInvoke: String(fm.meta["disable-model-invocation"] || "").toLowerCase() !== "true", path: ".vrcloud-agent/skills/" + name + "/SKILL.md", extras, size: text.length, updatedAt: fs.statSync(file).mtimeMs, }; } function listSkills() { if (!fs.existsSync(SKILLS_DIR)) return []; return fs.readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory() && SKILL_NAME_RE.test(d.name)) .map((d) => readSkill(d.name)).filter(Boolean) .map((s) => ({ name: s.name, description: s.description, autoInvoke: s.autoInvoke, path: s.path, extras: s.extras, updatedAt: s.updatedAt })) .sort((a, b) => a.name.localeCompare(b.name)); } function yamlStr(s) { s = String(s || "").replace(/\s+/g, " ").trim(); return /[:#"'{}\[\],&*?|<>=!%@`]/.test(s) ? JSON.stringify(s) : s; } app.get("/api/ai/skills", (req, res) => res.json({ skills: listSkills(), dir: ".vrcloud-agent/skills" })); app.get("/api/ai/skills/:name", (req, res) => { const s = readSkill(String(req.params.name || "")); if (!s) return res.status(404).json({ error: "Skill tidak ditemukan" }); res.json(s); }); app.post("/api/ai/skills", (req, res) => { try { const b = req.body || {}; const name = String(b.name || "").trim().toLowerCase(); if (!SKILL_NAME_RE.test(name)) return res.status(400).json({ error: "Nama skill: huruf kecil/angka/tanda hubung, maks 64 karakter" }); const description = String(b.description || "").trim(); if (!description) return res.status(400).json({ error: "Deskripsi wajib diisi (kapan skill ini dipakai)" }); if (description.length > 1024) return res.status(400).json({ error: "Deskripsi maks 1024 karakter" }); const content = String(b.content || "").replace(/\r\n/g, "\n").trim(); const autoInvoke = !!b.autoInvoke; const dir = path.join(SKILLS_DIR, name); fs.mkdirSync(dir, { recursive: true }); const fmLines = ["---", "name: " + name, "description: " + yamlStr(description)]; if (!autoInvoke) fmLines.push("disable-model-invocation: true"); fmLines.push("---", ""); fs.writeFileSync(path.join(dir, "SKILL.md"), fmLines.join("\n") + (content || "# " + name + "\n\n## Instructions\n") + "\n", "utf8"); filesChanged([".vrcloud-agent/skills/" + name + "/SKILL.md"], "skills"); res.json(readSkill(name)); } catch (e) { res.status(400).json({ error: e.message }); } }); app.delete("/api/ai/skills/:name", (req, res) => { const name = String(req.params.name || ""); if (!SKILL_NAME_RE.test(name)) return res.status(400).json({ error: "Nama tidak valid" }); const dir = path.join(SKILLS_DIR, name); if (!fs.existsSync(path.join(dir, "SKILL.md"))) return res.status(404).json({ error: "Skill tidak ditemukan" }); try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { return res.status(400).json({ error: e.message }); } filesChanged([".vrcloud-agent/skills/" + name + "/SKILL.md"], "skills"); res.json({ ok: true }); }); // Nama skill dari klien: valid, maks 3. function skillNamesFrom(names) { return Array.isArray(names) ? names.map(String).filter((n) => SKILL_NAME_RE.test(n)).slice(0, 3) : []; } // Sisipkan isi skill yang dipilih pengguna (chip "/skill") ke depan pesan. function withSkills(message, list) { if (!list.length) return message; const blocks = []; list.forEach((n) => { const s = readSkill(n); if (!s) return; blocks.push("[Skill: " + n + "] " + (s.description || "") + "\nFollow this skill's instructions for the request below. Supporting files live in " + ".vrcloud-agent/skills/" + n + "/ (read them when referenced).\n\n" + String(s.content || "").slice(0, 20000)); }); if (!blocks.length) return message; return blocks.join("\n\n---\n\n") + "\n\n---\n\nUser request:\n" + message; } // Daftar file untuk autocomplete "@" di chat (cache singkat, folder berat dilewati). const SKIP_DIRS = new Set(Checkpoints.HEAVY_DIRS.concat([".idea", ".vscode"])); let fileIndex = { at: 0, list: [] }; function buildFileIndex() { if (Date.now() - fileIndex.at < 20000) return fileIndex.list; const list = []; const walk = (dir, relDir, depth) => { if (list.length >= 20000 || depth > 14) return; let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; } for (const d of ents) { if (list.length >= 20000) return; const relp = relDir ? relDir + "/" + d.name : d.name; if (d.isDirectory()) { if (!SKIP_DIRS.has(d.name)) walk(path.join(dir, d.name), relp, depth + 1); } else if (d.isFile()) list.push(relp); } }; walk(WORKSPACE, "", 0); fileIndex = { at: Date.now(), list }; return list; } app.get("/api/files", (req, res) => { const q = String(req.query.q || "").toLowerCase().replace(/\\/g, "/").trim(); const limit = Math.min(60, parseInt(req.query.limit, 10) || 30); const list = buildFileIndex(); if (!q) return res.json({ files: list.slice(0, limit) }); const scored = []; for (const p of list) { const lp = p.toLowerCase(); const base = lp.slice(lp.lastIndexOf("/") + 1); let score = -1; if (base === q) score = 0; else if (base.startsWith(q)) score = 1; else if (base.indexOf(q) !== -1) score = 2; else if (lp.indexOf(q) !== -1) score = 3; else { let i = 0; for (let j = 0; j < lp.length && i < q.length; j++) if (lp[j] === q[i]) i++; if (i === q.length) score = 4; } if (score >= 0) scored.push({ p, score, len: p.length }); } scored.sort((a, b) => a.score - b.score || a.len - b.len); res.json({ files: scored.slice(0, limit).map((x) => x.p) }); }); app.get("/api/ai/sessions", (req, res) => res.json({ sessions: aiChat.listSessions() })); app.get("/api/ai/session", (req, res) => { const id = String((req.query && req.query.id) || ""); if (!id) return res.status(400).json({ error: "id wajib" }); const session = aiChat.getSession(id); if (!session) return res.status(404).json({ error: "Percakapan tidak ditemukan" }); res.json(session); }); app.post("/api/ai/session", (req, res) => { const id = req.body && req.body.id; res.json(aiChat.createSession(id)); }); app.post("/api/ai/sessions/import", (req, res) => { const list = (req.body && req.body.sessions) || []; res.json({ sessions: aiChat.importSessions(list) }); }); // Simpan setelan API dari web (key/model). Field yang absen tidak diubah. app.post("/api/ai/config", (req, res) => { const body = req.body || {}; const config = {}; if (Object.prototype.hasOwnProperty.call(body, "apiKey")) config.apiKey = body.apiKey; if (Object.prototype.hasOwnProperty.call(body, "anthropicKey")) config.anthropicKey = body.anthropicKey; if (Object.prototype.hasOwnProperty.call(body, "searchKey")) config.searchKey = body.searchKey; if (Object.prototype.hasOwnProperty.call(body, "provider")) config.provider = body.provider; if (Object.prototype.hasOwnProperty.call(body, "model")) config.model = body.model; if (Object.prototype.hasOwnProperty.call(body, "params")) config.params = body.params; if (Object.prototype.hasOwnProperty.call(body, "mode")) config.mode = body.mode; if (Object.prototype.hasOwnProperty.call(body, "verifyCmd")) config.verifyCmd = body.verifyCmd; if (Object.prototype.hasOwnProperty.call(body, "browserTools")) config.browserTools = body.browserTools; if (Object.prototype.hasOwnProperty.call(body, "desktopTools")) config.desktopTools = body.desktopTools; if (Object.prototype.hasOwnProperty.call(body, "imageTools")) config.imageTools = body.imageTools; if (Object.prototype.hasOwnProperty.call(body, "promptCache")) config.promptCache = body.promptCache; if (Object.prototype.hasOwnProperty.call(body, "memory")) config.memory = body.memory; if (Object.prototype.hasOwnProperty.call(body, "orchestration")) config.orchestration = body.orchestration; aiChat.setConfig(config).then( (status) => res.json(status), (e) => res.status(400).json({ error: e.message }) ); }); // Login akun Grok/xAI (device-code OAuth). Token disimpan di server, bukan di browser. app.post("/api/ai/grok/login", (req, res) => { const cancel = !!(req.body && req.body.cancel); const op = cancel ? aiChat.cancelGrokLogin() : aiChat.startGrokLogin(); Promise.resolve(op).then( (status) => res.json(status), (e) => res.status(400).json({ error: e.message }) ); }); app.post("/api/ai/grok/logout", (req, res) => { aiChat.logoutGrok().then( (status) => res.json(status), (e) => res.status(400).json({ error: e.message }) ); }); // Daftar model untuk key aktif (atau key yang dikirim di body untuk uji coba). app.post("/api/ai/models", (req, res) => { const apiKey = req.body && req.body.apiKey; const provider = req.body && req.body.provider; const refresh = !!(req.body && req.body.refresh); aiChat.listModels(apiKey, provider, refresh).then( (models) => res.json({ models }), (e) => res.status(400).json({ error: e.message }) ); }); app.post("/api/ai/reset", (req, res) => { const sessionId = String((req.body && req.body.sessionId) || "default"); aiChat.reset(sessionId).then( () => res.json({ ok: true }), (e) => res.status(400).json({ error: e.message }) ); }); function ndjson(res) { res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); res.setHeader("Cache-Control", "no-store"); res.setHeader("X-Accel-Buffering", "no"); return (t, v) => { try { res.write(JSON.stringify({ t: t, v: v }) + "\n"); } catch (e) {} }; } // Stream balasan. Putusnya klien (refresh / ganti tab) TIDAK membatalkan run. app.post("/api/ai/chat", (req, res) => { const sessionId = String((req.body && req.body.sessionId) || "default"); const skillNames = skillNamesFrom(req.body && req.body.skills); const message = withSkills((req.body && req.body.message) || "", skillNames); const display = (req.body && req.body.display) || ""; const title = (req.body && req.body.title) || ""; // Gambar tempel: [{ data(base64), mimeType, name }], maks 4 x ~2MB. const images = Array.isArray(req.body && req.body.images) ? req.body.images.filter((im) => im && typeof im.data === "string" && im.data.length < 3 * 1024 * 1024 && /^image\/(png|jpe?g|gif|webp)$/i.test(String(im.mimeType || "")) ).slice(0, 4) : []; const avail = aiChat.status(); if (!avail.enabled) return res.status(503).json({ error: avail.reason || "AI Chat tidak aktif" }); if (aiChat.isBusy(sessionId)) return res.status(409).json({ error: "Masih memproses pesan sebelumnya, tunggu sebentar", busy: true }); const frame = ndjson(res); const noEditorContext = !!(req.body && req.body.noEditorContext); // pill konteks dimatikan untuk pesan ini aiChat.send(sessionId, message, (kind, text) => frame(kind, text), { display: display, title: title, images: images, skills: skillNames, noEditorContext }).then( // Frame penutup sama seperti /api/ai/live: klien tahu run berakhir "finished", // "cancelled" (Stop dari tab lain), atau "error". (status) => { frame("done", status || "finished"); res.end(); }, (e) => { if (e && /Masih memproses/.test(e.message)) { if (!res.headersSent) return res.status(409).json({ error: e.message, busy: true }); } if (!res.headersSent) res.status(500).json({ error: e.message }); else { frame("error", e.message); res.end(); } } ); }); // Sambung ke run yang sedang berjalan (setelah refresh / browser lain). app.post("/api/ai/live", (req, res) => { const sessionId = String((req.body && req.body.sessionId) || "default"); const frame = ndjson(res); let unsub = function () {}; let ended = false; const finish = () => { if (ended) return; ended = true; try { unsub(); } catch (e) {} try { res.end(); } catch (e) {} }; unsub = aiChat.subscribe(sessionId, (kind, text) => { frame(kind, text); if (kind === "done" || kind === "error") finish(); }); // Klien putus: res 'close'. (req 'close' di Node >= 16 menyala begitu body selesai // dibaca — itu yang dulu memutus stream live seketika.) res.on("close", finish); }); // Proses shell latar belakang milik agent: hanya yang masih hidup. app.get("/api/ai/procs", (req, res) => { const extra = String(req.query.pids || "").split(/[,\s]+/).map((n) => Number(n)).filter((n) => n > 1); res.setHeader("Cache-Control", "no-store"); res.json({ procs: BgProcs.list(extra) }); }); app.post("/api/ai/procs/stop", (req, res) => { const body = req.body || {}; if (body.all) BgProcs.stopAll(); else BgProcs.stop(body.pid); res.json({ procs: BgProcs.list() }); }); // Hentikan run yang sedang berjalan. app.post("/api/ai/stop", (req, res) => { const sessionId = String((req.body && req.body.sessionId) || "default"); aiChat.stop(sessionId).then( (stopped) => res.json({ ok: true, stopped }), (e) => res.status(400).json({ error: e.message }) ); }); // ---- Server + WebSocket collaboration + terminal bersama ----------------- let tlsInfo = null; let server; if (USE_HTTPS) { tlsInfo = ensureTlsMaterial(path.join(__dirname, "data", "tls")); const opts = tlsInfo.pfx ? { pfx: tlsInfo.pfx, passphrase: tlsInfo.passphrase } : { cert: tlsInfo.cert, key: tlsInfo.key }; server = https.createServer(opts, app); } else { server = http.createServer(app); } const terminals = new TerminalManager({ workspace: WORKSPACE, rcfile: RCFILE, shell: SHELL, store, safe, }); const hub = new RealtimeHub({ server, store, workspace: WORKSPACE, safe, terminals, browser, workspaceEvents, lspManager }); hubRef = hub; // agent AI & restore checkpoint memakai ini untuk menyegarkan editor/tree const terminalWss = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 }); app.get("/api/session-status", (req, res) => res.json({ revision: store.state.revision, connectedBrowsers: hub.clients.size, terminals: terminals.status(), updatedAt: store.state.updatedAt, })); server.on("upgrade", (req, socket, head) => { if (!authenticated(req)) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); return socket.destroy(); } let pathname = ""; try { pathname = new URL(req.url, "http://localhost").pathname; } catch (e) {} const target = pathname === "/sync" ? hub.wss : pathname === "/terminal" ? terminalWss : pathname === "/desktop" ? desktopWss : null; if (!target) return socket.destroy(); target.handleUpgrade(req, socket, head, (ws) => target.emit("connection", ws, req)); }); terminalWss.on("connection", (ws, req) => { let id = ""; try { id = new URL(req.url, "http://localhost").searchParams.get("id") || ""; } catch (e) {} if (!id) return ws.close(1008, "Parameter id wajib"); // Gagal spawn shell/tmux tidak boleh menjatuhkan proses server. try { terminals.attach(ws, id); } catch (e) { console.warn("[terminal] gagal attach " + id + ": " + e.message); try { ws.close(1011, String(e.message || "gagal membuka terminal").slice(0, 120)); } catch (e2) {} } }); let shutdownPromise = null; function shutdown() { if (shutdownPromise) return shutdownPromise; shutdownPromise = (async () => { try { store.flush(); } catch (e) {} const serverClosed = new Promise((resolve) => { try { server.close(() => resolve()); } catch (e) { resolve(); } }); try { workspaceEvents.flush(); } catch (e) {} const activeClosers = []; if (aiChat && typeof aiChat.close === "function") activeClosers.push(aiChat.close()); if (lspManager && typeof lspManager.close === "function") activeClosers.push(lspManager.close()); await Promise.allSettled(activeClosers); try { contextEngine.close(); } catch (e) {} await Promise.allSettled([ workspaceIndex.close(), memoryStore.close(), ]); try { workspaceEvents.close(); } catch (e) {} try { if (browser && typeof browser.close === "function") await browser.close(); else if (browser) browser.cleanupConnection(); } catch (e) {} await Promise.race([ serverClosed, new Promise((resolve) => setTimeout(resolve, 1000)), ]); })(); return shutdownPromise; } function handleShutdownSignal() { const timeoutMs = Math.max(1000, parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "10000", 10) || 10000); const forced = setTimeout(() => process.exit(1), timeoutMs); shutdown().then( () => { clearTimeout(forced); process.exit(0); }, (error) => { console.error("[shutdown] " + ((error && error.stack) || error)); clearTimeout(forced); process.exit(1); } ); } process.on("SIGTERM", handleShutdownSignal); process.on("SIGINT", handleShutdownSignal); server.listen(PORT, HOST, () => { const scheme = USE_HTTPS ? "https" : "http"; const bindAll = HOST === "0.0.0.0" || HOST === "::"; const ips = bindAll && tlsInfo && tlsInfo.ips && tlsInfo.ips.length ? tlsInfo.ips : [bindAll ? "127.0.0.1" : HOST]; const urls = ips.filter((ip, i, a) => a.indexOf(ip) === i).map((ip) => scheme + "://" + ip + ":" + PORT + "/"); console.log("VRCloud IDE berjalan di " + urls.join(" ")); if (USE_HTTPS && tlsInfo && tlsInfo.selfSigned) console.log("Sertifikat self-signed: browser meminta konfirmasi sekali saat membuka https://IP:" + PORT); console.log(`Workspace: ${WORKSPACE}`); console.log(`Login: aktif (user=${AUTH_USER})`); });