Add dashboard backup upload and download

This commit is contained in:
Shannon Sands 2026-06-29 12:05:09 +10:00 committed by Teknium
parent 8fe800ee1a
commit 476875acb9
5 changed files with 524 additions and 38 deletions

View file

@ -338,6 +338,7 @@ fi
# shell isn't a second interpreter — defends against $HERMES_HOME values
# containing shell metacharacters. PR #30136 review item O2.
as_hermes mkdir -p \
"$HERMES_HOME/backups" \
"$HERMES_HOME/cron" \
"$HERMES_HOME/sessions" \
"$HERMES_HOME/logs" \

View file

@ -33,6 +33,7 @@ import threading
import time
import urllib.error
import urllib.parse
import zipfile
from hermes_cli._subprocess_compat import windows_detach_flags, windows_hide_flags
import urllib.request
@ -9631,17 +9632,63 @@ class BackupRequest(BaseModel):
output: Optional[str] = None
def _dashboard_backup_dir() -> Path:
return get_hermes_home() / "backups"
def _new_dashboard_backup_path() -> Path:
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
return _dashboard_backup_dir() / f"hermes-backup-{stamp}-{secrets.token_hex(4)}.zip"
@app.post("/api/ops/backup")
async def run_backup(body: BackupRequest):
args = ["backup"]
archive: Optional[Path] = None
if body.output:
args.append(body.output.strip())
else:
archive = _new_dashboard_backup_path()
try:
archive.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Could not create backup directory: {exc}",
)
args.append(str(archive))
try:
proc = _spawn_hermes_action(args, "backup")
except Exception as exc:
_log.exception("Failed to spawn backup")
raise HTTPException(status_code=500, detail=f"Failed to run backup: {exc}")
return {"ok": True, "pid": proc.pid, "name": "backup"}
response = {"ok": True, "pid": proc.pid, "name": "backup"}
if archive is not None:
response["archive"] = str(archive)
return response
@app.get("/api/ops/backup/download")
async def download_dashboard_backup(archive: str):
try:
backup_dir = _dashboard_backup_dir().expanduser().resolve(strict=False)
target = Path(archive).expanduser().resolve(strict=True)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Backup not found")
except (OSError, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid backup path")
if not _path_is_under(backup_dir, target):
raise HTTPException(status_code=403, detail="Backup is outside the dashboard backup directory")
if not target.is_file():
raise HTTPException(status_code=404, detail="Backup not found")
return FileResponse(
path=str(target),
media_type="application/zip",
filename=target.name,
content_disposition_type="attachment",
)
class ImportRequest(BaseModel):
@ -9674,6 +9721,94 @@ async def run_import(body: ImportRequest):
return {"ok": True, "pid": proc.pid, "name": "import"}
def _safe_backup_upload_name(filename: str | None) -> str:
name = Path(filename or "backup.zip").name.strip()
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
if not name:
name = "backup.zip"
if not name.lower().endswith(".zip"):
name = f"{name}.zip"
return name
@app.post("/api/ops/import-upload")
async def run_import_upload(
file: UploadFile = File(...),
force: bool = Form(False),
):
staging_dir = _dashboard_backup_dir()
try:
staging_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Could not create import staging directory: {exc}",
)
safe_name = _safe_backup_upload_name(file.filename)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
target = staging_dir / f"dashboard-import-{stamp}-{secrets.token_hex(4)}-{safe_name}"
tmp_fd, tmp_name = tempfile.mkstemp(
prefix=f".{target.name}.",
suffix=".upload",
dir=str(staging_dir),
)
tmp_path = Path(tmp_name)
total = 0
renamed = False
try:
with os.fdopen(tmp_fd, "wb") as out:
while True:
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="Archive is too large")
out.write(chunk)
os.replace(tmp_path, target)
renamed = True
except HTTPException:
raise
except PermissionError:
raise HTTPException(
status_code=403,
detail="Import staging directory is not writable",
)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Could not write uploaded archive: {exc}",
)
finally:
if not renamed:
tmp_path.unlink(missing_ok=True)
await file.close()
if not zipfile.is_zipfile(target):
target.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail="Uploaded archive is not a valid zip file",
)
args = ["import", str(target)]
if force:
args.append("--force")
try:
proc = _spawn_hermes_action(args, "import")
except Exception as exc:
_log.exception("Failed to spawn import")
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
return {
"ok": True,
"pid": proc.pid,
"name": "import",
"archive": str(target),
"uploaded_bytes": total,
}
@app.get("/api/ops/hooks")
async def list_hooks():
"""List configured shell hooks from config.yaml with consent + health.

View file

@ -1609,6 +1609,145 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert captured["args"] == ["import", str(archive)]
def test_ops_backup_defaults_to_dashboard_downloadable_archive(self, monkeypatch):
from pathlib import Path
import hermes_cli.web_server as ws
from hermes_cli.config import get_hermes_home
captured = {}
def fake_spawn(subcommand, name):
captured["args"] = subcommand
captured["name"] = name
from types import SimpleNamespace as NS
return NS(pid=12345)
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
resp = self.client.post("/api/ops/backup", json={})
assert resp.status_code == 200
data = resp.json()
archive = Path(data["archive"])
assert data["name"] == "backup"
assert captured["name"] == "backup"
assert captured["args"] == ["backup", str(archive)]
assert archive.parent == get_hermes_home() / "backups"
assert archive.name.startswith("hermes-backup-")
assert archive.suffix == ".zip"
def test_ops_backup_uses_hosted_hermes_home(self, tmp_path, monkeypatch):
from pathlib import Path
import hermes_cli.web_server as ws
hosted_home = tmp_path / "opt-data"
monkeypatch.setenv("HERMES_HOME", str(hosted_home))
captured = {}
def fake_spawn(subcommand, name):
captured["args"] = subcommand
captured["name"] = name
from types import SimpleNamespace as NS
return NS(pid=12345)
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
resp = self.client.post("/api/ops/backup", json={})
assert resp.status_code == 200
archive = Path(resp.json()["archive"])
assert archive.parent == hosted_home / "backups"
assert captured["args"] == ["backup", str(archive)]
assert archive.parent.is_dir()
def test_ops_backup_download_streams_dashboard_backup(self, tmp_path):
import hermes_cli.web_server as ws
backup_dir = ws._dashboard_backup_dir()
backup_dir.mkdir(parents=True, exist_ok=True)
archive = backup_dir / "hermes-backup-test.zip"
archive.write_bytes(b"zip bytes")
resp = self.client.get(
"/api/ops/backup/download",
params={"archive": str(archive)},
)
assert resp.status_code == 200
assert resp.content == b"zip bytes"
assert "attachment" in resp.headers["content-disposition"]
outside = tmp_path / "outside.zip"
outside.write_bytes(b"nope")
denied = self.client.get(
"/api/ops/backup/download",
params={"archive": str(outside)},
)
assert denied.status_code == 403
def test_ops_import_upload_stages_archive_and_passes_force(self, tmp_path, monkeypatch):
import zipfile
from pathlib import Path
import hermes_cli.web_server as ws
archive = tmp_path / "backup.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("config.yaml", "model: {}\n")
captured = {}
def fake_spawn(subcommand, name):
captured["args"] = subcommand
captured["name"] = name
from types import SimpleNamespace as NS
return NS(pid=12345)
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
resp = self.client.post(
"/api/ops/import-upload",
data={"force": "true"},
files={
"file": (
"my backup.zip",
archive.read_bytes(),
"application/zip",
),
},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "import"
assert data["uploaded_bytes"] == archive.stat().st_size
staged = Path(captured["args"][1])
assert captured["name"] == "import"
assert captured["args"] == ["import", str(staged), "--force"]
assert staged.is_file()
assert staged.name.startswith("dashboard-import-")
assert staged.name.endswith("-my-backup.zip")
assert zipfile.is_zipfile(staged)
assert data["archive"] == str(staged)
def test_ops_import_upload_rejects_invalid_zip(self, monkeypatch):
import hermes_cli.web_server as ws
def fail_spawn(*_args):
raise AssertionError("invalid uploads must not spawn import")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn)
resp = self.client.post(
"/api/ops/import-upload",
data={"force": "true"},
files={"file": ("backup.zip", b"not a zip", "application/zip")},
)
assert resp.status_code == 400
assert "valid zip" in resp.json()["detail"]
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""

View file

@ -1082,12 +1082,25 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ output }),
}),
downloadBackup: (archive: string) =>
authedFetch(
`/api/ops/backup/download?archive=${encodeURIComponent(archive)}`,
),
runImport: (archive: string, force = false) =>
fetchJSON<ActionResponse>("/api/ops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archive, force }),
}),
runImportUpload: (file: File, force = false) => {
const form = new FormData();
form.append("force", String(force));
form.append("file", file, file.name);
return fetchJSON<ActionResponse>("/api/ops/import-upload", {
method: "POST",
body: form,
});
},
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
createHook: (body: HookCreate) =>
fetchJSON<{ ok: boolean; event: string; command: string; approved: boolean }>(
@ -1198,11 +1211,13 @@ export interface AuthMeResponse {
}
export interface ActionResponse {
archive?: string;
name: string;
ok: boolean;
pid: number | null;
error?: string;
message?: string;
uploaded_bytes?: number;
update_command?: string;
}

View file

@ -24,6 +24,7 @@ import {
Stethoscope,
Terminal,
Trash2,
Upload,
X,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
@ -72,6 +73,20 @@ function formatDuration(seconds: number): string {
return `${m}m`;
}
type BackupImportTarget =
| { kind: "upload"; file: File }
| { kind: "path"; path: string };
function backupImportLabel(target: BackupImportTarget | null): string {
if (!target) return "the archive";
return target.kind === "upload" ? target.file.name : target.path;
}
function backupFileName(path: string | null): string {
if (!path) return "No backup created yet";
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
/**
* Live action-log viewer for the spawn-based admin actions (doctor, audit,
* backup, import, skills update, checkpoints prune, gateway start/stop).
@ -80,17 +95,21 @@ function formatDuration(seconds: number): string {
function ActionLogViewer({
action,
onClose,
onComplete,
}: {
action: string;
onClose: () => void;
onComplete?: (action: string, exitCode: number | null) => void;
}) {
const [lines, setLines] = useState<string[]>([]);
const [running, setRunning] = useState(true);
const [exitCode, setExitCode] = useState<number | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const completeRef = useRef(false);
useEffect(() => {
let cancelled = false;
completeRef.current = false;
const poll = async () => {
try {
const st = await api.getActionStatus(action, 400);
@ -98,6 +117,10 @@ function ActionLogViewer({
setLines(st.lines);
setRunning(st.running);
setExitCode(st.exit_code);
if (!st.running && !completeRef.current) {
completeRef.current = true;
onComplete?.(action, st.exit_code);
}
if (st.running) timer.current = setTimeout(poll, 1200);
} catch {
if (!cancelled) setRunning(false);
@ -108,7 +131,7 @@ function ActionLogViewer({
cancelled = true;
if (timer.current) clearTimeout(timer.current);
};
}, [action]);
}, [action, onComplete]);
return (
<Card>
@ -169,12 +192,23 @@ export default function SystemPage() {
const [credLabel, setCredLabel] = useState("");
const [addingCred, setAddingCred] = useState(false);
const [pendingBackupArchive, setPendingBackupArchive] = useState<string | null>(
null,
);
const [downloadableBackupArchive, setDownloadableBackupArchive] = useState<
string | null
>(null);
const [downloadingBackup, setDownloadingBackup] = useState(false);
const importUploadInputRef = useRef<HTMLInputElement | null>(null);
const [importFile, setImportFile] = useState<File | null>(null);
const [importPath, setImportPath] = useState("");
// Restore-from-backup is destructive (overwrites the live config) and the
// spawned `hermes import` runs non-interactively (stdin is /dev/null), so
// its CLI "Continue? [y/N]" prompt would auto-abort. The dashboard owns the
// consent: confirm here, then call the endpoint with force=true.
const [importConfirmOpen, setImportConfirmOpen] = useState(false);
const [importingBackup, setImportingBackup] = useState(false);
const [importConfirmTarget, setImportConfirmTarget] =
useState<BackupImportTarget | null>(null);
// Create-hook modal.
const [hookModalOpen, setHookModalOpen] = useState(false);
@ -335,6 +369,77 @@ export default function SystemPage() {
}
};
const runDashboardBackup = async () => {
try {
const res = await api.runBackup();
setActiveAction(res.name);
setPendingBackupArchive(res.archive ?? null);
setDownloadableBackupArchive(null);
showToast("Backup started", "success");
} catch (e) {
showToast(`Backup failed: ${e}`, "error");
}
};
const handleActionComplete = useCallback(
(action: string, exitCode: number | null) => {
if (action === "backup" && pendingBackupArchive) {
if (exitCode === 0) {
setDownloadableBackupArchive(pendingBackupArchive);
showToast("Backup ready to download", "success");
} else {
setPendingBackupArchive(null);
}
}
},
[pendingBackupArchive, showToast],
);
const downloadBackup = async () => {
const archive = downloadableBackupArchive;
if (!archive) return;
setDownloadingBackup(true);
try {
const res = await api.downloadBackup(archive);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = backupFileName(archive);
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (e) {
showToast(`Download failed: ${e}`, "error");
} finally {
setDownloadingBackup(false);
}
};
const clearImportFile = () => {
setImportFile(null);
if (importUploadInputRef.current) importUploadInputRef.current.value = "";
};
const runBackupImport = async (target: BackupImportTarget) => {
setImportingBackup(true);
try {
const res =
target.kind === "upload"
? await api.runImportUpload(target.file, true)
: await api.runImport(target.path, true);
setActiveAction(res.name);
showToast("Import started", "success");
if (target.kind === "upload") clearImportFile();
} catch (e) {
showToast(`Import failed: ${e}`, "error");
} finally {
setImportingBackup(false);
}
};
// ── Debug share ────────────────────────────────────────────────────
// Unlike the fire-and-forget ops above, `debug share` produces shareable
// paste URLs that are the whole point — so we surface them as real,
@ -519,6 +624,15 @@ export default function SystemPage() {
return (
<div className="flex flex-col gap-8">
<Toast toast={toast} />
<input
ref={importUploadInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
className="hidden"
onChange={(event) => {
setImportFile(event.currentTarget.files?.[0] ?? null);
}}
/>
<ConfirmDialog
open={canUpdateHermes && updateConfirmOpen}
@ -668,6 +782,7 @@ export default function SystemPage() {
{activeAction && (
<ActionLogViewer
action={activeAction}
onComplete={handleActionComplete}
onClose={() => setActiveAction(null)}
/>
)}
@ -1046,9 +1161,6 @@ export default function SystemPage() {
<Button size="sm" ghost prefix={<ShieldCheck className="h-3.5 w-3.5" />} onClick={() => runOp(api.runSecurityAudit, "Security audit")}>
Security audit
</Button>
<Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(() => api.runBackup(), "Backup")}>
Create backup
</Button>
<Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}>
Update skills
</Button>
@ -1064,6 +1176,122 @@ export default function SystemPage() {
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="grid min-w-0 flex-1 gap-2">
<Label>Full backup</Label>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Button
size="sm"
ghost
prefix={<Database className="h-3.5 w-3.5" />}
onClick={() => void runDashboardBackup()}
>
Create backup
</Button>
<Button
size="sm"
ghost
disabled={!downloadableBackupArchive || downloadingBackup}
prefix={
downloadingBackup ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<Download className="h-3.5 w-3.5" />
)
}
onClick={() => void downloadBackup()}
>
Download backup
</Button>
<span
className="min-w-0 truncate text-xs text-muted-foreground"
title={pendingBackupArchive ?? "No backup created yet"}
>
{backupFileName(pendingBackupArchive)}
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
<div className="grid min-w-0 flex-1 gap-2">
<Label>Restore from backup upload</Label>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="button"
size="sm"
ghost
disabled={importingBackup}
prefix={<Upload className="h-3.5 w-3.5" />}
onClick={() => importUploadInputRef.current?.click()}
>
Choose restore zip
</Button>
<span
className="min-w-0 truncate text-xs text-muted-foreground"
title={importFile?.name ?? "No backup archive selected"}
>
{importFile?.name ?? "No backup archive selected"}
</span>
</div>
</div>
<Button
size="sm"
ghost
disabled={!importFile || importingBackup}
prefix={importingBackup ? <Spinner /> : undefined}
onClick={() => {
if (!importFile) return;
setImportConfirmTarget({ kind: "upload", file: importFile });
}}
>
Restore upload
</Button>
</div>
<div className="flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-end">
<div className="grid min-w-0 flex-1 gap-2">
<Label htmlFor="import-path">Restore from backups path</Label>
<Input
id="import-path"
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
placeholder="$HERMES_HOME/backups/hermes-backup.zip"
/>
</div>
<Button
size="sm"
ghost
disabled={!importPath.trim() || importingBackup}
prefix={importingBackup ? <Spinner /> : undefined}
onClick={() => {
const path = importPath.trim();
if (!path) return;
setImportConfirmTarget({ kind: "path", path });
}}
>
Restore path
</Button>
</div>
<ConfirmDialog
open={!!importConfirmTarget}
title="Restore full Hermes backup?"
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${backupImportLabel(importConfirmTarget)}. This cannot be undone.`}
destructive
confirmLabel="Restore"
cancelLabel="Cancel"
onCancel={() => setImportConfirmTarget(null)}
onConfirm={() => {
const target = importConfirmTarget;
setImportConfirmTarget(null);
if (target) void runBackupImport(target);
}}
/>
</CardContent>
</Card>
{/* Debug share uploads a redacted report + logs, returns shareable
links. Separated from the buttons above because its output is
persistent, copyable URLs, not a fire-and-forget log tail. */}
@ -1186,38 +1414,6 @@ export default function SystemPage() {
)}
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
<div className="grid gap-2 flex-1">
<Label htmlFor="import-path">Restore from backup archive</Label>
<Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" />
</div>
<Button
size="sm"
ghost
disabled={!importPath.trim()}
onClick={() => {
if (!importPath.trim()) return;
setImportConfirmOpen(true);
}}
>
Import
</Button>
<ConfirmDialog
open={importConfirmOpen}
title="Restore from backup?"
description={`This will overwrite your current Hermes configuration, skills, sessions, and data with the contents of ${importPath.trim() || "the archive"}. This cannot be undone.`}
destructive
confirmLabel="Restore"
cancelLabel="Cancel"
onCancel={() => setImportConfirmOpen(false)}
onConfirm={() => {
setImportConfirmOpen(false);
runOp(() => api.runImport(importPath.trim(), true), "Import");
}}
/>
</CardContent>
</Card>
</section>
{/* ── Checkpoints ───────────────────────────────────────────── */}