fix(mcp): env-aware WEBUI_URL + refuse delete_project unassign without auth

Blocker fixes from maintainer review of #1895.

WEBUI_URL: replace hardcoded 'http://127.0.0.1:8788' with HERMES_WEBUI_HOST/
HERMES_WEBUI_PORT env vars defaulting to 127.0.0.1:8787, mirroring the
contract in api/config.py:32-33. The 8788 default would have failed every
fresh upstream install — 8787 is canonical, 8788 is a local-deployment
quirk on hosts where 8787 is taken by another service.

delete_project no-auth path: remove the filesystem fallback that wrote
session_data['project_id']=None directly via os.replace(). That bypassed
_write_session_index() and left _index.json holding the stale project_id,
causing a running WebUI to keep grouping sessions under the deleted
project until something else triggered a re-compact. Even calling
Session.save() in-process would not have helped because the WebUI's
SESSIONS dict cache lives in a separate process and would overwrite our
update on its next save. The HTTP API is the only cache-safe path —
without auth we now refuse the unassign and surface a 'warning' field.

Tests: + test_delete_no_auth_refuses_unassign locks the new behaviour
(project deleted, sessions and index untouched, warning surfaced).

Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Samuel Gudi
2026-05-08 18:05:27 +02:00
committed by nesquena-hermes
parent 6b80cc781f
commit 453f2519f0
2 changed files with 74 additions and 16 deletions

View File

@@ -64,7 +64,12 @@ if _profile_arg is not None:
_profiles._active_profile = _profile_arg
# ── API auth state ─────────────────────────────────────────────────────────
WEBUI_URL = "http://127.0.0.1:8788"
# Mirror the env-var contract used by api/config.py:32-33 so a non-default
# WebUI port/host (e.g. when 8787 is held by another service on the host)
# Just Works without configuration drift between the WebUI process and MCP.
WEBUI_HOST = os.environ.get("HERMES_WEBUI_HOST", "127.0.0.1")
WEBUI_PORT = os.environ.get("HERMES_WEBUI_PORT", "8787")
WEBUI_URL = f"http://{WEBUI_HOST}:{WEBUI_PORT}"
_auth_cookie: str | None = None
_auth_expires: float = 0 # unix timestamp after which we re-auth
@@ -353,9 +358,29 @@ async def handle_delete_project(arguments: dict) -> list[TextContent]:
projects = [p for p in projects if p["project_id"] != project_id]
save_projects(projects)
# Unassign sessions — use API if auth available, filesystem otherwise
unassigned = 0
# Unassign sessions only when we can do it cache-safely via the HTTP API.
# The previous filesystem fallback wrote session_data directly with
# os.replace(), which bypassed _write_session_index() in api/models.py
# and left _index.json holding the stale project_id — a running WebUI
# would still group those sessions under the deleted project until a
# subsequent re-compact. Even calling Session.save() in-process would
# not help because the WebUI's SESSIONS dict cache (a separate process)
# still has the old project_id and overwrites our update on its next
# save. The HTTP API is the only cache-safe path; without auth we
# refuse and surface the limitation so the operator can act.
has_auth = bool(_api_password())
if not has_auth:
return [TextContent(type="text", text=json.dumps({
"ok": True,
"deleted": proj["name"],
"unassigned_sessions": 0,
"warning": "Set HERMES_WEBUI_PASSWORD to unassign sessions; "
"without auth the session index cannot be safely "
"updated and direct filesystem writes would cause "
"index drift in a running WebUI.",
}, ensure_ascii=False))]
unassigned = 0
if SESSION_DIR.exists():
for p in SESSION_DIR.glob("*.json"):
if p.name.startswith("_"):
@@ -364,19 +389,9 @@ async def handle_delete_project(arguments: dict) -> list[TextContent]:
session_data = json.loads(p.read_text(encoding="utf-8"))
if session_data.get("project_id") == project_id:
sid = p.stem
if has_auth:
result = _api_post("/api/session/move",
{"session_id": sid, "project_id": None})
if "ok" in result or "session" in result:
unassigned += 1
else:
# Filesystem fallback (may be overwritten by server cache)
session_data["project_id"] = None
tmp = p.with_suffix(".tmp")
tmp.write_text(
json.dumps(session_data, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, p)
result = _api_post("/api/session/move",
{"session_id": sid, "project_id": None})
if "ok" in result or "session" in result:
unassigned += 1
except Exception:
pass

View File

@@ -197,6 +197,49 @@ class TestDeleteProject:
result = await _call(self.mod, "delete_project", project_id=pid)
assert "error" in result
async def test_delete_no_auth_refuses_unassign(self):
"""Without HERMES_WEBUI_PASSWORD, delete_project must NOT touch
session JSONs. Direct FS writes would bypass _write_session_index()
and leave _index.json holding the stale project_id, causing a
running WebUI to keep grouping sessions under the deleted project.
The handler should: delete the project from projects.json, leave
every session JSON untouched, leave the index untouched, and
surface a `warning` field telling the operator to set the env var.
"""
from api.config import SESSION_DIR, SESSION_INDEX_FILE
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
# Create project + a session JSON that points at it
created = await _call(self.mod, "create_project", name="ToDelete")
pid = created["project_id"]
sid = "test_sess_001"
session_path = SESSION_DIR / f"{sid}.json"
session_payload = {
"session_id": sid,
"title": "T",
"project_id": pid,
"messages": [],
}
session_path.write_text(json.dumps(session_payload), encoding="utf-8")
# Index references the session under the project
SESSION_INDEX_FILE.write_text(
json.dumps([{"session_id": sid, "project_id": pid, "title": "T"}]),
encoding="utf-8")
index_before = SESSION_INDEX_FILE.read_text(encoding="utf-8")
session_before = session_path.read_text(encoding="utf-8")
result = await _call(self.mod, "delete_project", project_id=pid)
assert result["ok"] is True
assert result["unassigned_sessions"] == 0
assert "warning" in result
assert "HERMES_WEBUI_PASSWORD" in result["warning"]
# Session JSON untouched
assert session_path.read_text(encoding="utf-8") == session_before
# Index untouched
assert SESSION_INDEX_FILE.read_text(encoding="utf-8") == index_before
# ═══════════════════════════════════════════════════════════════════════════
# Profile Scoping