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 <donovan-yohan@users.noreply.github.com>
Co-authored-by: Donovan Yohan <34756395+donovan-yohan@users.noreply.github.com>
This commit is contained in:
Ben Barclay
2026-06-01 15:39:35 +10:00
committed by GitHub
parent 359f2be12e
commit c1a531d063
3 changed files with 104 additions and 3 deletions

View File

@ -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 <subcommand>`` 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

View File

@ -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

View File

@ -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}. */