test(cron): keep three invariants for the routed-fire isolation
The PR shipped nineteen tests across six files, most of them variations of one boundary. Keep the three that pin distinct behaviour: - a routed desktop-ticker fire runs under multiplex semantics for exactly its scope: a scope miss returns None instead of the launch credential and the parent os.environ is byte-identical afterwards; - a routed no_agent child never sees a launch-only name, whether the launch .env defined it or a launch external source supplied it (applied or lost to a pre-existing process value), while its own values come through; - administrator-managed keys keep policy precedence over the routed profile's own value. Everything else was either a positive control of the same seam, a set-membership check on a module-level constant, or a re-statement through a different entry point.
This commit is contained in:
@@ -543,46 +543,6 @@ def test_apply_external_secret_sources_status_line_suppresses_secret_names(
|
||||
assert "LEAK_THIS_TOKEN" not in err
|
||||
|
||||
|
||||
def test_private_hydration_records_skipped_existing_names_for_the_routed_scrub(tmp_path, monkeypatch):
|
||||
"""The private (routed-profile) hydration path must feed ``secret_source_names()`` like the
|
||||
process-global path does — including ``skipped_existing`` — or a name first observed through
|
||||
``hydrate_profile_secret_sources()`` is invisible to the routed-child scrub and a sibling inherits
|
||||
the ambient launch value for it (#107695 review on f5f88d5058)."""
|
||||
from agent.secret_sources import registry as reg_module
|
||||
from agent.secret_sources.base import FetchResult
|
||||
from agent.secret_sources.registry import AppliedVar, ApplyReport, SourceReport
|
||||
|
||||
home = tmp_path / "profile-b"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text("secrets:\n test-source:\n enabled: true\n", encoding="utf-8")
|
||||
monkeypatch.setattr(env_loader, "_SOURCE_SUPPLIED_NAMES", set())
|
||||
monkeypatch.setattr(env_loader, "_SECRET_SOURCES", {})
|
||||
monkeypatch.setattr(env_loader, "_APPLIED_HOMES", set())
|
||||
monkeypatch.setattr(env_loader, "_SECRET_SOURCE_VALUES_BY_HOME", {})
|
||||
|
||||
report = ApplyReport(
|
||||
sources=[SourceReport(name="test-source", label="Test Source", result=FetchResult(),
|
||||
applied=["APPLIED_SECRET"], skipped_existing=["CUSTOM_SOURCE_SECRET"])],
|
||||
provenance={"APPLIED_SECRET": AppliedVar(name="APPLIED_SECRET", source="test-source",
|
||||
shape="mapped", overrode_env=False)},
|
||||
)
|
||||
|
||||
def _fake_apply_all(_cfg, home_path, environ=None):
|
||||
if environ is not None:
|
||||
environ["APPLIED_SECRET"] = "applied-b"
|
||||
return report
|
||||
|
||||
monkeypatch.setattr(reg_module, "apply_all", _fake_apply_all)
|
||||
|
||||
env_loader.hydrate_profile_secret_sources(home)
|
||||
|
||||
names = set(env_loader.secret_source_names())
|
||||
assert {"APPLIED_SECRET", "CUSTOM_SOURCE_SECRET"} <= names
|
||||
# provenance stays honest: only the APPLIED name carries a source label
|
||||
assert env_loader.get_secret_source("APPLIED_SECRET") == "test-source"
|
||||
assert env_loader.get_secret_source("CUSTOM_SOURCE_SECRET") is None
|
||||
|
||||
|
||||
def test_external_secret_values_are_isolated_between_homes(tmp_path, monkeypatch):
|
||||
"""A later apply for the same key must not mutate an earlier home snapshot."""
|
||||
from agent.secret_scope import build_profile_secret_scope
|
||||
@@ -818,87 +778,3 @@ 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
|
||||
|
||||
@@ -378,74 +378,3 @@ 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
|
||||
|
||||
|
||||
def test_refresh_drops_a_name_the_rebuild_no_longer_supplies(tmp_path, monkeypatch):
|
||||
"""refresh_installed_secret_scope must REPLACE the installed mapping, not merge into it: a
|
||||
rotated/revoked source value would otherwise survive for the rest of the fire (#107695 review)."""
|
||||
from agent import secret_scope
|
||||
|
||||
(tmp_path / ".env").write_text("KEPT=new\n", encoding="utf-8")
|
||||
token = secret_scope.set_secret_scope({"REVOKED_PLUGIN_TOKEN": "old", "KEPT": "stale"})
|
||||
try:
|
||||
assert secret_scope.refresh_installed_secret_scope(tmp_path) is True
|
||||
live = dict(secret_scope.current_secret_scope() or {})
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(token)
|
||||
|
||||
assert live == {"KEPT": "new"}, live
|
||||
assert "REVOKED_PLUGIN_TOKEN" not in live
|
||||
|
||||
@@ -157,8 +157,7 @@ def test_a_routed_profile_fire_runs_under_multiplex_semantics_for_exactly_its_sc
|
||||
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
|
||||
the parent process env is byte-identical after the tick (#107692)."""
|
||||
import os
|
||||
|
||||
import cron.scheduler as scheduler
|
||||
@@ -176,13 +175,13 @@ def test_a_routed_profile_fire_runs_under_multiplex_semantics_for_exactly_its_sc
|
||||
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
|
||||
environ_before = dict(os.environ)
|
||||
|
||||
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.
|
||||
# Before the scope exists — where run_one_job's restart-safe handoff runs — 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:
|
||||
@@ -196,73 +195,4 @@ def test_a_routed_profile_fire_runs_under_multiplex_semantics_for_exactly_its_sc
|
||||
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()
|
||||
assert dict(os.environ) == environ_before
|
||||
|
||||
@@ -370,151 +370,12 @@ def test_agent_job_provider_classification_unchanged(error, expected):
|
||||
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
|
||||
|
||||
|
||||
def test_a_routed_profile_script_never_receives_a_launch_profile_only_value(hermes_env, monkeypatch):
|
||||
"""Negative control for the overlay above (#107695 review): a name the LAUNCH profile's .env
|
||||
defines and the routed scope does not must reach the routed child UNSET — not with the launch
|
||||
value. The secret scrub only knows classified names, so a custom or unclassified secret would
|
||||
otherwise cross the profile boundary; the launch profile's dotenv residue is dropped first."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_script import _run_job_script
|
||||
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
launch = get_process_hermes_home()
|
||||
(launch / ".env").write_text("LAUNCH_ONLY_VALUE=launch-only\nCUSTOM_CRON_VALUE=launch\n", encoding="utf-8")
|
||||
monkeypatch.setenv("LAUNCH_ONLY_VALUE", "launch-only")
|
||||
monkeypatch.setenv("CUSTOM_CRON_VALUE", "launch")
|
||||
routed = launch / "profiles" / "ops"
|
||||
(routed / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
# Under the routed home override the runner resolves scripts against THAT profile's scripts dir.
|
||||
script = routed / "scripts" / "probe_launch_only.sh"
|
||||
script.write_text('#!/bin/bash\necho "${CUSTOM_CRON_VALUE}|${LAUNCH_ONLY_VALUE:-<unset>}"\n')
|
||||
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
scope_token = secret_scope.set_secret_scope({"CUSTOM_CRON_VALUE": "routed"})
|
||||
try:
|
||||
ok, output = _run_job_script("probe_launch_only.sh")
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "routed|<unset>"
|
||||
assert os.environ["LAUNCH_ONLY_VALUE"] == "launch-only" # the parent process was not mutated
|
||||
|
||||
|
||||
def test_a_routed_profile_script_never_receives_a_launch_external_source_value(hermes_env, monkeypatch):
|
||||
"""External secret sources (vault, 1Password, ...) write their names into the shared
|
||||
``os.environ`` too, and ``strip_launch_profile_env`` only knows dotenv- and terminal-owned
|
||||
names. A name the LAUNCH profile's source supplied must still reach the routed child unset
|
||||
(#107695 review); a name the ROUTED profile's own source supplies must come through."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_script import _run_job_script
|
||||
from hermes_cli import env_loader
|
||||
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
launch = get_process_hermes_home()
|
||||
routed = launch / "profiles" / "ops"
|
||||
(routed / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("LAUNCH_VAULT_ONLY", "launch-vault-value")
|
||||
monkeypatch.setitem(env_loader._SECRET_SOURCES, "LAUNCH_VAULT_ONLY", "vault")
|
||||
monkeypatch.setitem(env_loader._SECRET_SOURCES, "ROUTED_VAULT_ONLY", "vault")
|
||||
script = routed / "scripts" / "probe_vault.sh"
|
||||
script.write_text('#!/bin/bash\necho "${LAUNCH_VAULT_ONLY:-<unset>}|${ROUTED_VAULT_ONLY:-<unset>}"\n')
|
||||
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
scope_token = secret_scope.set_secret_scope({"ROUTED_VAULT_ONLY": "routed-vault-value"})
|
||||
try:
|
||||
ok, output = _run_job_script("probe_vault.sh")
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "<unset>|routed-vault-value"
|
||||
assert os.environ["LAUNCH_VAULT_ONLY"] == "launch-vault-value" # parent untouched
|
||||
|
||||
|
||||
def test_a_routed_profile_script_never_receives_a_launch_key_removed_from_dotenv_after_boot(hermes_env, monkeypatch):
|
||||
"""Lifecycle negative control (#107695 review): the launch profile's .env loaded ``STALE_LAUNCH_KEY``
|
||||
at boot, the operator then removed the key from the file, and the long-running process still holds
|
||||
the old value in ``os.environ`` (dotenv never unsets). A re-parse of the CURRENT file no longer names
|
||||
it, so a strip built from the file alone let the stale value reach a routed child. The strip must
|
||||
work from every key any dotenv load put into the process env during its lifetime."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_script import _run_job_script
|
||||
from hermes_cli import env_loader
|
||||
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
launch = get_process_hermes_home()
|
||||
monkeypatch.setattr(env_loader, "_LOADED_DOTENV_KEYS", set(env_loader._LOADED_DOTENV_KEYS))
|
||||
monkeypatch.setenv("STALE_LAUNCH_KEY", "placeholder") # so monkeypatch restores the parent env afterwards
|
||||
(launch / ".env").write_text("STALE_LAUNCH_KEY=stale-launch-value\n", encoding="utf-8")
|
||||
env_loader._load_dotenv_with_fallback(launch / ".env", override=True) # the boot-time load
|
||||
assert os.environ["STALE_LAUNCH_KEY"] == "stale-launch-value"
|
||||
(launch / ".env").write_text("# key removed after boot\n", encoding="utf-8")
|
||||
|
||||
routed = launch / "profiles" / "ops"
|
||||
(routed / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
script = routed / "scripts" / "probe_stale.sh"
|
||||
script.write_text('#!/bin/bash\necho "${STALE_LAUNCH_KEY:-<unset>}"\n')
|
||||
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
scope_token = secret_scope.set_secret_scope({})
|
||||
try:
|
||||
ok, output = _run_job_script("probe_stale.sh")
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "<unset>"
|
||||
assert os.environ["STALE_LAUNCH_KEY"] == "stale-launch-value" # the parent process was not mutated
|
||||
|
||||
|
||||
def test_a_routed_profile_script_never_receives_a_launch_source_value_that_lost_to_the_process_env(hermes_env, monkeypatch):
|
||||
"""A launch-profile source SUPPLIED ``CUSTOM_VAULT_SECRET`` but a pre-existing process value won
|
||||
(``skipped_existing``), so it never entered the provenance map ``secret_source_names()`` used to be
|
||||
built from — and the launch value reached a routed child with an empty scope (#107695 review). The
|
||||
ownership set must include every source-supplied name, applied or skipped."""
|
||||
def test_a_routed_profile_script_never_receives_a_launch_only_name(hermes_env, monkeypatch):
|
||||
"""A no_agent script fired for a SIBLING profile runs with that profile's scope overlaid and
|
||||
NONE of the launch profile's residue (#107695 review): a name the launch ``.env`` defines, and a
|
||||
name a launch external source SUPPLIED — applied, or skipped because a process value already won
|
||||
(``skipped_existing``, so it never entered the provenance map) — reach the child unset. The
|
||||
routed profile's own values come through, and the parent ``os.environ`` is never mutated."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
@@ -526,45 +387,52 @@ def test_a_routed_profile_script_never_receives_a_launch_source_value_that_lost_
|
||||
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
launch = get_process_hermes_home()
|
||||
(launch / ".env").write_text("LAUNCH_ONLY_VALUE=launch-only\nCUSTOM_CRON_VALUE=launch\n", encoding="utf-8")
|
||||
(launch / "config.yaml").write_text("secrets:\n test-source:\n enabled: true\n", encoding="utf-8")
|
||||
monkeypatch.setenv("CUSTOM_VAULT_SECRET", "launch-value")
|
||||
for name, value in (("LAUNCH_ONLY_VALUE", "launch-only"), ("CUSTOM_CRON_VALUE", "launch"),
|
||||
("LAUNCH_VAULT_ONLY", "launch-vault-value"), ("LAUNCH_SKIPPED_SECRET", "launch-value")):
|
||||
monkeypatch.setenv(name, value)
|
||||
monkeypatch.setattr(env_loader, "_SOURCE_SUPPLIED_NAMES", set())
|
||||
monkeypatch.setattr(env_loader, "_SECRET_SOURCES", {})
|
||||
monkeypatch.setattr(env_loader, "_SECRET_SOURCES", {"LAUNCH_VAULT_ONLY": "vault", "ROUTED_VAULT_ONLY": "vault"})
|
||||
monkeypatch.setattr(env_loader, "_APPLIED_HOMES", set())
|
||||
monkeypatch.setattr(env_loader, "_SECRET_SOURCE_VALUES_BY_HOME", {})
|
||||
monkeypatch.setattr(reg_module, "apply_all", lambda _cfg, home_path, **_kw: ApplyReport(
|
||||
sources=[SourceReport(name="test-source", label="Test Source", result=FetchResult(),
|
||||
applied=[], skipped_existing=["CUSTOM_VAULT_SECRET"])],
|
||||
applied=[], skipped_existing=["LAUNCH_SKIPPED_SECRET"])],
|
||||
provenance={}))
|
||||
env_loader._apply_external_secret_sources(launch) # the real registry path, source loses to the env
|
||||
assert "CUSTOM_VAULT_SECRET" in env_loader.secret_source_names()
|
||||
env_loader._apply_external_secret_sources(launch) # the real registry path; the source loses to the env
|
||||
environ_before = dict(os.environ)
|
||||
|
||||
routed = launch / "profiles" / "ops"
|
||||
(routed / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
script = routed / "scripts" / "probe_skipped.sh"
|
||||
script.write_text('#!/bin/bash\necho "${CUSTOM_VAULT_SECRET:-<unset>}"\n')
|
||||
# Under the routed home override the runner resolves scripts against THAT profile's scripts dir.
|
||||
script = routed / "scripts" / "probe_launch_only.sh"
|
||||
script.write_text(
|
||||
'#!/bin/bash\necho "${CUSTOM_CRON_VALUE}|${ROUTED_VAULT_ONLY}|${LAUNCH_ONLY_VALUE:-<unset>}'
|
||||
'|${LAUNCH_VAULT_ONLY:-<unset>}|${LAUNCH_SKIPPED_SECRET:-<unset>}"\n'
|
||||
)
|
||||
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
scope_token = secret_scope.set_secret_scope({})
|
||||
scope_token = secret_scope.set_secret_scope(
|
||||
{"CUSTOM_CRON_VALUE": "routed", "ROUTED_VAULT_ONLY": "routed-vault-value"})
|
||||
try:
|
||||
ok, output = _run_job_script("probe_skipped.sh")
|
||||
ok, output = _run_job_script("probe_launch_only.sh")
|
||||
finally:
|
||||
secret_scope.reset_secret_scope(scope_token)
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "<unset>"
|
||||
assert os.environ["CUSTOM_VAULT_SECRET"] == "launch-value" # parent untouched
|
||||
assert output.strip() == "routed|routed-vault-value|<unset>|<unset>|<unset>"
|
||||
assert dict(os.environ) == environ_before
|
||||
|
||||
|
||||
def test_a_routed_profile_script_keeps_administrator_managed_values_over_its_own(hermes_env, monkeypatch):
|
||||
"""Managed-scope precedence (#107695 review on f5f88d5058): the administrator's managed ``.env`` is
|
||||
applied LAST with override in the launch process, so it beats the user's own ``.env``. Recording its
|
||||
keys as launch residue stripped ``ORG_POLICY_FLAG`` before the routed overlay, and the routed
|
||||
profile's own value replaced policy. Managed keys are not residue, and they are re-applied over the
|
||||
routed scope so the child sees the same precedence the launch process does."""
|
||||
applied LAST with override in the launch process, so it beats the user's own ``.env``. Managed keys
|
||||
are not launch residue, and they are re-applied over the routed scope so the child sees the same
|
||||
precedence the launch process does."""
|
||||
import os
|
||||
|
||||
from agent import secret_scope
|
||||
@@ -582,8 +450,6 @@ def test_a_routed_profile_script_keeps_administrator_managed_values_over_its_own
|
||||
monkeypatch.setenv("ORG_POLICY_FLAG", "placeholder")
|
||||
env_loader._apply_managed_env() # the boot-time managed load
|
||||
assert os.environ["ORG_POLICY_FLAG"] == "managed-value"
|
||||
assert "ORG_POLICY_FLAG" in env_loader.managed_dotenv_keys()
|
||||
assert "ORG_POLICY_FLAG" not in env_loader.launch_dotenv_keys()
|
||||
|
||||
routed = launch / "profiles" / "ops"
|
||||
(routed / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
@@ -604,55 +470,3 @@ def test_a_routed_profile_script_keeps_administrator_managed_values_over_its_own
|
||||
assert ok, output
|
||||
assert output.strip() == "managed-value"
|
||||
assert os.environ["ORG_POLICY_FLAG"] == "managed-value" # parent untouched
|
||||
|
||||
|
||||
def test_strip_launch_profile_env_never_treats_managed_keys_as_residue(hermes_env, monkeypatch):
|
||||
"""The exclusion stands on its own (#107695 review on f5f88d5058): ``kanban_db_dispatch`` and
|
||||
``scheduler_delivery`` strip and spawn ``hermes -p <profile>`` with NO scope overlay and no managed
|
||||
re-apply afterwards, so for them the strip itself must leave administrator-managed keys in place.
|
||||
|
||||
The case that matters is a key defined in BOTH the user's launch ``.env`` and the managed ``.env`` —
|
||||
the precedence conflict managed override exists for. That key IS launch residue by every other rule
|
||||
(it is in the launch file and was recorded as loaded), and only the managed exclusion keeps the
|
||||
policy value in the child. A launch-only recorded key is still removed."""
|
||||
from agent import secret_scope
|
||||
from hermes_cli import env_loader
|
||||
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
from tools.environments.local import strip_launch_profile_env
|
||||
|
||||
launch = get_process_hermes_home()
|
||||
routed = launch / "profiles" / "ops"
|
||||
routed.mkdir(parents=True, exist_ok=True)
|
||||
# The user's own .env ALSO sets ORG_POLICY_FLAG; the managed .env overrode it at boot.
|
||||
(launch / ".env").write_text("ORG_POLICY_FLAG=user-value\n", encoding="utf-8")
|
||||
monkeypatch.setattr(env_loader, "_LOADED_DOTENV_KEYS", {"LAUNCH_ONLY_RECORDED", "ORG_POLICY_FLAG"})
|
||||
monkeypatch.setattr(env_loader, "_MANAGED_DOTENV_KEYS", {"ORG_POLICY_FLAG"})
|
||||
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
context_token = secret_scope.set_multiplex_context(True)
|
||||
try:
|
||||
env = strip_launch_profile_env({"ORG_POLICY_FLAG": "managed-value", "LAUNCH_ONLY_RECORDED": "stale"})
|
||||
finally:
|
||||
secret_scope.reset_multiplex_context(context_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert env == {"ORG_POLICY_FLAG": "managed-value"}
|
||||
|
||||
|
||||
def test_single_profile_child_keeps_its_own_external_source_value(hermes_env, monkeypatch):
|
||||
"""No multiplexing: os.environ IS this profile's environment, so the source-name strip must not
|
||||
run at all — the child keeps its own vault value even if the per-home snapshot were missing."""
|
||||
from agent import secret_scope
|
||||
from cron.scheduler_script import _run_job_script
|
||||
from hermes_cli import env_loader
|
||||
|
||||
monkeypatch.setenv("OWN_VAULT_KEY", "own-vault-value")
|
||||
monkeypatch.setitem(env_loader._SECRET_SOURCES, "OWN_VAULT_KEY", "vault")
|
||||
script = hermes_env / "scripts" / "probe_own_vault.sh"
|
||||
script.write_text('#!/bin/bash\necho "${OWN_VAULT_KEY:-<unset>}"\n')
|
||||
|
||||
assert secret_scope.is_multiplex_active() is False
|
||||
ok, output = _run_job_script("probe_own_vault.sh")
|
||||
|
||||
assert ok, output
|
||||
assert output.strip() == "own-vault-value"
|
||||
|
||||
@@ -258,49 +258,6 @@ def _stub_external_worker_launch(scheduler, monkeypatch):
|
||||
return spawned, payloads, handoff, get
|
||||
|
||||
|
||||
def test_launch_external_worker_treats_a_routed_fire_as_multiplexed(tmp_path, monkeypatch):
|
||||
"""A fire routed to another profile is multiplexed at the handoff boundary (#107695 review on
|
||||
f5f88d5058). ``run_one_job`` only enables the context in ``_install_fire_secret_scope``, which runs
|
||||
AFTER this handoff, so a routed desktop fire on the managed path serialized ``multiplex_active=False``
|
||||
and the worker inherited the launch profile's residue. The payload must carry ``True`` and the
|
||||
worker env must not carry a launch-only value — and the context must not outlive the handoff."""
|
||||
import cron.scheduler as scheduler
|
||||
import hermes_constants
|
||||
from agent import secret_scope
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from tools.process_registry import GatewayChildDispatch
|
||||
|
||||
launch = tmp_path / "launch"
|
||||
routed = tmp_path / "routed"
|
||||
launch.mkdir()
|
||||
routed.mkdir()
|
||||
(launch / ".env").write_text("LAUNCH_ONLY_SECRET=launch-secret\n", encoding="utf-8")
|
||||
(routed / ".env").write_text("", encoding="utf-8")
|
||||
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(
|
||||
"scoped", ["scope", "--", *command]),
|
||||
)
|
||||
spawned, payloads, _handoff, _get = _stub_external_worker_launch(scheduler, monkeypatch)
|
||||
|
||||
assert not secret_scope.is_multiplex_active() # the desktop tick itself is NOT a multiplexer
|
||||
home_token = set_hermes_home_override(str(routed))
|
||||
try:
|
||||
assert scheduler._launch_external_cron_worker(
|
||||
{"id": "job-r", "execution_id": "exec-1", "prompt": "work"}) is True
|
||||
finally:
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
assert payloads[0]["multiplex_active"] is True
|
||||
assert "LAUNCH_ONLY_SECRET" not in spawned[0][1]["env"]
|
||||
assert not secret_scope.is_multiplex_active() # enabled for the handoff span only
|
||||
assert os.environ["LAUNCH_ONLY_SECRET"] == "launch-secret" # parent untouched
|
||||
|
||||
|
||||
def test_launch_external_worker_uses_restart_safe_scope_and_acknowledges(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
|
||||
@@ -103,80 +103,6 @@ def test_refresh_secret_sources_repulls_when_plugin_enabled(monkeypatch):
|
||||
assert called == {"reset": 1, "load": 1}
|
||||
|
||||
|
||||
def test_refresh_reconciles_once_when_the_last_plugin_source_is_removed(monkeypatch):
|
||||
"""Removal regression (#107695 review): ``discover_and_load(force=True)`` unloads the old
|
||||
registration first, so a discovery that finds no enabled plugin source used to return before the
|
||||
cache reset and the installed-scope refresh — the per-home snapshot and the current scope kept the
|
||||
removed plugin's names. After a discovery that DID re-apply plugin sources, the next one that finds
|
||||
none must reconcile exactly once; a home that never had a plugin source stays a no-op."""
|
||||
mgr = PluginManager()
|
||||
called = {"reset": 0, "load": 0, "scope": 0}
|
||||
|
||||
import agent.secret_sources.registry as reg
|
||||
|
||||
sources = [_StubSource()]
|
||||
monkeypatch.setattr(reg, "list_plugin_sources", lambda: list(sources))
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"secrets": {"myvault": {"enabled": True}}})
|
||||
monkeypatch.setattr("hermes_cli.env_loader.reset_secret_source_cache",
|
||||
lambda *a, **kw: called.__setitem__("reset", called["reset"] + 1))
|
||||
monkeypatch.setattr("hermes_cli.env_loader.load_hermes_dotenv",
|
||||
lambda **kw: called.__setitem__("load", called["load"] + 1))
|
||||
monkeypatch.setattr("agent.secret_scope.refresh_installed_secret_scope",
|
||||
lambda *a, **kw: called.__setitem__("scope", called["scope"] + 1) or True)
|
||||
|
||||
mgr._refresh_secret_sources_after_discovery() # plugin source present and enabled
|
||||
assert called == {"reset": 1, "load": 1, "scope": 1}
|
||||
|
||||
sources.clear() # the plugin is gone (force-reload unloaded it)
|
||||
mgr._refresh_secret_sources_after_discovery()
|
||||
assert called == {"reset": 2, "load": 2, "scope": 2} # reconciled once so its names drop out
|
||||
|
||||
mgr._refresh_secret_sources_after_discovery()
|
||||
assert called == {"reset": 2, "load": 2, "scope": 2} # and not again: nothing left to reconcile
|
||||
|
||||
|
||||
def test_refresh_retries_removal_cleanup_after_a_failed_attempt(monkeypatch):
|
||||
"""The reconcile marker must survive a failed cleanup (#107695 review on f5f88d5058): clearing it
|
||||
before the fallible reset/reload/refresh left the removed plugin's credential active while every
|
||||
later no-source discovery returned early. It clears only once cleanup succeeds."""
|
||||
mgr = PluginManager()
|
||||
calls = {"load": 0}
|
||||
fail = {"on": True}
|
||||
|
||||
import agent.secret_sources.registry as reg
|
||||
|
||||
sources = [_StubSource()]
|
||||
monkeypatch.setattr(reg, "list_plugin_sources", lambda: list(sources))
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"secrets": {"myvault": {"enabled": True}}})
|
||||
monkeypatch.setattr("hermes_cli.env_loader.reset_secret_source_cache", lambda *a, **kw: None)
|
||||
monkeypatch.setattr("agent.secret_scope.refresh_installed_secret_scope", lambda *a, **kw: True)
|
||||
|
||||
def _load(**kw):
|
||||
calls["load"] += 1
|
||||
if fail["on"]:
|
||||
raise RuntimeError("reload blew up")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.env_loader.load_hermes_dotenv", _load)
|
||||
|
||||
fail["on"] = False
|
||||
mgr._refresh_secret_sources_after_discovery() # enabled: marker set
|
||||
assert calls["load"] == 1
|
||||
|
||||
sources.clear()
|
||||
fail["on"] = True
|
||||
mgr._refresh_secret_sources_after_discovery() # removal cleanup attempt fails
|
||||
assert calls["load"] == 2
|
||||
assert mgr._plugin_secret_sources_reconciled is True # NOT cleared by a failed attempt
|
||||
|
||||
fail["on"] = False
|
||||
mgr._refresh_secret_sources_after_discovery() # retried, succeeds
|
||||
assert calls["load"] == 3
|
||||
assert mgr._plugin_secret_sources_reconciled is False
|
||||
|
||||
mgr._refresh_secret_sources_after_discovery() # nothing left to reconcile
|
||||
assert calls["load"] == 3
|
||||
|
||||
|
||||
def test_refresh_respects_custom_is_enabled(monkeypatch):
|
||||
"""A source with custom activation (no ``enabled`` key) is re-pulled."""
|
||||
mgr = PluginManager()
|
||||
|
||||
Reference in New Issue
Block a user