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