fix(serve): launch-profile scope decided at entry; send keeps scope authority; per-reset release

Three edges of the fail-closed multi-profile host (#111620 review, andrexibiza P1 + P2,
kvnloo finding 1):

- `send` under a routed profile's scope `update()`d the installed scope from raw `.env`,
  reversing build_profile_secret_scope's precedence (user .env, then external secret
  sources) for the rest of the request; a stale user value beat the secret-manager one.
  The installed scope is authoritative as-is; only the config.yaml setdefault bridge runs.
- The launch profile's body was scoped only when `is_multiplex_active()` was already true
  at entry, while get_secret consults that global on every read. A launch RPC / dashboard
  request entering single-profile and resuming after a concurrent first `?profile=B`
  activation raised UnscopedSecretError mid-request. The launch profile's secret scope
  (its .env + external sources over the launch env: live while single-profile, the frozen
  snapshot once multiplexing is active) is now bound for every launch-profile body, so the
  credential source is fixed at entry. The terminal policy overlay stays multiplex-only
  (standalone terminal execution keeps its os.environ bridge). _publish_env_value mirrors a
  same-request .env write into that scope AND os.environ for the launch profile, only into
  the scope for a routed one (serves_routed_profile).
- _release_profile_runtime_scope_tokens reset terminal → secret → home in sequence under
  one outer suppress; a failing terminal reset left the previous profile's secrets and
  HERMES_HOME installed for the next body in that context. Each reset is now independent;
  the first failure is re-raised after every scope is released.

tests/tui_gateway/test_multi_profile_hosting_transitions.py: manager-vs-dotenv precedence
through _load_hermes_env, TUI-RPC and dashboard barrier tests (launch enters single-profile,
B activates on another thread, launch resumes and still resolves its injected credential,
never B's), forced terminal-reset failure still releases secret + home. 4/4 red on base.
This commit is contained in:
teknium1
2026-09-16 00:05:37 -07:00
committed by Teknium
parent 3fe8e5e443
commit 8609743389
7 changed files with 208 additions and 49 deletions
@@ -0,0 +1,135 @@
"""Serve / dashboard profile scopes stay authoritative across the first-secondary transition.
Three edges of the fail-closed multi-profile host (review of #111620):
* ``send`` under a routed profile's scope must keep the composed scope (user ``.env`` then external
secret sources) authoritative — replaying raw ``.env`` reversed that precedence for the request;
* a launch-profile body that enters while the process is still single-profile must keep resolving
its own credential after a concurrent first secondary flips ``get_secret`` to fail closed
(``_MULTIPLEX_ACTIVE`` is consulted on every read; the scope decision is made once at entry);
* releasing a runtime scope is per-reset best-effort: a failing terminal reset must not leave the
previous profile's secrets / HERMES_HOME installed for the next body in that context.
"""
from __future__ import annotations
import threading
from pathlib import Path
import pytest
import tui_gateway.server as server
from tui_gateway import launch_profile_policy as lpp
A_VAL = "a-only-secret-0001"
B_VAL = "b-only-secret-0002"
ENV_VAL = "systemd-injected-0003"
@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}\nSHARED_TOKEN=b-dotenv-stale\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.setenv("A_ONLY_TOKEN", A_VAL)
monkeypatch.setenv("INJECTED_TOKEN", ENV_VAL) # systemd / op run credential injection, no file
monkeypatch.setattr(server, "_hermes_home", root)
monkeypatch.setattr(server, "_served_profile_homes", set())
monkeypatch.setattr(lpp, "_snapshot", None)
monkeypatch.setattr("agent.secret_scope._MULTIPLEX_ACTIVE", False)
return root, b
def test_send_keeps_external_source_value_over_raw_dotenv(two_homes, monkeypatch):
"""B's ``.env`` and B's secret manager both define SHARED_TOKEN; the installed scope (manager
wins) survives ``_load_hermes_env`` for the routed ``send``."""
from hermes_cli import env_loader
from hermes_cli.send_cmd import _load_hermes_env
root, b = two_homes
# B's secret manager already hydrated for this process (a hydrated home is not re-pulled).
monkeypatch.setattr(env_loader, "_SECRET_SOURCE_VALUES_BY_HOME",
{str(b.resolve()): {"SHARED_TOKEN": "b-manager-fresh"}})
monkeypatch.setattr(env_loader, "_APPLIED_HOMES", {str(b.resolve())})
with server._session_profile_runtime_scope({"profile_home": str(b)}):
from agent.secret_scope import current_secret_scope, get_secret
assert get_secret("SHARED_TOKEN") == "b-manager-fresh"
_load_hermes_env()
assert get_secret("SHARED_TOKEN") == "b-manager-fresh"
assert current_secret_scope()["B_ONLY_TOKEN"] == B_VAL
def test_launch_body_survives_first_secondary_activation(two_homes):
"""Barrier: the launch RPC enters single-profile, B activates on another thread, the launch RPC
resumes and still resolves its env-injected credential instead of raising."""
from agent.secret_scope import get_secret, is_multiplex_active
root, b = two_homes
entered, activated = threading.Event(), threading.Event()
seen: dict = {}
def launch_body():
with server._session_profile_runtime_scope({"profile_home": None}):
seen["before"] = get_secret("INJECTED_TOKEN")
entered.set()
assert activated.wait(10)
seen["multiplex_now"] = is_multiplex_active()
seen["after"] = get_secret("INJECTED_TOKEN")
seen["b_leak"] = get_secret("B_ONLY_TOKEN")
t = threading.Thread(target=launch_body)
t.start()
assert entered.wait(10)
assert server._profile_home("b") == b # first secondary: freezes the launch env, flips fail-closed
activated.set()
t.join(10)
assert seen == {"before": ENV_VAL, "multiplex_now": True, "after": ENV_VAL, "b_leak": None}
def test_launch_body_survives_first_secondary_activation_on_the_dashboard(two_homes, monkeypatch):
pytest.importorskip("fastapi")
from agent.secret_scope import get_secret
from hermes_cli import web_server_profiles as wsp
root, b = two_homes
monkeypatch.setattr(wsp, "_resolve_profile_dir", lambda name: b)
entered, activated = threading.Event(), threading.Event()
seen: dict = {}
def launch_request():
with wsp._config_profile_scope(None):
seen["before"] = get_secret("INJECTED_TOKEN")
entered.set()
assert activated.wait(10)
seen["after"] = get_secret("INJECTED_TOKEN")
t = threading.Thread(target=launch_request)
t.start()
assert entered.wait(10)
with wsp._config_profile_scope("b") as scoped:
assert scoped == b and get_secret("B_ONLY_TOKEN") == B_VAL
activated.set()
t.join(10)
assert seen == {"before": ENV_VAL, "after": ENV_VAL}
def test_release_resets_every_scope_when_one_reset_fails(two_homes, monkeypatch):
from agent.secret_scope import current_secret_scope
from hermes_constants import get_hermes_home_override
from tools import terminal_scope
root, b = two_homes
scopes = server._profile_runtime_scope_tokens(str(b))
assert current_secret_scope() is not None and get_hermes_home_override() == str(b)
def exploding(_token):
raise RuntimeError("terminal reset blew up")
monkeypatch.setattr(terminal_scope, "reset_terminal_scope", exploding)
server._release_build_profile_scopes(scopes) # suppresses the re-raised failure
assert current_secret_scope() is None
assert get_hermes_home_override() is None
assert Path(server._hermes_home) == root