Files
hermes-agent/tests/tui_gateway/test_profile_target_unavailable.py
teknium1 105e27f962 fix(tui-gateway): unavailable profile param is JSON-RPC 4064, not a ws dispatch crash
_profile_home deliberately raises for an explicit target that no longer exists
(an unavailable target must never fall back to the launch profile). Nothing
translated that exception, so a desktop client still holding a deleted profile
turned every profile-scoped RPC (session.create/resume, config.get/set, ...)
into "ws dispatch crash" + -32603. Raise a typed ProfileUnavailableError
(FileNotFoundError subclass, so method-level contracts are unchanged) and map
it once in handle_request to code 4064 — the code the other profile-scoped
surfaces already use for "profile not found". The display-only
_response_profile_name fall-back from #107831 now catches the typed error.

Folds the salvaged test into the existing target-unavailable file (one
invariant test covering both methods and the display helper). Fixes #107829.
2026-09-13 15:45:57 -07:00

101 lines
4.5 KiB
Python

"""An unavailable explicit target must never become the launch profile."""
from pathlib import Path
import pytest
def test_explicit_profile_target_never_falls_back(tmp_path, monkeypatch):
from tui_gateway import server
from hermes_state import SessionDB
home = tmp_path / ".hermes"
worker = home / "profiles" / "worker"
worker.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(server, "_hermes_home", home)
for path, marker in ((home, "launch"), (worker, "worker")):
(path / "config.yaml").write_text(f"terminal:\n cwd: /{marker}\n")
with SessionDB(db_path=path / "state.db") as db:
db.create_session(marker, "tui")
for name, marker in ((None, "launch"), ("default", "launch"), ("DEFAULT", "launch"), ("worker", "worker")):
with server._profile_db({"profile": name}) as db:
assert db.get_session(marker)
response = server._methods["config.get"](1, {"profile": name, "key": "full"})
assert response["result"]["config"]["terminal"]["cwd"] == f"/{marker}"
before = (home / "config.yaml").read_bytes()
worker.rename(worker.with_name("gone"))
for name in ("worker", "unknown"):
with pytest.raises(FileNotFoundError):
with server._profile_db({"profile": name}):
pytest.fail("unavailable profile reached a database")
with pytest.raises(FileNotFoundError):
server._methods["config.set"](2, {"profile": name, "key": "busy", "value": "steer"})
assert (home / "config.yaml").read_bytes() == before
# A real resolution I/O failure must propagate, too (no predicate patch).
profiles = home / "profiles"
profiles.rename(home / "saved-profiles")
profiles.symlink_to("profiles")
with pytest.raises((OSError, RuntimeError)):
server._profile_home("worker")
def test_custom_root_basename_target_fails_closed_when_unavailable(tmp_path, monkeypatch):
"""An explicit profile request matching a custom root basename fails closed."""
from tui_gateway import server
custom_home = tmp_path / "customer-data"
custom_home.mkdir()
(custom_home / "config.yaml").write_text("terminal:\n cwd: /custom\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(custom_home))
monkeypatch.setattr(server, "_hermes_home", custom_home)
with pytest.raises(FileNotFoundError):
server._profile_home("customer-data")
with pytest.raises(FileNotFoundError):
with server._profile_db({"profile": "customer-data"}):
pass
@pytest.mark.parametrize("name", ["..", "../outside", "../../tmp", "a/b", "a\\b", ".hidden"])
def test_profile_param_traversal_fails_closed(tmp_path, monkeypatch, name):
"""A traversal-shaped ``profile`` param must never resolve outside profiles/."""
from tui_gateway import server
home = tmp_path / ".hermes"
outside = tmp_path / "outside"
outside.mkdir(parents=True) # a real directory the traversal could land on
home.mkdir()
(home / "config.yaml").write_text("terminal:\n cwd: /launch\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(server, "_hermes_home", home)
with pytest.raises(FileNotFoundError):
server._profile_home(name)
with pytest.raises(FileNotFoundError):
with server._profile_db({"profile": name}):
pass
def test_unavailable_profile_is_a_typed_rpc_error_not_a_dispatch_crash(tmp_path, monkeypatch):
"""A client still holding a deleted profile gets JSON-RPC 4064 from every profile-scoped
method (#107829) — the method itself keeps raising, the dispatcher chokepoint maps it."""
from tui_gateway import server
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("terminal:\n cwd: /launch\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(server, "_hermes_home", home)
for method, params in (("session.create", {"profile": "gone"}),
("config.get", {"profile": "gone", "key": "full"})):
resp = server.handle_request({"jsonrpc": "2.0", "id": 7, "method": method, "params": params})
assert resp["error"]["code"] == 4064, resp
assert "gone" in resp["error"]["message"]
assert server._response_profile_name("gone") == server._current_profile_name()