fix(tui_gateway): serve fails closed when hosting a second profile home; profile RPCs bind the full runtime scope
`hermes serve` / the Desktop backend hosted many profile homes (session
profile_home, the `profile` RPC param, hosted rooms) but never called
agent.secret_scope.set_multiplex_active, so every unscoped get_secret read for
a secondary silently returned the LAUNCH profile's os.environ value, and
@_profile_scoped bound only HERMES_HOME: `config.get full` for profile B
expanded B's `${VAR}` refs to the default profile's plaintext credentials,
model.options listed the default's env-keyed providers, llm.oneshot billed the
default's auxiliary key.
- tui_gateway/launch_profile_policy.py (was launch_terminal_policy.py): the
first time _profile_home registers a non-launch home the process freezes
the launch env and flips get_secret to fail closed
(activate_multi_profile_hosting); launch_secret_scope composes the launch
profile's .env + external sources over that frozen env so systemd / op-run
injection survives the flip while a secondary never sees it.
- model_switch._profile_runtime_scope_tokens is the ONE composer for
home + secret + terminal scope: a named profile binds its own files; the
launch profile binds its frozen-env scope once multiplexing is active and
stays unscoped in a single-profile process (legacy os.environ precedence).
_profile_scoped, _profile_scoped_rpc, _session_profile_runtime_scope,
_bind_build_profile_scopes and _prepare_turn_input all go through it.
- Hosted-room / Group Chat turns for a DEFAULT-profile member in a
`multiplex_profiles: true` gateway no longer die at agent build with
UnscopedSecretError: `profile_home is None` was treated as "no scope"
in _start_agent_build._build and _prepare_turn_input.
- llm.oneshot runs under the session's (or params.profile's) scope;
_lap_builtin_rows / _overlay_has_creds / _provider_has_credentials read
provider keys through _scoped_key_env instead of raw os.environ;
methods_groups._profile_execution_policy resolves the hosted-room policy
(which reads provider credentials) under the profile's full scope.
Live repro (real `hermes serve`, two homes, config.get {key: full, profile: b}):
base a_ref: <A_VALUE> b_ref: ${B_ONLY_TOKEN} env_ref: <ENV_INJECTED>
head a_ref: ${A_ONLY_TOKEN} b_ref: <B_VALUE> env_ref: ${ENV_INJECTED_TOKEN}
Control (one home, --single): launch config still resolves env_ref from os.environ.
This commit is contained in:
@@ -739,10 +739,12 @@ class _PickerBuild:
|
||||
|
||||
def _lap_builtin_rows(b: _PickerBuild, data: dict, user_providers: dict) -> None:
|
||||
"""Section 1: models.dev-mapped providers with api_key auth."""
|
||||
from hermes_cli.model_switch import _declared_model_ids
|
||||
from hermes_cli.model_switch import _declared_model_ids, _scoped_key_env
|
||||
from agent.models_dev import get_provider_info
|
||||
for hermes_id, mdev_id, pconfig, env_vars in _iter_builtin_candidates(data, b.excluded, b.seen_slugs):
|
||||
if not (_any_env(env_vars) or _raw_pool_usable(hermes_id)):
|
||||
# Per-profile scope, never raw os.environ: a secondary profile's picker otherwise listed the
|
||||
# LAUNCH profile's env-keyed providers and hid its own .env-keyed ones.
|
||||
if not (_any_env(env_vars, _scoped_key_env) or _raw_pool_usable(hermes_id)):
|
||||
continue
|
||||
model_ids = _live_or_curated_ids(hermes_id, b.curated)
|
||||
# A providers.<built-in>.models block extends the discovered catalog; section 3 cannot
|
||||
@@ -764,7 +766,8 @@ def _overlay_has_creds(b: _PickerBuild, pid: str, hermes_slug: str, overlay) ->
|
||||
if overlay.auth_type == "aws_sdk":
|
||||
has_creds = _has_aws_sdk_creds_for_listing(hermes_slug, b.current_provider)
|
||||
else:
|
||||
has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, os.environ.get)
|
||||
from hermes_cli.model_switch import _scoped_key_env
|
||||
has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, _scoped_key_env)
|
||||
# External-process providers (copilot-acp) hold no key/token/pool entry by design — the
|
||||
# spawned ACP subprocess brings its own auth. "Configured" means the executable resolves.
|
||||
# "Configured" means the executable resolves, which is exactly what get_auth_status() reports for them;
|
||||
|
||||
@@ -705,7 +705,8 @@ def _provider_has_credentials(pid: str) -> bool:
|
||||
if pid == "custom":
|
||||
return bool((_get_custom_base_url() or "").strip())
|
||||
if pid == "openrouter":
|
||||
return has_usable_secret(os.getenv("OPENROUTER_API_KEY", ""))
|
||||
from hermes_cli.model_switch import _scoped_key_env
|
||||
return has_usable_secret(_scoped_key_env("OPENROUTER_API_KEY"))
|
||||
status = get_auth_status(pid)
|
||||
return bool(status.get("logged_in") or status.get("configured"))
|
||||
except Exception:
|
||||
|
||||
@@ -96,7 +96,7 @@ def build_profile_terminal_scope(
|
||||
file is unreadable.
|
||||
|
||||
*env_overlay* is a TRUSTED ``TERMINAL_*`` mapping captured from the launch process before
|
||||
multiplexing began (``tui_gateway/launch_terminal_policy.py``): the launch profile's
|
||||
multiplexing began (``tui_gateway/launch_profile_policy.py``): the launch profile's
|
||||
env-only policy (``TERMINAL_ENV=ssh`` from systemd, ``op run``, a launcher bridge) has no
|
||||
file to rebuild it from, and reading live ``os.environ`` here is the leak this module
|
||||
closes. It sits where the process env sits in the standalone bridge — explicit YAML keys
|
||||
|
||||
@@ -414,7 +414,7 @@ def _rebuild_session_agent(sid: str, session: dict, **kwargs):
|
||||
# No live agent to inherit from (rebuild before the deferred build ran): open the profile's store the
|
||||
# same FAIL-CLOSED way _start_agent_build does rather than letting _make_agent reach for the launch db.
|
||||
opened = session_db is None and bool(profile_home)
|
||||
scopes = _bind_build_profile_scopes(profile_home) if profile_home else None
|
||||
scopes = _bind_build_profile_scopes(profile_home)
|
||||
try:
|
||||
# Resolve fallible config before allocating a replacement or moving its handle.
|
||||
config_model_seen = _config_model_target()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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_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 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)."""
|
||||
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.update(build_profile_secret_scope(Path(launch_home)))
|
||||
return scope
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Launch-profile ``TERMINAL_*`` snapshot for multiplexed TUI-gateway turns.
|
||||
|
||||
Once this backend serves a secondary profile, launch-profile turns bind a terminal scope instead
|
||||
of reading ambient ``os.environ`` (a secondary context must never become the launch turn's
|
||||
authority; #107422). A scope rebuilt from ``<launch home>/.env`` + ``config.yaml`` alone drops
|
||||
the launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh TERMINAL_SSH_HOST=...``
|
||||
injected by systemd / ``op run`` / a launcher bridge has no file to rebuild it from and silently
|
||||
became ``backend=local``. The env is trusted exactly once: frozen at multiplex activation, before
|
||||
any secondary code has run in this process, and never re-read from ambient state afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_snapshot: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def capture_launch_terminal_env() -> Dict[str, str]:
|
||||
"""Freeze the process's ``TERMINAL_*`` env; the first capture wins, later calls are no-ops.
|
||||
|
||||
Called by ``server._profile_home`` immediately before the first secondary home is registered
|
||||
as served — the last moment ambient env is provably the launch profile's own.
|
||||
"""
|
||||
global _snapshot
|
||||
with _lock:
|
||||
if _snapshot is None:
|
||||
_snapshot = {k: v for k, v in os.environ.items() if k.startswith("TERMINAL_")}
|
||||
return dict(_snapshot)
|
||||
|
||||
|
||||
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 (``_profile_home`` is the only writer of
|
||||
``_served_profile_homes``); a first capture here only happens when a harness populated the
|
||||
served set directly.
|
||||
"""
|
||||
return capture_launch_terminal_env()
|
||||
@@ -126,17 +126,15 @@ def _api_server_key(profile: str | None = None) -> str:
|
||||
|
||||
|
||||
def _profile_execution_policy(profile: str) -> dict:
|
||||
"""Resolve execution policy under the exact multiplexed profile home."""
|
||||
"""Resolve execution policy under the exact multiplexed profile's FULL runtime scope: the policy
|
||||
reads provider credentials (``_xai_credentials_present`` -> ``get_env_value``), which under a
|
||||
home-only override resolved from the launch process env (or raised once hosting fails closed)."""
|
||||
from gateway.hosted_room_execution_policy import execution_policy_mapping
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
token = None
|
||||
if _bound_server is not None and profile not in {_current_profile(), _profile_name()}:
|
||||
token = set_hermes_home_override(str(_foreign_profile_home(profile)))
|
||||
try:
|
||||
if _bound_server is None:
|
||||
return execution_policy_mapping(target_profile=profile)
|
||||
home = None if profile in {_current_profile(), _profile_name()} else _foreign_profile_home(profile)
|
||||
with _bound_server._session_profile_runtime_scope({"profile_home": str(home) if home else None}):
|
||||
return execution_policy_mapping(target_profile=profile)
|
||||
finally:
|
||||
if token is not None:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
def _room_link_run_storage_durable() -> bool:
|
||||
|
||||
@@ -1079,8 +1079,12 @@ def _(rid, params: dict, session: dict) -> dict:
|
||||
|
||||
|
||||
@method("llm.oneshot")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Stateless one-shot LLM request; a live ``session_id`` lends its model, else the ``task`` backend."""
|
||||
"""Stateless one-shot LLM request; a live ``session_id`` lends its model, else the ``task`` backend.
|
||||
Runs under the session's profile scope (else ``params.profile`` / the launch scope): the aux
|
||||
task config and its API key otherwise resolved from the LAUNCH profile — a secondary's titles /
|
||||
project ideas ran on, and billed, the default profile's auxiliary provider."""
|
||||
template = (params.get("template") or "").strip() or None
|
||||
instructions = params.get("instructions") or ""
|
||||
user_input = params.get("input") or ""
|
||||
@@ -1094,11 +1098,12 @@ def _(rid, params: dict) -> dict:
|
||||
session = _sessions.get(params.get("session_id") or "")
|
||||
try:
|
||||
from agent.oneshot import run_oneshot
|
||||
return _ok(rid, {"text": run_oneshot(
|
||||
instructions=instructions, user_input=user_input, template=template, variables=variables,
|
||||
task=(params.get("task") or "title_generation").strip() or "title_generation",
|
||||
max_tokens=_int_param(params, "max_tokens", 1024) or 1024, temperature=temperature,
|
||||
main_runtime=_main_runtime_from_agent(session.get("agent")) if session else None)})
|
||||
with (_session_profile_runtime_scope(session) if session else contextlib.nullcontext()):
|
||||
return _ok(rid, {"text": run_oneshot(
|
||||
instructions=instructions, user_input=user_input, template=template, variables=variables,
|
||||
task=(params.get("task") or "title_generation").strip() or "title_generation",
|
||||
max_tokens=_int_param(params, "max_tokens", 1024) or 1024, temperature=temperature,
|
||||
main_runtime=_main_runtime_from_agent(session.get("agent")) if session else None)})
|
||||
except (KeyError, ValueError) as e:
|
||||
return _err(rid, 4031 if isinstance(e, KeyError) else 4032, str(e))
|
||||
except Exception as e:
|
||||
|
||||
@@ -48,16 +48,17 @@ def _profile_scoped_rpc(
|
||||
return err
|
||||
args = (rid, params, session)
|
||||
scope = contextlib.nullcontext()
|
||||
if profile := _str_arg(params, "profile") if scoped else "":
|
||||
if scoped:
|
||||
# _profile_home is the ONE resolver: it registers the served home (flipping this
|
||||
# process to fail-closed multi-profile hosting) and answers None for the launch
|
||||
# profile, which then binds its own scope once multiplexing is active.
|
||||
profile = _str_arg(params, "profile")
|
||||
try:
|
||||
try:
|
||||
profile_dir = _tools_mod("hermes_cli.profiles").get_profile_dir(profile)
|
||||
except ValueError: # traversal-shaped name: same answer as a missing dir
|
||||
profile_dir = None
|
||||
if not profile_dir or not profile_dir.is_dir():
|
||||
home = _profile_home(profile)
|
||||
except ProfileUnavailableError:
|
||||
return _err(rid, 4064, f"profile '{profile}' not found")
|
||||
_tools_mod("hermes_cli.env_loader").hydrate_profile_secret_sources(profile_dir)
|
||||
scope = _session_profile_runtime_scope({"profile_home": str(profile_dir)})
|
||||
scope = _session_profile_runtime_scope({"profile_home": str(home) if home else None})
|
||||
except Exception as e:
|
||||
if not catch_resolve:
|
||||
raise
|
||||
@@ -1035,12 +1036,11 @@ def _(rid, params: dict) -> dict:
|
||||
return err
|
||||
# The client sends session_id, not profile; the live session is authoritative.
|
||||
home = (session or {}).get("profile_home")
|
||||
scopes = _bind_build_profile_scopes(home) if home else None
|
||||
scopes = _bind_build_profile_scopes(home)
|
||||
try:
|
||||
return _configure_session_tools(rid, params, sid, session)
|
||||
finally:
|
||||
if scopes is not None:
|
||||
_release_build_profile_scopes(scopes)
|
||||
_release_build_profile_scopes(scopes)
|
||||
|
||||
|
||||
def _configure_session_tools(rid, params: dict, sid: str, session) -> dict:
|
||||
|
||||
+55
-14
@@ -51,25 +51,66 @@ def _restore_agent_model_runtime(agent, snapshot: dict | None) -> None:
|
||||
agent.reasoning_config = snapshot["reasoning_config"]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _session_profile_runtime_scope(session: dict):
|
||||
"""Bind model resolution to the session's profile config and secrets."""
|
||||
profile_home = session.get("profile_home")
|
||||
if not profile_home:
|
||||
yield
|
||||
return
|
||||
home_token = set_hermes_home_override(profile_home)
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
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":
|
||||
"""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)."""
|
||||
scopes = _TurnScopes()
|
||||
if profile_home:
|
||||
home = Path(profile_home)
|
||||
# External sources first: the requested profile may never have been served in this process.
|
||||
from hermes_cli.env_loader import hydrate_profile_secret_sources
|
||||
hydrate_profile_secret_sources(home)
|
||||
secrets = build_profile_secret_scope(home)
|
||||
overlay = None
|
||||
scopes.home = set_hermes_home_override(str(home))
|
||||
elif _launch_profile_scope_needed():
|
||||
# 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.
|
||||
from tui_gateway.launch_profile_policy import launch_secret_scope, launch_terminal_env
|
||||
home = Path(_hermes_home)
|
||||
secrets = launch_secret_scope(home)
|
||||
overlay = launch_terminal_env()
|
||||
else:
|
||||
return 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, reset_terminal_scope
|
||||
terminal_token = install_profile_terminal_scope(Path(profile_home))
|
||||
from tools.terminal_scope import install_profile_terminal_scope
|
||||
scopes.terminal = install_profile_terminal_scope(home, env_overlay=overlay)
|
||||
return scopes
|
||||
|
||||
|
||||
def _release_profile_runtime_scope_tokens(scopes: "_TurnScopes | None") -> None:
|
||||
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)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _session_profile_runtime_scope(session: dict):
|
||||
"""Bind model resolution to the session's profile config and secrets (launch profile included
|
||||
once the process multiplexes; see ``_profile_runtime_scope_tokens``)."""
|
||||
scopes = _profile_runtime_scope_tokens(session.get("profile_home"))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_terminal_scope(terminal_token)
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
_release_profile_runtime_scope_tokens(scopes)
|
||||
|
||||
|
||||
def _restart_completed_failed_agent_build(sid: str, session: dict, failed_ready: threading.Event | None) -> bool:
|
||||
|
||||
@@ -444,24 +444,14 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images
|
||||
scopes = st.scopes
|
||||
scopes.approval = set_current_session_key(session["session_key"])
|
||||
scopes.session_tokens = _set_session_context(session["session_key"], ui_session_id=sid)
|
||||
profile_home = session.get("profile_home")
|
||||
if profile_home:
|
||||
scopes.home = set_hermes_home_override(profile_home)
|
||||
scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
from tools.terminal_scope import install_profile_terminal_scope
|
||||
scopes.terminal = install_profile_terminal_scope(Path(profile_home))
|
||||
elif _served_profile_homes:
|
||||
# Multiplex residual of #68559 / #107422: the launch profile used to run
|
||||
# unscoped and fall back to ambient os.environ. Once any secondary home
|
||||
# has been served, bind the launch home's own terminal policy so a
|
||||
# poisoned ambient bridge can never become the launch turn's authority.
|
||||
# The launch process's env-only policy (TERMINAL_ENV=ssh from systemd /
|
||||
# a launcher) has no file to rebuild it from: overlay the TERMINAL_*
|
||||
# snapshot frozen at multiplex activation, never live os.environ.
|
||||
from tools.terminal_scope import install_profile_terminal_scope
|
||||
from tui_gateway.launch_terminal_policy import launch_terminal_env
|
||||
scopes.terminal = install_profile_terminal_scope(
|
||||
Path(_hermes_home), env_overlay=launch_terminal_env())
|
||||
# Profile turn: that profile's home + secrets + terminal policy. Launch-profile turn: unscoped in a
|
||||
# single-profile process; once multiplexing is active (#68559 / #107422 residual) its OWN scope,
|
||||
# built from the env frozen at activation — get_secret() fails closed then, so an unscoped default
|
||||
# member's hosted-room turn otherwise died with UnscopedSecretError, and ambient TERMINAL_* a
|
||||
# secondary context poisoned must never become the launch turn's authority.
|
||||
bound = _profile_runtime_scope_tokens(session.get("profile_home"))
|
||||
if bound is not None:
|
||||
scopes.home, scopes.secret, scopes.terminal = bound.home, bound.secret, bound.terminal
|
||||
# The sudo password callback is thread-local: without re-wiring here, sudo prompts
|
||||
# fall through to /dev/tty and hang the headless gateway (re-run is a no-op).
|
||||
_wire_callbacks(sid)
|
||||
|
||||
+35
-39
@@ -511,10 +511,12 @@ def _profile_home(profile: str | None) -> Path | None:
|
||||
if home.resolve() == Path(_hermes_home).resolve():
|
||||
return None # already the launch profile (no override needed)
|
||||
if home not in _served_profile_homes:
|
||||
# Last moment ambient TERMINAL_* is provably the launch profile's own: freeze it for
|
||||
# launch-profile turns before any secondary code runs (tui_gateway/launch_terminal_policy.py).
|
||||
from tui_gateway.launch_terminal_policy import capture_launch_terminal_env
|
||||
capture_launch_terminal_env()
|
||||
# This process now hosts a second profile home: freeze the launch env as the launch
|
||||
# profile's own and flip get_secret() to fail closed, so an unscoped read for a
|
||||
# secondary raises instead of returning the launch profile's os.environ value
|
||||
# (tui_gateway/launch_profile_policy.py). Must run before any secondary code.
|
||||
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting
|
||||
activate_multi_profile_hosting()
|
||||
_served_profile_homes.add(home) # the change watcher must stat every served sibling store too
|
||||
return home
|
||||
|
||||
@@ -525,25 +527,21 @@ _served_profile_homes: set[Path] = set()
|
||||
|
||||
|
||||
def _profile_scoped(handler):
|
||||
"""Bind ``params['profile']``'s HERMES_HOME around a handler (pets/projects resolve via
|
||||
``get_hermes_home``, so app-global remote mode still hits the focused profile). No-op for launch.
|
||||
"""Bind ``params['profile']``'s full runtime scope (HERMES_HOME + secrets + terminal policy) around a
|
||||
handler, so config.yaml ``${VAR}`` refs, provider credential checks and ``.env`` writes resolve to
|
||||
THAT profile (app-global remote mode hits the focused profile). Home alone left ``get_secret`` on the
|
||||
launch process's ``os.environ``: ``config.get full`` for a secondary shipped the default profile's
|
||||
expanded secrets and ``config.set`` published a secondary's ``.env`` edit into the shared process env.
|
||||
|
||||
Secondary-profile adapters are constructed inside ``_profile_runtime_scope`` (secret scope installed +
|
||||
multiplex active) — the same discriminator the Buzz/SimpleX adapters use for this bug class (#98738).
|
||||
Once multiplexing is active, launch-profile *turns* bind their own terminal scope
|
||||
(``prompt_turn._prepare_turn_input``) so they never depend on ambient ``os.environ``
|
||||
that a secondary context might have poisoned (#107422). Single-profile processes stay
|
||||
unscoped and keep legacy ``os.environ`` precedence.
|
||||
Launch profile: unscoped while this is a single-profile process (legacy ``os.environ`` precedence,
|
||||
systemd / ``op run`` injection); once multiplexing is active it binds its own scope from the env
|
||||
frozen at activation (``_session_profile_runtime_scope``), never ambient state a secondary context
|
||||
might have poisoned (#107422).
|
||||
"""
|
||||
def wrapper(rid, params):
|
||||
home = _profile_home(params.get("profile") if isinstance(params, dict) else None)
|
||||
if home is None:
|
||||
with _session_profile_runtime_scope({"profile_home": str(home) if home else None}):
|
||||
return handler(rid, params)
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
return handler(rid, params)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -953,31 +951,29 @@ def _wait_agent_for_prompt(session: dict, rid: str, sid: str) -> dict | None:
|
||||
return _err(rid, 5032, err) if (err := session.get("agent_error")) else None
|
||||
|
||||
|
||||
def _bind_build_profile_scopes(profile_home: str) -> "_TurnScopes":
|
||||
"""Bind a session profile's HERMES_HOME / secret / terminal scopes for an agent build. 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."""
|
||||
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
|
||||
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()
|
||||
scopes.home = set_hermes_home_override(profile_home)
|
||||
with contextlib.suppress(Exception):
|
||||
scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
scopes.terminal = None
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.terminal_scope import install_profile_terminal_scope
|
||||
scopes.terminal = install_profile_terminal_scope(Path(profile_home))
|
||||
return _profile_runtime_scope_tokens(profile_home)
|
||||
if profile_home: # secret/terminal helper failed: keep at least the home + terminal refusal scope
|
||||
scopes.home = set_hermes_home_override(profile_home)
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.terminal_scope import install_profile_terminal_scope
|
||||
scopes.terminal = install_profile_terminal_scope(Path(profile_home))
|
||||
return scopes
|
||||
|
||||
|
||||
def _release_build_profile_scopes(scopes: "_TurnScopes") -> None:
|
||||
if scopes.home is not None:
|
||||
reset_hermes_home_override(scopes.home)
|
||||
if scopes.secret is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
reset_secret_scope(scopes.secret)
|
||||
if scopes.terminal is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
reset_terminal_scope(scopes.terminal)
|
||||
def _release_build_profile_scopes(scopes: "_TurnScopes | None") -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
_release_profile_runtime_scope_tokens(scopes)
|
||||
|
||||
|
||||
def _deferred_build_agent_kwargs(current: dict, session_db) -> dict:
|
||||
@@ -1117,8 +1113,8 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
||||
# Global-remote: bind the session profile's HERMES_HOME and hand the agent that profile's db —
|
||||
# DEDICATED and ours until _transfer_db_to_agent in the finally; FAIL CLOSED rather than
|
||||
# binding the launch DB and bleeding rows into the wrong state.db.
|
||||
scopes = _bind_build_profile_scopes(profile_home)
|
||||
if profile_home:
|
||||
scopes = _bind_build_profile_scopes(profile_home)
|
||||
session_db = _open_profile_session_db(profile_home)
|
||||
try:
|
||||
from tui_gateway.entry import ensure_mcp_discovery_started
|
||||
|
||||
Reference in New Issue
Block a user