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:
committed by
nesquena-hermes
parent
6b80cc781f
commit
453f2519f0
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user