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.
This commit is contained in:
Austin Pickett
2026-09-29 12:03:42 -04:00
parent f6250192c2
commit 69a96a7311
5 changed files with 87 additions and 30 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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:-<unset>}}|${{LAUNCH_ONLY_SECRET:-<unset>}}" > "{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|<unset>"
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() == "<unset>|launch-secret"
assert "ROUTED_ONLY_SECRET" not in os.environ

View File

@@ -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(