diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 1fd0ea99a00e1..abeea95dfc00c 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -208,6 +208,15 @@ def _hydrate_profile_secret_sources(home: Path) -> dict[str, str]: if _is_global_env(name) } local_env.update(load_env_file(home / ".env")) + # Mirror load_hermes_dotenv()'s .op.env bootstrap: the 1Password + # service-account token lives in /.op.env (gitignored), not + # .env. Without seeding it here a cold profile configured for the + # supported .op.env flow fails 1Password hydration (sweeper review + # on #74549). .env values win — never override an existing key. + op_env = home / ".op.env" + if op_env.exists(): + for _name, _value in load_env_file(op_env).items(): + local_env.setdefault(_name, _value) local_env["HERMES_HOME"] = str(home) report = apply_all(cfg, home, environ=local_env) except Exception: # noqa: BLE001 — preserve fail-open startup behavior diff --git a/tests/test_env_loader_secret_sources.py b/tests/test_env_loader_secret_sources.py index c0918f2b78a5b..303ed92268c52 100644 --- a/tests/test_env_loader_secret_sources.py +++ b/tests/test_env_loader_secret_sources.py @@ -174,6 +174,78 @@ def test_cold_profile_bitwarden_uses_profile_bootstrap_without_global_env( assert os.environ.get("ANTHROPIC_API_KEY") is None +def test_cold_profile_hydration_seeds_op_env_bootstrap(tmp_path, monkeypatch): + """The .op.env bootstrap file must feed cold-profile hydration. + + load_hermes_dotenv() reads /.op.env for OP_SERVICE_ACCOUNT_TOKEN + (the documented gitignored 1Password bootstrap); hydration must mirror + that or a cold profile using the supported .op.env flow fails 1Password + resolution (sweeper review on #74549). .env wins on conflict. + """ + monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) + (tmp_path / ".env").write_text("UNRELATED=x\n", encoding="utf-8") + (tmp_path / ".op.env").write_text( + "OP_SERVICE_ACCOUNT_TOKEN=ops_from-op-env\n", encoding="utf-8" + ) + (tmp_path / "config.yaml").write_text( + "secrets:\n" + " onepassword:\n" + " enabled: true\n", + encoding="utf-8", + ) + + from agent.secret_sources import registry as reg_module + + seen_env = {} + + def _capture_apply_all(_cfg, home_path, environ=None): + from agent.secret_sources.registry import ApplyReport + seen_env.update(environ or {}) + return ApplyReport(sources=[], provenance={}) + + monkeypatch.setattr(reg_module, "apply_all", _capture_apply_all) + reg_module._reset_registry_for_tests() + + env_loader.hydrate_profile_secret_sources(tmp_path) + + assert seen_env.get("OP_SERVICE_ACCOUNT_TOKEN") == "ops_from-op-env" + # Never leaked into the process env. + assert os.environ.get("OP_SERVICE_ACCOUNT_TOKEN") is None + + +def test_cold_profile_hydration_dotenv_wins_over_op_env(tmp_path, monkeypatch): + """.env takes precedence over .op.env for the same key (setdefault).""" + monkeypatch.delenv("OP_SERVICE_ACCOUNT_TOKEN", raising=False) + (tmp_path / ".env").write_text( + "OP_SERVICE_ACCOUNT_TOKEN=ops_from-dotenv\n", encoding="utf-8" + ) + (tmp_path / ".op.env").write_text( + "OP_SERVICE_ACCOUNT_TOKEN=ops_from-op-env\n", encoding="utf-8" + ) + (tmp_path / "config.yaml").write_text( + "secrets:\n" + " onepassword:\n" + " enabled: true\n", + encoding="utf-8", + ) + + from agent.secret_sources import registry as reg_module + + seen_env = {} + + def _capture_apply_all(_cfg, home_path, environ=None): + from agent.secret_sources.registry import ApplyReport + seen_env.update(environ or {}) + return ApplyReport(sources=[], provenance={}) + + monkeypatch.setattr(reg_module, "apply_all", _capture_apply_all) + reg_module._reset_registry_for_tests() + + env_loader.hydrate_profile_secret_sources(tmp_path) + + assert seen_env.get("OP_SERVICE_ACCOUNT_TOKEN") == "ops_from-dotenv" + + def test_apply_external_secret_sources_noop_when_disabled(tmp_path, monkeypatch): """Disabled Bitwarden config must not touch the source map."""