Files
hermes-agent/tests/hermes_cli/test_web_routers_destructive_profile_scope.py
shali10 40523600b0 fix(sessions): refuse to delete a session row a live turn still owns (#123583)
Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:

- Underlying `delete_session` and `delete_sessions` now accept an opt-in
  kwarg `exclude_active_write_guards=True` running inside `_do` write
  transaction, eliminating the race condition where a turn acquires the lease
  between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
  the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
  reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
  * Web `DELETE /api/sessions/{id}` -> 409 Conflict
  * Web `POST /api/sessions/bulk-delete` -> skips active rows
  * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
    parents of active conversations are not pruned
  * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
  * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
  * CLI browse picker -> refuses active delete
  * TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
  `tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2026-09-27 20:49:04 +05:30

468 lines
19 KiB
Python

"""One backend serves every profile, so a REST handler that reads ``get_hermes_home()``
directly mutates the LAUNCH profile's data no matter which profile the request named.
These pin both halves of the contract:
* a named profile is the one that gets wiped / written / spawned against, and the launch
profile survives untouched;
* an UNNAMED profile is refused (400) on a destructive or privileged route while several
profiles are served, and still means the launch profile on a genuinely single-profile host.
Multi-profile hosting is NOT faked here. ``destructive_profile`` asks
``agent.secret_scope.is_multiplex_active()``, and that flag is only ever set by the boot
wiring (``activate_multi_profile_hosting_eagerly``) or by the lazy backstop inside
``_config_profile_scope``. Patching the predicate would leave exactly that wiring — the
thing the whole 400 branch rests on — untested, so the tests drive the real path and the
autouse fixture below puts the process-global flag back (activation is one-way per process).
"""
import json
import zipfile
import pytest
import hermes_yaml as yaml
@pytest.fixture(autouse=True)
def _multiplex_state_is_per_test():
"""Multi-profile activation is a one-way PROCESS-global flip; contain it to one test.
Entered single-profile (so a leak from an earlier module cannot make an unnamed
request 400 by accident) and restored exactly as found, including the frozen launch
env snapshot activation captures.
"""
import agent.secret_scope as secret_scope
from tui_gateway import launch_profile_policy
was_active = secret_scope.is_multiplex_active()
snapshot = launch_profile_policy._snapshot
secret_scope.set_multiplex_active(False)
launch_profile_policy._snapshot = None
try:
yield
finally:
secret_scope.set_multiplex_active(was_active)
launch_profile_policy._snapshot = snapshot
@pytest.fixture
def homes(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated launch home + one named profile, both seeded with real files."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
launch_home = get_hermes_home()
profiles_root = launch_home / "profiles"
beta = profiles_root / "worker_beta"
for home in (launch_home, beta):
(home / "memories").mkdir(parents=True, exist_ok=True)
(home / "memories" / "MEMORY.md").write_text(f"memory of {home.name}\n", encoding="utf-8")
(home / "memories" / "USER.md").write_text(f"user of {home.name}\n", encoding="utf-8")
(home / "webhook_subscriptions.json").write_text(
json.dumps({"alerts": {"secret": "s", "events": []}}), encoding="utf-8")
(home / "config.yaml").write_text(yaml.safe_dump({
"hooks": {"pre_tool_call": [{"command": "/bin/true"}]},
# A per-home marker every scoped READ path can be identified by.
"proxy": {"label": home.name},
}), encoding="utf-8")
(home / ".update_check").write_text("cached\n", encoding="utf-8")
# Non-empty: an empty ``.env`` is the crashed-``profile create`` shell that
# ``named_profile_has_servable_identity`` deliberately refuses to count as a tenant.
(beta / ".env").write_text("BETA=1\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: launch_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"launch": launch_home, "worker_beta": beta}
@pytest.fixture
def client(monkeypatch, homes):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", homes["launch"] / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
class _StubDB:
"""Just enough session store for the delete/prune bodies; records nothing itself."""
def delete_sessions(self, ids, **_kwargs):
return len(ids)
def delete_empty_sessions(self):
return 1
def count_open_prune_matches(self, **_filters):
return 0
def list_prune_candidates(self, **_filters):
return []
def prune_sessions(self, **_kwargs):
return 1
def close(self):
pass
@pytest.fixture
def seams(monkeypatch):
"""Record the three off-process effects these routes have, and neutralise them.
``spawn`` = the argv of every backgrounded ``hermes`` action, ``db`` = the profile every
session-store open names, ``pool_home`` = the home the credential-pool body resolves.
A route that reaches any of them after a 400 shows up as a non-empty list.
"""
import agent.credential_pool as credential_pool
import agent.credential_sources as credential_sources
from hermes_cli import web_server_gateway, web_server_sessions
from hermes_cli.config import get_hermes_home
record = {"spawn": [], "db": [], "pool_home": []}
class _Proc:
pid = 4242
def _spawn(argv, name):
record["spawn"].append((name, list(argv)))
return _Proc()
def _open_db(profile, *, read_only):
record["db"].append(profile)
return _StubDB()
class _Pool:
def remove_index(self, _index):
record["pool_home"].append(str(get_hermes_home()))
return type("_Entry", (), {"source": ""})()
def entries(self):
return []
monkeypatch.setattr(web_server_gateway, "_spawn_hermes_action", _spawn)
monkeypatch.setattr(web_server_sessions, "_open_session_db_for_profile", _open_db)
monkeypatch.setattr(credential_pool, "load_pool", lambda _provider: _Pool())
monkeypatch.setattr(credential_sources, "find_removal_step", lambda *_a: None)
return record
@pytest.fixture
def multiplexed(homes):
"""Arm the guard the way a real two-profile host does: the BOOT probe.
``activate_multi_profile_hosting_eagerly`` enumerates this host's servable profile
homes and flips ``set_multiplex_active`` itself; asserting it returned True is what
proves the wiring — not just the flag — is what refuses the unnamed requests below.
"""
from agent.secret_scope import is_multiplex_active
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting_eagerly
assert activate_multi_profile_hosting_eagerly() is True, "boot probe did not see two homes"
assert is_multiplex_active()
def _cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
def _hooks(home):
return _cfg(home).get("hooks") or {}
def _zip(tmp_path):
path = tmp_path / "backup.zip"
with zipfile.ZipFile(path, "w") as zf:
zf.writestr("manifest.json", "{}")
return path
def _q(profile):
"""``?profile=`` fragment, or nothing at all when the request names no profile."""
return f"?profile={profile}" if profile else ""
def _body_profile(profile):
"""The session family takes its profile in the BODY, so an unnamed request omits the key."""
return {"profile": profile} if profile else {}
# Every destructive/privileged route, as a call taking (client, profile-name-or-"", tmp_path).
DESTRUCTIVE = {
"memory-reset": lambda c, p, t: c.post(f"/api/memory/reset{_q(p)}", json={"target": "all"}),
"webhook-delete": lambda c, p, t: c.delete(f"/api/webhooks/alerts{_q(p)}"),
"hook-delete": lambda c, p, t: c.request(
"DELETE", f"/api/ops/hooks{_q(p)}", json={"event": "pre_tool_call", "command": "/bin/true"}),
"hook-create": lambda c, p, t: c.post(
f"/api/ops/hooks{_q(p)}", json={"event": "pre_tool_call", "command": "/bin/armed"}),
"checkpoints-prune": lambda c, p, t: c.post(f"/api/ops/checkpoints/prune{_q(p)}"),
"import": lambda c, p, t: c.post(f"/api/ops/import{_q(p)}", json={"archive": str(_zip(t))}),
"import-upload": lambda c, p, t: c.post(
f"/api/ops/import-upload{_q(p)}",
files={"file": ("backup.zip", _zip(t).read_bytes(), "application/zip")}),
"credentials-pool-delete": lambda c, p, t: c.delete(f"/api/credentials/pool/anthropic/0{_q(p)}"),
"curator-run": lambda c, p, t: c.post(f"/api/curator/run{_q(p)}"),
"sessions-prune": lambda c, p, t: c.post(
"/api/sessions/prune", json={"dry_run": False, **_body_profile(p)}),
"sessions-empty": lambda c, p, t: c.delete(f"/api/sessions/empty{_q(p)}"),
"sessions-bulk-delete": lambda c, p, t: c.post(
"/api/sessions/bulk-delete", json={"ids": ["abc"], **_body_profile(p)}),
}
@pytest.mark.parametrize("route", sorted(DESTRUCTIVE))
def test_destructive_route_refuses_an_unnamed_profile_while_multiplexing(
client, homes, seams, multiplexed, tmp_path, route
):
"""No profile named + several served = refused, and provably nothing happened."""
resp = DESTRUCTIVE[route](client, "", tmp_path)
assert resp.status_code == 400, resp.text
assert seams == {"spawn": [], "db": [], "pool_home": []}, f"{route} reached a backend anyway"
for home in homes.values():
assert (home / "memories" / "MEMORY.md").exists()
assert "alerts" in json.loads((home / "webhook_subscriptions.json").read_text())
assert _hooks(home) == {"pre_tool_call": [{"command": "/bin/true"}]}
# Routes whose effect is a file inside the target home: (call, "did it happen here?").
FILE_EFFECTS = {
"memory-reset": (DESTRUCTIVE["memory-reset"],
lambda home: not (home / "memories" / "MEMORY.md").exists()),
"webhook-delete": (DESTRUCTIVE["webhook-delete"],
lambda home: "alerts" not in json.loads((home / "webhook_subscriptions.json").read_text())),
"hook-delete": (DESTRUCTIVE["hook-delete"], lambda home: not _hooks(home)),
"hook-create": (DESTRUCTIVE["hook-create"],
lambda home: any(e.get("command") == "/bin/armed"
for e in _hooks(home).get("pre_tool_call", []))),
}
@pytest.mark.parametrize("route", sorted(FILE_EFFECTS))
def test_named_profile_is_the_only_one_touched(client, homes, seams, tmp_path, route):
call, happened = FILE_EFFECTS[route]
resp = call(client, "worker_beta", tmp_path)
assert resp.status_code == 200, resp.text
assert happened(homes["worker_beta"]), f"{route} did not act on worker_beta"
assert not happened(homes["launch"]), f"{route} also hit the launch profile"
# Routes whose effect is a backgrounded ``hermes`` subprocess: the profile must reach its argv,
# because nothing else in that process knows which home the request meant.
SPAWNING = {
"checkpoints-prune": DESTRUCTIVE["checkpoints-prune"],
"curator-run": DESTRUCTIVE["curator-run"],
"import": DESTRUCTIVE["import"],
}
@pytest.mark.parametrize("route", sorted(SPAWNING))
def test_spawned_action_carries_the_named_profile(client, homes, seams, tmp_path, route):
resp = SPAWNING[route](client, "worker_beta", tmp_path)
assert resp.status_code == 200, resp.text
assert [argv[:2] for _name, argv in seams["spawn"]] == [["-p", "worker_beta"]]
@pytest.mark.parametrize("route", ["sessions-prune", "sessions-empty", "sessions-bulk-delete"])
def test_session_route_opens_the_named_profiles_store(client, homes, seams, tmp_path, route):
resp = DESTRUCTIVE[route](client, "worker_beta", tmp_path)
assert resp.status_code == 200, resp.text
assert seams["db"] == ["worker_beta"]
def test_session_prune_dry_run_still_works_unnamed_while_multiplexing(client, seams, multiplexed):
"""A preview deletes nothing, so the confirm dialog must keep working without a profile."""
resp = client.post("/api/sessions/prune", json={"dry_run": True})
assert resp.status_code == 200, resp.text
assert resp.json()["removed"] == 0
assert seams["db"] == [None]
def test_credential_pool_delete_runs_in_the_named_profiles_home(client, homes, seams):
resp = client.delete("/api/credentials/pool/anthropic/0?profile=worker_beta")
assert resp.status_code == 200, resp.text
assert seams["pool_home"] == [str(homes["worker_beta"])]
# --- activation: the wiring the 400 branch depends on -------------------------------
def test_a_request_for_another_profile_arms_the_guard(client, homes):
"""The lazy backstop: the first cross-profile request is itself the activation."""
from agent.secret_scope import is_multiplex_active
assert not is_multiplex_active()
assert client.get("/api/memory?profile=worker_beta").status_code == 200
assert is_multiplex_active()
assert client.post("/api/memory/reset", json={"target": "all"}).status_code == 400
assert (homes["launch"] / "memories" / "MEMORY.md").exists()
def test_unnamed_profile_still_means_the_launch_profile_on_a_single_profile_host(
client, homes, monkeypatch, tmp_path
):
"""A plain ``hermes serve`` has nothing to confuse: `curl` with no profile is unchanged."""
from agent.secret_scope import is_multiplex_active
from hermes_cli import profiles
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting_eagerly
empty_root = tmp_path / "no-named-profiles"
empty_root.mkdir()
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: empty_root)
assert activate_multi_profile_hosting_eagerly() is False
assert not is_multiplex_active()
resp = client.post("/api/memory/reset", json={"target": "all"})
assert resp.status_code == 200, resp.text
assert not (homes["launch"] / "memories" / "MEMORY.md").exists()
assert (homes["worker_beta"] / "memories" / "MEMORY.md").exists()
# --- config writes that are scoped but not destructive ------------------------------
def test_plugin_providers_writes_the_named_profiles_config(client, homes):
resp = client.put("/api/dashboard/plugin-providers?profile=worker_beta",
json={"context_engine": "compaction-v2"})
assert resp.status_code == 200, resp.text
assert _cfg(homes["worker_beta"]).get("context") == {"engine": "compaction-v2"}
assert "context" not in _cfg(homes["launch"])
@pytest.fixture
def readiness_only_in_beta(homes, monkeypatch):
"""A memory provider that is ready in worker_beta and nowhere else.
Readiness resolves through ``load_config()``, so a readiness check that runs OUTSIDE the
request's scope answers about the launch profile: it refuses a provider the target has
configured, and — the dangerous direction — accepts one only the launch profile has and
writes it into the target as a broken setting.
"""
from hermes_cli import web_server_memory
from hermes_cli.config import get_hermes_home
def _statuses():
ready = get_hermes_home().resolve() == homes["worker_beta"].resolve()
return [{"name": "mem0", "status": "ready" if ready else "not_configured"}]
monkeypatch.setattr(web_server_memory, "_discover_memory_provider_statuses", _statuses)
@pytest.mark.parametrize("call", [
pytest.param(lambda c: c.put("/api/memory/provider?profile=worker_beta", json={"provider": "mem0"}),
id="memory-provider"),
pytest.param(lambda c: c.put("/api/dashboard/plugin-providers?profile=worker_beta",
json={"memory_provider": "mem0"}),
id="plugin-providers"),
])
def test_memory_provider_readiness_is_judged_in_the_profile_being_written(
client, homes, readiness_only_in_beta, call
):
resp = call(client)
assert resp.status_code == 200, resp.text
assert _cfg(homes["worker_beta"])["memory"]["provider"] == "mem0"
assert "memory" not in _cfg(homes["launch"])
def test_local_models_quickstart_activates_into_the_named_profile(client, homes, monkeypatch):
"""Quickstart is ``activate`` plus a download; its config writes must follow ``?profile=``."""
from hermes_cli.config import get_hermes_home
from hermes_cli.web_routers import local_models as lm
entry = type("_Entry", (), {"id": "m1", "display_name": "M One", "min_engine": None})()
variant = type("_Variant", (), {"model_id": "m1-q4"})()
seen = []
monkeypatch.setattr(lm.hardware, "probe_budget", lambda **_kw: None)
monkeypatch.setattr(lm, "_quickstart_target", lambda _body, _budget: (entry, variant))
monkeypatch.setattr(lm, "_runtime_target", lambda *_a: ("b1", "cpu"))
monkeypatch.setattr(lm, "_download_plan", lambda *_a: [])
monkeypatch.setattr(lm.binaries, "installed_engine", lambda _backend: "installed")
monkeypatch.setattr(lm.bootstrap, "staged_model_ids", lambda: {"m1-q4"})
monkeypatch.setattr(lm, "_set_runtime_enabled", lambda _on: (lambda: None))
monkeypatch.setattr(lm, "_ensure_server", lambda *_a, **_k: None)
monkeypatch.setattr(lm, "_assign_default", lambda *_a: seen.append(str(get_hermes_home())))
# Run the job body inline; the real spawner's ``on_exit`` is what frees the quickstart lock.
monkeypatch.setattr(lm, "_spawn_job", lambda job, name, body, **kw: (body(), kw["on_exit"]()))
resp = client.post("/api/local-models/quickstart?profile=worker_beta", json={})
assert resp.status_code == 200, resp.text
assert seen == [str(homes["worker_beta"])]
# --- the read/side-effect routes scoped in the same sweep ---------------------------
def test_curator_pause_writes_the_named_profiles_state(client, homes):
resp = client.put("/api/curator/paused?profile=worker_beta", json={"paused": True})
assert resp.status_code == 200, resp.text
assert json.loads((homes["worker_beta"] / "skills" / ".curator_state").read_text())["paused"] is True
assert not (homes["launch"] / "skills" / ".curator_state").exists()
def test_forced_update_check_runs_in_the_named_profiles_scope(client, homes, monkeypatch):
from hermes_cli import source_check
from hermes_cli.config import get_hermes_home
from hermes_cli.web_routers import actions
seen = []
def check(*, force):
seen.append((get_hermes_home(), force))
return {"behind": 0, "commits": []}
monkeypatch.setattr(source_check, "check_for_updates", check)
monkeypatch.setattr(actions, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(actions, "detect_install_method", lambda _root: "git")
resp = client.get("/api/hermes/update/check?force=true&profile=worker_beta")
assert resp.status_code == 200, resp.text
assert resp.json()["behind"] == 0
assert seen == [(homes["worker_beta"], True)]
def test_egress_status_reads_the_named_profiles_config(client, homes, monkeypatch):
from hermes_cli import proxy_cli
from hermes_cli.config import load_config
monkeypatch.setattr(proxy_cli, "format_status_text",
lambda **_kw: str((load_config().get("proxy") or {}).get("label")))
resp = client.get("/api/egress/status?profile=worker_beta")
assert resp.status_code == 200, resp.text
assert resp.json()["text"] == "worker_beta"
def test_memory_provider_setup_runs_in_the_named_profiles_home(client, homes, monkeypatch):
from hermes_cli.config import get_hermes_home
from hermes_cli.web_routers import memory_providers as mp
monkeypatch.setattr(mp, "_memory_provider_manifest", lambda _name: {"name": "mem0"})
monkeypatch.setattr(mp, "_load_memory_provider", lambda _name: None)
monkeypatch.setattr(mp, "_install_memory_provider_setup",
lambda name: {"ok": True, "provider": name, "home": str(get_hermes_home())})
resp = client.post("/api/memory/providers/mem0/setup?profile=worker_beta", json={})
assert resp.status_code == 200, resp.text
assert resp.json()["home"] == str(homes["worker_beta"])