Add config editor

This commit is contained in:
Arjun Komath
2026-05-09 10:11:47 +10:00
parent 4dad9e815e
commit 5f274a9786
8 changed files with 352 additions and 38 deletions
+1
View File
@@ -195,3 +195,4 @@ This avoids repeatedly reading large files and provides instant context about th
5. **Gateway spawn inherits stdio** → logs appear in wrapper output (src/server.js:134)
6. **WebSocket auth requires proxy event handlers** → Direct `req.headers` modification doesn't work for WebSocket upgrades with http-proxy; must use `proxyReqWs` event (src/server.js:741) to reliably inject Authorization header
7. **Control UI requires allowInsecureAuth to bypass pairing** → Set `gateway.controlUi.allowInsecureAuth=true` during onboarding to prevent "disconnected (1008): pairing required" errors (GitHub issue #2284). Wrapper already handles bearer token auth, so device pairing is unnecessary.
8. **Config Editor (`/setup/config`) writes `openclaw.json` directly** → unlike onboarding (which uses `openclaw config set --json`), the editor parses+validates JSON, makes a `.bak-<timestamp>` copy, then `fs.writeFileSync`s the new contents and calls `restartGateway()`. This is intentional: it's an operator-targeted advanced tool, so direct writes are simpler and let users fix arbitrarily-broken config without depending on the CLI.
+146
View File
@@ -0,0 +1,146 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>🦞 OpenClaw — Config Editor</title>
<link rel="stylesheet" href="/styles.css">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('configApp', () => ({
path: '',
exists: false,
content: '',
loading: false,
saving: false,
message: '',
error: '',
async init() {
await this.load();
},
async httpJson(url, opts = {}) {
opts.credentials = 'same-origin';
const res = await fetch(url, opts);
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { /* leave null */ }
if (!res.ok) {
const err = (body && body.error) || text || res.statusText;
throw new Error('HTTP ' + res.status + ': ' + err);
}
return body;
},
async load() {
this.loading = true;
this.error = '';
this.message = '';
try {
const j = await this.httpJson('/setup/api/config/raw');
this.path = j.path || '';
this.exists = !!j.exists;
this.content = j.content || '';
} catch (e) {
this.error = String(e.message || e);
} finally {
this.loading = false;
}
},
async save() {
this.error = '';
this.message = '';
try {
JSON.parse(this.content);
} catch (e) {
this.error = 'Invalid JSON: ' + (e.message || String(e));
return;
}
if (!confirm('Save config and restart the gateway? A timestamped .bak backup will be created next to openclaw.json.')) {
return;
}
this.saving = true;
try {
const j = await this.httpJson('/setup/api/config/raw', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: this.content })
});
let msg = 'Saved to ' + (j.path || this.path) + '.';
if (j.backupPath) msg += '\nBackup: ' + j.backupPath;
msg += j.restarted ? '\nGateway restarted.' : '\nGateway not running — no restart needed.';
this.message = msg;
this.exists = true;
} catch (e) {
this.error = String(e.message || e);
} finally {
this.saving = false;
}
},
}));
});
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
</head>
<body x-data="configApp()">
<div class="container">
<div class="logo-wrap">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" class="logo-img">
</picture>
</div>
<div style="margin-bottom:1.25rem">
<a href="/setup" class="inline-link" style="font-size:0.875rem">← Back to Setup</a>
</div>
<div class="card">
<div class="card-header">
<div>
<h2 class="card-title">Config Editor</h2>
<p class="card-subtitle">Direct edit of <code>openclaw.json</code>. Saving creates a timestamped <code>.bak</code> backup and restarts the gateway.</p>
</div>
</div>
<p class="form-hint" style="margin-bottom:0.75rem">
<strong>File:</strong>
<span x-text="path || '(loading…)'"></span>
<span x-show="!loading && !exists" style="color:var(--text-muted)">(does not exist yet)</span>
</p>
<textarea
class="config-textarea"
x-model="content"
:disabled="loading || saving"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
></textarea>
<div class="btn-row" style="margin-top:1rem;display:flex;gap:0.75rem;flex-wrap:wrap">
<button @click="load()" :disabled="loading || saving" class="btn btn-secondary">
<span x-show="!loading">Reload</span>
<span x-show="loading" style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="spinner spinner-inline" aria-hidden="true"></span>
Loading
</span>
</button>
<button @click="save()" :disabled="loading || saving" class="btn btn-primary">
<span x-show="!saving">Save &amp; Restart Gateway</span>
<span x-show="saving" style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="spinner spinner-inline" aria-hidden="true"></span>
Saving
</span>
</button>
</div>
<div x-show="error" x-cloak class="setup-error-panel" style="margin-top:1rem" x-text="error"></div>
<div x-show="message" x-cloak class="log-output" style="margin-top:1rem" x-text="message"></div>
</div>
</div>
</body>
</html>
-3
View File
@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw</title>
<link rel="stylesheet" href="/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
.fade-out { opacity: 0; transition: opacity 0.4s ease; }
.fade-in { opacity: 1; transition: opacity 0.4s ease; }
-3
View File
@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>🦞 OpenClaw — Server Logs</title>
<link rel="stylesheet" href="/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('logsApp', () => ({
+76 -7
View File
@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>🦞 OpenClaw — Personal AI Assistant</title>
<link rel="stylesheet" href="/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('setupApp', () => ({
@@ -48,6 +45,9 @@
exporting: false,
showExportInfo: false,
importing: false,
showImportModal: false,
pendingImportFile: null,
importArchivePassword: '',
showDevicesModal: false,
devicesLoading: false,
devices: [],
@@ -354,11 +354,31 @@
}
},
async importData(event) {
onImportFileSelected(event) {
const f = event.target.files && event.target.files[0];
event.target.value = '';
if (!f) return;
if (!confirm('Import backup? This overwrites your config and workspace under /data and restarts the gateway.')) return;
this.pendingImportFile = f;
this.importArchivePassword = '';
this.showImportModal = true;
},
cancelImport() {
if (this.importing) return;
this.showImportModal = false;
this.pendingImportFile = null;
this.importArchivePassword = '';
},
async confirmImport() {
const f = this.pendingImportFile;
if (!f) return;
const headers = { 'content-type': 'application/zip' };
const pw = (this.importArchivePassword || '').trim();
if (pw) {
headers['x-archive-password'] = btoa(unescape(encodeURIComponent(pw)));
}
this.importing = true;
this.log = 'Uploading ' + f.name + ' (' + f.size + ' bytes)...\n';
@@ -368,7 +388,7 @@
const res = await fetch('/setup/api/import', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/zip' },
headers,
body: buf,
});
const text = await res.text();
@@ -377,6 +397,9 @@
if (j.output) this.log += j.output + '\n';
if (res.ok && j.ok) {
this.log += '✓ Import complete. Gateway restarted.\n';
this.showImportModal = false;
this.pendingImportFile = null;
this.importArchivePassword = '';
await this.refreshStatus();
} else {
this.log += '✗ Import failed: ' + (j.error || text) + '\n';
@@ -586,7 +609,7 @@
<span class="action-title">Import Data</span>
</div>
<p class="action-desc">Restore from a password-protected ZIP. Overwrites config and workspace, then restarts the gateway.</p>
<input x-ref="importInput" @change="importData($event)" type="file" accept=".zip,application/zip" style="display:none" />
<input x-ref="importInput" @change="onImportFileSelected($event)" type="file" accept=".zip,application/zip" style="display:none" />
<button @click="$refs.importInput.click()" :disabled="loading || importing || exporting" class="action-btn">
<span x-show="!importing">Import Data</span>
<span x-show="importing" style="display:inline-flex;align-items:center;justify-content:center;gap:0.5rem">
@@ -608,6 +631,19 @@
View Logs
</a>
</div>
<div class="action-card action-violet">
<div class="action-card-header">
<div class="action-icon">
<svg class="icon-md" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
</div>
<span class="action-title">Edit Config</span>
</div>
<p class="action-desc">Directly edit <code>openclaw.json</code>. Creates a backup and restarts the gateway on save.</p>
<a href="/setup/config" class="action-btn" style="display:block;text-align:center;text-decoration:none">
Edit Config
</a>
</div>
</div>
<div x-show="log" class="log-output" x-text="log"></div>
@@ -924,6 +960,39 @@
</div>
</div>
<div x-show="showImportModal" x-cloak class="modal-overlay" @click.self="cancelImport()">
<div class="modal-card modal-md">
<h3 class="modal-title mb-4">Import Backup</h3>
<p class="text-sm text-muted mb-4">
Restoring <strong x-text="pendingImportFile?.name"></strong>
(<span x-text="pendingImportFile ? Math.round(pendingImportFile.size / 1024) + ' KB' : ''"></span>).
This overwrites your config and workspace under <code>/data</code>, then restarts the gateway.
</p>
<div class="form-group">
<label class="label">Archive password</label>
<input
x-model="importArchivePassword"
type="password"
autocomplete="off"
placeholder="Leave blank to use this instance's SETUP_PASSWORD"
:disabled="importing"
class="input"
/>
<p class="text-sm text-muted" style="margin-top:0.25rem">Use the SETUP_PASSWORD of the instance the backup was exported from. Leave blank if it was this instance.</p>
</div>
<div class="btn-pair">
<button @click="confirmImport()" :disabled="importing" class="btn btn-primary">
<span x-show="!importing">Import &amp; Restart</span>
<span x-show="importing" style="display:inline-flex;align-items:center;gap:0.5rem">
<span class="spinner spinner-inline" aria-hidden="true"></span>
Importing
</span>
</button>
<button @click="cancelImport()" :disabled="importing" class="btn btn-close-full" style="margin-top:0">Cancel</button>
</div>
</div>
</div>
<div x-show="showPairingModal" x-cloak class="modal-overlay" @click.self="if (!pairingSubmitting) showPairingModal = false">
<div class="modal-card modal-sm">
<h3 class="modal-title mb-4">Approve Channel Access</h3>
+18 -1
View File
@@ -7,7 +7,7 @@
}
:root {
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--bg: #f5f5f5;
--bg-card: #fff;
--bg-input: #fafafa;
@@ -157,6 +157,23 @@ img { max-width: 100%; height: auto; }
.form-group + .form-group { margin-top: 1.25rem; }
.config-textarea {
width: 100%;
min-height: 460px;
background: var(--bg-input);
border: 1px solid var(--border-input);
border-radius: 0.5rem;
padding: 0.875rem 1rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--text);
outline: none;
resize: vertical;
line-height: 1.5;
tab-size: 2;
}
.config-textarea:focus { border-color: #dc2626; }
.btn {
display: inline-flex;
align-items: center;
-3
View File
@@ -6,9 +6,6 @@
<title>🦞 OpenClaw TUI</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<link rel="stylesheet" href="/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="tui-body">
<div class="tui-header">
+111 -21
View File
@@ -575,6 +575,61 @@ app.get("/setup", requireSetupAuth, (_req, res) => {
res.sendFile(path.join(process.cwd(), "src", "public", "setup.html"));
});
app.get("/setup/config", requireSetupAuth, (_req, res) => {
res.sendFile(path.join(process.cwd(), "src", "public", "config.html"));
});
app.get("/setup/api/config/raw", requireSetupAuth, (_req, res) => {
const p = configPath();
const exists = fs.existsSync(p);
const content = exists ? fs.readFileSync(p, "utf8") : "";
res.json({ ok: true, path: p, exists, content });
});
app.post("/setup/api/config/raw", requireSetupAuth, async (req, res) => {
const content = String((req.body && req.body.content) || "");
if (content.length > 500_000) {
return res
.status(413)
.json({ ok: false, error: "Config too large (max 500KB)" });
}
try {
JSON.parse(content);
} catch (err) {
return res
.status(400)
.json({ ok: false, error: `Invalid JSON: ${err.message}` });
}
fs.mkdirSync(STATE_DIR, { recursive: true });
const p = configPath();
let backupPath = null;
if (fs.existsSync(p)) {
backupPath = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
fs.copyFileSync(p, backupPath);
}
// Write to a sibling tmp file then renameSync over the live config so a
// crash or disk-full mid-write can't leave openclaw.json truncated.
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
fs.renameSync(tmp, p);
} catch (err) {
try { fs.rmSync(tmp, { force: true }); } catch {}
return res
.status(500)
.json({ ok: false, error: `Failed to write config: ${err.message}` });
}
let restarted = false;
if (isConfigured()) {
await restartGateway();
restarted = true;
}
res.json({ ok: true, path: p, backupPath, restarted });
});
app.get("/setup/api/status", requireSetupAuth, async (_req, res) => {
const { version } = await getOpenclawInfo();
@@ -1540,6 +1595,20 @@ app.post(
try { fs.rmSync(tmpExtract, { recursive: true, force: true }); } catch {}
};
let archivePassword = SETUP_PASSWORD;
const headerPw = req.get("x-archive-password");
if (headerPw) {
try {
const decoded = Buffer.from(headerPw, "base64").toString("utf8").trim();
if (decoded) archivePassword = decoded;
} catch {
return res.status(400).json({
ok: false,
error: "Invalid X-Archive-Password header.",
});
}
}
const reject = (status, error, extra = {}) => {
const e = new Error(error);
e.status = status;
@@ -1599,25 +1668,23 @@ app.post(
);
}
if (gatewayProc) {
intentionalRestart = true;
try {
gatewayProc.kill("SIGTERM");
} catch (err) {
log.warn("import", `kill error: ${err.message}`);
}
await sleep(750);
gatewayProc = null;
intentionalRestart = false;
gatewayWasStopped = true;
}
// Extract into a sibling directory under /data so renames into
// STATE_DIR / WORKSPACE_DIR are intra-volume (atomic on POSIX).
// We extract BEFORE stopping the gateway so a wrong password or
// corrupt archive doesn't cost the user any gateway downtime.
fs.mkdirSync(tmpExtract, { recursive: true });
const extractResult = await runCmd("unzip", ["-P", SETUP_PASSWORD, "-o", tmpZip, "-d", tmpExtract]);
const extractResult = await runCmd("unzip", ["-P", archivePassword, "-o", tmpZip, "-d", tmpExtract]);
if (extractResult.code !== 0) {
log.error("import", `unzip exit ${extractResult.code}: ${extractResult.output}`);
// unzip exit 82 = wrong password; 81 = needs password but none given.
if (extractResult.code === 82 || extractResult.code === 81) {
throw reject(
400,
headerPw
? "Incorrect archive password."
: "Incorrect archive password. If this backup was exported from another instance, supply that instance's SETUP_PASSWORD.",
);
}
throw reject(500, "Failed to extract archive.", { output: extractResult.output });
}
@@ -1634,17 +1701,40 @@ app.post(
{ target: stateAbs, source: path.join(tmpExtract, stateRel) },
{ target: workspaceAbs, source: path.join(tmpExtract, workspaceRel) },
];
for (const { target, source } of replacements) {
if (!fs.existsSync(source)) continue;
// Even after walkRejectSymlinks, the entry at the source path itself
// could be a regular file masquerading as data/.openclaw. Require an
// actual directory before swapping it in for STATE_DIR / WORKSPACE_DIR.
if (!fs.lstatSync(source).isDirectory()) {
// Even after walkRejectSymlinks, the entry at the source path itself
// could be a regular file masquerading as data/.openclaw. Require an
// actual directory before swapping it in for STATE_DIR / WORKSPACE_DIR.
// Run this validation BEFORE stopping the gateway so a malformed
// archive doesn't cost any downtime.
const validReplacements = [];
for (const r of replacements) {
if (!fs.existsSync(r.source)) continue;
if (!fs.lstatSync(r.source).isDirectory()) {
throw reject(
400,
`Archive entry ${path.relative(tmpExtract, source)} is not a directory.`,
`Archive entry ${path.relative(tmpExtract, r.source)} is not a directory.`,
);
}
validReplacements.push(r);
}
// Now that the archive is fully extracted and validated, take down the
// gateway. From here through the rename swap, downtime is unavoidable.
if (gatewayProc) {
intentionalRestart = true;
try {
gatewayProc.kill("SIGTERM");
} catch (err) {
log.warn("import", `kill error: ${err.message}`);
}
await sleep(750);
gatewayProc = null;
intentionalRestart = false;
gatewayWasStopped = true;
}
for (const { target, source } of validReplacements) {
const backup = `${target}.bak-${ts}`;
if (fs.existsSync(target)) {
fs.renameSync(target, backup);