diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..6ea5a7e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "24" diff --git a/CLAUDE.md b/CLAUDE.md index a80b465..2e940b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,8 @@ npm start # Syntax check npm run lint -# Local smoke test (requires Docker) -npm run smoke +# Smoke test in Docker (scripts not included in production image) +docker run --rm -v $(pwd)/scripts:/app/scripts openclaw-railway-template npm run smoke ``` ## Docker Build & Local Testing diff --git a/Dockerfile b/Dockerfile index 1f0c2de..3395a8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build openclaw from source to avoid npm packaging gaps (some dist files are not shipped). -FROM node:22-bookworm AS openclaw-build +FROM node:24.1.0-bookworm AS openclaw-build # Dependencies needed for openclaw build RUN apt-get update \ @@ -39,7 +39,7 @@ RUN pnpm ui:install && pnpm ui:build # Runtime image -FROM node:22-bookworm +FROM node:24.1.0-bookworm ENV NODE_ENV=production RUN apt-get update \ @@ -87,6 +87,15 @@ RUN printf '%s\n' '#!/usr/bin/env bash' 'exec node /openclaw/dist/entry.js "$@"' COPY src ./src +RUN useradd -m -s /bin/bash openclaw \ + && chown -R openclaw:openclaw /app /openclaw \ + && mkdir -p /data && chown openclaw:openclaw /data + ENV PORT=8080 EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD curl -f http://localhost:8080/setup/healthz || exit 1 + +USER openclaw CMD ["node", "src/server.js"] diff --git a/package.json b/package.json index 6614294..9b5e0d1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "engines": { - "node": ">=22" + "node": ">=24" }, "scripts": { "dev": "node src/server.js", diff --git a/scripts/smoke.js b/scripts/smoke.js index c9a1a48..a56506c 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -1,8 +1,67 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; -const r = spawnSync("openclaw", ["--version"], { encoding: "utf8" }); -if (r.status !== 0) { - console.error(r.stdout || r.stderr); - process.exit(r.status ?? 1); +const PORT = process.env.PORT || 8080; + +const versionCheck = spawnSync("openclaw", ["--version"], { encoding: "utf8" }); +if (versionCheck.status !== 0) { + console.error(versionCheck.stdout || versionCheck.stderr); + process.exit(versionCheck.status ?? 1); } -console.log("openclaw ok:", r.stdout.trim()); +console.log("✓ openclaw version:", versionCheck.stdout.trim()); + +console.log(`Starting server on port ${PORT}...`); +const serverProc = spawn("node", ["src/server.js"], { + env: { ...process.env, PORT: String(PORT), SETUP_PASSWORD: "smoke-test" }, + stdio: ["ignore", "pipe", "pipe"], +}); + +let serverOutput = ""; +serverProc.stdout.on("data", (d) => (serverOutput += d.toString())); +serverProc.stderr.on("data", (d) => (serverOutput += d.toString())); + +async function waitForServer(maxWaitMs = 10000) { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + try { + const res = await fetch(`http://localhost:${PORT}/setup/healthz`); + if (res.ok) return true; + } catch { + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +async function runTests() { + try { + const ready = await waitForServer(); + if (!ready) { + console.error("✗ Server did not become ready"); + console.error("Server output:", serverOutput); + process.exit(1); + } + console.log("✓ Server started on port", PORT); + + const healthRes = await fetch(`http://localhost:${PORT}/setup/healthz`); + if (!healthRes.ok) { + console.error("✗ /setup/healthz returned", healthRes.status); + process.exit(1); + } + const healthBody = await healthRes.json(); + if (!healthBody.ok) { + console.error("✗ /setup/healthz returned unexpected body:", healthBody); + process.exit(1); + } + console.log("✓ /setup/healthz returns 200 with { ok: true }"); + + console.log("\n✓ All smoke tests passed"); + } finally { + serverProc.kill("SIGTERM"); + } +} + +runTests().catch((err) => { + console.error("Smoke test error:", err); + serverProc.kill("SIGTERM"); + process.exit(1); +}); diff --git a/src/server.js b/src/server.js index 0cca02b..42c800a 100644 --- a/src/server.js +++ b/src/server.js @@ -26,14 +26,16 @@ function resolveGatewayToken() { try { const existing = fs.readFileSync(tokenPath, "utf8").trim(); if (existing) return existing; - } catch { + } catch (err) { + console.warn(`[gateway-token] could not read existing token: ${err.code || err.message}`); } const generated = crypto.randomBytes(32).toString("hex"); try { fs.mkdirSync(STATE_DIR, { recursive: true }); fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 }); - } catch { + } catch (err) { + console.warn(`[gateway-token] could not persist token: ${err.code || err.message}`); } return generated; } @@ -92,6 +94,9 @@ async function waitForGatewayReady(opts = {}) { return true; } } catch (err) { + if (err.code !== "ECONNREFUSED") { + console.warn(`[gateway] health check error: ${err.code || err.message}`); + } } } await sleep(250); @@ -167,7 +172,8 @@ async function restartGateway() { if (gatewayProc) { try { gatewayProc.kill("SIGTERM"); - } catch { + } catch (err) { + console.warn(`[gateway] kill error: ${err.message}`); } await sleep(750); gatewayProc = null; @@ -194,7 +200,11 @@ function requireSetupAuth(req, res, next) { const decoded = Buffer.from(encoded, "base64").toString("utf8"); const idx = decoded.indexOf(":"); const password = idx >= 0 ? decoded.slice(idx + 1) : ""; - if (password !== SETUP_PASSWORD) { + const passwordBuf = Buffer.from(password); + const expectedBuf = Buffer.from(SETUP_PASSWORD); + const isValid = passwordBuf.length === expectedBuf.length && + crypto.timingSafeEqual(passwordBuf, expectedBuf); + if (!isValid) { res.set("WWW-Authenticate", 'Basic realm="OpenClaw Setup"'); return res.status(401).send("Invalid password"); } @@ -417,6 +427,34 @@ function runCmd(cmd, args, opts = {}) { }); } +const VALID_FLOWS = ["quickstart", "advanced", "manual"]; +const VALID_AUTH_CHOICES = [ + "codex-cli", "openai-codex", "openai-api-key", + "claude-cli", "token", "apiKey", + "gemini-api-key", "google-antigravity", "google-gemini-cli", + "openrouter-api-key", "ai-gateway-api-key", + "moonshot-api-key", "kimi-code-api-key", + "zai-api-key", "minimax-api", "minimax-api-lightning", + "qwen-portal", "github-copilot", "copilot-proxy", + "synthetic-api-key", "opencode-zen", +]; + +function validatePayload(payload) { + if (payload.flow && !VALID_FLOWS.includes(payload.flow)) { + return `Invalid flow: ${payload.flow}. Must be one of: ${VALID_FLOWS.join(", ")}`; + } + if (payload.authChoice && !VALID_AUTH_CHOICES.includes(payload.authChoice)) { + return `Invalid authChoice: ${payload.authChoice}`; + } + const stringFields = ["telegramToken", "discordToken", "slackBotToken", "slackAppToken", "authSecret"]; + for (const field of stringFields) { + if (payload[field] !== undefined && typeof payload[field] !== "string") { + return `Invalid ${field}: must be a string`; + } + } + return null; +} + app.post("/setup/api/run", requireSetupAuth, async (req, res) => { try { if (isConfigured()) { @@ -432,6 +470,10 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); const payload = req.body || {}; + const validationError = validatePayload(payload); + if (validationError) { + return res.status(400).json({ ok: false, output: validationError }); + } const onboardArgs = buildOnboardArgs(payload); const onboard = await runCmd(OPENCLAW_NODE, clawArgs(onboardArgs)); @@ -440,33 +482,6 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { const ok = onboard.code === 0 && isConfigured(); if (ok) { - await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "gateway.mode", "local"])); - await runCmd( - OPENCLAW_NODE, - clawArgs(["config", "set", "gateway.auth.mode", "token"]), - ); - await runCmd( - OPENCLAW_NODE, - clawArgs([ - "config", - "set", - "gateway.auth.token", - OPENCLAW_GATEWAY_TOKEN, - ]), - ); - await runCmd( - OPENCLAW_NODE, - clawArgs(["config", "set", "gateway.bind", "loopback"]), - ); - await runCmd( - OPENCLAW_NODE, - clawArgs([ - "config", - "set", - "gateway.port", - String(INTERNAL_GATEWAY_PORT), - ]), - ); await runCmd( OPENCLAW_NODE, clawArgs(["config", "set", "gateway.controlUi.allowInsecureAuth", "true"]), @@ -478,100 +493,47 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => { ); const helpText = channelsHelp.output || ""; - const supports = (name) => helpText.includes(name); + async function configureChannel(name, cfgObj) { + if (!helpText.includes(name)) { + return `\n[${name}] skipped (this openclaw build does not list ${name} in \`channels add --help\`)\n`; + } + const set = await runCmd( + OPENCLAW_NODE, + clawArgs(["config", "set", "--json", `channels.${name}`, JSON.stringify(cfgObj)]), + ); + const get = await runCmd( + OPENCLAW_NODE, + clawArgs(["config", "get", `channels.${name}`]), + ); + return `\n[${name} config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}` + + `\n[${name} verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; + } if (payload.telegramToken?.trim()) { - if (!supports("telegram")) { - extra += - "\n[telegram] skipped (this openclaw 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( - OPENCLAW_NODE, - clawArgs([ - "config", - "set", - "--json", - "channels.telegram", - JSON.stringify(cfgObj), - ]), - ); - const get = await runCmd( - OPENCLAW_NODE, - clawArgs(["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)"}`; - } + extra += await configureChannel("telegram", { + enabled: true, + dmPolicy: "pairing", + botToken: payload.telegramToken.trim(), + groupPolicy: "allowlist", + streamMode: "partial", + }); } if (payload.discordToken?.trim()) { - if (!supports("discord")) { - extra += - "\n[discord] skipped (this openclaw 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( - OPENCLAW_NODE, - clawArgs([ - "config", - "set", - "--json", - "channels.discord", - JSON.stringify(cfgObj), - ]), - ); - const get = await runCmd( - OPENCLAW_NODE, - clawArgs(["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)"}`; - } + extra += await configureChannel("discord", { + enabled: true, + token: payload.discordToken.trim(), + groupPolicy: "allowlist", + dm: { policy: "pairing" }, + }); } if (payload.slackBotToken?.trim() || payload.slackAppToken?.trim()) { - if (!supports("slack")) { - extra += - "\n[slack] skipped (this openclaw 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( - OPENCLAW_NODE, - clawArgs([ - "config", - "set", - "--json", - "channels.slack", - JSON.stringify(cfgObj), - ]), - ); - const get = await runCmd( - OPENCLAW_NODE, - clawArgs(["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)"}`; - } + extra += await configureChannel("slack", { + enabled: true, + botToken: payload.slackBotToken?.trim() || undefined, + appToken: payload.slackAppToken?.trim() || undefined, + }); } await restartGateway(); @@ -741,7 +703,8 @@ server.on("upgrade", async (req, socket, head) => { } try { await ensureGatewayRunning(); - } catch { + } catch (err) { + console.warn(`[websocket] gateway not ready: ${err.message}`); socket.destroy(); return; } @@ -749,9 +712,11 @@ server.on("upgrade", async (req, socket, head) => { }); process.on("SIGTERM", () => { + console.log("[wrapper] received SIGTERM, shutting down"); try { if (gatewayProc) gatewayProc.kill("SIGTERM"); - } catch { + } catch (err) { + console.warn(`[wrapper] error killing gateway: ${err.message}`); } process.exit(0); });