diff --git a/tests/tui_gateway/test_multi_profile_hosting_fail_closed.py b/tests/tui_gateway/test_multi_profile_hosting_fail_closed.py index 3dc1412099..8ae0869145 100644 --- a/tests/tui_gateway/test_multi_profile_hosting_fail_closed.py +++ b/tests/tui_gateway/test_multi_profile_hosting_fail_closed.py @@ -28,6 +28,8 @@ A_API_KEY = "launch-api-key-0004" B_API_KEY = "secondary-api-key-0005" A_BASE_URL = "https://launch.example.invalid/v1" B_BASE_URL = "https://secondary.example.invalid/v1" +A_CODEX_URL = "https://launch.example.invalid/codex" +B_CODEX_URL = "https://secondary.example.invalid/codex" @pytest.fixture @@ -37,10 +39,12 @@ def two_homes(tmp_path, monkeypatch): b = root / "profiles" / "b" b.mkdir(parents=True) (root / ".env").write_text( - f"A_ONLY_TOKEN={A_VAL}\nHERMES_API_KEY={A_API_KEY}\nHERMES_BASE_URL={A_BASE_URL}\n", + f"A_ONLY_TOKEN={A_VAL}\nHERMES_API_KEY={A_API_KEY}\nHERMES_BASE_URL={A_BASE_URL}\n" + f"HERMES_CODEX_BASE_URL={A_CODEX_URL}\n", encoding="utf-8") (b / ".env").write_text( - f"B_ONLY_TOKEN={B_VAL}\nHERMES_API_KEY={B_API_KEY}\nHERMES_BASE_URL={B_BASE_URL}\n", + f"B_ONLY_TOKEN={B_VAL}\nHERMES_API_KEY={B_API_KEY}\nHERMES_BASE_URL={B_BASE_URL}\n" + f"HERMES_CODEX_BASE_URL={B_CODEX_URL}\n", encoding="utf-8") for home in (root, b): (home / "config.yaml").write_text( @@ -50,6 +54,7 @@ def two_homes(tmp_path, monkeypatch): monkeypatch.setenv("A_ONLY_TOKEN", A_VAL) # the launch process loaded its own .env monkeypatch.setenv("HERMES_API_KEY", A_API_KEY) monkeypatch.setenv("HERMES_BASE_URL", A_BASE_URL) + monkeypatch.setenv("HERMES_CODEX_BASE_URL", A_CODEX_URL) monkeypatch.setenv("INJECTED_TOKEN", ENV_VAL) # systemd / op run credential injection monkeypatch.setattr(server, "_hermes_home", root) monkeypatch.setattr(server, "_served_profile_homes", set()) @@ -199,6 +204,67 @@ def test_manual_compress_routes_bind_the_sessions_full_runtime_scope(two_homes, assert "B_ONLY_TOKEN" not in os.environ +def test_live_review_binds_runtime_scope_under_multiplex(two_homes, monkeypatch): + """Desktop /review is off-turn; start_review must still see the session's secrets (#117544).""" + from agent.secret_scope import UnscopedSecretError, get_secret + from hermes_constants import get_hermes_home + from tui_gateway.transport import StdioTransport + + root, b = two_homes + seen = [] + + def fake_start_review(agent, snapshot, prompt): + seen.append(( + Path(get_hermes_home()), + get_secret("A_ONLY_TOKEN"), + get_secret("B_ONLY_TOKEN"), + get_secret("HERMES_CODEX_BASE_URL"), + )) + return {"status": "dispatched", "delegation_id": "deleg_x"} + + def invoke(profile_home): + sid = f"review-{len(seen)}" + session = { + "agent": object(), + "profile_home": str(profile_home) if profile_home else None, + "history": [{"role": "user", "content": "hi"}], + "history_lock": threading.Lock(), + "running": False, + "session_key": sid, + "cwd": "", + "source": "desktop", + "transport": StdioTransport(lambda: None, threading.Lock()), + } + server._sessions[sid] = session + token = server.bind_transport(session["transport"]) + try: + with ( + monkeypatch.context() as ctx, + ): + ctx.setattr(server, "_session_uses_compute_host", lambda value: False) + from unittest.mock import patch + with patch("agent.review_engine.start_review", fake_start_review): + out = server._live_slash_command_output(sid, session, "review", "") + assert out == "Review started. Results will return here." + finally: + server.reset_transport(token) + server._sessions.pop(sid, None) + + invoke(None) + _probe("b") + with pytest.raises(UnscopedSecretError): + get_secret("HERMES_CODEX_BASE_URL") + invoke(b) + invoke(None) + + assert seen == [ + (root, A_VAL, None, A_CODEX_URL), + (b, None, B_VAL, B_CODEX_URL), + (root, A_VAL, None, A_CODEX_URL), + ] + assert os.environ["HERMES_CODEX_BASE_URL"] == A_CODEX_URL + + def test_config_show_keeps_each_profiles_values_after_multiplex_activation(two_homes): """A→B→A config.show calls resolve the requested profile instead of running unscoped.""" root, b = two_homes diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 2bf17d90a1..0e52b3bfee 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -44,7 +44,12 @@ def _format_live_review_output(sid: str, session: Optional[dict], arg: str) -> s runtime_token = _current_runtime_session_record.set(session) try: from agent.review_engine import format_dispatch_note, start_review - result = start_review(agent, snapshot, arg or "") + # slash.exec is off-turn (RPC pool). start_review → resolve_runtime_provider + # reads HERMES_CODEX_BASE_URL via get_secret; under multiplex that raises + # UnscopedSecretError unless the same runtime scope a turn binds is here + # (#117544; same wrap as _compress_live_with_feedback / #116611). + with _session_profile_runtime_scope(session): + result = start_review(agent, snapshot, arg or "") except ValueError as exc: return str(exc) except Exception as exc: