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:
@@ -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