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:
teknium1
2026-09-14 23:44:10 -07:00
committed by Teknium
parent 45ab3ad57f
commit fdd5995ecb
12 changed files with 202 additions and 144 deletions
+6 -3
View File
@@ -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;
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()
+66
View File
@@ -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
-42
View File
@@ -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()
+7 -9
View File
@@ -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:
+11 -6
View File
@@ -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:
+10 -10
View File
@@ -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
View File
@@ -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:
+8 -18
View File
@@ -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
View File
@@ -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