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.
279 lines
12 KiB
Python
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")]
|