diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index ee71fee5326..6e17e6b3611 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -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" \ diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 70fcfa73d33..f04c1b0201d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 2377661aa1d..d4b62eb76ce 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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.""" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index db347997271..3e7d72c9f46 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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("/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("/api/ops/import-upload", { + method: "POST", + body: form, + }); + }, getHooks: () => fetchJSON("/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; } diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 24cb68894b4..30db6cc3492 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -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([]); const [running, setRunning] = useState(true); const [exitCode, setExitCode] = useState(null); const timer = useRef | 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 ( @@ -169,12 +192,23 @@ export default function SystemPage() { const [credLabel, setCredLabel] = useState(""); const [addingCred, setAddingCred] = useState(false); + const [pendingBackupArchive, setPendingBackupArchive] = useState( + null, + ); + const [downloadableBackupArchive, setDownloadableBackupArchive] = useState< + string | null + >(null); + const [downloadingBackup, setDownloadingBackup] = useState(false); + const importUploadInputRef = useRef(null); + const [importFile, setImportFile] = useState(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(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 (
+ { + setImportFile(event.currentTarget.files?.[0] ?? null); + }} + /> setActiveAction(null)} /> )} @@ -1046,9 +1161,6 @@ export default function SystemPage() { - @@ -1064,6 +1176,122 @@ export default function SystemPage() { + + +
+
+ +
+ + + + {backupFileName(pendingBackupArchive)} + +
+
+
+ +
+
+ +
+ + + {importFile?.name ?? "No backup archive selected"} + +
+
+ +
+ +
+
+ + setImportPath(e.target.value)} + placeholder="$HERMES_HOME/backups/hermes-backup.zip" + /> +
+ +
+ setImportConfirmTarget(null)} + onConfirm={() => { + const target = importConfirmTarget; + setImportConfirmTarget(null); + if (target) void runBackupImport(target); + }} + /> +
+
+ {/* 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() { )} - - -
- - setImportPath(e.target.value)} placeholder="/path/to/hermes-backup.zip" /> -
- - setImportConfirmOpen(false)} - onConfirm={() => { - setImportConfirmOpen(false); - runOp(() => api.runImport(importPath.trim(), true), "Import"); - }} - /> -
-
{/* ── Checkpoints ───────────────────────────────────────────── */}