diff --git a/agent/secret_scope.py b/agent/secret_scope.py index e2dcd2d8a3..c27fd99e9b 100644 --- a/agent/secret_scope.py +++ b/agent/secret_scope.py @@ -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 diff --git a/cron/scheduler.py b/cron/scheduler.py index 99d8b0485f..2b07b2cc03 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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 diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index 8332631be7..b9f4d4d384 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -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): diff --git a/cron/scheduler_script.py b/cron/scheduler_script.py index 074f4fa987..f640fe8f14 100644 --- a/cron/scheduler_script.py +++ b/cron/scheduler_script.py @@ -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 diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 2f2f895eb5..7c65fc68f2 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -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 diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index a979c39491..58325e9843 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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: diff --git a/tests/agent/test_env_loader_secret_sources.py b/tests/agent/test_env_loader_secret_sources.py index a35002ae31..c1984db591 100644 --- a/tests/agent/test_env_loader_secret_sources.py +++ b/tests/agent/test_env_loader_secret_sources.py @@ -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 diff --git a/tests/agent/test_secret_scope.py b/tests/agent/test_secret_scope.py index 5a42f842d8..d192bd14ce 100644 --- a/tests/agent/test_secret_scope.py +++ b/tests/agent/test_secret_scope.py @@ -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 diff --git a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py index 31d5dc22da..9cfc915365 100644 --- a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py +++ b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py @@ -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() diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index 669f97f1d1..0bc29e8d72 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -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