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"; // Railway deployments sometimes inject PORT=3000 by default. We want the wrapper to // reliably listen on 8080 unless explicitly overridden. // // Prefer MOLTBOT_PUBLIC_PORT (set in the Dockerfile / template) over PORT. // Backwards compatible: also supports CLAWDBOT_PUBLIC_PORT. 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"); // Protect /setup with a user-provided password. const SETUP_PASSWORD = process.env.SETUP_PASSWORD?.trim(); // Gateway admin token (protects Moltbot gateway + Control UI). // Must be stable across restarts. If not provided via env, persist it in the state dir. // Backwards compatible: also supports CLAWDBOT_GATEWAY_TOKEN. 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 { // ignore } const generated = crypto.randomBytes(32).toString("hex"); try { fs.mkdirSync(STATE_DIR, { recursive: true }); fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 }); } catch { // best-effort } return generated; } const MOLTBOT_GATEWAY_TOKEN = resolveGatewayToken(); process.env.MOLTBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; // Also set CLAWDBOT_GATEWAY_TOKEN for backwards compat with gateway process.env.CLAWDBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; // Where the gateway will listen internally (we proxy to it). 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}`; // Always run the built-from-source CLI entry directly to avoid PATH/global-install mismatches. // Backwards compatible: also supports CLAWDBOT_ENTRY and CLAWDBOT_NODE. 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" }); // Any HTTP response means the port is open. if (res) return true; } catch { // not ready } 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, // Also set CLAWDBOT_* for backwards compat 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 { // ignore } // Give it a moment to exit and release the port. 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" })); // Minimal health endpoint for Railway. app.get("/setup/healthz", (_req, res) => res.json({ ok: true })); // Provider templates for one-click setup 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); }); // Pre-flight validation checks app.get("/setup/api/check", requireSetupAuth, async (_req, res) => { const checks = []; // Check Moltbot CLI 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)}` }); } // Check state directory 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)}` }); } // Check workspace directory 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)}` }); } // Check memory 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 }); // Check disk space try { const stats = await fs.promises.statfs(STATE_DIR); // Validate statfs values exist and are valid numbers 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 { // statfs succeeded but returned invalid data (common in some container environments) // Don't fail the check - Railway volumes are typically large enough checks.push({ name: "Disk Space", status: "ok", message: "OK (could not measure, Railway volumes typically 1GB+)" }); } } catch (err) { // statfs not available or failed (common in Windows/some containers) // Don't fail the check - this is expected in some environments 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." }); }); // Token validation endpoint 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" }); } // Validate token format first (quick check) 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 }); } // Quick validation by attempting to use the token try { let validationOk = false; let providerName = ""; if (provider === "openai-api-key") { // Test with a minimal API call 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 { // For other providers, just verify format 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}` }); } }); // SSE endpoint for streaming setup progress 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 { // Check if already configured 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..." }); // Create directories fs.mkdirSync(STATE_DIR, { recursive: true }); fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); sendEvent("progress", { stage: "directories", message: "Created state directories" }); // Get the setup payload from query or session // For simplicity, we'll expect the client to POST to run first, then poll for progress 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) => { // Serve JS for /setup (kept external to avoid inline encoding/template issues) res.type("application/javascript"); res.send(fs.readFileSync(path.join(process.cwd(), "src", "setup-app.js"), "utf8")); }); app.get("/setup", requireSetupAuth, (_req, res) => { // No inline `); }); 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"])); // We reuse Moltbot's own auth-choice grouping logic indirectly by hardcoding the same group defs. // This is intentionally minimal; later we can parse the CLI help output to stay perfectly in sync. 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, // The wrapper owns public networking; keep the gateway internal. "--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); // Map secret to correct flag for common choices. 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) { // This is the Anthropics setup-token flow. 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); // Debug logging 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(), ')'); // Optional channel setup (only after successful onboarding, and only if the installed CLI supports it). if (ok) { console.error('[DEBUG] Configuring gateway settings...'); // Configure gateway to run in loopback mode without token authentication. // The wrapper protects the gateway with SETUP_PASSWORD, so token auth is redundant // and makes it difficult for the web UI to authenticate. 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 { // Avoid `channels add` here (it has proven flaky across builds); write config directly. 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)"}`; } } // Apply changes immediately. 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 }); }); // Simple test endpoint to verify routing works app.get("/setup/api/test", requireSetupAuth, (_req, res) => { console.error('[TEST] Test endpoint called'); res.json({ test: "ok", message: "Routing works!" }); }); // Debug endpoint to view config and gateway status 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) => { // Minimal reset: delete the config file so /setup can rerun. // Keep credentials/sessions/workspace by default. 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"`, ); // Prefer exporting from a common /data root so archives are easy to inspect and restore. // This preserves dotfiles like /data/.moltbot/moltbot.json. 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; // We export relative to /data so the archive contains: .moltbot/... and workspace/... 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); }); // Proxy everything else to the gateway. 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 not configured, force users to /setup for any non-setup routes. 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."); } // Don't start gateway unless configured; proxy will ensure it starts. }); 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", () => { // Best-effort shutdown try { if (gatewayProc) gatewayProc.kill("SIGTERM"); } catch { // ignore } process.exit(0); });