mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
30fae3da7f
commit
70872e7fa6
8 files changed
+216
-18
No files matched your search
+17
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user