From c1a531d063efd6285fa17e22059276ae5202747d Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 1 Jun 2026 15:39:35 +1000 Subject: [PATCH] fix(dashboard): guard update endpoint in Docker with structured guidance (salvage #34831) (#36263) * fix: guard dashboard update in Docker * fix(dashboard): align action response type --------- Co-authored-by: Donovan Yohan Co-authored-by: Donovan Yohan <34756395+donovan-yohan@users.noreply.github.com> --- hermes_cli/web_server.py | 42 +++++++++++++++++++- tests/hermes_cli/test_web_server.py | 60 +++++++++++++++++++++++++++++ web/src/lib/api.ts | 5 ++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 398b0f5de..bb65d455a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -49,6 +49,9 @@ from hermes_cli.config import ( save_env_value, remove_env_value, check_config_version, + detect_install_method, + format_docker_update_message, + recommended_update_command_for_method, redact_key, ) from gateway.status import get_running_pid, read_runtime_status @@ -772,6 +775,26 @@ _ACTION_LOG_FILES: Dict[str, str] = { # report liveness and exit code without shelling out to ``ps``. _ACTION_PROCS: Dict[str, subprocess.Popen] = {} +# ``name`` → completed synthetic action result for actions the server handled +# without spawning a subprocess (for example, unsupported Docker updates). +_ACTION_RESULTS: Dict[str, Dict[str, Any]] = {} + + +def _record_completed_action(name: str, message: str, exit_code: int = 1) -> None: + """Record a non-spawned action result and write it to the action log.""" + log_file_name = _ACTION_LOG_FILES[name] + _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = _ACTION_LOG_DIR / log_file_name + with open(log_path, "ab", buffering=0) as log_file: + log_file.write( + f"\n=== {name} completed {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() + ) + log_file.write(message.encode("utf-8", errors="replace")) + if not message.endswith("\n"): + log_file.write(b"\n") + _ACTION_PROCS.pop(name, None) + _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None} + def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: """Spawn ``hermes `` detached and record the Popen handle. @@ -805,6 +828,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: popen_kwargs["start_new_session"] = True proc = subprocess.Popen(cmd, **popen_kwargs) + _ACTION_RESULTS.pop(name, None) _ACTION_PROCS[name] = proc return proc @@ -841,6 +865,19 @@ async def restart_gateway(): @app.post("/api/hermes/update") async def update_hermes(): """Kick off ``hermes update`` in the background.""" + install_method = detect_install_method(PROJECT_ROOT) + if install_method == "docker": + message = format_docker_update_message() + _record_completed_action("hermes-update", message, exit_code=1) + return { + "ok": False, + "pid": None, + "name": "hermes-update", + "error": "docker_update_unsupported", + "message": message, + "update_command": recommended_update_command_for_method(install_method), + } + try: proc = _spawn_hermes_action(["update"], "hermes-update") except Exception as exc: @@ -1065,9 +1102,10 @@ async def get_action_status(name: str, lines: int = 200): proc = _ACTION_PROCS.get(name) if proc is None: + result = _ACTION_RESULTS.get(name) running = False - exit_code: Optional[int] = None - pid: Optional[int] = None + exit_code = result.get("exit_code") if result else None + pid = result.get("pid") if result else None else: exit_code = proc.poll() running = exit_code is None diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 9f6efd25d..d7a5c25a5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -348,6 +348,66 @@ class TestWebServerEndpoints: resp = self.client.post("/api/audio/speak", json={"text": " "}) assert resp.status_code == 400 + def test_update_hermes_returns_docker_guidance_without_spawning(self, monkeypatch): + import hermes_cli.web_server as web_server + + spawned = False + + def fail_spawn(*_args, **_kwargs): + nonlocal spawned + spawned = True + raise AssertionError("docker update guard should not spawn hermes update") + + monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "docker") + monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn) + web_server._ACTION_PROCS.pop("hermes-update", None) + web_server._ACTION_RESULTS.pop("hermes-update", None) + + resp = self.client.post("/api/hermes/update") + + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert data["name"] == "hermes-update" + assert data["pid"] is None + assert data["error"] == "docker_update_unsupported" + assert "docker pull nousresearch/hermes-agent:latest" in data["message"] + assert spawned is False + + status = self.client.get("/api/actions/hermes-update/status") + assert status.status_code == 200 + status_data = status.json() + assert status_data["running"] is False + assert status_data["exit_code"] == 1 + assert status_data["pid"] is None + assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"]) + + def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch): + import hermes_cli.web_server as web_server + + class Proc: + pid = 12345 + + def poll(self): + return None + + calls = [] + + def fake_spawn(subcommand, name): + calls.append((subcommand, name)) + return Proc() + + monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git") + monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn) + web_server._ACTION_PROCS.pop("hermes-update", None) + web_server._ACTION_RESULTS.pop("hermes-update", None) + + resp = self.client.post("/api/hermes/update") + + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "pid": 12345, "name": "hermes-update"} + assert calls == [(["update"], "hermes-update")] + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): import gateway.config as gateway_config import hermes_cli.web_server as web_server diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f475201d1..228a08b1a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -526,7 +526,10 @@ export interface AuthMeResponse { export interface ActionResponse { name: string; ok: boolean; - pid: number; + pid: number | null; + error?: string; + message?: string; + update_command?: string; } /** Per-call overrides for {@link fetchJSON}. */