Skip to content

Commit b51ebf3

Browse files
authored
Setup UI: debug console + config editor + backup import
2 parents fbafbce + 04805c2 commit b51ebf3

3 files changed

Lines changed: 369 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This repo packages **OpenClaw** for Railway with a small **/setup** web wizard s
88
- A friendly **Setup Wizard** at `/setup` (protected by a password)
99
- Persistent state via **Railway Volume** (so config/credentials/memory survive redeploys)
1010
- One-click **Export backup** (so users can migrate off Railway later)
11+
- **Import backup** from `/setup` (advanced recovery)
1112

1213
## How it works (high level)
1314

src/server.js

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,47 @@ app.get("/setup", requireSetupAuth, (_req, res) => {
264264
 | 
265265
<a href="/setup/export" target="_blank">Download backup (.tar.gz)</a>
266266
</div>
267+
268+
<div style="margin-top: 0.75rem">
269+
<div class="muted" style="margin-bottom:0.25rem"><strong>Import backup</strong> (advanced): restores into <code>/data</code> and restarts the gateway.</div>
270+
<input id="importFile" type="file" accept=".tar.gz,application/gzip" />
271+
<button id="importRun" style="background:#7c2d12; margin-top:0.5rem">Import</button>
272+
<pre id="importOut" style="white-space:pre-wrap"></pre>
273+
</div>
274+
</div>
275+
276+
<div class="card">
277+
<h2>Debug console</h2>
278+
<p class="muted">Run a small allowlist of safe commands (no shell). Useful for debugging and recovery.</p>
279+
280+
<div style="display:flex; gap:0.5rem; align-items:center">
281+
<select id="consoleCmd" style="flex: 1">
282+
<option value="gateway.restart">gateway.restart (wrapper-managed)</option>
283+
<option value="gateway.stop">gateway.stop (wrapper-managed)</option>
284+
<option value="gateway.start">gateway.start (wrapper-managed)</option>
285+
<option value="openclaw.status">openclaw status</option>
286+
<option value="openclaw.health">openclaw health</option>
287+
<option value="openclaw.doctor">openclaw doctor</option>
288+
<option value="openclaw.logs.tail">openclaw logs --tail N</option>
289+
<option value="openclaw.config.get">openclaw config get &lt;path&gt;</option>
290+
<option value="openclaw.version">openclaw --version</option>
291+
</select>
292+
<input id="consoleArg" placeholder="Optional arg (e.g. 200, gateway.port)" style="flex: 1" />
293+
<button id="consoleRun" style="background:#0f172a">Run</button>
294+
</div>
295+
<pre id="consoleOut" style="white-space:pre-wrap"></pre>
296+
</div>
297+
298+
<div class="card">
299+
<h2>Config editor (advanced)</h2>
300+
<p class="muted">Edits the full config file on disk (JSON5). Saving creates a timestamped <code>.bak-*</code> backup and restarts the gateway.</p>
301+
<div class="muted" id="configPath"></div>
302+
<textarea id="configText" style="width:100%; height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;"></textarea>
303+
<div style="margin-top:0.5rem">
304+
<button id="configReload" style="background:#1f2937">Reload</button>
305+
<button id="configSave" style="background:#111; margin-left:0.5rem">Save</button>
306+
</div>
307+
<pre id="configOut" style="white-space:pre-wrap"></pre>
267308
</div>
268309
269310
<div class="card">
@@ -604,6 +645,133 @@ app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => {
604645
});
605646
});
606647

648+
// --- Debug console (Option A: allowlisted commands + config editor) ---
649+
650+
function redactSecrets(text) {
651+
if (!text) return text;
652+
// Very small best-effort redaction. (Config paths/values may still contain secrets.)
653+
return String(text)
654+
.replace(/(sk-[A-Za-z0-9_-]{10,})/g, "[REDACTED]")
655+
.replace(/(gho_[A-Za-z0-9_]{10,})/g, "[REDACTED]")
656+
.replace(/(xox[baprs]-[A-Za-z0-9-]{10,})/g, "[REDACTED]")
657+
.replace(/(AA[A-Za-z0-9_-]{10,}:\S{10,})/g, "[REDACTED]");
658+
}
659+
660+
const ALLOWED_CONSOLE_COMMANDS = new Set([
661+
// Wrapper-managed lifecycle
662+
"gateway.restart",
663+
"gateway.stop",
664+
"gateway.start",
665+
666+
// OpenClaw CLI helpers
667+
"openclaw.version",
668+
"openclaw.status",
669+
"openclaw.health",
670+
"openclaw.doctor",
671+
"openclaw.logs.tail",
672+
"openclaw.config.get",
673+
]);
674+
675+
app.post("/setup/api/console/run", requireSetupAuth, async (req, res) => {
676+
const payload = req.body || {};
677+
const cmd = String(payload.cmd || "").trim();
678+
const arg = String(payload.arg || "").trim();
679+
680+
if (!ALLOWED_CONSOLE_COMMANDS.has(cmd)) {
681+
return res.status(400).json({ ok: false, error: "Command not allowed" });
682+
}
683+
684+
try {
685+
if (cmd === "gateway.restart") {
686+
await restartGateway();
687+
return res.json({ ok: true, output: "Gateway restarted (wrapper-managed).\n" });
688+
}
689+
if (cmd === "gateway.stop") {
690+
if (gatewayProc) {
691+
try { gatewayProc.kill("SIGTERM"); } catch {}
692+
await sleep(750);
693+
gatewayProc = null;
694+
}
695+
return res.json({ ok: true, output: "Gateway stopped (wrapper-managed).\n" });
696+
}
697+
if (cmd === "gateway.start") {
698+
const r = await ensureGatewayRunning();
699+
return res.json({ ok: Boolean(r.ok), output: r.ok ? "Gateway started.\n" : `Gateway not started: ${r.reason}\n` });
700+
}
701+
702+
if (cmd === "openclaw.version") {
703+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["--version"]));
704+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
705+
}
706+
if (cmd === "openclaw.status") {
707+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["status"]));
708+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
709+
}
710+
if (cmd === "openclaw.health") {
711+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["health"]));
712+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
713+
}
714+
if (cmd === "openclaw.doctor") {
715+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["doctor"]));
716+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
717+
}
718+
if (cmd === "openclaw.logs.tail") {
719+
const lines = Math.max(50, Math.min(1000, Number.parseInt(arg || "200", 10) || 200));
720+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["logs", "--tail", String(lines)]));
721+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
722+
}
723+
if (cmd === "openclaw.config.get") {
724+
if (!arg) return res.status(400).json({ ok: false, error: "Missing config path" });
725+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["config", "get", arg]));
726+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
727+
}
728+
729+
return res.status(400).json({ ok: false, error: "Unhandled command" });
730+
} catch (err) {
731+
return res.status(500).json({ ok: false, error: String(err) });
732+
}
733+
});
734+
735+
app.get("/setup/api/config/raw", requireSetupAuth, async (_req, res) => {
736+
try {
737+
const p = configPath();
738+
const exists = fs.existsSync(p);
739+
const content = exists ? fs.readFileSync(p, "utf8") : "";
740+
res.json({ ok: true, path: p, exists, content });
741+
} catch (err) {
742+
res.status(500).json({ ok: false, error: String(err) });
743+
}
744+
});
745+
746+
app.post("/setup/api/config/raw", requireSetupAuth, async (req, res) => {
747+
try {
748+
const content = String((req.body && req.body.content) || "");
749+
if (content.length > 500_000) {
750+
return res.status(413).json({ ok: false, error: "Config too large" });
751+
}
752+
753+
fs.mkdirSync(STATE_DIR, { recursive: true });
754+
755+
const p = configPath();
756+
// Backup
757+
if (fs.existsSync(p)) {
758+
const backupPath = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
759+
fs.copyFileSync(p, backupPath);
760+
}
761+
762+
fs.writeFileSync(p, content, { encoding: "utf8", mode: 0o600 });
763+
764+
// Apply immediately.
765+
if (isConfigured()) {
766+
await restartGateway();
767+
}
768+
769+
res.json({ ok: true, path: p });
770+
} catch (err) {
771+
res.status(500).json({ ok: false, error: String(err) });
772+
}
773+
});
774+
607775
app.post("/setup/api/pairing/approve", requireSetupAuth, async (req, res) => {
608776
const { channel, code } = req.body || {};
609777
if (!channel || !code) {
@@ -674,6 +842,95 @@ app.get("/setup/export", requireSetupAuth, async (_req, res) => {
674842
stream.pipe(res);
675843
});
676844

845+
function isUnderDir(p, root) {
846+
const abs = path.resolve(p);
847+
const r = path.resolve(root);
848+
return abs === r || abs.startsWith(r + path.sep);
849+
}
850+
851+
function looksSafeTarPath(p) {
852+
if (!p) return false;
853+
// tar paths always use / separators
854+
if (p.startsWith("/") || p.startsWith("\\")) return false;
855+
// windows drive letters
856+
if (/^[A-Za-z]:[\\/]/.test(p)) return false;
857+
// path traversal
858+
if (p.split("/").includes("..")) return false;
859+
return true;
860+
}
861+
862+
async function readBodyBuffer(req, maxBytes) {
863+
return new Promise((resolve, reject) => {
864+
const chunks = [];
865+
let total = 0;
866+
req.on("data", (chunk) => {
867+
total += chunk.length;
868+
if (total > maxBytes) {
869+
reject(new Error("payload too large"));
870+
req.destroy();
871+
return;
872+
}
873+
chunks.push(chunk);
874+
});
875+
req.on("end", () => resolve(Buffer.concat(chunks)));
876+
req.on("error", reject);
877+
});
878+
}
879+
880+
// Import a backup created by /setup/export.
881+
// This is intentionally limited to restoring into /data to avoid overwriting arbitrary host paths.
882+
app.post("/setup/import", requireSetupAuth, async (req, res) => {
883+
try {
884+
const dataRoot = "/data";
885+
if (!isUnderDir(STATE_DIR, dataRoot) || !isUnderDir(WORKSPACE_DIR, dataRoot)) {
886+
return res
887+
.status(400)
888+
.type("text/plain")
889+
.send("Import is only supported when OPENCLAW_STATE_DIR and OPENCLAW_WORKSPACE_DIR are under /data (Railway volume).\n");
890+
}
891+
892+
// Stop gateway before restore so we don't overwrite live files.
893+
if (gatewayProc) {
894+
try { gatewayProc.kill("SIGTERM"); } catch {}
895+
await sleep(750);
896+
gatewayProc = null;
897+
}
898+
899+
const buf = await readBodyBuffer(req, 250 * 1024 * 1024); // 250MB max
900+
if (!buf.length) return res.status(400).type("text/plain").send("Empty body\n");
901+
902+
// Extract into /data.
903+
// We only allow safe relative paths, and we intentionally do NOT delete existing files.
904+
// (Users can reset/redeploy or manually clean the volume if desired.)
905+
const tmpPath = path.join(os.tmpdir(), `openclaw-import-${Date.now()}.tar.gz`);
906+
fs.writeFileSync(tmpPath, buf);
907+
908+
await tar.x({
909+
file: tmpPath,
910+
cwd: dataRoot,
911+
gzip: true,
912+
strict: true,
913+
onwarn: () => {},
914+
filter: (p) => {
915+
// Allow only paths that look safe.
916+
return looksSafeTarPath(p);
917+
},
918+
});
919+
920+
try { fs.rmSync(tmpPath, { force: true }); } catch {}
921+
922+
// Restart gateway after restore.
923+
if (isConfigured()) {
924+
await restartGateway();
925+
}
926+
927+
res.type("text/plain").send("OK - imported backup into /data and restarted gateway.\n");
928+
} catch (err) {
929+
console.error("[import]", err);
930+
res.status(500).type("text/plain").send(String(err));
931+
}
932+
});
933+
677934
// Proxy everything else to the gateway.
678935
const proxy = httpProxy.createProxyServer({
679936
target: GATEWAY_TARGET,

0 commit comments

Comments
 (0)