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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user