fix(gateway): the default profile keeps env-only credentials after the in-place multiplex switch

Once a single-profile gateway started multiplexing for a newly created profile, the
default profile's re-wired adapter handlers, cron fires and the kanban decomposer
bound a files-only secret scope (build_profile_secret_scope). A key that reached the
process only through docker -e, systemd Environment= or op run has no .env line, so
new turns read None and a turn already in flight raised UnscopedSecretError.

Every per-home binding point now builds through
tui_gateway.launch_profile_policy.owner_secret_scope: the launch home gets
launch_secret_scope (env frozen at activation + .env), any other home its own files
only. A standalone primary-adapter body is also marked at entry (launch_body), so if
fail-closed is armed mid-turn its unscoped reads resolve from the launch mapping
rather than raising. No os.environ fallthrough is added.

The e2e test now sends the default profile's message through the production handler
the switch re-wires, instead of calling launch_profile_scope_if_multiplexed().
This commit is contained in:
Teknium committed 2026-10-20 08:42:13 -07:00
1 parent 30fae3da7f
commit 70872e7fa6
8 files changed
+216 -18

No files matched your search

+17 -1
View File
@@ -18,7 +18,7 @@ import threading
from collections import OrderedDict
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Dict, Mapping, NamedTuple, Optional, Tuple
from typing import Callable, Dict, Mapping, NamedTuple, Optional, Tuple
# Process-global (describes the deployment mode, not a per-task value): set once
@@ -108,6 +108,19 @@ def serves_routed_profile() -> bool:
_SECRET_SCOPE: ContextVar[Optional[_BoundScope]] = ContextVar("_SECRET_SCOPE", default=None)
# A launch-profile body that entered before this process started multiplexing (no scope bound,
# single-profile semantics): the launch profile's own mapping to read once fail-closed is armed
# mid-body. Set only by ``tui_gateway.launch_profile_policy.launch_body``; never ``os.environ``.
_LAUNCH_BODY_SCOPE: ContextVar[Optional[Callable[[], Mapping[str, str]]]] = ContextVar(
"_LAUNCH_BODY_SCOPE", default=None)
def set_launch_body_scope(resolver: Callable[[], Mapping[str, str]]) -> Token:
return _LAUNCH_BODY_SCOPE.set(resolver)
def reset_launch_body_scope(token: Token) -> None:
_LAUNCH_BODY_SCOPE.reset(token)
class UnscopedSecretError(RuntimeError):
@@ -233,6 +246,9 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
return val
return default if serves_routed_profile() else _environ_or(name, default)
if is_multiplex_active():
launch_scope = _LAUNCH_BODY_SCOPE.get()
if launch_scope is not None:
return launch_scope().get(name, default)
raise UnscopedSecretError(
name,
f"get_secret({name!r}) called with no profile secret scope active "
+3 -3
View File
@@ -3219,14 +3219,14 @@ def _install_fire_secret_scope() -> tuple[contextvars.Token, Optional[contextvar
here rather than at the tick means no read is ever fail-closed without a scope to read — the
restart-safe handoff in ``run_one_job`` runs before this and keeps today's semantics (#107692).
"""
from agent.secret_scope import (
build_profile_secret_scope, set_multiplex_context, set_secret_scope)
from agent.secret_scope import set_multiplex_context, set_secret_scope
from cron.scheduler_provider import routed_profile_fire
from hermes_cli.env_loader import hydrate_profile_secret_sources
from tui_gateway.launch_profile_policy import owner_secret_scope
home = Path(_get_hermes_home())
hydrate_profile_secret_sources(home)
scope_token = set_secret_scope(build_profile_secret_scope(home), profile_home=str(home))
scope_token = set_secret_scope(owner_secret_scope(home), profile_home=str(home))
context_token = set_multiplex_context(True) if routed_profile_fire() else None
return scope_token, context_token
+3 -4
View File
@@ -312,15 +312,14 @@ def _default_profile_secret_scope():
LLM reads ``auxiliary.*`` from ``get_hermes_home()``, so its credentials come
from that same home. No-op for single-profile gateways.
"""
from agent.secret_scope import (
build_profile_secret_scope, is_multiplex_active, reset_secret_scope, set_secret_scope)
from agent.secret_scope import is_multiplex_active, reset_secret_scope, set_secret_scope
from hermes_constants import get_hermes_home
from tui_gateway.launch_profile_policy import owner_secret_scope
if not is_multiplex_active():
yield
return
token = set_secret_scope(
build_profile_secret_scope(Path(get_hermes_home())), profile_home=str(get_hermes_home()))
token = set_secret_scope(owner_secret_scope(Path(get_hermes_home())), profile_home=str(get_hermes_home()))
try:
yield
finally:
+4 -4
View File
@@ -1788,13 +1788,13 @@ def _load_profile_secret_scope(profile_home: Path) -> dict:
"""Hydrate and load one profile's secrets under its home override."""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
# Caller already hydrated external sources off-loop (#99519).
from agent.secret_scope import build_profile_secret_scope
from hermes_cli.env_loader import hydrate_profile_secret_sources
from tui_gateway.launch_profile_policy import owner_secret_scope
home_token = set_hermes_home_override(str(profile_home))
try:
hydrate_profile_secret_sources(Path(profile_home))
return build_profile_secret_scope(Path(profile_home))
return owner_secret_scope(Path(profile_home))
finally:
reset_hermes_home_override(home_token)
@@ -1818,8 +1818,8 @@ def _profile_runtime_scope(
elif hydrate_secrets:
secrets = _load_profile_secret_scope(Path(profile_home))
else:
from agent.secret_scope import build_profile_secret_scope # caller already hydrated off-loop
secrets = build_profile_secret_scope(Path(profile_home))
from tui_gateway.launch_profile_policy import owner_secret_scope # caller already hydrated off-loop
secrets = owner_secret_scope(Path(profile_home))
secret_token = set_secret_scope(secrets, profile_home=str(profile_home))
# Install the routed profile's COMPLETE terminal policy, never ambient TERMINAL_* a prior turn set.
# Without it terminal_tool reads the process-global TERMINAL_* vars a previous profile's turn may have
+6 -3
View File
@@ -1804,10 +1804,13 @@ class GatewayAdapterLifecycleMixin:
def _standalone_scoped(self, handler):
"""Standalone twin of the ``_make_default_profile_*`` wrappers: run ``handler`` under
``_standalone_launch_scope`` so slash commands and turns keep resolving the launch profile's
credentials after a hosted room flipped the process-wide guard (#112878). Decided per event:
activation happens after the adapters were wired."""
credentials after a hosted room flipped the process-wide guard (#112878). The credential
body is marked at entry (``launch_body``): a turn already running when a second profile
switches this gateway to multiplexing keeps resolving its launch credentials."""
from tui_gateway.launch_profile_policy import launch_body
async def _handler(*args):
with self._standalone_launch_scope():
with launch_body(), self._standalone_launch_scope():
return await handler(*args)
return _handler
@@ -0,0 +1,142 @@
"""A single-profile gateway that starts multiplexing in place for a newly created profile must keep
the DEFAULT profile's own work running: keys injected only into the process env (docker -e, systemd
Environment=, op run) have no .env line, so every launch-home scope binds the launch secret scope
(env frozen at activation + .env), and the launch profile's live MCP connections stay resolvable."""
import asyncio
import types
import pytest
import hermes_constants
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner
from gateway.status import flush_runtime_status, write_runtime_status
from hermes_cli import gateway_migrate as gm
from hermes_cli.gateway_multiplex_mode import SINGLE_PROFILE_REASON
@pytest.fixture
def standalone_host(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
(home / "profiles").mkdir(parents=True)
(home / "config.yaml").write_text("model: {default: m}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
monkeypatch.setattr(gm, "_installed_services", lambda _home: [])
monkeypatch.setattr(gm, "_live_gateway_pid", lambda _home: None)
monkeypatch.setattr(gm, "_host_supports_migration", lambda: None)
# Provider key injected by the service manager (docker -e / systemd Environment=), no .env line.
monkeypatch.setenv("OPENROUTER_API_KEY", "launch-env-key")
runner = GatewayRunner(GatewayConfig(multiplex_profiles=False))
runner._running = True
runner.session_runtime_descriptor = {"state": "ready", "served_profiles": [], "capabilities": []}
runner.session_ticket_store = None
write_runtime_status(gateway_state="running", multiplex_standalone_reason=SINGLE_PROFILE_REASON,
served_profiles=[])
flush_runtime_status()
yield runner, home
from gateway.runtime_ownership import process_ownership
for profile in (home / "profiles").iterdir():
process_ownership.release(profile.resolve())
def _mkprofile(home, name):
d = home / "profiles" / name
d.mkdir()
(d / "config.yaml").write_text("model: {default: m}\n", encoding="utf-8")
(d / ".env").write_text("OPENROUTER_API_KEY=bot-key\n", encoding="utf-8")
return d
def _read():
from agent.secret_scope import UnscopedSecretError, get_secret
try:
return get_secret("OPENROUTER_API_KEY")
except UnscopedSecretError:
return "UnscopedSecretError"
@pytest.mark.asyncio
async def test_default_turn_in_flight_across_the_flip_keeps_its_credentials(standalone_host):
runner, home = standalone_host
started, release, seen = asyncio.Event(), asyncio.Event(), []
async def fake_handle(event):
seen.append(_read())
started.set()
await release.wait() # a tool call / provider retry later in the same turn
seen.append(_read())
runner._handle_message = fake_handle
handler = runner._primary_message_handler() # what the adapter holds before activation
turn = asyncio.create_task(handler(types.SimpleNamespace(source=None)))
await started.wait()
_mkprofile(home, "daily-news-bot")
result = await runner.reconcile_served_profiles(reason="control-socket")
assert result["added"] == ["daily-news-bot"]
release.set()
await turn
assert seen == ["launch-env-key", "launch-env-key"], seen
@pytest.mark.asyncio
async def test_default_turn_after_the_flip_keeps_env_injected_credentials(standalone_host):
runner, home = standalone_host
seen = []
async def fake_handle(event):
seen.append(_read())
runner._handle_message = fake_handle
runner.adapters = {"fake": types.SimpleNamespace(
set_message_handler=lambda h: setattr(runner, "_rewired", h),
set_busy_session_handler=lambda h: None, set_platform_event_handler=lambda h: None)}
_mkprofile(home, "daily-news-bot")
await runner.reconcile_served_profiles(reason="control-socket")
src = types.SimpleNamespace(platform=None, profile=None, chat_id="1", user_id="u")
runner._admit_primary_source = lambda source, default_home: default_home
await runner._rewired(types.SimpleNamespace(source=src))
assert seen == ["launch-env-key"], seen
@pytest.mark.asyncio
async def test_default_profiles_mcp_connections_survive_the_flip(standalone_host):
from tools import mcp_tool as core
from tools.mcp_tool_handlers import _make_check_fn
from tools.mcp_tool_scope import _server_key
runner, home = standalone_host
key = _server_key("github") # single-profile discovery keys by bare name
core._servers[key] = types.SimpleNamespace(session=object(), _is_recycled_stdio=lambda: False)
try:
check = _make_check_fn("github")
assert check()
_mkprofile(home, "daily-news-bot")
await runner.reconcile_served_profiles(reason="control-socket")
from gateway.run import _profile_runtime_scope
with _profile_runtime_scope(home): # the re-wired primary handler's scope
assert check(), "default profile's live MCP server vanished after the flip"
finally:
core._servers.pop(key, None)
def test_default_cron_fire_and_kanban_tick_after_the_flip_keep_env_injected_credentials(standalone_host):
from cron.scheduler import _fire_secret_scope
from gateway.kanban_watchers_dispatcher import _default_profile_secret_scope
runner, home = standalone_host
_mkprofile(home, "daily-news-bot")
asyncio.run(runner.reconcile_served_profiles(reason="control-socket"))
with _fire_secret_scope():
assert _read() == "launch-env-key"
with _default_profile_secret_scope():
assert _read() == "launch-env-key"
@pytest.mark.asyncio
async def test_activation_does_not_run_once_shutdown_began(standalone_host):
from agent.secret_scope import is_multiplex_active
runner, home = standalone_host
_mkprofile(home, "daily-news-bot")
runner._running = False # stop() flips this first
await runner.reconcile_served_profiles(reason="control-socket")
assert not is_multiplex_active(), "a shutting-down gateway armed fail-closed secrets"
@@ -8,6 +8,7 @@ it from the SAME process: no restart, no drain, and unscoped secret reads fail c
import asyncio
import os
import shutil
from types import SimpleNamespace
import pytest
@@ -80,11 +81,18 @@ async def test_profile_created_after_a_single_profile_boot_is_served_without_a_r
assert is_multiplex_active()
from agent.secret_scope import UnscopedSecretError, get_secret
from gateway.run import _profile_runtime_scope
from tui_gateway.launch_profile_policy import launch_profile_scope_if_multiplexed
with _profile_runtime_scope(bot.resolve()):
assert get_secret("OPENAI_API_KEY") == "bot-own-key"
with launch_profile_scope_if_multiplexed():
assert get_secret("OPENAI_API_KEY") == "launch-key"
# The default profile's next message goes through the production handler the switch re-wired.
seen = []
async def fake_handle(event):
seen.append(get_secret("OPENAI_API_KEY"))
runner._handle_message = fake_handle
source = SimpleNamespace(platform=None, profile=None, chat_id="1", user_id="u")
await runner._primary_message_handler()(SimpleNamespace(source=source))
assert seen == ["launch-key"]
with pytest.raises(UnscopedSecretError):
get_secret("OPENAI_API_KEY")
+30
View File
@@ -153,6 +153,36 @@ def launch_secret_scope(launch_home: str | Path) -> dict[str, str]:
return scope
def owner_secret_scope(home: str | Path) -> dict[str, str]:
"""THE secret mapping for a body owned by *home*: ``launch_secret_scope`` when *home* is this
process's own (its env-only keys from docker ``-e`` / systemd ``Environment=`` / ``op run`` have
no file to rebuild from), else the profile's own files (``build_profile_secret_scope``). Every
per-home binding point (routed gateway turns, cron fires, kanban ticks) builds through here, so
the launch profile keeps its env-only credentials when a single-profile gateway starts
multiplexing in place."""
from agent.secret_scope import _is_process_home, build_profile_secret_scope
if _is_process_home(Path(home)):
return launch_secret_scope(home)
return build_profile_secret_scope(Path(home))
@contextlib.contextmanager
def launch_body() -> Iterator[None]:
"""Mark a standalone launch-profile body (a primary-adapter message) at ENTRY. Nothing is bound
while single-profile, so its reads keep the process-env semantics they always had; if a second
profile switches the gateway to fail-closed mid-body, an unscoped read resolves from
``launch_secret_scope`` (frozen env + ``.env``) instead of raising ``UnscopedSecretError``."""
from agent.secret_scope import reset_launch_body_scope, set_launch_body_scope
from hermes_constants import get_process_hermes_home
home = get_process_hermes_home()
token = set_launch_body_scope(lambda: launch_secret_scope(home))
try:
yield
finally:
reset_launch_body_scope(token)
@contextlib.contextmanager
def launch_profile_runtime_scope(launch_home: str | Path) -> Iterator[None]:
"""Bind the launch profile's own runtime scope for one body: HERMES_HOME override naming the