tests/tui_gateway/test_multi_profile_hosting_fail_closed.py — through the registered RPC handlers: config.get for a secondary resolves only its own secrets and flips the process fail-closed; llm.oneshot / model.options bodies see the profile's home + secrets; a launch-profile agent build binds its own scope once multiplexing is active; CONTROL: a single-profile serve keeps the os.environ fall-through. tests/hermes_cli/test_web_multi_profile_scope.py — through the real FastAPI app: GET /api/config?profile=b expands only B's refs and never mutates os.environ; console `send` for B lands B's .env in the request scope, not the process env. All red on origin/main by source swap (control green), green on head. test_profile_terminal_scope_entrypoints follows the launch_profile_policy rename and releases the launch turn's new secret scope. tests/conftest.py resets the process-global hosting latch (_MULTIPLEX_ACTIVE, the frozen launch env, _served_profile_homes) per test: one request routed to a named profile otherwise left every later test in the file fail-closed.
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""Dashboard routers that read a named profile's config/credentials run under that profile's FULL
|
|
scope (home + secrets) and never mutate the dashboard process environment.
|
|
|
|
Regression for the cross-profile leak class in ``hermes dashboard`` / ``hermes serve``:
|
|
``_config_profile_scope`` bound only HERMES_HOME, so ``GET /api/config?profile=B`` expanded B's
|
|
``${VAR}`` refs to the DEFAULT profile's plaintext credentials (its ``os.environ``), and console
|
|
``send`` for B (``send_cmd._load_hermes_env``) copied B's ``.env`` into the shared process env with
|
|
``override=True``, so every later default-profile read saw B's tokens.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi")
|
|
from starlette.testclient import TestClient # noqa: E402
|
|
|
|
A_VAL = "a-only-secret-0001"
|
|
B_VAL = "b-only-secret-0002"
|
|
|
|
|
|
@pytest.fixture
|
|
def two_homes(tmp_path, monkeypatch):
|
|
root = tmp_path / "hermes_home"
|
|
b = root / "profiles" / "b"
|
|
b.mkdir(parents=True)
|
|
(root / ".env").write_text(f"A_ONLY_TOKEN={A_VAL}\n", encoding="utf-8")
|
|
(b / ".env").write_text(f"B_ONLY_TOKEN={B_VAL}\nTELEGRAM_BOT_TOKEN=b-telegram-token\n", encoding="utf-8")
|
|
for home in (root, b):
|
|
(home / "config.yaml").write_text(
|
|
"model:\n default: openai/gpt-4o-mini\n api_key: ${A_ONLY_TOKEN}\n"
|
|
"custom_probe:\n a_ref: ${A_ONLY_TOKEN}\n b_ref: ${B_ONLY_TOKEN}\n",
|
|
encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setenv("A_ONLY_TOKEN", A_VAL) # the dashboard process loaded its own .env
|
|
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
|
from agent import secret_scope
|
|
from tui_gateway import launch_profile_policy as lpp
|
|
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
|
|
monkeypatch.setattr(lpp, "_snapshot", None)
|
|
from hermes_cli import config as cfg_mod
|
|
for attr in ("_CONFIG_CACHE", "_config_cache"):
|
|
if hasattr(cfg_mod, attr):
|
|
monkeypatch.setattr(cfg_mod, attr, None if not isinstance(getattr(cfg_mod, attr), dict) else {})
|
|
return root, b
|
|
|
|
|
|
@pytest.fixture
|
|
def client(two_homes):
|
|
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
|
|
c = TestClient(app)
|
|
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
return c
|
|
|
|
|
|
def test_get_config_for_named_profile_expands_only_its_own_secrets(client, two_homes):
|
|
from agent.secret_scope import is_multiplex_active
|
|
|
|
resp = client.get("/api/config?profile=b")
|
|
assert resp.status_code == 200, resp.text
|
|
probe = resp.json()["custom_probe"]
|
|
assert probe["b_ref"] == B_VAL
|
|
assert probe["a_ref"] == "${A_ONLY_TOKEN}" # not the dashboard profile's value
|
|
# Hosting a second profile flipped the process to fail-closed; environ is untouched.
|
|
assert is_multiplex_active()
|
|
assert os.environ["A_ONLY_TOKEN"] == A_VAL and "B_ONLY_TOKEN" not in os.environ
|
|
|
|
# The dashboard's own profile still resolves its own value (frozen launch env).
|
|
probe_a = client.get("/api/config").json()["custom_probe"]
|
|
assert probe_a["a_ref"] == A_VAL and probe_a["b_ref"] == "${B_ONLY_TOKEN}"
|
|
|
|
|
|
def test_console_send_for_named_profile_does_not_write_process_env(two_homes, monkeypatch):
|
|
"""``send`` loads the target profile's ``.env`` for the gateway config loader; inside a
|
|
multi-profile host that must land in the request's scope, never ``os.environ``."""
|
|
from hermes_cli.web_routers.chat_ws import _execute_console_line
|
|
|
|
root, b = two_homes
|
|
seen = {}
|
|
|
|
def fake_send(args):
|
|
import hermes_cli.send_cmd as send_cmd
|
|
from gateway.config import _getenv
|
|
send_cmd._load_hermes_env()
|
|
seen["loader_sees"] = _getenv("TELEGRAM_BOT_TOKEN")
|
|
seen["environ_has"] = "TELEGRAM_BOT_TOKEN" in os.environ
|
|
seen["home"] = Path(os.environ.get("HERMES_HOME", ""))
|
|
return '{"success": true}'
|
|
|
|
class Engine:
|
|
def execute(self, line, *, confirmed=False):
|
|
import argparse
|
|
return fake_send(argparse.Namespace())
|
|
|
|
_execute_console_line(Engine(), "send --to telegram hi", confirmed=False, profile="b")
|
|
assert seen["loader_sees"] == "b-telegram-token" # the loader gets B's token through the scope
|
|
assert seen["environ_has"] is False # and the dashboard process env never learns it
|
|
assert "B_ONLY_TOKEN" not in os.environ
|