Files
hermes-agent/tests/tui_gateway/test_secret_capture_session.py
Hermes Agent 283de37b6b fix(tui): refuse secret requests after the owning runtime closes
A background/btw/preview worker keeps its bound HERMES_UI_SESSION_ID
after the session closes (_spawn_side_agent copies the context onto the
worker thread; the close path cancels only requests already open), so a
post-close secret ask registered, waited the full 300s deadline for a
client that never reconnects, and a late answer settled into the saver
with no owner left to revalidate. Fence admission at the ask: no live
session record for the owner, no request — the skill reports setup
needed.
2026-09-25 19:10:28 -05:00

279 lines
12 KiB
Python

"""Skill credential prompts go to the session that owns the turn, never the last wired one.
``set_secret_capture_callback`` holds one process-global callback, so the closure sid is just
whichever session called ``_wire_callbacks`` last. The prompt recipient has to be the UI owner
bound by ``_set_session_context``: the same session whose profile scope the answer is saved into.
Without a bound owner there is nobody to ask, so the prompt is skipped rather than guessed.
"""
import json
import sys
import threading
import pytest
def _gateway(monkeypatch):
"""Import the real gateway after neutralizing process-wide import side effects."""
from hermes_cli import banner
monkeypatch.setattr(banner, "prefetch_update_check", lambda: None)
monkeypatch.setattr(sys, "stdout", sys.stdout)
monkeypatch.setattr(sys, "excepthook", sys.excepthook)
monkeypatch.setattr(threading, "excepthook", threading.excepthook)
from agent.vault_backends import unlock
from tools import project_tools, skills_tool, terminal_tool, terminal_tool_sudo
from tui_gateway import server, server_requests
monkeypatch.setattr(terminal_tool, "_callback_tls", threading.local())
monkeypatch.setattr(unlock, "_callback_tls", threading.local())
monkeypatch.setattr(unlock, "_current_session_tls", threading.local())
monkeypatch.setattr(project_tools, "_workspace_callback", None)
monkeypatch.setattr(skills_tool, "_secret_capture_callback", None)
monkeypatch.setattr(terminal_tool_sudo, "_sudo_password_cache", {})
monkeypatch.delenv("HERMES_UI_SESSION_ID", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
return server, server_requests, skills_tool
def _client(server, server_requests, monkeypatch, values):
"""Stand in for the renderer: record every frame and answer each ``secret`` request with the
value its recipient would type. Returns ``(frames, completed)``; ``completed`` is set when a
``background.complete`` event arrives."""
frames, completed = [], threading.Event()
def write(frame):
frames.append(frame)
if frame.get("method") == "secret":
sid = frame["params"]["session_id"]
assert server_requests.resolve_response(
{"jsonrpc": "2.0", "id": frame["id"], "result": {"value": values[sid]}}
)
elif (frame.get("params") or {}).get("type") == "background.complete":
completed.set()
return True
monkeypatch.setattr(server, "write_json", write)
return frames, completed
def _capture(server, server_requests, skills_tool, monkeypatch, *, values):
"""Drive the real secret ask and record which session received it and what was stored."""
stored = []
def save(key, value):
stored.append((key, value))
return {"success": True, "stored_as": key, "validated": False}
frames, _ = _client(server, server_requests, monkeypatch, values)
monkeypatch.setattr("hermes_cli.config.save_env_value_secure", save)
result = skills_tool._capture_required_environment_variables(
"demo-skill", [{"name": "DEMO_TOKEN", "prompt": "Token"}]
)
return frames, stored, result
def test_secret_prompt_goes_to_active_turn_not_last_wired_session(monkeypatch):
"""While session A's turn is active, a prompt must not land on the closure sid."""
from gateway.session_context import get_session_env
server, server_requests, skills_tool = _gateway(monkeypatch)
server._wire_callbacks("session-A")
server._wire_callbacks("session-B") # replaces the process-global callback
# A live turn's UI owner is a live session record (secret admission is fenced to one;
# see test_closed_runtime_secret_request_is_refused).
with server._sessions_lock:
server._sessions["session-A"] = {"agent": object(), "session_key": "key-A"}
server._sessions["session-B"] = {"agent": object(), "session_key": "key-B"}
tokens = server._set_session_context("turn-A", ui_session_id="session-A")
try:
assert get_session_env("HERMES_UI_SESSION_ID") == "session-A"
frames, stored, result = _capture(
server, server_requests, skills_tool, monkeypatch,
values={"session-A": "owner-secret", "session-B": "closure-secret"},
)
finally:
server._clear_session_context(tokens)
with server._sessions_lock:
for sid in ("session-A", "session-B"):
server._sessions.pop(sid, None)
server_requests.reset_for_tests()
assert [frame["method"] for frame in frames] == ["secret"]
assert frames[0]["params"]["session_id"] == "session-A"
assert stored == [("DEMO_TOKEN", "owner-secret")]
assert result == {"missing_names": [], "setup_skipped": False, "gateway_setup_hint": None}
assert server_requests.open_requests("session-A") == []
assert server_requests.open_requests("session-B") == []
def test_secret_prompt_without_bound_owner_is_skipped_not_guessed(monkeypatch):
"""No UI owner in context: the wired sid is not ownership evidence, so nobody is asked."""
from gateway.session_context import get_session_env
server, server_requests, skills_tool = _gateway(monkeypatch)
server._wire_callbacks("only-session")
tokens = server._set_session_context("ownerless-task", cwd="")
try:
assert get_session_env("HERMES_UI_SESSION_ID") == ""
frames, stored, result = _capture(
server, server_requests, skills_tool, monkeypatch,
values={"only-session": "guessed-secret"},
)
finally:
server._clear_session_context(tokens)
server_requests.reset_for_tests()
assert frames == []
assert stored == []
assert result == {"missing_names": ["DEMO_TOKEN"], "setup_skipped": True, "gateway_setup_hint": None}
_SKILL = """---
name: secret-skill
description: needs a token
required_environment_variables:
- name: DEMO_TOKEN
prompt: Demo token
---
# secret-skill
"""
@pytest.fixture
def two_profiles(tmp_path, monkeypatch):
launch = tmp_path / ".hermes"
homes = [launch / "profiles" / name for name in ("a", "b")]
for home in homes:
skill_dir = home / "skills" / "demo" / "secret-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(_SKILL, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.delenv("DEMO_TOKEN", raising=False)
return launch, *homes
def _saved_tokens(home):
env = home / ".env"
lines = env.read_text(encoding="utf-8").splitlines() if env.exists() else []
return [line for line in lines if line.startswith("DEMO_TOKEN=")]
def test_background_prompt_secret_reaches_its_own_session_and_profile(two_profiles, monkeypatch):
"""prompt.background for A after B wired last: A is asked, and A's answer lands in A/.env.
The real RPC, worker thread, profile scope, skill_view readiness path, request settlement and
credential saver run; only the model boundary is replaced (it loads the skill as a tool call would).
"""
launch, home_a, home_b = two_profiles
server, server_requests, skills_tool = _gateway(monkeypatch)
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
class ModelBoundary:
def __init__(self, **_kw):
pass
def run_conversation(self, **_kw):
view = json.loads(skills_tool.skill_view("secret-skill", preprocess=False))
return {"final_response": view.get("readiness_status") or view.get("error")}
monkeypatch.setattr("run_agent.AIAgent", ModelBoundary)
monkeypatch.setattr(server, "_background_agent_kwargs", lambda _agent, _task_id: {})
sessions = {
sid: {"agent": object(), "session_key": f"key-{sid}", "profile_home": str(home), "cwd": str(home)}
for sid, home in (("session-A", home_a), ("session-B", home_b))
}
frames, completed = _client(
server, server_requests, monkeypatch, {"session-A": "a-secret", "session-B": "b-secret"})
with server._sessions_lock:
server._sessions.update(sessions)
try:
server._wire_callbacks("session-A")
server._wire_callbacks("session-B") # B owns the process-global callback now
reply = server._methods["prompt.background"](
"rid", {"session_id": "session-A", "text": "load secret-skill"})
assert "error" not in reply, reply
assert completed.wait(30), "background worker never completed"
finally:
with server._sessions_lock:
for sid in sessions:
server._sessions.pop(sid, None)
server_requests.reset_for_tests()
asks = [frame["params"] for frame in frames if frame.get("method") == "secret"]
assert [(ask["session_id"], ask["env_var"]) for ask in asks] == [("session-A", "DEMO_TOKEN")]
assert _saved_tokens(home_a) == ["DEMO_TOKEN=a-secret"]
assert _saved_tokens(home_b) == []
assert _saved_tokens(launch) == []
done = [frame["params"] for frame in frames if (frame.get("params") or {}).get("type") == "background.complete"]
assert [(event["session_id"], event["payload"]["text"]) for event in done] == [("session-A", "available")]
def test_closed_runtime_secret_request_is_refused(two_profiles, monkeypatch):
"""After its session closed, a worker's secret ask must create no request and save nothing.
andrexibiza's P2 scenario: start ``prompt.background`` for A, pause immediately before
credential capture, close A through the real ``session.close`` handler, then resume the
worker. ``_spawn_side_agent`` keeps A's UI/profile context on the worker thread, and the
close path cancels only requests ALREADY open — the ask that lands after the close used to
register (``_session_client_answers_requests`` treats an absent session as answerable,
``write_json`` falls back to stdio) and wait 300s, and a late answer settled into the saver
with the owner gone. The refusal must be at admission: zero post-close ``secret`` requests,
zero ``.env`` writes, and the skill reports setup needed.
"""
launch, home_a, home_b = two_profiles
server, server_requests, skills_tool = _gateway(monkeypatch)
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
ask_gate = threading.Event() # main → worker: resume the ask
ask_reached = threading.Event() # worker → main: paused right before credential capture
agent_a = object()
session_a = {
"agent": agent_a, "session_key": "key-session-A", "profile_home": str(home_a), "cwd": str(home_a)}
class ModelBoundary:
def __init__(self, **_kw):
pass
def run_conversation(self, **_kw):
ask_reached.set()
ask_gate.wait(30) # pause right before the skill's credential capture
view = json.loads(skills_tool.skill_view("secret-skill", preprocess=False))
return {"final_response": view.get("readiness_status") or view.get("error")}
monkeypatch.setattr("run_agent.AIAgent", ModelBoundary)
monkeypatch.setattr(server, "_background_agent_kwargs", lambda _agent, _task_id: {})
frames, completed = _client(server, server_requests, monkeypatch, {"session-A": "a-secret"})
with server._sessions_lock:
server._sessions["session-A"] = session_a
try:
server._wire_callbacks("session-B") # closure sid must not be borrowed either
reply = server._methods["prompt.background"](
"rid", {"session_id": "session-A", "text": "load secret-skill"})
assert "error" not in reply, reply
assert ask_reached.wait(30), "background worker never reached the ask"
# The real close path: the close handler pops the session and tears it down.
assert "error" not in server._methods["session.close"]("rid2", {"session_id": "session-A"})
with server._sessions_lock:
assert "session-A" not in server._sessions
ask_gate.set() # resume the worker: its secret ask arrives AFTER the close
assert completed.wait(30), "background worker never completed"
finally:
ask_gate.set()
with server._sessions_lock:
server._sessions.pop("session-A", None)
server_requests.reset_for_tests()
assert [frame for frame in frames if frame.get("method") == "secret"] == []
assert server_requests.open_request_count() == 0
assert _saved_tokens(home_a) == []
assert _saved_tokens(home_b) == []
assert _saved_tokens(launch) == []
done = [frame["params"] for frame in frames if (frame.get("params") or {}).get("type") == "background.complete"]
assert [(event["session_id"], event["payload"]["text"]) for event in done] == [("session-A", "setup_needed")]