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:
John Paul Soliva
2026-09-11 04:37:57 +09:00
committed by kshitij
parent 49b8f06abe
commit dbede34f6e
10 changed files with 407 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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