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:
+10
-5
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user