Files
hermes-agent/tui_gateway/launch_profile_policy.py
T
teknium1 8609743389 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.
2026-09-16 00:35:00 -07:00

79 lines
3.8 KiB
Python

"""Launch-profile policy for a process that hosts several profile homes (``hermes serve`` /
``hermes dashboard`` pooling, ``?profile=``, hosted rooms; the multiplexed gateway's own worker).
Two facts anchor this module:
* ``agent.secret_scope.get_secret`` fails closed ONLY while ``set_multiplex_active(True)`` holds.
A ``serve`` backend that hosts a second profile home never flipped it, so every unscoped read
for a secondary profile silently returned the LAUNCH profile's ``os.environ`` value. The flip
happens here, at the moment the process first learns it hosts another profile home.
* Once multiplexing is active the launch profile is a profile too: its turns/RPCs must run under
their own scope instead of ambient ``os.environ`` (a secondary context may have poisoned it,
#107422). A scope rebuilt from ``<launch home>/.env`` + ``config.yaml`` alone would drop the
launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh`` or a provider key injected
by systemd / ``op run`` has no file to rebuild it from. The process env is trusted exactly
once: frozen at activation, before any secondary code has run, never re-read afterwards.
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from typing import Dict, Optional
_lock = threading.Lock()
_snapshot: Optional[Dict[str, str]] = None
def capture_launch_env() -> Dict[str, str]:
"""Freeze the process env as the launch profile's own; the first capture wins.
Called at activation, immediately before the first secondary home is registered as
served — the last moment ambient env is provably the launch profile's.
"""
global _snapshot
with _lock:
if _snapshot is None:
_snapshot = dict(os.environ)
return dict(_snapshot)
def activate_multi_profile_hosting() -> None:
"""This process now hosts a profile home other than its launch home: freeze the launch env
and make unscoped credential reads fail closed (``get_secret`` raises instead of borrowing)."""
from agent.secret_scope import set_multiplex_active
capture_launch_env()
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.
Production always captured at activation; a first capture here only happens when the
multiplexer flag was set by another owner (the messaging gateway) or a harness.
"""
return {k: v for k, v in capture_launch_env().items() if k.startswith("TERMINAL_")}
def launch_secret_scope(launch_home: "str | Path") -> Dict[str, str]:
"""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 _launch_env().items() if not _is_global_env(k)}
scope.update(build_profile_secret_scope(Path(launch_home)))
return scope