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
+10 -5
View File
@@ -2594,13 +2594,18 @@ def _publish_env_value(key: str, value: Optional[str]) -> None:
#77490, #88441.
"""
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
from agent.secret_scope import current_secret_scope, serves_routed_profile
scope = current_secret_scope() if is_multiplex_active() else None
scope, routed = current_secret_scope(), serves_routed_profile()
except Exception:
scope = None
target = scope if isinstance(scope, dict) else (None if scope is not None else os.environ)
if target is not None:
scope, routed = None, False
# The launch profile's own body runs under a scope snapshot even single-profile (the TUI /
# dashboard launch scope), so a same-request read after the write must see it there too; a
# routed profile's value never reaches the shared process env.
targets = [scope] if isinstance(scope, dict) else []
if not routed and (scope is None or isinstance(scope, dict)):
targets.append(os.environ)
for target in targets:
if value is None:
target.pop(key, None)
else:
+4 -4
View File
@@ -139,6 +139,9 @@ def _load_hermes_env() -> None:
running ``send`` for profile B under its secret scope) it is the installed scope mapping: writing B's
``.env`` into the shared process env would hand every other profile's later reads B's tokens
(``gateway.config._getenv`` reads the scope first, so the loader sees the same values either way).
The installed scope is already ``build_profile_secret_scope``'s composition — user ``.env``, then
the profile's external secret sources over it — so it is authoritative as-is; replaying raw
``.env`` over it would let a stale user value beat the secret-manager one for this request.
"""
import os
try:
@@ -146,13 +149,10 @@ def _load_hermes_env() -> None:
home = get_hermes_home()
except Exception:
return
from agent.secret_scope import current_secret_scope, is_multiplex_active, load_env_file
from agent.secret_scope import current_secret_scope, is_multiplex_active
scope = current_secret_scope() if is_multiplex_active() else None
if isinstance(scope, dict):
target: dict = scope
env_path = home / ".env"
if env_path.exists():
target.update(load_env_file(env_path))
else:
target = os.environ
env_path = home / ".env"
+9 -8
View File
@@ -246,8 +246,7 @@ def _config_profile_scope(profile: Optional[str]):
Explicit names resolving to the process home retain current-profile semantics.
Still enter the requested home so a nested scope cannot retain another profile.
"""
from agent.secret_scope import (
build_profile_secret_scope, is_multiplex_active, reset_secret_scope, set_secret_scope)
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
from hermes_cli.env_loader import hydrate_profile_secret_sources
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting, launch_secret_scope
@@ -261,17 +260,19 @@ def _config_profile_scope(profile: Optional[str]):
activate_multi_profile_hosting()
hydrate_profile_secret_sources(scoped) # first call may block on the source's fetch
secrets = build_profile_secret_scope(scoped)
elif is_multiplex_active():
secrets = launch_secret_scope(process_home)
else:
secrets = None # single-profile dashboard: legacy os.environ precedence (systemd / op-run injection)
# The dashboard's own profile: its launch-env scope (live env + .env while single-profile, so
# systemd / op-run injection keeps resolving; frozen at activation afterwards). Bound even
# before any secondary is served so the request's credential source is decided HERE: a
# concurrent first ``?profile=B`` request flips ``get_secret`` to fail closed mid-request,
# and an unscoped launch request would then raise ``UnscopedSecretError`` on its next read.
secrets = launch_secret_scope(process_home)
with (_hermes_home_scope(profile_dir) if profile_dir is not None else nullcontext()):
token = set_secret_scope(secrets) if secrets is not None else None
token = set_secret_scope(secrets)
try:
yield scoped
finally:
if token is not None:
reset_secret_scope(token)
reset_secret_scope(token)
# Terminal backend picker rows — GUI counterpart of terminal.backend. Keep in sync with
@@ -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
+16 -4
View File
@@ -47,6 +47,14 @@ def activate_multi_profile_hosting() -> None:
set_multiplex_active(True)
def _launch_env() -> Dict[str, str]:
"""The launch profile's env: frozen once multiplexing is active; the LIVE process env before
(no secondary has run yet, so it is provably the launch profile's, and freezing it early would
miss values the launch process still bridges at startup)."""
from agent.secret_scope import is_multiplex_active
return capture_launch_env() if is_multiplex_active() else dict(os.environ)
def launch_terminal_env() -> Dict[str, str]:
"""The frozen launch ``TERMINAL_*`` overlay for a launch-profile turn's terminal scope.
@@ -57,10 +65,14 @@ def launch_terminal_env() -> Dict[str, str]:
def launch_secret_scope(launch_home: "str | Path") -> Dict[str, str]:
"""The launch profile's secret mapping: its ``.env`` + external sources over the frozen
launch env (systemd / ``op run`` injection survives the fail-closed flip; a secondary never
sees it because its scope is built from its own files only)."""
"""The launch profile's secret mapping: its ``.env`` + external sources over the launch env
(systemd / ``op run`` injection survives the fail-closed flip; a secondary never sees it because
its scope is built from its own files only). Bound for EVERY launch-profile body, multiplexing or
not, so the body's credential source is decided once at entry: a request that entered while
single-profile keeps resolving from this mapping after a concurrent first secondary flips
``get_secret`` to fail closed (``_MULTIPLEX_ACTIVE`` is read on every ``get_secret``, the
scope decision was made at entry)."""
from agent.secret_scope import _is_global_env, build_profile_secret_scope
scope = {k: v for k, v in capture_launch_env().items() if not _is_global_env(k)}
scope = {k: v for k, v in _launch_env().items() if not _is_global_env(k)}
scope.update(build_profile_secret_scope(Path(launch_home)))
return scope
+31 -23
View File
@@ -51,19 +51,16 @@ def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None:
agent.reasoning_config = snapshot["reasoning_config"]
def _launch_profile_scope_needed() -> bool:
"""A launch-profile body must run scoped once this process multiplexes (``get_secret`` fails
closed and ambient ``os.environ`` may carry a secondary's residue); a single-profile process
stays unscoped so systemd / ``op run`` credential injection keeps its ``os.environ`` fallthrough."""
from agent.secret_scope import is_multiplex_active
return is_multiplex_active()
def _profile_runtime_scope_tokens(profile_home) -> "_TurnScopes | None":
def _profile_runtime_scope_tokens(profile_home) -> "_TurnScopes":
"""Bind HERMES_HOME + secret + terminal scope for ``profile_home`` (None = launch profile) and
return the reset tokens; None when nothing needs binding (unscoped single-profile launch body).
The launch profile's scope is its ``.env`` over the env frozen at activation (never live
``os.environ``: a secondary context may have written to it since, #107422)."""
return the reset tokens. The launch profile's SECRET scope is always bound — its ``.env`` over
the launch env (live while single-profile, frozen at activation afterwards; never live
``os.environ`` once a secondary context may have written to it, #107422) — so the credential
source is fixed at entry and an in-flight launch body survives a concurrent first-secondary
activation instead of hitting ``UnscopedSecretError`` mid-request. Its terminal policy is bound
only once multiplexing is active: single-profile terminal execution keeps the standalone
``os.environ`` bridge."""
from agent.secret_scope import is_multiplex_active
scopes = _TurnScopes()
if profile_home:
home = Path(profile_home)
@@ -73,16 +70,18 @@ def _profile_runtime_scope_tokens(profile_home) -> "_TurnScopes | None":
secrets = build_profile_secret_scope(home)
overlay = None
scopes.home = set_hermes_home_override(str(home))
elif _launch_profile_scope_needed():
else:
# No home override: the launch home IS get_hermes_home() (``_profile_home`` answers None for
# "already the launch profile"); only its secrets + terminal policy need binding.
# "already the launch profile"); only its secrets (+ terminal policy under multiplex) need binding.
from tui_gateway.launch_profile_policy import launch_secret_scope, launch_terminal_env
home = Path(_hermes_home)
secrets = launch_secret_scope(home)
scopes.secret = set_secret_scope(secrets)
if not is_multiplex_active():
return scopes
overlay = launch_terminal_env()
else:
return None
scopes.secret = set_secret_scope(secrets)
if scopes.secret is None:
scopes.secret = set_secret_scope(secrets)
# Same terminal policy the gateway binds per turn: a docker-configured profile
# must never resolve the launch process's pinned env. Failure → refusal scope.
from tools.terminal_scope import install_profile_terminal_scope
@@ -91,15 +90,24 @@ def _profile_runtime_scope_tokens(profile_home) -> "_TurnScopes | None":
def _release_profile_runtime_scope_tokens(scopes: "_TurnScopes | None") -> None:
"""Release terminal → secret → home. Each reset is independent: a failing terminal reset must
not leave the previous profile's secrets / HERMES_HOME installed for the next body in this
context (a fail-open scope leak on the teardown path). The first failure is re-raised after
every scope has been released."""
if scopes is None:
return
from tools.terminal_scope import reset_terminal_scope
if scopes.terminal is not None:
reset_terminal_scope(scopes.terminal)
if scopes.secret is not None:
reset_secret_scope(scopes.secret)
if scopes.home is not None:
reset_hermes_home_override(scopes.home)
first_error: BaseException | None = None
for token, reset in ((scopes.terminal, reset_terminal_scope), (scopes.secret, reset_secret_scope),
(scopes.home, reset_hermes_home_override)):
if token is None:
continue
try:
reset(token)
except Exception as exc: # noqa: BLE001 — keep releasing the remaining scopes
first_error = first_error or exc
if first_error is not None:
raise first_error
@contextlib.contextmanager
+3 -5
View File
@@ -961,13 +961,11 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None:
def _bind_build_profile_scopes(profile_home: "str | None") -> "_TurnScopes | None":
"""Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. ``None`` is the
launch profile: unscoped in a single-profile process, its own frozen-env scope once multiplexing is
active (a hosted-room turn for a default member otherwise died at build with ``UnscopedSecretError``
because the launch profile was treated as "no scope"). Fail-open per scope (the build must not die on
launch profile: its own launch-env secret scope (live env while single-profile, frozen once
multiplexing is active — a hosted-room turn for a default member otherwise died at build with
``UnscopedSecretError`` because the launch profile was treated as "no scope"). Fail-open per scope (the build must not die on
a scope helper); the terminal installer itself fails closed (malformed policy → refusal scope) so
_make_agent's terminal probing / cwd hints resolve the routed profile."""
if not profile_home and not _launch_profile_scope_needed():
return None
scopes = _TurnScopes()
with contextlib.suppress(Exception):
return _profile_runtime_scope_tokens(profile_home)