fix(cron): a routed profile's cron fire in the desktop backend runs under multiplex semantics
The desktop backend ticks EVERY local profile's cron store from one process — its own docstring says "like a multiplex gateway" (hermes_cli/web_server.py) — but never sets the process-global multiplex flag, and cannot: its own chat turns are unscoped and would fail closed. Every isolation in the tree keys on that flag — the guard that keeps a routed `.env` out of the shared `os.environ`, `get_secret`'s fail-closed miss, passthrough resolution, the MCP and kanban subprocess scrubs — so all of it was inert for a sibling profile's fire. Verified: a secondary profile's API keys replaced the launch profile's in `os.environ` with `override=True` and stayed there after the tick, and a scope miss read the launch profile's tokens (#107692). Give multiplex mode a context-local counterpart. `set_multiplex_context` (agent/secret_scope.py) is OR'd into `is_multiplex_active()`. `_profile_cron_scope` only MARKS a fire whose home is not the process's own (`routed_profile_fire`, decided against `get_process_hermes_home()`, the override-immune resolver); `_install_fire_secret_scope` in cron/scheduler.py installs the profile's hydrated secret scope and, for a marked fire, the multiplex context — for exactly that span, dropped again before the scope by `_reset_fire_secret_scope`. Multiplex semantics are therefore never active in cron without a scope to read: `run_one_job`'s restart-safe handoff runs before the body's scope and keeps today's semantics (its own scope is #107413 / #106050's seam, left untouched so this composes with whichever lands). Every existing multiplex-keyed isolation applies inside the routed fire with no per-site patching; the launch profile's own fires and the backend's turns keep single-profile semantics; marker and override both reach the pool worker via `copy_context()`. `get_secret` read the raw global in its miss branch; it now goes through `is_multiplex_active()`. The dotenv guard keeps its pinned flag-only form (#77970). Two consequences of suppressing the write are handled rather than left as regressions: - a `no_agent` script's env is `os.environ.copy()`, which no longer carries the routed `.env`; the runner overlays the installed scope onto the base BEFORE sanitizing, so the same scrub / passthrough rules apply to those values and the parent process is never mutated; - plugin secret sources are discovered on the fire's first agent build, after the scope froze, and the post-discovery reload is hydrate-only under multiplex semantics; the refresh now folds the values into the installed scope in place (`refresh_installed_secret_scope`, the pattern `_publish_env_value` already uses for `.env` writes under multiplex). And the profile's external secret sources are hydrated before the scope is frozen, the order gateway/run.py and the external cron worker already use. Tests pin each direction: the marker without the semantics before the scope, the semantics on and off exactly with it, the marker reaching a copy_context worker; the process's own profile staying single-profile; the restart-safe handoff's child env building without raising under a routed tick with a passthrough key registered; a real child process receiving the routed values while `os.environ` keeps the launch value; a source registered after the freeze reaching the fire through the real PluginManager refresh. Reverting any one direction fails a distinct test. (cherry picked from commit 2f87677425d2cca19286ac83bc45cab23e546669)
This commit is contained in:
committed by
kshitij
parent
49b8f06abe
commit
dbede34f6e
@@ -23,6 +23,14 @@ from typing import Dict, Mapping, Optional
|
||||
# at gateway startup when gateway.multiplex_profiles is true.
|
||||
_MULTIPLEX_ACTIVE: bool = False
|
||||
|
||||
# Context-local counterpart: a task serving a profile OTHER than the process's own, inside a
|
||||
# process that is not a multiplexer as a whole — the desktop backend's cron ticker firing a
|
||||
# sibling profile's job. Every isolation keyed on ``is_multiplex_active()`` (the routed-dotenv
|
||||
# guard, ``get_secret``'s fail-closed miss, subprocess scrubbing, passthrough) applies inside
|
||||
# it while the process's own turns keep single-profile semantics. A contextvar, so it reaches
|
||||
# the pool worker together with the home override via ``copy_context()``.
|
||||
_MULTIPLEX_CONTEXT: ContextVar[bool] = ContextVar("_MULTIPLEX_CONTEXT", default=False)
|
||||
|
||||
|
||||
def set_multiplex_active(active: bool) -> None:
|
||||
"""Mark whether the process is a profile multiplexer (get_secret fails closed)."""
|
||||
@@ -30,8 +38,19 @@ def set_multiplex_active(active: bool) -> None:
|
||||
_MULTIPLEX_ACTIVE = bool(active)
|
||||
|
||||
|
||||
def set_multiplex_context(active: bool) -> Token:
|
||||
"""Run the current task under multiplex semantics regardless of the process flag.
|
||||
Returns a reset token; pair with :func:`reset_multiplex_context` in a ``finally``."""
|
||||
return _MULTIPLEX_CONTEXT.set(bool(active))
|
||||
|
||||
|
||||
def reset_multiplex_context(token: Token) -> None:
|
||||
_MULTIPLEX_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def is_multiplex_active() -> bool:
|
||||
return _MULTIPLEX_ACTIVE
|
||||
"""True in a multiplexing process, or for a task running under multiplex semantics."""
|
||||
return _MULTIPLEX_ACTIVE or _MULTIPLEX_CONTEXT.get()
|
||||
|
||||
|
||||
_SECRET_SCOPE: ContextVar[Optional[Mapping[str, str]]] = ContextVar("_SECRET_SCOPE", default=None)
|
||||
@@ -125,8 +144,8 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
val = scope.get(name)
|
||||
if val is not None:
|
||||
return val
|
||||
return default if _MULTIPLEX_ACTIVE else _environ_or(name, default)
|
||||
if _MULTIPLEX_ACTIVE:
|
||||
return default if is_multiplex_active() else _environ_or(name, default)
|
||||
if is_multiplex_active():
|
||||
raise UnscopedSecretError(
|
||||
f"get_secret({name!r}) called with no profile secret scope active "
|
||||
f"while multiplexing is on. This credential read must run inside a "
|
||||
@@ -230,3 +249,18 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
|
||||
external_secrets = {}
|
||||
secrets.update((k, v) for k, v in external_secrets.items() if not _is_global_env(k))
|
||||
return secrets
|
||||
|
||||
|
||||
def refresh_installed_secret_scope(hermes_home: Path) -> bool:
|
||||
"""Fold a fresh build of *hermes_home*'s secrets into the INSTALLED scope, in place.
|
||||
|
||||
A scope is frozen when installed, but a fire can learn of new values afterwards: a routed cron
|
||||
fire's first agent build discovers plugin secret sources, and under multiplex semantics the
|
||||
reload that follows is hydrate-only (never ``os.environ``), so nothing else would carry those
|
||||
values into the scope this fire already holds. The caller names the home the installed scope
|
||||
was built for. True when a scope was updated; False when none is installed."""
|
||||
scope = _SECRET_SCOPE.get()
|
||||
if not isinstance(scope, dict):
|
||||
return False
|
||||
scope.update(build_profile_secret_scope(hermes_home))
|
||||
return True
|
||||
|
||||
@@ -2849,6 +2849,44 @@ def _deliver_crash_failure(
|
||||
|
||||
|
||||
|
||||
def _install_fire_secret_scope() -> "tuple[contextvars.Token, Optional[contextvars.Token]]":
|
||||
"""Install the firing profile's secret scope for the span ``_run_one_job_body`` runs, delivery
|
||||
included, and return the tokens ``_reset_fire_secret_scope`` needs.
|
||||
|
||||
Hydrate the profile's external secret sources BEFORE freezing the scope — the order
|
||||
gateway/run.py and the external cron worker already use: ``build_profile_secret_scope`` only
|
||||
READS the per-home source map, so a scope frozen first would carry no vault-backed value.
|
||||
|
||||
For a fire routed to a profile other than the process's own (marked by
|
||||
``cron.scheduler_provider._profile_cron_scope``) also run under multiplex semantics — for
|
||||
exactly this span and no wider. The desktop backend ticks every local profile from a process
|
||||
that is not a multiplexer, so nothing else isolates that fire; and switching the context on
|
||||
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 cron.scheduler_provider import routed_profile_fire
|
||||
from hermes_cli.env_loader import hydrate_profile_secret_sources
|
||||
|
||||
home = Path(_get_hermes_home())
|
||||
hydrate_profile_secret_sources(home)
|
||||
scope_token = set_secret_scope(build_profile_secret_scope(home))
|
||||
context_token = set_multiplex_context(True) if routed_profile_fire() else None
|
||||
return scope_token, context_token
|
||||
|
||||
|
||||
def _reset_fire_secret_scope(tokens: "tuple[contextvars.Token, Optional[contextvars.Token]]") -> None:
|
||||
"""Undo ``_install_fire_secret_scope`` — the context first, so multiplex semantics never
|
||||
outlive the scope they depend on."""
|
||||
from agent.secret_scope import reset_multiplex_context, reset_secret_scope
|
||||
|
||||
scope_token, context_token = tokens
|
||||
if context_token is not None:
|
||||
reset_multiplex_context(context_token)
|
||||
reset_secret_scope(scope_token)
|
||||
|
||||
|
||||
def _run_one_job_body(
|
||||
job: dict, *, adapters=None, loop=None, verbose: bool = False,
|
||||
extra_prompt: Optional[str] = None, fire_claim_lost: Optional[_CancelEventLike] = None,
|
||||
@@ -2865,8 +2903,6 @@ def _run_one_job_body(
|
||||
job["id"], source="direct", scheduled_instant=job.get("_scheduled_instant"))["id"]
|
||||
delivery_attempted = False
|
||||
delivery_error = None
|
||||
from agent.secret_scope import (
|
||||
build_profile_secret_scope, reset_secret_scope, set_secret_scope)
|
||||
|
||||
_scope_token = None
|
||||
_terminal_scope_token = None
|
||||
@@ -2894,7 +2930,7 @@ def _run_one_job_body(
|
||||
|
||||
# get_secret() fails closed outside a scope; the ticker thread has none. Delivery adapters
|
||||
# resolve credentials, so the scope must span delivery too (reset in the outer finally).
|
||||
_scope_token = set_secret_scope(build_profile_secret_scope(_get_hermes_home()))
|
||||
_scope_token = _install_fire_secret_scope()
|
||||
# Same for terminal policy (gateway/run.py _profile_runtime_scope): else the ticker reads
|
||||
# process-global TERMINAL_* env a concurrent profile pinned. Resolution failure installs a
|
||||
# refusal scope — terminal execution raises instead of using the launch process's policy.
|
||||
@@ -3034,7 +3070,7 @@ def _run_one_job_body(
|
||||
# Function-level on purpose: must scope delivery, deferred teardown, claim-loss handling and
|
||||
# bookkeeping — not just run_job. Do not move into the run block's finally.
|
||||
if _scope_token is not None:
|
||||
reset_secret_scope(_scope_token)
|
||||
_reset_fire_secret_scope(_scope_token)
|
||||
if _terminal_scope_token is not None:
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ execution + delivery stay in cron.scheduler.run_job / _deliver_result; never rei
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from contextvars import ContextVar
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
@@ -91,12 +92,38 @@ def _existing_profile_homes(profile_homes: list) -> list:
|
||||
return [entry for entry in profile_homes if Path(_profile_entry(entry)[1]).is_dir()]
|
||||
|
||||
|
||||
# Set by _profile_cron_scope: this task fires a profile OTHER than the process's own. A marker only.
|
||||
# Multiplex semantics are switched on where the profile's secret scope is installed
|
||||
# (cron.scheduler._install_fire_secret_scope) and off with it — never at the tick — so no read can
|
||||
# be fail-closed without a scope to read: run_one_job's restart-safe handoff runs before that
|
||||
# scope and keeps today's semantics (its own scope is #107413 / #106050's seam).
|
||||
_ROUTED_PROFILE_FIRE: ContextVar[bool] = ContextVar("_ROUTED_PROFILE_FIRE", default=False)
|
||||
|
||||
|
||||
def routed_profile_fire() -> bool:
|
||||
"""True inside a tick for a profile other than the process's own (marker, see above)."""
|
||||
return _ROUTED_PROFILE_FIRE.get()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _profile_cron_scope(home):
|
||||
"""Scope the calling thread to one profile's home + cron store for the block."""
|
||||
from cron.jobs import use_cron_store
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
"""Scope the calling thread to one profile's home + cron store for the block.
|
||||
|
||||
A profile OTHER than the process's own is MARKED as a routed fire (``routed_profile_fire``).
|
||||
The desktop backend ticks every local profile from one process "like a multiplex gateway"
|
||||
without setting the process-global multiplex flag, so every isolation keyed on
|
||||
``is_multiplex_active()`` was inert for those fires: a sibling profile's ``.env`` landed in
|
||||
the shared ``os.environ`` with ``override=True`` and a scope miss read the launch profile's
|
||||
credentials (#107692). ``cron.scheduler._install_fire_secret_scope`` turns the marker into
|
||||
multiplex semantics for exactly the span the profile's secret scope covers. The process's own
|
||||
profile keeps single-profile semantics. The override and the marker both reach the pool
|
||||
worker via ``copy_context()``. Under a real multiplexer the process flag is already on."""
|
||||
from cron.jobs import use_cron_store
|
||||
from hermes_constants import (
|
||||
get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override)
|
||||
|
||||
routed = Path(home).resolve() != get_process_hermes_home().resolve()
|
||||
routed_token = _ROUTED_PROFILE_FIRE.set(routed)
|
||||
# Record per-profile heartbeat after each tick cycle. Distinguish a COMPLETED cycle (``_tick_error``
|
||||
# unset) — where each profile's beat reflects its own outcome, so a yielding profile does not darken
|
||||
# healthy siblings — from an aborted one (exception), where no profile completed and all beats are
|
||||
@@ -107,6 +134,7 @@ def _profile_cron_scope(home):
|
||||
yield
|
||||
finally:
|
||||
reset_hermes_home_override(home_token)
|
||||
_ROUTED_PROFILE_FIRE.reset(routed_token)
|
||||
|
||||
|
||||
class CronScheduler(ABC):
|
||||
|
||||
@@ -349,7 +349,16 @@ def _run_job_script(
|
||||
# reader threads on non-UTF-8 Windows (#45099).
|
||||
"encoding": "utf-8",
|
||||
"errors": "replace"}
|
||||
env = build_subprocess_env()
|
||||
# A routed profile's script (desktop multi-profile ticker, multiplex gateway) must see ITS
|
||||
# profile's .env + vault values — the process env holds the launch profile's. Overlay the
|
||||
# installed scope onto the base BEFORE sanitizing, so the same scrub / passthrough rules
|
||||
# apply to those values as to any other; the parent process is never mutated.
|
||||
from agent.secret_scope import current_secret_scope
|
||||
base = dict(os.environ)
|
||||
scope = current_secret_scope()
|
||||
if scope:
|
||||
base.update(scope)
|
||||
env = build_subprocess_env(base=base)
|
||||
env.update(env_overlay)
|
||||
# Subprocess cwd only (default: scripts-dir parent). NEVER os.chdir() the process.
|
||||
# Use the job's workdir as the subprocess cwd when configured, otherwise default to the scripts-dir
|
||||
|
||||
@@ -334,6 +334,8 @@ def load_hermes_dotenv(
|
||||
# Multiplex gateway: while a routed profile-home override is active, copying that profile's .env
|
||||
# into os.environ would expose its credentials to sibling turns and every spawned child. Unscoped
|
||||
# startup loads keep the normal path; external sources still refresh against the profile mapping.
|
||||
# (``is_multiplex_active()`` is also true, context-locally, for a routed cron fire in the desktop
|
||||
# backend — see ``cron.scheduler_provider._profile_cron_scope``.)
|
||||
from agent.secret_scope import is_multiplex_active
|
||||
from hermes_constants import get_hermes_home_override
|
||||
|
||||
|
||||
@@ -1294,6 +1294,12 @@ class PluginManager(PluginLoaderMixin, PluginDispatchMixin, PluginLedgerMixin):
|
||||
home = get_hermes_home()
|
||||
reset_secret_source_cache(home)
|
||||
load_hermes_dotenv(hermes_home=home)
|
||||
# A scope installed for this home was frozen BEFORE these sources existed — a routed cron
|
||||
# fire builds its scope in run_one_job and only then, on its first agent build, discovers
|
||||
# plugins; under multiplex semantics the load above is hydrate-only, so fold the values
|
||||
# into the installed scope or THIS fire never sees the plugin credential.
|
||||
from agent.secret_scope import refresh_installed_secret_scope
|
||||
refresh_installed_secret_scope(Path(home))
|
||||
logger.debug("Re-applied secret sources after plugin discovery for: %s",
|
||||
", ".join(sorted(enabled_names)))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -778,3 +778,87 @@ def test_home_scoped_reset_preserves_sibling_snapshot(tmp_path, monkeypatch, _fr
|
||||
assert env_loader.get_secret_source_values(home) == {}
|
||||
assert env_loader.get_secret_source_values(sibling) == {"GLM_API_KEY": "vault-b"}
|
||||
assert str(sibling.resolve()) in env_loader._APPLIED_HOMES
|
||||
|
||||
|
||||
def test_profile_scope_carries_vault_secrets_only_when_hydrated_first(tmp_path, monkeypatch):
|
||||
"""``build_profile_secret_scope`` only READS the per-home source map, so hydration must run
|
||||
BEFORE the scope is frozen — the order ``gateway/run.py`` and the external cron worker use.
|
||||
|
||||
This is load-bearing once the process-global dotenv write is suppressed for a scoped home:
|
||||
a vault-backed secret then has no other route into the run.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from agent.secret_scope import build_profile_secret_scope
|
||||
from agent.secret_sources import registry as reg_module
|
||||
|
||||
home = tmp_path / "routed"
|
||||
home.mkdir()
|
||||
(home / ".env").write_text("BWS_ACCESS_TOKEN=0.test-token\n", encoding="utf-8")
|
||||
(home / "config.yaml").write_text(
|
||||
"secrets:\n bitwarden:\n enabled: true\n project_id: p\n"
|
||||
" access_token_env: BWS_ACCESS_TOKEN\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.test-token")
|
||||
|
||||
import agent.secret_sources.bitwarden as bw_module
|
||||
monkeypatch.setattr(bw_module, "find_bws", lambda **_kw: Path("/fake/bws"))
|
||||
monkeypatch.setattr(bw_module, "fetch_bitwarden_secrets",
|
||||
lambda **_kw: ({"VAULT_ONLY_KEY": "from-vault"}, []))
|
||||
reg_module._reset_registry_for_tests()
|
||||
env_loader._APPLIED_HOMES.discard(str(home.resolve()))
|
||||
|
||||
# Frozen before hydration: the vault value cannot be in the scope.
|
||||
assert "VAULT_ONLY_KEY" not in build_profile_secret_scope(home)
|
||||
|
||||
# Hydrated first — as run_one_job now does — the scope carries it.
|
||||
env_loader.hydrate_profile_secret_sources(home)
|
||||
assert build_profile_secret_scope(home).get("VAULT_ONLY_KEY") == "from-vault"
|
||||
|
||||
|
||||
def test_a_plugin_source_discovered_mid_fire_reaches_the_installed_scope(tmp_path, monkeypatch):
|
||||
"""A routed cron fire freezes its scope before its first agent build discovers plugin secret
|
||||
sources; under multiplex semantics the post-discovery reload is hydrate-only, so without an
|
||||
in-place refresh THIS fire never saw the plugin credential (#107692 review)."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
from agent.secret_sources import registry as reg_module
|
||||
from agent.secret_sources.base import SECRET_SOURCE_API_VERSION, FetchResult, SecretSource
|
||||
from hermes_cli.plugins import PluginManager
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
class _LateVault(SecretSource):
|
||||
api_version = SECRET_SOURCE_API_VERSION
|
||||
shape = "bulk"
|
||||
name = "latevault"
|
||||
|
||||
def is_enabled(self, cfg: dict) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
||||
return FetchResult(secrets={"PLUGIN_ONLY_KEY": "from-plugin"})
|
||||
|
||||
launch, home = tmp_path / "launch", tmp_path / "launch" / "profiles" / "ops"
|
||||
home.mkdir(parents=True)
|
||||
(home / ".env").write_text("XAI_API_KEY=routed\n", encoding="utf-8")
|
||||
(home / "config.yaml").write_text("secrets:\n latevault:\n enabled: true\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(launch))
|
||||
monkeypatch.delenv("PLUGIN_ONLY_KEY", raising=False)
|
||||
reg_module._reset_registry_for_tests()
|
||||
env_loader.reset_secret_source_cache()
|
||||
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
home_token = set_hermes_home_override(str(home))
|
||||
scope_token = secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(home))
|
||||
try:
|
||||
assert secret_scope.get_secret("PLUGIN_ONLY_KEY") is None # frozen before the plugin existed
|
||||
assert reg_module.register_source(_LateVault()) # discovery registers the source...
|
||||
PluginManager(scope_key=str(home))._refresh_secret_sources_after_discovery() # ...and re-pulls
|
||||
assert secret_scope.get_secret("PLUGIN_ONLY_KEY") == "from-plugin"
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reg_module._reset_registry_for_tests()
|
||||
assert "PLUGIN_ONLY_KEY" not in os.environ
|
||||
|
||||
@@ -378,3 +378,57 @@ class TestSecretScopeAcrossExecutorThreads:
|
||||
finally:
|
||||
pool.shutdown(wait=True)
|
||||
ss.reset_secret_scope(token)
|
||||
|
||||
|
||||
class TestMultiplexContext:
|
||||
"""A task can run under multiplex semantics without flipping the process flag: the desktop
|
||||
backend ticks sibling profiles' cron jobs from a process whose own turns stay single-profile."""
|
||||
|
||||
def test_context_turns_multiplex_on_for_the_task_only(self):
|
||||
assert ss.is_multiplex_active() is False
|
||||
token = ss.set_multiplex_context(True)
|
||||
try:
|
||||
assert ss.is_multiplex_active() is True
|
||||
finally:
|
||||
ss.reset_multiplex_context(token)
|
||||
assert ss.is_multiplex_active() is False
|
||||
|
||||
def test_context_propagates_through_copy_context_like_the_pool_dispatch(self):
|
||||
import contextvars
|
||||
import threading
|
||||
|
||||
token = ss.set_multiplex_context(True)
|
||||
try:
|
||||
ctx = contextvars.copy_context() # what cron's _submit_with_guard hands the worker
|
||||
finally:
|
||||
ss.reset_multiplex_context(token)
|
||||
seen = {}
|
||||
worker = threading.Thread(target=lambda: seen.update(v=ctx.run(ss.is_multiplex_active)))
|
||||
worker.start()
|
||||
worker.join()
|
||||
assert seen["v"] is True
|
||||
assert ss.is_multiplex_active() is False # the caller's own context is untouched
|
||||
|
||||
def test_scoped_miss_under_context_never_reads_the_process_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LAUNCH_ONLY_TOKEN", "launch-token")
|
||||
token = ss.set_multiplex_context(True)
|
||||
scope_token = ss.set_secret_scope({"ROUTED_KEY": "routed"})
|
||||
try:
|
||||
assert ss.get_secret("ROUTED_KEY") == "routed"
|
||||
assert ss.get_secret("LAUNCH_ONLY_TOKEN") is None
|
||||
finally:
|
||||
ss.reset_secret_scope(scope_token)
|
||||
ss.reset_multiplex_context(token)
|
||||
assert ss.get_secret("LAUNCH_ONLY_TOKEN") == "launch-token" # single-profile semantics resume
|
||||
|
||||
def test_refresh_installed_secret_scope_folds_in_values_learned_after_the_freeze(self, tmp_path):
|
||||
(tmp_path / ".env").write_text("EARLY_KEY=early\n", encoding="utf-8")
|
||||
scope_token = ss.set_secret_scope(ss.build_profile_secret_scope(tmp_path))
|
||||
try:
|
||||
(tmp_path / ".env").write_text("EARLY_KEY=early\nLATE_KEY=late\n", encoding="utf-8")
|
||||
assert ss.get_secret("LATE_KEY") is None
|
||||
assert ss.refresh_installed_secret_scope(tmp_path) is True
|
||||
assert ss.get_secret("LATE_KEY") == "late"
|
||||
finally:
|
||||
ss.reset_secret_scope(scope_token)
|
||||
assert ss.refresh_installed_secret_scope(tmp_path) is False # nothing installed
|
||||
|
||||
@@ -150,3 +150,119 @@ def test_desktop_ticker_gates_on_profile_gateway_running(tmp_path, monkeypatch,
|
||||
assert all(gate(name, home) for name, home in homes)
|
||||
running.update(home for _, home in homes)
|
||||
assert not any(gate(name, home) for name, home in homes)
|
||||
|
||||
|
||||
def test_a_routed_profile_fire_runs_under_multiplex_semantics_for_exactly_its_scope(tmp_path, monkeypatch):
|
||||
"""The desktop ticker fires a SIBLING profile's job from a process that is not a multiplexer.
|
||||
The tick only MARKS the fire as routed; multiplex semantics switch on where run_one_job
|
||||
installs the profile's secret scope and off with it — so the routed .env stays out of the
|
||||
shared os.environ, a scope miss never falls back to the launch profile's credentials, and
|
||||
nothing that runs before the scope (the restart-safe handoff) can be fail-closed (#107692)."""
|
||||
import contextvars
|
||||
import os
|
||||
|
||||
import cron.scheduler as scheduler
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_provider import _profile_cron_scope, routed_profile_fire
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
launch, routed = tmp_path / "launch", tmp_path / "launch" / "profiles" / "ops"
|
||||
for home in (launch, routed):
|
||||
(home / "cron").mkdir(parents=True)
|
||||
(launch / ".env").write_text("XAI_API_KEY=launch-key\nDISCORD_BOT_TOKEN=launch-bot\n", encoding="utf-8")
|
||||
(routed / ".env").write_text("XAI_API_KEY=routed-key\nROUTED_ONLY=routed-only\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(launch))
|
||||
monkeypatch.setenv("XAI_API_KEY", "launch-key")
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "launch-bot")
|
||||
monkeypatch.delenv("ROUTED_ONLY", raising=False)
|
||||
secret_scope.set_multiplex_active(False) # the desktop backend never sets the process flag
|
||||
|
||||
with _profile_cron_scope(routed):
|
||||
# Before the scope exists — where run_one_job's restart-safe handoff runs — nothing is
|
||||
# multiplex: the marker is set, the semantics are not.
|
||||
assert routed_profile_fire() is True
|
||||
assert secret_scope.is_multiplex_active() is False
|
||||
ctx = contextvars.copy_context() # what _submit_with_guard hands the pool worker
|
||||
|
||||
tokens = scheduler._install_fire_secret_scope()
|
||||
try:
|
||||
assert secret_scope.is_multiplex_active() is True
|
||||
# The job's per-run dotenv reload, exactly as cron/scheduler does it: hydrate-only.
|
||||
assert load_hermes_dotenv(hermes_home=routed, load_external_secrets=False) == []
|
||||
assert secret_scope.get_secret("XAI_API_KEY") == "routed-key"
|
||||
assert secret_scope.get_secret("DISCORD_BOT_TOKEN") is None # never the launch bot
|
||||
finally:
|
||||
scheduler._reset_fire_secret_scope(tokens)
|
||||
assert secret_scope.is_multiplex_active() is False # off with the scope, not later
|
||||
|
||||
assert routed_profile_fire() is False
|
||||
assert os.environ["XAI_API_KEY"] == "launch-key"
|
||||
assert "ROUTED_ONLY" not in os.environ
|
||||
|
||||
# The marker reaches the worker that performs the write; the worker's own scope install is
|
||||
# what turns multiplex semantics on there.
|
||||
seen = {}
|
||||
|
||||
def _worker():
|
||||
tokens = scheduler._install_fire_secret_scope()
|
||||
try:
|
||||
seen["v"] = secret_scope.is_multiplex_active()
|
||||
finally:
|
||||
scheduler._reset_fire_secret_scope(tokens)
|
||||
|
||||
worker = threading.Thread(target=lambda: ctx.run(_worker))
|
||||
worker.start()
|
||||
worker.join()
|
||||
assert seen["v"] is True
|
||||
|
||||
|
||||
def test_the_process_own_profile_fire_keeps_single_profile_semantics(tmp_path, monkeypatch):
|
||||
"""The launch profile's own fire is not routed: its scope install leaves the process in
|
||||
single-profile semantics, so its .env keeps loading as today and a scope miss still reads
|
||||
the process env (systemd / ``op run`` injected keys)."""
|
||||
import cron.scheduler as scheduler
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_provider import _profile_cron_scope, routed_profile_fire
|
||||
|
||||
launch = tmp_path / "launch"
|
||||
(launch / "cron").mkdir(parents=True)
|
||||
(launch / ".env").write_text("XAI_API_KEY=launch-key\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(launch))
|
||||
monkeypatch.setenv("SHELL_INJECTED_TOKEN", "from-systemd")
|
||||
secret_scope.set_multiplex_active(False)
|
||||
|
||||
with _profile_cron_scope(launch):
|
||||
assert routed_profile_fire() is False
|
||||
tokens = scheduler._install_fire_secret_scope()
|
||||
try:
|
||||
assert secret_scope.is_multiplex_active() is False
|
||||
assert secret_scope.get_secret("SHELL_INJECTED_TOKEN") == "from-systemd"
|
||||
finally:
|
||||
scheduler._reset_fire_secret_scope(tokens)
|
||||
|
||||
|
||||
def test_the_restart_safe_handoff_is_not_fail_closed_by_a_routed_tick(tmp_path, monkeypatch):
|
||||
"""run_one_job hands a fire to the external worker BEFORE the body installs the profile scope.
|
||||
A routed tick must not make that handoff read secrets fail-closed: with a passthrough key
|
||||
registered, building the worker env there raises UnscopedSecretError if multiplex semantics
|
||||
are on with no scope (#107399's path). The tick only marks the fire; the handoff keeps its
|
||||
current semantics (its own scope is #107413 / #106050's seam)."""
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_provider import _profile_cron_scope
|
||||
from tools.env_passthrough import clear_env_passthrough, is_env_passthrough, register_env_passthrough
|
||||
from tools.environments.local import build_subprocess_env
|
||||
|
||||
launch, routed = tmp_path / "launch", tmp_path / "launch" / "profiles" / "ops"
|
||||
for home in (launch, routed):
|
||||
(home / "cron").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(launch))
|
||||
monkeypatch.setenv("SERVICE_TOKEN", "A")
|
||||
secret_scope.set_multiplex_active(False)
|
||||
register_env_passthrough(["SERVICE_TOKEN"])
|
||||
try:
|
||||
assert is_env_passthrough("SERVICE_TOKEN")
|
||||
with _profile_cron_scope(routed):
|
||||
assert secret_scope.is_multiplex_active() is False
|
||||
build_subprocess_env(scrub_secrets=True) # the handoff's child env, pre-scope: must not raise
|
||||
finally:
|
||||
clear_env_passthrough()
|
||||
|
||||
@@ -368,3 +368,30 @@ def test_agent_job_provider_classification_unchanged(error, expected):
|
||||
|
||||
job = {"name": "daily-digest", "no_agent": False}
|
||||
assert expected in _summarize_cron_failure_for_delivery(job, error)
|
||||
|
||||
|
||||
def test_a_routed_profile_script_receives_its_own_profile_env(hermes_env, monkeypatch):
|
||||
"""A no_agent script fired for a SIBLING profile sees that profile's .env values — via the
|
||||
installed scope, never by copying them into the parent's os.environ (#107692 review)."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_script import _run_job_script
|
||||
|
||||
monkeypatch.setenv("CUSTOM_CRON_VALUE", "launch")
|
||||
monkeypatch.delenv("ROUTED_ONLY_VALUE", raising=False)
|
||||
script = hermes_env / "scripts" / "probe_env.sh"
|
||||
script.write_text('#!/bin/bash\necho "${CUSTOM_CRON_VALUE}|${ROUTED_ONLY_VALUE}"\n')
|
||||
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
scope_token = secret_scope.set_secret_scope(
|
||||
{"CUSTOM_CRON_VALUE": "routed", "ROUTED_ONLY_VALUE": "routed-only"})
|
||||
try:
|
||||
ok, output = _run_job_script("probe_env.sh")
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "routed|routed-only"
|
||||
assert os.environ["CUSTOM_CRON_VALUE"] == "launch" # the parent process was not mutated
|
||||
|
||||
Reference in New Issue
Block a user