| import childProcess from "node:child_process"; |
| import crypto from "node:crypto"; |
| import fs from "node:fs"; |
| import os from "node:os"; |
| import path from "node:path"; |
|
|
| import express from "express"; |
| import httpProxy from "http-proxy"; |
| import * as tar from "tar"; |
|
|
| |
| |
| |
| |
| |
| const PORT = Number.parseInt( |
| process.env.MOLTBOT_PUBLIC_PORT ?? |
| process.env.CLAWDBOT_PUBLIC_PORT ?? |
| process.env.PORT ?? "8080", 10 |
| ); |
| const STATE_DIR = |
| process.env.MOLTBOT_STATE_DIR?.trim() || |
| process.env.CLAWDBOT_STATE_DIR?.trim() || |
| path.join(os.homedir(), ".moltbot"); |
| const WORKSPACE_DIR = |
| process.env.MOLTBOT_WORKSPACE_DIR?.trim() || |
| process.env.CLAWDBOT_WORKSPACE_DIR?.trim() || |
| path.join(STATE_DIR, "workspace"); |
|
|
| |
| const SETUP_PASSWORD = process.env.SETUP_PASSWORD?.trim(); |
|
|
| |
| |
| |
| function resolveGatewayToken() { |
| const envTok = |
| process.env.MOLTBOT_GATEWAY_TOKEN?.trim() || |
| process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); |
| if (envTok) return envTok; |
|
|
| const tokenPath = path.join(STATE_DIR, "gateway.token"); |
| try { |
| const existing = fs.readFileSync(tokenPath, "utf8").trim(); |
| if (existing) return existing; |
| } catch { |
| |
| } |
|
|
| const generated = crypto.randomBytes(32).toString("hex"); |
| try { |
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 }); |
| } catch { |
| |
| } |
| return generated; |
| } |
|
|
| const MOLTBOT_GATEWAY_TOKEN = resolveGatewayToken(); |
| process.env.MOLTBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; |
| |
| process.env.CLAWDBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; |
|
|
| |
| const INTERNAL_GATEWAY_PORT = Number.parseInt(process.env.INTERNAL_GATEWAY_PORT ?? "18789", 10); |
| const INTERNAL_GATEWAY_HOST = process.env.INTERNAL_GATEWAY_HOST ?? "127.0.0.1"; |
| const GATEWAY_TARGET = `http://${INTERNAL_GATEWAY_HOST}:${INTERNAL_GATEWAY_PORT}`; |
|
|
| |
| |
| const MOLTBOT_ENTRY = process.env.MOLTBOT_ENTRY?.trim() || process.env.CLAWDBOT_ENTRY?.trim() || "/moltbot/dist/entry.js"; |
| const MOLTBOT_NODE = process.env.MOLTBOT_NODE?.trim() || process.env.CLAWDBOT_NODE?.trim() || "node"; |
|
|
| function moltArgs(args) { |
| return [MOLTBOT_ENTRY, ...args]; |
| } |
|
|
| function configPath() { |
| return process.env.MOLTBOT_CONFIG_PATH?.trim() || |
| process.env.CLAWDBOT_CONFIG_PATH?.trim() || |
| path.join(STATE_DIR, "moltbot.json"); |
| } |
|
|
| function isConfigured() { |
| try { |
| return fs.existsSync(configPath()); |
| } catch { |
| return false; |
| } |
| } |
|
|
| let gatewayProc = null; |
| let gatewayStarting = null; |
|
|
| function sleep(ms) { |
| return new Promise((r) => setTimeout(r, ms)); |
| } |
|
|
| async function waitForGatewayReady(opts = {}) { |
| const timeoutMs = opts.timeoutMs ?? 20_000; |
| const start = Date.now(); |
| while (Date.now() - start < timeoutMs) { |
| try { |
| const res = await fetch(`${GATEWAY_TARGET}/moltbot`, { method: "GET" }); |
| |
| if (res) return true; |
| } catch { |
| |
| } |
| await sleep(250); |
| } |
| return false; |
| } |
|
|
| async function startGateway() { |
| if (gatewayProc) return; |
| if (!isConfigured()) throw new Error("Gateway cannot start: not configured"); |
|
|
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
| const args = [ |
| "gateway", |
| "run", |
| "--bind", |
| "loopback", |
| "--port", |
| String(INTERNAL_GATEWAY_PORT), |
| "--auth", |
| "token", |
| "--token", |
| MOLTBOT_GATEWAY_TOKEN, |
| ]; |
|
|
| gatewayProc = childProcess.spawn(MOLTBOT_NODE, moltArgs(args), { |
| stdio: "inherit", |
| env: { |
| ...process.env, |
| MOLTBOT_STATE_DIR: STATE_DIR, |
| MOLTBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
| |
| CLAWDBOT_STATE_DIR: STATE_DIR, |
| CLAWDBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
| }, |
| }); |
|
|
| gatewayProc.on("error", (err) => { |
| console.error(`[gateway] spawn error: ${String(err)}`); |
| gatewayProc = null; |
| }); |
|
|
| gatewayProc.on("exit", (code, signal) => { |
| console.error(`[gateway] exited code=${code} signal=${signal}`); |
| gatewayProc = null; |
| }); |
| } |
|
|
| async function ensureGatewayRunning() { |
| if (!isConfigured()) return { ok: false, reason: "not configured" }; |
| if (gatewayProc) return { ok: true }; |
| if (!gatewayStarting) { |
| gatewayStarting = (async () => { |
| await startGateway(); |
| const ready = await waitForGatewayReady({ timeoutMs: 20_000 }); |
| if (!ready) { |
| throw new Error("Gateway did not become ready in time"); |
| } |
| })().finally(() => { |
| gatewayStarting = null; |
| }); |
| } |
| await gatewayStarting; |
| return { ok: true }; |
| } |
|
|
| async function restartGateway() { |
| if (gatewayProc) { |
| try { |
| gatewayProc.kill("SIGTERM"); |
| } catch { |
| |
| } |
| |
| await sleep(750); |
| gatewayProc = null; |
| } |
| return ensureGatewayRunning(); |
| } |
|
|
| function requireSetupAuth(req, res, next) { |
| if (!SETUP_PASSWORD) { |
| return res |
| .status(500) |
| .type("text/plain") |
| .send("SETUP_PASSWORD is not set. Set it in Railway Variables before using /setup."); |
| } |
|
|
| const header = req.headers.authorization || ""; |
| const [scheme, encoded] = header.split(" "); |
| if (scheme !== "Basic" || !encoded) { |
| res.set("WWW-Authenticate", 'Basic realm="Moltbot Setup"'); |
| return res.status(401).send("Auth required"); |
| } |
| const decoded = Buffer.from(encoded, "base64").toString("utf8"); |
| const idx = decoded.indexOf(":"); |
| const password = idx >= 0 ? decoded.slice(idx + 1) : ""; |
| if (password !== SETUP_PASSWORD) { |
| res.set("WWW-Authenticate", 'Basic realm="Moltbot Setup"'); |
| return res.status(401).send("Invalid password"); |
| } |
| return next(); |
| } |
|
|
| const app = express(); |
| app.disable("x-powered-by"); |
| app.use(express.json({ limit: "1mb" })); |
|
|
| |
| app.get("/setup/healthz", (_req, res) => res.json({ ok: true })); |
|
|
| |
| const PROVIDER_TEMPLATES = { |
| openai: { |
| name: "OpenAI", |
| description: "GPT-4 and GPT-3.5 models", |
| authChoice: "openai-api-key", |
| placeholder: "sk-...", |
| fields: { |
| authSecret: { |
| label: "API Key", |
| type: "password", |
| help: "Get your key from https://platform.openai.com/api-keys", |
| helpUrl: "https://platform.openai.com/api-keys" |
| } |
| }, |
| icon: "🤖" |
| }, |
| anthropic: { |
| name: "Anthropic Claude", |
| description: "Claude 3.5 Sonnet, Opus, and Haiku", |
| authChoice: "anthropic-api-key", |
| placeholder: "sk-ant-...", |
| fields: { |
| authSecret: { |
| label: "API Key", |
| type: "password", |
| help: "Get your key from https://console.anthropic.com/", |
| helpUrl: "https://console.anthropic.com/" |
| } |
| }, |
| icon: "🧠" |
| }, |
| google: { |
| name: "Google Gemini", |
| description: "Gemini Pro and Ultra models", |
| authChoice: "gemini-api-key", |
| placeholder: "AIza...", |
| fields: { |
| authSecret: { |
| label: "API Key", |
| type: "password", |
| help: "Get your key from https://makersuite.google.com/app/apikey", |
| helpUrl: "https://makersuite.google.com/app/apikey" |
| } |
| }, |
| icon: "💎" |
| }, |
| atlascloud: { |
| name: "Atlas Cloud", |
| description: "Multi-provider API with OpenAI, Anthropic, and more", |
| authChoice: "atlascloud-api-key", |
| placeholder: "aat_...", |
| fields: { |
| authSecret: { |
| label: "API Key", |
| type: "password", |
| help: "Get your key from your Atlas Cloud dashboard", |
| helpUrl: "https://atlascloud.ai" |
| }, |
| baseUrl: { |
| label: "Base URL (optional)", |
| type: "text", |
| placeholder: "https://api.atlascloud.ai/v1", |
| default: "https://api.atlascloud.ai/v1" |
| } |
| }, |
| icon: "☁️" |
| }, |
| openrouter: { |
| name: "OpenRouter", |
| description: "Access to 100+ AI models through one API", |
| authChoice: "openrouter-api-key", |
| placeholder: "sk-or-...", |
| fields: { |
| authSecret: { |
| label: "API Key", |
| type: "password", |
| help: "Get your key from https://openrouter.ai/keys", |
| helpUrl: "https://openrouter.ai/keys" |
| } |
| }, |
| icon: "🔀" |
| } |
| }; |
|
|
| app.get("/setup/api/templates", requireSetupAuth, (_req, res) => { |
| res.json({ templates: PROVIDER_TEMPLATES }); |
| }); |
|
|
| app.get("/setup/api/templates/:provider", requireSetupAuth, (req, res) => { |
| const template = PROVIDER_TEMPLATES[req.params.provider]; |
| if (!template) { |
| return res.status(404).json({ error: "Template not found" }); |
| } |
| res.json(template); |
| }); |
|
|
| |
| app.get("/setup/api/check", requireSetupAuth, async (_req, res) => { |
| const checks = []; |
|
|
| |
| try { |
| const versionResult = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
| checks.push({ |
| name: "Moltbot CLI", |
| status: "ok", |
| message: `Version ${versionResult.output.trim()}` |
| }); |
| } catch (err) { |
| checks.push({ |
| name: "Moltbot CLI", |
| status: "error", |
| message: `CLI not accessible: ${String(err)}` |
| }); |
| } |
|
|
| |
| try { |
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| const stateAccessible = fs.accessSync(STATE_DIR, fs.constants.W_OK); |
| checks.push({ |
| name: "State Directory", |
| status: "ok", |
| message: `Writable: ${STATE_DIR}` |
| }); |
| } catch (err) { |
| checks.push({ |
| name: "State Directory", |
| status: "error", |
| message: `Cannot write to ${STATE_DIR}: ${String(err)}` |
| }); |
| } |
|
|
| |
| try { |
| fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
| checks.push({ |
| name: "Workspace Directory", |
| status: "ok", |
| message: `Writable: ${WORKSPACE_DIR}` |
| }); |
| } catch (err) { |
| checks.push({ |
| name: "Workspace Directory", |
| status: "error", |
| message: `Cannot write to ${WORKSPACE_DIR}: ${String(err)}` |
| }); |
| } |
|
|
| |
| const totalMem = Math.round(os.totalmem() / 1024 / 1024); |
| const freeMem = Math.round(os.freemem() / 1024 / 1024); |
| checks.push({ |
| name: "Available Memory", |
| status: freeMem > 256 ? "ok" : "warning", |
| message: `${freeMem}MB available (512MB+ recommended)`, |
| value: freeMem |
| }); |
|
|
| |
| try { |
| const stats = await fs.promises.statfs(STATE_DIR); |
| |
| if (stats && typeof stats.bavail === 'number' && typeof stats.frsize === 'number' && stats.bavail > 0 && stats.frsize > 0) { |
| const freeSpace = Math.round(stats.bavail * stats.frsize / 1024 / 1024); |
| checks.push({ |
| name: "Disk Space", |
| status: freeSpace > 100 ? "ok" : "warning", |
| message: `${freeSpace}MB available`, |
| value: freeSpace |
| }); |
| } else { |
| |
| |
| checks.push({ |
| name: "Disk Space", |
| status: "ok", |
| message: "OK (could not measure, Railway volumes typically 1GB+)" |
| }); |
| } |
| } catch (err) { |
| |
| |
| checks.push({ |
| name: "Disk Space", |
| status: "ok", |
| message: "OK (could not measure, Railway volumes typically 1GB+)" |
| }); |
| } |
|
|
| const allOk = checks.every(c => c.status === "ok"); |
| res.json({ |
| ready: allOk, |
| checks, |
| summary: allOk ? "All checks passed. Ready to setup." : "Some checks failed. Please fix issues before proceeding." |
| }); |
| }); |
|
|
| |
| app.post("/setup/api/validate-token", requireSetupAuth, async (req, res) => { |
| const { provider, token, baseUrl } = req.body || {}; |
|
|
| if (!provider || !token) { |
| return res.status(400).json({ valid: false, error: "Provider and token are required" }); |
| } |
|
|
| |
| let formatValid = true; |
| let formatError = ""; |
|
|
| switch (provider) { |
| case "openai-api-key": |
| if (!token.startsWith("sk-")) { |
| formatValid = false; |
| formatError = "OpenAI API keys should start with 'sk-'"; |
| } |
| break; |
| case "anthropic-api-key": |
| if (!token.startsWith("sk-ant-")) { |
| formatValid = false; |
| formatError = "Anthropic API keys should start with 'sk-ant-'"; |
| } |
| break; |
| case "gemini-api-key": |
| if (!token.startsWith("AIza")) { |
| formatValid = false; |
| formatError = "Gemini API keys should start with 'AIza'"; |
| } |
| break; |
| case "atlascloud-api-key": |
| if (!token.startsWith("aat_")) { |
| formatValid = false; |
| formatError = "Atlas Cloud API keys should start with 'aat_'"; |
| } |
| break; |
| case "openrouter-api-key": |
| if (!token.startsWith("sk-or-")) { |
| formatValid = false; |
| formatError = "OpenRouter API keys should start with 'sk-or-'"; |
| } |
| break; |
| } |
|
|
| if (!formatValid) { |
| return res.json({ valid: false, error: formatError }); |
| } |
|
|
| |
| try { |
| let validationOk = false; |
| let providerName = ""; |
|
|
| if (provider === "openai-api-key") { |
| |
| const response = await fetch("https://api.openai.com/v1/models", { |
| headers: { "Authorization": `Bearer ${token}` }, |
| signal: AbortSignal.timeout(5000) |
| }); |
| validationOk = response.ok; |
| providerName = "OpenAI"; |
| } else if (provider === "anthropic-api-key") { |
| const response = await fetch("https://api.anthropic.com/v1/messages", { |
| method: "POST", |
| headers: { |
| "x-api-key": token, |
| "anthropic-version": "2023-06-01", |
| "content-type": "application/json" |
| }, |
| body: JSON.stringify({ |
| model: "claude-3-haiku-20240307", |
| max_tokens: 1, |
| messages: [{ role: "user", content: "test" }] |
| }), |
| signal: AbortSignal.timeout(5000) |
| }); |
| validationOk = response.status !== 401; |
| providerName = "Anthropic"; |
| } else if (provider === "atlascloud-api-key") { |
| const apiUrl = baseUrl || "https://api.atlascloud.ai/v1"; |
| const response = await fetch(`${apiUrl}/models`, { |
| headers: { "Authorization": `Bearer ${token}` }, |
| signal: AbortSignal.timeout(5000) |
| }); |
| validationOk = response.ok; |
| providerName = "Atlas Cloud"; |
| } else { |
| |
| validationOk = true; |
| } |
|
|
| res.json({ |
| valid: validationOk, |
| provider: providerName, |
| message: validationOk ? "Token validated successfully" : "Token validation failed" |
| }); |
| } catch (err) { |
| res.json({ |
| valid: false, |
| error: `Validation failed: ${err.message}` |
| }); |
| } |
| }); |
|
|
| |
| app.get("/setup/api/progress", requireSetupAuth, async (req, res) => { |
| res.setHeader("Content-Type", "text/event-stream"); |
| res.setHeader("Cache-Control", "no-cache"); |
| res.setHeader("Connection", "keep-alive"); |
|
|
| const sendEvent = (event, data) => { |
| res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); |
| }; |
|
|
| try { |
| |
| if (isConfigured()) { |
| sendEvent("progress", { stage: "complete", message: "Already configured" }); |
| await ensureGatewayRunning(); |
| sendEvent("done", { success: true, message: "Setup already complete" }); |
| return res.end(); |
| } |
|
|
| sendEvent("progress", { stage: "starting", message: "Initializing setup..." }); |
|
|
| |
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
| sendEvent("progress", { stage: "directories", message: "Created state directories" }); |
|
|
| |
| |
| sendEvent("progress", { stage: "waiting", message: "Waiting for setup parameters..." }); |
|
|
| } catch (err) { |
| sendEvent("error", { error: String(err) }); |
| res.end(); |
| } |
| }); |
|
|
| app.get("/setup/app.js", requireSetupAuth, (_req, res) => { |
| |
| res.type("application/javascript"); |
| res.send(fs.readFileSync(path.join(process.cwd(), "src", "setup-app.js"), "utf8")); |
| }); |
|
|
| app.get("/setup", requireSetupAuth, (_req, res) => { |
| |
| res.type("html").send(`<!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Moltbot Setup</title> |
| <style> |
| body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 2rem; max-width: 900px; } |
| .card { border: 1px solid #ddd; border-radius: 12px; padding: 1.25rem; margin: 1rem 0; } |
| label { display:block; margin-top: 0.75rem; font-weight: 600; } |
| input, select { width: 100%; padding: 0.6rem; margin-top: 0.25rem; } |
| button { padding: 0.8rem 1.2rem; border-radius: 10px; border: 0; background: #111; color: #fff; font-weight: 700; cursor: pointer; } |
| code { background: #f6f6f6; padding: 0.1rem 0.3rem; border-radius: 6px; } |
| .muted { color: #555; } |
| </style> |
| </head> |
| <body> |
| <h1>Moltbot Setup</h1> |
| <p class="muted">This wizard configures Moltbot by running the same onboarding command it uses in the terminal, but from the browser.</p> |
| |
| <div class="card"> |
| <h2>Status</h2> |
| <div id="status">Loading...</div> |
| <div style="margin-top: 0.75rem"> |
| <a href="/moltbot" target="_blank">Open Moltbot UI</a> |
| | |
| <a href="/setup/export" target="_blank">Download backup (.tar.gz)</a> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <h2>🚀 Quick Setup</h2> |
| <p class="muted">Click a provider to auto-fill configuration:</p> |
| <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; margin: 1rem 0;"> |
| <button onclick="useProvider('openai')" style="background:#10a37f; padding:0.6rem;">🤖 OpenAI</button> |
| <button onclick="useProvider('anthropic')" style="background:#d4a573; color:white; padding:0.6rem;">🧠 Anthropic</button> |
| <button onclick="useProvider('google')" style="background:#4285f4; color:white; padding:0.6rem;">💎 Gemini</button> |
| <button onclick="useProvider('atlascloud')" style="background:#8b5cf6; color:white; padding:0.6rem;">☁️ Atlas Cloud</button> |
| <button onclick="useProvider('openrouter')" style="background:#6366f1; color:white; padding:0.6rem;">🔀 OpenRouter</button> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <h2>✅ Pre-flight Checks</h2> |
| <button onclick="runPreflightChecks()" style="margin-bottom: 0.75rem;">Run Checks</button> |
| <div id="preflightResults" style="margin-top: 0.75rem;"></div> |
| </div> |
| |
| <div class="card"> |
| <h2>1) Model/auth provider</h2> |
| <p class="muted">Matches the groups shown in the terminal onboarding.</p> |
| <label>Provider group</label> |
| <select id="authGroup"></select> |
| |
| <label>Auth method</label> |
| <select id="authChoice"></select> |
| |
| <label>Key / Token (if required)</label> |
| <input id="authSecret" type="password" placeholder="Paste API key / token if applicable" onblur="validateToken()" /> |
| <button id="validateBtn" onclick="validateToken()" type="button" style="margin-top:0.5rem; padding:0.5rem 1rem; background:#6b7280; font-size:0.9rem;">Validate Token</button> |
| <div id="authSecretHelp" class="muted" style="margin-top: 0.5rem; display:none;"></div> |
| |
| <label>Base URL (optional, for Atlas Cloud)</label> |
| <input id="baseUrl" type="text" placeholder="https://api.atlascloud.ai/v1" /> |
| <div class="muted" style="margin-top: 0.25rem;">Leave blank to use provider default</div> |
| |
| <label>Wizard flow</label> |
| <select id="flow"> |
| <option value="quickstart">quickstart</option> |
| <option value="advanced">advanced</option> |
| <option value="manual">manual</option> |
| </select> |
| </div> |
| |
| <div class="card"> |
| <h2>2) Optional: Channels</h2> |
| <p class="muted">You can also add channels later inside Moltbot, but this helps you get messaging working immediately.</p> |
| |
| <label>Telegram bot token (optional)</label> |
| <input id="telegramToken" type="password" placeholder="123456:ABC..." /> |
| <div class="muted" style="margin-top: 0.25rem"> |
| Get it from BotFather: open Telegram, message <code>@BotFather</code>, run <code>/newbot</code>, then copy the token. |
| </div> |
| |
| <label>Discord bot token (optional)</label> |
| <input id="discordToken" type="password" placeholder="Bot token" /> |
| <div class="muted" style="margin-top: 0.25rem"> |
| Get it from the Discord Developer Portal: create an application, add a Bot, then copy the Bot Token.<br/> |
| <strong>Important:</strong> Enable <strong>MESSAGE CONTENT INTENT</strong> in Bot → Privileged Gateway Intents, or the bot will crash on startup. |
| </div> |
| |
| <label>Slack bot token (optional)</label> |
| <input id="slackBotToken" type="password" placeholder="xoxb-..." /> |
| |
| <label>Slack app token (optional)</label> |
| <input id="slackAppToken" type="password" placeholder="xapp-..." /> |
| </div> |
| |
| <div class="card"> |
| <h2>3) Run onboarding</h2> |
| <button id="run">Run setup</button> |
| <button id="pairingApprove" style="background:#1f2937; margin-left:0.5rem">Approve pairing</button> |
| <button id="reset" style="background:#444; margin-left:0.5rem">Reset setup</button> |
| <button id="debug" style="background:#1f2937; margin-left:0.5rem">Debug info</button> |
| <button id="test" style="background:#059669; margin-left:0.5rem">Test endpoint</button> |
| <pre id="log" style="white-space:pre-wrap"></pre> |
| <p class="muted">Reset deletes the Moltbot config file so you can rerun onboarding. Pairing approval lets you grant DM access when dmPolicy=pairing. Debug info shows current system state. Test endpoint verifies routing works.</p> |
| </div> |
| |
| <script src="/setup/app.js"></script> |
| </body> |
| </html>`); |
| }); |
|
|
| app.get("/setup/api/status", requireSetupAuth, async (_req, res) => { |
| const version = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
| const channelsHelp = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
|
|
| |
| |
| const authGroups = [ |
| { value: "openai", label: "OpenAI", hint: "Codex OAuth + API key", options: [ |
| { value: "codex-cli", label: "OpenAI Codex OAuth (Codex CLI)" }, |
| { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, |
| { value: "openai-api-key", label: "OpenAI API key" } |
| ]}, |
| { value: "anthropic", label: "Anthropic", hint: "Claude Code CLI + API key", options: [ |
| { value: "claude-cli", label: "Anthropic token (Claude Code CLI)" }, |
| { value: "token", label: "Anthropic token (paste setup-token)" }, |
| { value: "apiKey", label: "Anthropic API key" } |
| ]}, |
| { value: "google", label: "Google", hint: "Gemini API key + OAuth", options: [ |
| { value: "gemini-api-key", label: "Google Gemini API key" }, |
| { value: "google-antigravity", label: "Google Antigravity OAuth" }, |
| { value: "google-gemini-cli", label: "Google Gemini CLI OAuth" } |
| ]}, |
| { value: "openrouter", label: "OpenRouter", hint: "API key", options: [ |
| { value: "openrouter-api-key", label: "OpenRouter API key" } |
| ]}, |
| { value: "atlascloud", label: "Atlas Cloud", hint: "Multi-provider API", options: [ |
| { value: "atlascloud-api-key", label: "Atlas Cloud API key" } |
| ]}, |
| { value: "ai-gateway", label: "Vercel AI Gateway", hint: "API key", options: [ |
| { value: "ai-gateway-api-key", label: "Vercel AI Gateway API key" } |
| ]}, |
| { value: "moonshot", label: "Moonshot AI", hint: "Kimi K2 + Kimi Code", options: [ |
| { value: "moonshot-api-key", label: "Moonshot AI API key" }, |
| { value: "kimi-code-api-key", label: "Kimi Code API key" } |
| ]}, |
| { value: "zai", label: "Z.AI (GLM 4.7)", hint: "API key", options: [ |
| { value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" } |
| ]}, |
| { value: "minimax", label: "MiniMax", hint: "M2.1 (recommended)", options: [ |
| { value: "minimax-api", label: "MiniMax M2.1" }, |
| { value: "minimax-api-lightning", label: "MiniMax M2.1 Lightning" } |
| ]}, |
| { value: "qwen", label: "Qwen", hint: "OAuth", options: [ |
| { value: "qwen-portal", label: "Qwen OAuth" } |
| ]}, |
| { value: "copilot", label: "Copilot", hint: "GitHub + local proxy", options: [ |
| { value: "github-copilot", label: "GitHub Copilot (GitHub device login)" }, |
| { value: "copilot-proxy", label: "Copilot Proxy (local)" } |
| ]}, |
| { value: "synthetic", label: "Synthetic", hint: "Anthropic-compatible (multi-model)", options: [ |
| { value: "synthetic-api-key", label: "Synthetic API key" } |
| ]}, |
| { value: "opencode-zen", label: "OpenCode Zen", hint: "API key", options: [ |
| { value: "opencode-zen", label: "OpenCode Zen (multi-model proxy)" } |
| ]} |
| ]; |
|
|
| res.json({ |
| configured: isConfigured(), |
| gatewayTarget: GATEWAY_TARGET, |
| moltbotVersion: version.output.trim(), |
| channelsAddHelp: channelsHelp.output, |
| authGroups, |
| }); |
| }); |
|
|
| function buildOnboardArgs(payload) { |
| const args = [ |
| "onboard", |
| "--non-interactive", |
| "--accept-risk", |
| "--json", |
| "--no-install-daemon", |
| "--skip-health", |
| "--workspace", |
| WORKSPACE_DIR, |
| |
| "--gateway-bind", |
| "loopback", |
| "--gateway-port", |
| String(INTERNAL_GATEWAY_PORT), |
| "--gateway-auth", |
| "token", |
| "--gateway-token", |
| MOLTBOT_GATEWAY_TOKEN, |
| "--flow", |
| payload.flow || "quickstart" |
| ]; |
|
|
| if (payload.authChoice) { |
| args.push("--auth-choice", payload.authChoice); |
|
|
| |
| const secret = (payload.authSecret || "").trim(); |
| const map = { |
| "openai-api-key": "--openai-api-key", |
| "apiKey": "--anthropic-api-key", |
| "atlascloud-api-key": "--atlascloud-api-key", |
| "openrouter-api-key": "--openrouter-api-key", |
| "ai-gateway-api-key": "--ai-gateway-api-key", |
| "moonshot-api-key": "--moonshot-api-key", |
| "kimi-code-api-key": "--kimi-code-api-key", |
| "gemini-api-key": "--gemini-api-key", |
| "zai-api-key": "--zai-api-key", |
| "minimax-api": "--minimax-api-key", |
| "minimax-api-lightning": "--minimax-api-key", |
| "synthetic-api-key": "--synthetic-api-key", |
| "opencode-zen": "--opencode-zen-api-key" |
| }; |
| const flag = map[payload.authChoice]; |
| if (flag && secret) { |
| args.push(flag, secret); |
| } |
|
|
| if (payload.authChoice === "token" && secret) { |
| |
| args.push("--token-provider", "anthropic", "--token", secret); |
| } |
| } |
|
|
| return args; |
| } |
|
|
| function runCmd(cmd, args, opts = {}) { |
| return new Promise((resolve) => { |
| const proc = childProcess.spawn(cmd, args, { |
| ...opts, |
| env: { |
| ...process.env, |
| CLAWDBOT_STATE_DIR: STATE_DIR, |
| CLAWDBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
| }, |
| }); |
|
|
| let out = ""; |
| proc.stdout?.on("data", (d) => (out += d.toString("utf8"))); |
| proc.stderr?.on("data", (d) => (out += d.toString("utf8"))); |
|
|
| proc.on("error", (err) => { |
| out += `\n[spawn error] ${String(err)}\n`; |
| resolve({ code: 127, output: out }); |
| }); |
|
|
| proc.on("close", (code) => resolve({ code: code ?? 0, output: out })); |
| }); |
| } |
|
|
| app.post("/setup/api/run", requireSetupAuth, async (req, res) => { |
| try { |
| if (isConfigured()) { |
| await ensureGatewayRunning(); |
| return res.json({ ok: true, output: "Already configured.\nUse Reset setup if you want to rerun onboarding.\n" }); |
| } |
|
|
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
| const payload = req.body || {}; |
| const onboardArgs = buildOnboardArgs(payload); |
|
|
| |
| console.error('[DEBUG] Onboard args:', onboardArgs); |
| console.error('[DEBUG] Payload authChoice:', payload.authChoice); |
| console.error('[DEBUG] Payload authSecret length:', payload.authSecret?.length || 0); |
| console.error('[DEBUG] Config path:', configPath()); |
|
|
| const onboard = await runCmd(MOLTBOT_NODE, moltArgs(onboardArgs)); |
|
|
| console.error('[DEBUG] Onboard exit code:', onboard.code); |
| console.error('[DEBUG] isConfigured() after onboard:', isConfigured()); |
| console.error('[DEBUG] Config file exists:', fs.existsSync(configPath())); |
|
|
| let extra = ""; |
|
|
| const ok = onboard.code === 0 && isConfigured(); |
|
|
| console.error('[DEBUG] Setup ok status:', ok, '(exit code:', onboard.code, ', configured:', isConfigured(), ')'); |
|
|
| |
| if (ok) { |
| console.error('[DEBUG] Configuring gateway settings...'); |
|
|
| |
| |
| |
| await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.auth.mode", "none"])); |
| await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.bind", "loopback"])); |
| await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.port", String(INTERNAL_GATEWAY_PORT)])); |
|
|
| console.error('[DEBUG] Gateway settings configured, restarting gateway...'); |
|
|
| const channelsHelp = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
| const helpText = channelsHelp.output || ""; |
|
|
| const supports = (name) => helpText.includes(name); |
|
|
| if (payload.telegramToken?.trim()) { |
| if (!supports("telegram")) { |
| extra += "\n[telegram] skipped (this moltbot build does not list telegram in `channels add --help`)\n"; |
| } else { |
| |
| const token = payload.telegramToken.trim(); |
| const cfgObj = { |
| enabled: true, |
| dmPolicy: "pairing", |
| botToken: token, |
| groupPolicy: "allowlist", |
| streamMode: "partial", |
| }; |
| const set = await runCmd( |
| MOLTBOT_NODE, |
| moltArgs(["config", "set", "--json", "channels.telegram", JSON.stringify(cfgObj)]), |
| ); |
| const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.telegram"])); |
| extra += `\n[telegram config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
| extra += `\n[telegram verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
| } |
| } |
|
|
| if (payload.discordToken?.trim()) { |
| if (!supports("discord")) { |
| extra += "\n[discord] skipped (this moltbot build does not list discord in `channels add --help`)\n"; |
| } else { |
| const token = payload.discordToken.trim(); |
| const cfgObj = { |
| enabled: true, |
| token, |
| groupPolicy: "allowlist", |
| dm: { |
| policy: "pairing", |
| }, |
| }; |
| const set = await runCmd( |
| MOLTBOT_NODE, |
| moltArgs(["config", "set", "--json", "channels.discord", JSON.stringify(cfgObj)]), |
| ); |
| const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.discord"])); |
| extra += `\n[discord config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
| extra += `\n[discord verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
| } |
| } |
|
|
| if (payload.slackBotToken?.trim() || payload.slackAppToken?.trim()) { |
| if (!supports("slack")) { |
| extra += "\n[slack] skipped (this moltbot build does not list slack in `channels add --help`)\n"; |
| } else { |
| const cfgObj = { |
| enabled: true, |
| botToken: payload.slackBotToken?.trim() || undefined, |
| appToken: payload.slackAppToken?.trim() || undefined, |
| }; |
| const set = await runCmd( |
| MOLTBOT_NODE, |
| moltArgs(["config", "set", "--json", "channels.slack", JSON.stringify(cfgObj)]), |
| ); |
| const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.slack"])); |
| extra += `\n[slack config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
| extra += `\n[slack verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
| } |
| } |
|
|
| |
| console.error('[DEBUG] Restarting gateway...'); |
| await restartGateway(); |
| console.error('[DEBUG] Gateway restarted, checking if running...'); |
| const running = gatewayProc !== null; |
| console.error('[DEBUG] Gateway running after restart:', running); |
| } |
|
|
| console.error('[DEBUG] Returning response: ok=', ok); |
| return res.status(ok ? 200 : 500).json({ |
| ok, |
| output: `${onboard.output}${extra}`, |
| }); |
| } catch (err) { |
| console.error("[/setup/api/run] error:", err); |
| return res.status(500).json({ ok: false, output: `Internal error: ${String(err)}` }); |
| } |
| }); |
|
|
| app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => { |
| const v = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
| const help = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
| res.json({ |
| wrapper: { |
| node: process.version, |
| port: PORT, |
| stateDir: STATE_DIR, |
| workspaceDir: WORKSPACE_DIR, |
| configPath: configPath(), |
| gatewayTokenFromEnv: Boolean( |
| process.env.MOLTBOT_GATEWAY_TOKEN?.trim() || |
| process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() |
| ), |
| gatewayTokenPersisted: fs.existsSync(path.join(STATE_DIR, "gateway.token")), |
| railwayCommit: process.env.RAILWAY_GIT_COMMIT_SHA || null, |
| }, |
| moltbot: { |
| entry: MOLTBOT_ENTRY, |
| node: MOLTBOT_NODE, |
| version: v.output.trim(), |
| channelsAddHelpIncludesTelegram: help.output.includes("telegram"), |
| }, |
| }); |
| }); |
|
|
| app.post("/setup/api/pairing/approve", requireSetupAuth, async (req, res) => { |
| const { channel, code } = req.body || {}; |
| if (!channel || !code) { |
| return res.status(400).json({ ok: false, error: "Missing channel or code" }); |
| } |
| const r = await runCmd(MOLTBOT_NODE, moltArgs(["pairing", "approve", String(channel), String(code)])); |
| return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: r.output }); |
| }); |
|
|
| |
| app.get("/setup/api/test", requireSetupAuth, (_req, res) => { |
| console.error('[TEST] Test endpoint called'); |
| res.json({ test: "ok", message: "Routing works!" }); |
| }); |
|
|
| |
| app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => { |
| try { |
| console.error('[DEBUG] Building debug info...'); |
|
|
| const cfgPath = configPath(); |
| console.error('[DEBUG] configPath():', cfgPath); |
|
|
| const cfgExists = fs.existsSync(cfgPath); |
| console.error('[DEBUG] config exists:', cfgExists); |
|
|
| const stateExists = fs.existsSync(STATE_DIR); |
| console.error('[DEBUG] state dir exists:', stateExists); |
|
|
| const workspaceExists = fs.existsSync(WORKSPACE_DIR); |
| console.error('[DEBUG] workspace dir exists:', workspaceExists); |
|
|
| const configured = isConfigured(); |
| console.error('[DEBUG] isConfigured():', configured); |
|
|
| const info = { |
| configPath: cfgPath, |
| configExists: cfgExists, |
| stateDir: STATE_DIR, |
| stateDirExists: stateExists, |
| workspaceDir: WORKSPACE_DIR, |
| workspaceDirExists: workspaceExists, |
| gatewayRunning: gatewayProc !== null, |
| gatewayProcExists: gatewayProc !== null, |
| gatewayProcPid: gatewayProc?.pid || null, |
| configured: configured, |
| }; |
|
|
| console.error('[DEBUG] Built info object:', JSON.stringify(info)); |
|
|
| if (info.configExists) { |
| try { |
| info.configContent = JSON.parse(fs.readFileSync(cfgPath, "utf8")); |
| console.error('[DEBUG] Config content loaded'); |
| } catch (e) { |
| info.configError = String(e); |
| console.error('[DEBUG] Config error:', e); |
| } |
| } |
|
|
| console.error('[DEBUG] Sending response'); |
| res.json(info); |
| } catch (err) { |
| console.error('[DEBUG] Error in debug endpoint:', err); |
| res.status(500).json({ error: String(err), stack: err.stack }); |
| } |
| }); |
|
|
| app.post("/setup/api/reset", requireSetupAuth, async (_req, res) => { |
| |
| |
| try { |
| fs.rmSync(configPath(), { force: true }); |
| res.type("text/plain").send("OK - deleted config file. You can rerun setup now."); |
| } catch (err) { |
| res.status(500).type("text/plain").send(String(err)); |
| } |
| }); |
|
|
| app.get("/setup/export", requireSetupAuth, async (_req, res) => { |
| fs.mkdirSync(STATE_DIR, { recursive: true }); |
| fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
| res.setHeader("content-type", "application/gzip"); |
| res.setHeader( |
| "content-disposition", |
| `attachment; filename="moltbot-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz"`, |
| ); |
|
|
| |
| |
| const stateAbs = path.resolve(STATE_DIR); |
| const workspaceAbs = path.resolve(WORKSPACE_DIR); |
|
|
| const dataRoot = "/data"; |
| const underData = (p) => p === dataRoot || p.startsWith(dataRoot + path.sep); |
|
|
| let cwd = "/"; |
| let paths = [stateAbs, workspaceAbs].map((p) => p.replace(/^\//, "")); |
|
|
| if (underData(stateAbs) && underData(workspaceAbs)) { |
| cwd = dataRoot; |
| |
| paths = [ |
| path.relative(dataRoot, stateAbs) || ".", |
| path.relative(dataRoot, workspaceAbs) || ".", |
| ]; |
| } |
|
|
| const stream = tar.c( |
| { |
| gzip: true, |
| portable: true, |
| noMtime: true, |
| cwd, |
| onwarn: () => {}, |
| }, |
| paths, |
| ); |
|
|
| stream.on("error", (err) => { |
| console.error("[export]", err); |
| if (!res.headersSent) res.status(500); |
| res.end(String(err)); |
| }); |
|
|
| stream.pipe(res); |
| }); |
|
|
| |
| const proxy = httpProxy.createProxyServer({ |
| target: GATEWAY_TARGET, |
| ws: true, |
| xfwd: true, |
| }); |
|
|
| proxy.on("error", (err, _req, _res) => { |
| console.error("[proxy]", err); |
| }); |
|
|
| app.use(async (req, res) => { |
| |
| if (!isConfigured() && !req.path.startsWith("/setup")) { |
| return res.redirect("/setup"); |
| } |
|
|
| if (isConfigured()) { |
| try { |
| await ensureGatewayRunning(); |
| } catch (err) { |
| return res.status(503).type("text/plain").send(`Gateway not ready: ${String(err)}`); |
| } |
| } |
|
|
| return proxy.web(req, res, { target: GATEWAY_TARGET }); |
| }); |
|
|
| const server = app.listen(PORT, "0.0.0.0", () => { |
| console.log(`[wrapper] listening on :${PORT}`); |
| console.log(`[wrapper] state dir: ${STATE_DIR}`); |
| console.log(`[wrapper] workspace dir: ${WORKSPACE_DIR}`); |
| console.log(`[wrapper] gateway token: ${MOLTBOT_GATEWAY_TOKEN ? "(set)" : "(missing)"}`); |
| console.log(`[wrapper] gateway target: ${GATEWAY_TARGET}`); |
| if (!SETUP_PASSWORD) { |
| console.warn("[wrapper] WARNING: SETUP_PASSWORD is not set; /setup will error."); |
| } |
| |
| }); |
|
|
| server.on("upgrade", async (req, socket, head) => { |
| if (!isConfigured()) { |
| socket.destroy(); |
| return; |
| } |
| try { |
| await ensureGatewayRunning(); |
| } catch { |
| socket.destroy(); |
| return; |
| } |
| proxy.ws(req, socket, head, { target: GATEWAY_TARGET }); |
| }); |
|
|
| process.on("SIGTERM", () => { |
| |
| try { |
| if (gatewayProc) gatewayProc.kill("SIGTERM"); |
| } catch { |
| |
| } |
| process.exit(0); |
| }); |
|
|