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:
committed by
kshitij
parent
49b8f06abe
commit
dbede34f6e
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user