fix(cron): only strip launch external-source names when multiplexing

The source-name strip added alongside the external-source fix ran
unconditionally. Outside multiplexing there is no other profile to leak from --
os.environ IS this profile's environment -- so popping those names relied on the
routed scope overlay putting each one back, which in turn relies on the
per-home snapshot recorded at boot. Correct today, but it made a single-profile
child's credentials depend on bookkeeping that has nothing to do with isolation.

Guard it the way strip_launch_profile_env guards itself: no multiplexing, no
strip. A single-profile no_agent child now keeps a byte-identical env even if a
source's snapshot were ever missing. Pinned by a regression that runs a real
child with a source-owned name in os.environ and no multiplex context; making
the strip unconditional fails it.

(cherry picked from commit afa429b30a1c9c9b4c011f7d2426a9f099911596)
This commit is contained in:
John Paul Soliva
2026-09-13 10:01:36 +09:00
committed by kshitij
parent 0943e77136
commit 3dedff6a12
2 changed files with 29 additions and 6 deletions
+10 -6
View File
@@ -356,18 +356,22 @@ def _run_job_script(
# then overlay the installed scope, then sanitize, so routed values pass the same scrub /
# passthrough rules as any other. No-op outside multiplex or for the launch profile's own
# fires; the parent process is never mutated.
from agent.secret_scope import _is_global_env, current_secret_scope
from agent.secret_scope import _is_global_env, current_secret_scope, is_multiplex_active
from hermes_cli.env_loader import secret_source_names
from tools.environments.local import strip_launch_profile_env
base = strip_launch_profile_env(dict(os.environ))
# strip_launch_profile_env only knows dotenv- and terminal-config-owned names. External
# secret sources (vault, 1Password, ...) also write their names into the shared os.environ,
# tracked in secret_source_names(), and a name the LAUNCH profile's source supplied is not
# this profile's to see. Drop them all here; the overlay below puts back exactly the ones
# the routed profile's own sources supply (build_profile_secret_scope folds them in).
for name in secret_source_names():
if not _is_global_env(name):
base.pop(name, None)
# this profile's to see. Drop them; the overlay below puts back exactly the ones the routed
# profile's own sources supply (build_profile_secret_scope folds get_secret_source_values in).
# Guarded like strip_launch_profile_env itself: with no multiplexing there is no other
# profile to leak from -- os.environ IS this profile's environment -- so a single-profile
# child keeps byte-identical env even if a source's per-home snapshot is ever missing.
if is_multiplex_active():
for name in secret_source_names():
if not _is_global_env(name):
base.pop(name, None)
scope = current_secret_scope()
if scope:
base.update(scope)
+19
View File
@@ -467,3 +467,22 @@ def test_a_routed_profile_script_never_receives_a_launch_external_source_value(h
assert ok, output
assert output.strip() == "<unset>|routed-vault-value"
assert os.environ["LAUNCH_VAULT_ONLY"] == "launch-vault-value" # parent untouched
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"