Files
hermes-agent/tests/hermes_cli/test_web_multi_profile_scope.py
teknium1 b66e7f2149 test(multiplex): invariant tests for fail-closed serve hosting and full-scope profile RPCs / routers
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.
2026-09-15 03:46:29 -07:00

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