From 69a96a731141832dcd888e18eb7e165cadd82012 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 29 Sep 2026 12:03:42 -0400 Subject: [PATCH] fix(cron): detect a routed fire from its home, not a ticker-only marker The dashboard's manual Run now binds the sibling profile via web_server_cron._cron_store_scope, not the ticker's _profile_cron_scope, so the marker-based routed_profile_fire() stayed False there: the routed profile's .env was reloaded into the shared os.environ and the managed external-worker handoff serialized multiplex_active=False. routed_profile_fire() now compares the fire's active HERMES_HOME with get_routing_process_hermes_home(), so every entry point (ticker, dashboard Run now, CLI, gateway forward) and the external-worker handoff share one answer. Regression test drives the real dashboard fire path A -> B -> A with no_agent jobs. --- cron/scheduler.py | 4 +- cron/scheduler_provider.py | 47 +++++++------- hermes_cli/env_loader.py | 2 +- ...est_cron_multiplex_desktop_ticker_scope.py | 63 +++++++++++++++++++ tests/cron/test_restart_safe_worker.py | 1 - 5 files changed, 87 insertions(+), 30 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 55062ce29c..6276f94772 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -3161,8 +3161,8 @@ def _install_fire_secret_scope() -> "tuple[contextvars.Token, Optional[contextva 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 + For a fire routed to a profile other than the process's own (``routed_profile_fire`` on the + fire's home — every entry point: ticker, dashboard Run now, CLI) 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 diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index 1b63253cc1..07768cde33 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -6,7 +6,6 @@ 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 @@ -93,38 +92,35 @@ 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(home=None) -> bool: + """True when a fire runs for a profile OTHER than the process's own. + Derived from the fire's home itself (``home``, else the task's active ``get_hermes_home()``), never from + a marker one entry point sets: the desktop ticker (``_profile_cron_scope``), the dashboard's + manual Run now (``hermes_cli.web_server_cron._cron_store_scope``) and any other caller that + binds a HERMES_HOME override for a sibling profile all reach ``run_one_job`` the same way, and a + marker set only in the ticker left the manual path with the original cross-profile leak. -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() + The desktop backend fires every local profile from one process 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 this into multiplex semantics for exactly + the span the profile's secret scope covers, and the restart-safe handoff marks the worker + payload with it. The launch identity is ``get_routing_process_hermes_home()`` (gateway/AGENTS.md + "One launch-home identity").""" + from hermes_constants import get_hermes_home, get_routing_process_hermes_home, hermes_home_key + + target = home if home is not None else get_hermes_home() + return hermes_home_key(target) != hermes_home_key(get_routing_process_hermes_home()) @contextlib.contextmanager def _profile_cron_scope(home): - """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.""" + """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 ( - get_routing_process_hermes_home, reset_hermes_home_override, set_hermes_home_override) + from hermes_constants import set_hermes_home_override, reset_hermes_home_override - routed = Path(home).resolve() != get_routing_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 @@ -135,7 +131,6 @@ 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/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index e845cde993..d521e8725d 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -466,7 +466,7 @@ def load_hermes_dotenv( # from the config the override resolves to, i.e. the routed profile's cwd into the shared env. # 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``.) + # backend — see ``cron.scheduler_provider.routed_profile_fire``.) from agent.secret_scope import is_multiplex_active from hermes_constants import get_hermes_home, get_hermes_home_override diff --git a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py index 461e0da8ee..1fc80a0366 100644 --- a/tests/cron/test_cron_multiplex_desktop_ticker_scope.py +++ b/tests/cron/test_cron_multiplex_desktop_ticker_scope.py @@ -239,3 +239,66 @@ def test_routed_fire_scope_carries_managed_env_authority(tmp_path, monkeypatch, assert secret_scope.get_secret("ORG_API_KEY") == expected finally: scheduler._reset_fire_secret_scope(tokens) + + +def test_dashboard_run_now_isolates_a_sibling_profile_fire_like_the_ticker(tmp_path, monkeypatch): + """The dashboard's manual Run now binds the sibling profile through ``_cron_store_scope``, not the + ticker's ``_profile_cron_scope`` (#126982 review). Routed-fire detection follows the fire's HOME, + so this entry point gets the same isolation: the routed ``.env`` reaches the job but never the + shared ``os.environ``, the launch profile's own credential never reaches the routed job, and the + launch profile's own fire afterwards (A -> B -> A) keeps single-profile semantics.""" + import os + + import cron.scheduler as scheduler + import hermes_cli.web_server_cron as web_server_cron + from agent import secret_scope + from cron.jobs import create_job + from cron.scheduler_provider import InProcessCronScheduler + from hermes_cli import profiles + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + launch = tmp_path / ".hermes" + routed = launch / "profiles" / "ops" + probe = tmp_path / "probe" + probe.mkdir() + for home in (launch, routed): + (home / "cron").mkdir(parents=True, exist_ok=True) + (home / "scripts").mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("model: test-model\n", encoding="utf-8") + (launch / ".env").write_text("LAUNCH_ONLY_SECRET=launch-secret\n", encoding="utf-8") + (routed / ".env").write_text("ROUTED_ONLY_SECRET=routed-secret\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setenv("LAUNCH_ONLY_SECRET", "launch-secret") + monkeypatch.delenv("ROUTED_ONLY_SECRET", raising=False) + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: launch) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: launch / "profiles") + monkeypatch.setattr(scheduler, "_hermes_home", None) + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: InProcessCronScheduler()) + secret_scope.set_multiplex_active(False) # the desktop backend never sets the process flag + + def _job_for(home, name): + (home / "scripts" / f"{name}.sh").write_text( + "#!/usr/bin/env bash\n" + f'echo "${{ROUTED_ONLY_SECRET:-}}|${{LAUNCH_ONLY_SECRET:-}}" > "{probe / name}"\n', + encoding="utf-8") + token = set_hermes_home_override(str(home)) + try: + return create_job(prompt=None, schedule="every 1h", script=f"{name}.sh", + no_agent=True, deliver="local")["id"] + finally: + reset_hermes_home_override(token) + + routed_job, launch_job = _job_for(routed, "routed"), _job_for(launch, "launch") + environ_before = dict(os.environ) + + # B: the sibling profile, fired from the launch process's dashboard. + assert web_server_cron._fire_cron_job_for_profile("ops", routed_job) is True + assert (probe / "routed").read_text().strip() == "routed-secret|" + assert "ROUTED_ONLY_SECRET" not in os.environ + assert dict(os.environ) == environ_before + assert secret_scope.is_multiplex_active() is False + + # A again: the launch profile's own fire is not routed and still sees its own env. + assert web_server_cron._fire_cron_job_for_profile("default", launch_job) is True + assert (probe / "launch").read_text().strip() == "|launch-secret" + assert "ROUTED_ONLY_SECRET" not in os.environ diff --git a/tests/cron/test_restart_safe_worker.py b/tests/cron/test_restart_safe_worker.py index 879517c1e3..982e9af9fe 100644 --- a/tests/cron/test_restart_safe_worker.py +++ b/tests/cron/test_restart_safe_worker.py @@ -340,7 +340,6 @@ def test_launch_external_worker_treats_a_routed_fire_as_multiplexed(tmp_path, mo monkeypatch.setenv("LAUNCH_ONLY_SECRET", "launch-secret") monkeypatch.setattr(scheduler, "_get_hermes_home", lambda: routed) monkeypatch.setattr(hermes_constants, "get_process_hermes_home", lambda: launch) - monkeypatch.setattr("cron.scheduler_provider.routed_profile_fire", lambda: True) monkeypatch.setattr( "tools.process_registry.restart_safe_gateway_child_argv", lambda command, *, unit_suffix, require_restart_safe_scope=False: GatewayChildDispatch(