"""Regression tests for issue #3957. On a **non-default profile**, two read-only endpoints broke because they resolved provider credentials / model cache from the process-global *default* profile instead of the per-request (cookie-scoped, issue #798) active profile: Facet A — ``/api/providers`` + ``/api/models`` did not apply the active profile's ``.env`` around the read, so ``get_auth_status()`` / ``provider_model_ids()`` / custom-key lookups resolved against the default profile's credentials. On a non-default profile the auth probes could stall past the 30s frontend abort → "Failed to load providers: Request timed out." Facet B — the ``/api/models`` disk cache path was a single import-time ``STATE_DIR / "models_cache.json"`` shared across every profile, while the cache *fingerprint* is profile-specific → a non-default profile rejected the shared snapshot on every read and cold-rebuilt (the slow path). The fix: - ``api.config._get_models_cache_path()`` returns a profile-keyed path (``models_cache..json`` for named profiles; unchanged ``models_cache.json`` for the default/root profile). - ``api.profiles.profile_env_for_active_request_readonly()`` applies the active per-request profile's env around the read; no-op for the default profile. """ import os import sys import types from pathlib import Path import api.config as config import api.profiles as profiles # ───────────────────────────────────────────────────────────────────────────── # Facet B — profile-keyed models disk cache # ───────────────────────────────────────────────────────────────────────────── def _force_active_profile(monkeypatch, name, *, root=False): """Make get_active_profile_name() return *name* and control root detection. Avoids the subprocess list_profiles_api() call inside _is_root_profile by patching it to a pure function of the name. """ monkeypatch.setattr(profiles, "get_active_profile_name", lambda: name) monkeypatch.setattr( profiles, "_is_root_profile", lambda n: bool(root) or n in ("", "default") ) # config imports these names lazily inside _get_models_cache_path, so the # patches on the profiles module are what matter. def test_models_cache_path_default_profile_unchanged(monkeypatch): """Default/root profile keeps the original models_cache.json filename.""" monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") monkeypatch.setattr(profiles, "_is_root_profile", lambda n: n in ("", "default")) assert config._get_models_cache_path() == config._models_cache_path assert config._get_models_cache_path().name == "models_cache.json" def test_models_cache_path_empty_profile_unchanged(monkeypatch): """An empty/unset active profile falls back to the default path.""" monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "") monkeypatch.setattr(profiles, "_is_root_profile", lambda n: n in ("", "default")) assert config._get_models_cache_path() == config._models_cache_path def test_models_cache_path_named_profile_is_distinct(monkeypatch): """A named profile gets its own cache file, not the default's.""" _force_active_profile(monkeypatch, "work") path = config._get_models_cache_path() assert path != config._models_cache_path assert path.name == "models_cache.work.json" assert path.parent == config._models_cache_path.parent def test_models_cache_path_two_named_profiles_do_not_collide(monkeypatch): """Distinct non-default profiles never share a cache file (the bug).""" _force_active_profile(monkeypatch, "work") work = config._get_models_cache_path() _force_active_profile(monkeypatch, "personal") personal = config._get_models_cache_path() assert work != personal assert work != config._models_cache_path assert personal != config._models_cache_path def test_models_cache_path_sanitizes_unsafe_chars(monkeypatch): """Defense in depth: the on-disk filename is always filesystem-safe.""" _force_active_profile(monkeypatch, "weird/../name") path = config._get_models_cache_path() # No path separators or traversal can leak into the filename. assert path.parent == config._models_cache_path.parent assert "/" not in path.name assert ".." not in path.name.replace("models_cache.", "").replace(".json", "") def test_models_cache_path_falls_back_on_resolution_error(monkeypatch): """If profile resolution raises, fall back to the default path (no crash).""" def _boom(): raise RuntimeError("profiles unavailable") monkeypatch.setattr(profiles, "get_active_profile_name", _boom) assert config._get_models_cache_path() == config._models_cache_path # ───────────────────────────────────────────────────────────────────────────── # Facet A — profile-env applied around the read-only endpoints # ───────────────────────────────────────────────────────────────────────────── def test_active_request_env_noop_for_default_profile(monkeypatch): """The context manager is a true no-op for the default profile.""" monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") monkeypatch.setattr(profiles, "_is_root_profile", lambda n: n in ("", "default")) monkeypatch.delenv("ISSUE_3957_PROBE", raising=False) with profiles.profile_env_for_active_request_readonly("test"): # No env mutation, no HERMES_HOME change for the default profile. assert os.environ.get("ISSUE_3957_PROBE") is None assert os.environ.get("ISSUE_3957_PROBE") is None def test_active_request_env_applies_named_profile_env(monkeypatch, tmp_path): """A named profile's .env is bound to thread-local state, process env untouched.""" base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_PROBE=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.delenv("ISSUE_3957_PROBE", raising=False) # Simulate the per-request cookie context (issue #798). monkeypatch.setenv("ISSUE_3957_PROBE", "from-process-env") profiles.set_request_profile("work") try: assert profiles.get_active_profile_name() == "work" assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-process-env" with profiles.profile_env_for_active_request_readonly("test"): assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-work-profile" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" # Restored after the block exits. assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-process-env" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" finally: profiles.clear_request_profile() def test_active_request_env_restores_on_exception(monkeypatch, tmp_path): """Env is restored even if the wrapped read raises.""" base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_PROBE=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.delenv("ISSUE_3957_PROBE", raising=False) monkeypatch.setenv("ISSUE_3957_PROBE", "from-process-env") profiles.set_request_profile("work") try: with_raised = False try: with profiles.profile_env_for_active_request_readonly("test"): assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-work-profile" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" raise ValueError("boom") except ValueError: with_raised = True assert with_raised assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-process-env" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" finally: profiles.clear_request_profile() def test_active_request_scope_prefers_profile_key_over_process_env_for_custom_provider( monkeypatch, tmp_path, ): """Profile-scope thread env resolves custom-provider env vars before process env.""" base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_CUSTOM_KEY=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_CUSTOM_KEY", "from-process-env") monkeypatch.setattr( config, "get_config", lambda: { "custom_providers": [ {"name": "Team", "api_key": "${ISSUE_3957_CUSTOM_KEY}"} ] }, ) profiles.set_request_profile("work") try: assert config.resolve_custom_provider_connection("custom:team") == ( "from-process-env", None, ) with profiles.profile_env_for_active_request_readonly("test"): assert config.resolve_custom_provider_connection("custom:team") == ( "from-work-profile", None, ) assert os.environ.get("ISSUE_3957_CUSTOM_KEY") == "from-process-env" assert config._thread_local_env_value("ISSUE_3957_CUSTOM_KEY") == ( "from-process-env" ) finally: profiles.clear_request_profile() def test_active_request_scope_sets_context_local_hermes_home(monkeypatch, tmp_path): """Request scope keeps agent-side Hermes-home readers on the active profile.""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) current_home = {"value": None} fake_constants = types.SimpleNamespace() def _set_override(path): previous = current_home["value"] current_home["value"] = Path(path) return previous def _reset_override(token): current_home["value"] = token fake_constants.set_hermes_home_override = _set_override fake_constants.reset_hermes_home_override = _reset_override fake_constants.get_hermes_home = lambda: current_home["value"] monkeypatch.setitem(sys.modules, "hermes_constants", fake_constants) profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("test"): assert fake_constants.get_hermes_home() == work_home finally: profiles.clear_request_profile() def test_active_request_scope_restores_state_when_home_reset_fails(monkeypatch, tmp_path): """Readonly scope still clears thread-local state if Hermes-home reset raises.""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) (work_home / ".env").write_text( "ISSUE_3957_PROBE=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_PROBE", "from-process-env") current_home = {"value": None} fake_constants = types.SimpleNamespace() def _set_override(path): previous = current_home["value"] current_home["value"] = Path(path) return previous def _reset_override(token): current_home["value"] = token raise RuntimeError("reset failed") fake_constants.set_hermes_home_override = _set_override fake_constants.reset_hermes_home_override = _reset_override fake_constants.get_hermes_home = lambda: current_home["value"] monkeypatch.setitem(sys.modules, "hermes_constants", fake_constants) profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("test"): assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-work-profile" assert fake_constants.get_hermes_home() == work_home assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-process-env" assert getattr(config._thread_ctx, "block_process_env_fallback", False) is False finally: profiles.clear_request_profile() def test_active_request_legacy_scope_still_mirrors_process_env(monkeypatch, tmp_path): """Live-model request scope still mirrors env for agent-side readers.""" base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_PROBE=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_PROBE", "from-process-env") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request("test"): assert os.environ.get("ISSUE_3957_PROBE") == "from-work-profile" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" finally: profiles.clear_request_profile() def test_active_request_readonly_scope_blocks_process_env_fallback(monkeypatch, tmp_path): """Named profiles without a key should not inherit the process-default key.""" from api.providers import _provider_has_key base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("OPENAI_API_KEY", "from-process-env") profiles.set_request_profile("work") try: assert _provider_has_key("openai") is True with profiles.profile_env_for_active_request_readonly("test"): assert _provider_has_key("openai") is False finally: profiles.clear_request_profile() def test_active_request_readonly_scope_blocks_pool_env_seed(monkeypatch, tmp_path): """Readonly profile reads must not let load_pool seed process-default keys.""" from api.providers import _get_provider_api_key, _provider_has_key base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) # Point HERMES_HOME at this test's own tmp base too (#4740). The readonly scope # blocks the process-env credential fallback correctly, but the credential pool's # global-root fallback (read_credential_pool -> _load_global_auth_store) resolves # the root via get_default_hermes_root(), which reads os.environ["HERMES_HOME"] # DIRECTLY — not the thread-local/scoped home. Under full-suite ordering that env # var points at the shared persistent test state dir, where an EARLIER test # persisted an openrouter credential_pool entry (source: env:OPENROUTER_API_KEY) # into auth.json. read_credential_pool then falls back to that global store, finds # the leaked openrouter entry, and _has_explicit_pool_credentials returns True — # an order-dependent false failure that doesn't reproduce in isolation. Pinning # HERMES_HOME to this test's empty tmp base makes the global-root fallback resolve # to a clean dir with no leaked auth.json. monkeypatch.setenv("HERMES_HOME", str(base)) monkeypatch.setenv("OPENROUTER_API_KEY", "from-process-env") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("test"): assert _provider_has_key("openrouter") is False assert _get_provider_api_key("openrouter") is None finally: profiles.clear_request_profile() assert (work_home / "auth.json").exists() is False def test_providers_and_models_routes_wrap_in_profile_env(): """The two read routes are profile-scoped for non-default profiles (#3957). Structural guard: a future refactor that drops the wiring would silently reintroduce the bug, so pin it at the source level. - /api/providers and /api/provider/quota wrap the synchronous read in profile_env_for_active_request_readonly. - /api/models/live stays on the mirrored profile_env_for_active_request path because provider_model_ids() still delegates into agent helpers that read process env / HERMES_HOME directly. - /api/models relies on get_available_models() using the mirrored request scope for the budget<=0 sync rebuild plus profile_scope_for_detached_worker for the detached rebuild worker (the request-thread wrapper cannot reach the worker thread — Codex CORE finding). """ routes_src = Path(profiles.__file__).resolve().parent.joinpath("routes.py").read_text( encoding="utf-8" ) assert 'with profile_env_for_active_request("/api/models/live"' in routes_src assert "profile_env_for_active_request_readonly" in routes_src config_src = Path(config.__file__).resolve().read_text(encoding="utf-8") assert "profile_env_for_active_request as _prof_env_request" in config_src assert "profile_scope_for_detached_worker" in config_src assert "_get_models_cache_path" in config_src def test_models_sync_rebuild_uses_legacy_mirrored_env(monkeypatch, tmp_path): """The budget<=0 sync rebuild still mirrors profile env into os.environ.""" base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_PROBE=from-work-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_PROBE", "from-process-env") monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0) monkeypatch.setattr(config, "_available_models_cache", None) monkeypatch.setattr(config, "_available_models_cache_ts", 0.0) monkeypatch.setattr(config, "_available_models_cache_source_fingerprint", None) monkeypatch.setattr(config, "_cache_build_in_progress", False) monkeypatch.setattr(config, "_load_models_cache_from_disk", lambda: None) monkeypatch.setattr(config, "_save_models_cache_to_disk", lambda result: None) monkeypatch.setattr(config, "_models_cache_source_fingerprint", lambda: "issue-3957") seen = {} def _capture_rebuild(_builder): seen["process_env"] = os.environ.get("ISSUE_3957_PROBE") seen["thread_env"] = config._thread_local_env_value("ISSUE_3957_PROBE") return {"active_provider": None, "default_model": "", "groups": []} monkeypatch.setattr(config, "_invoke_models_rebuild", _capture_rebuild) profiles.set_request_profile("work") try: result = config.get_available_models() finally: profiles.clear_request_profile() assert seen["process_env"] == "from-work-profile" assert seen["thread_env"] == "from-work-profile" assert os.environ.get("ISSUE_3957_PROBE") == "from-process-env" assert result["groups"] == [] def test_thread_local_env_value_none_default_returns_empty_string(monkeypatch): """A None default never escapes the string-return contract.""" monkeypatch.setattr(config._thread_ctx, "env", {"ISSUE_3957_NONE": None}, raising=False) monkeypatch.setattr( config._thread_ctx, "block_process_env_fallback", False, raising=False ) assert config._thread_local_env_value("ISSUE_3957_NONE", None) == "" # ───────────────────────────────────────────────────────────────────────────── # Facet A (worker thread) — the detached models-rebuild worker is profile-scoped # (Codex CORE finding: the request-thread wrapper cannot reach the worker thread) # ───────────────────────────────────────────────────────────────────────────── def test_detached_worker_scope_noop_for_default_profile(monkeypatch): """profile_scope_for_detached_worker is a no-op for the default profile.""" monkeypatch.setattr(profiles, "_is_root_profile", lambda n: n in ("", "default")) monkeypatch.delenv("ISSUE_3957_WPROBE", raising=False) # Default/empty name → no TLS set, no env applied. with profiles.profile_scope_for_detached_worker("default", "test"): assert profiles.get_active_profile_name() in ("", "default") assert os.environ.get("ISSUE_3957_WPROBE") is None with profiles.profile_scope_for_detached_worker("", "test"): assert os.environ.get("ISSUE_3957_WPROBE") is None def test_detached_worker_scope_binds_profile_on_new_thread(monkeypatch, tmp_path): """A worker thread re-binds the captured profile's TLS + env + cache path. Reproduces the Codex CORE finding: WITHOUT the scope a new thread resolves the default profile (cache path models_cache.json, no profile env); WITH it the thread resolves the captured profile's cache file + .env. """ import threading base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_WPROBE=worker-env\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) # Point the default models cache at an isolated tmp file so the named path # derives from it (models_cache.work.json under the same dir). default_cache = tmp_path / "state" / "models_cache.json" default_cache.parent.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(config, "_models_cache_path", default_cache) monkeypatch.delenv("ISSUE_3957_WPROBE", raising=False) out = {} def worker(): # No TLS on this fresh thread yet → default profile resolution (the bug). out["before_name"] = config._get_models_cache_path().name out["before_env"] = os.environ.get("ISSUE_3957_WPROBE") with profiles.profile_scope_for_detached_worker("work", "test-worker"): out["inside_name"] = config._get_models_cache_path().name out["inside_env"] = os.environ.get("ISSUE_3957_WPROBE") out["after_name"] = config._get_models_cache_path().name out["after_env"] = os.environ.get("ISSUE_3957_WPROBE") t = threading.Thread(target=worker) t.start() t.join() assert out["before_name"] == "models_cache.json" # default (no scope) assert out["before_env"] is None assert out["inside_name"] == "models_cache.work.json" # profile-scoped assert out["inside_env"] == "worker-env" assert out["after_name"] == "models_cache.json" # restored assert out["after_env"] is None def test_detached_worker_prefers_profile_key_for_custom_provider(monkeypatch, tmp_path): """Detached worker scope resolves custom-provider env from thread profile, not process env.""" import threading base = tmp_path / ".hermes" (base / "profiles" / "work").mkdir(parents=True) (base / "profiles" / "work" / ".env").write_text( "ISSUE_3957_CUSTOM_KEY=from-worker-profile\n", encoding="utf-8" ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_CUSTOM_KEY", "from-process-env") monkeypatch.setattr( config, "get_config", lambda: { "custom_providers": [ {"name": "team", "api_key": "${ISSUE_3957_CUSTOM_KEY}"} ] }, ) out = {} def worker(): with profiles.profile_scope_for_detached_worker("work", "test-worker"): out["value"] = config.resolve_custom_provider_connection("custom:team")[0] t = threading.Thread(target=worker) t.start() t.join() assert out["value"] == "from-worker-profile" def test_detached_worker_scope_blocks_pool_env_seed(monkeypatch, tmp_path): """Detached worker scope must not let load_pool seed process-default keys.""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("OPENROUTER_API_KEY", "from-process-env") with profiles.profile_scope_for_detached_worker("work", "test-worker"): assert os.environ.get("OPENROUTER_API_KEY") is None assert config._has_explicit_pool_credentials("openrouter") is False assert getattr(config._thread_ctx, "block_process_env_fallback", False) is True assert os.environ.get("OPENROUTER_API_KEY") == "from-process-env" assert getattr(config._thread_ctx, "block_process_env_fallback", False) is False assert (work_home / "auth.json").exists() is False def test_detached_worker_scope_scrubs_absent_custom_provider_key_env(monkeypatch, tmp_path): """Detached worker scope clears missing custom-provider key_env fallbacks too.""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) (work_home / "config.yaml").write_text( "custom_providers:\n" " - name: Team\n" " base_url: https://example.invalid/v1\n" " key_env: ISSUE_3957_CUSTOM_KEY\n", encoding="utf-8", ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ISSUE_3957_CUSTOM_KEY", "from-process-env") with profiles.profile_scope_for_detached_worker("work", "test-worker"): assert os.environ.get("ISSUE_3957_CUSTOM_KEY") is None assert config._thread_local_env_value("ISSUE_3957_CUSTOM_KEY") == "" assert os.environ.get("ISSUE_3957_CUSTOM_KEY") == "from-process-env" def test_account_usage_subprocess_env_blocks_process_default_key(monkeypatch, tmp_path): """Readonly quota probes must not inherit process-default provider keys.""" from api.providers import _account_usage_subprocess_env base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("OPENAI_API_KEY", "from-process-env") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("quota probe"): env = _account_usage_subprocess_env(work_home, "openai", None) finally: profiles.clear_request_profile() assert env["HERMES_HOME"] == str(work_home) assert "OPENAI_API_KEY" not in env def test_active_request_scope_installs_secret_scope(monkeypatch, tmp_path): """Inside readonly scope, agent.secret_scope sees profile env, not process env.""" import types base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("OPENROUTER_API_KEY", "process-default-key") # Inject fake agent.secret_scope that records calls call_log = {} def fake_set_secret_scope(scope_dict): call_log["set_scope"] = dict(scope_dict) return "fake_token" def fake_reset_secret_scope(token): call_log["reset_called"] = True fake_secret_scope = types.ModuleType("agent.secret_scope") fake_secret_scope.set_secret_scope = fake_set_secret_scope fake_secret_scope.reset_secret_scope = fake_reset_secret_scope prev_agent = sys.modules.get("agent") prev_ss = sys.modules.get("agent.secret_scope") sys.modules["agent.secret_scope"] = fake_secret_scope sys.modules["agent"] = types.ModuleType("agent") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("test"): pass finally: profiles.clear_request_profile() if prev_ss is not None: sys.modules["agent.secret_scope"] = prev_ss else: sys.modules.pop("agent.secret_scope", None) if prev_agent is not None: sys.modules["agent"] = prev_agent else: sys.modules.pop("agent", None) profiles._secret_scope_available = None # Verify the scope was set with profile env only assert "set_scope" in call_log assert "OPENROUTER_API_KEY" not in call_log["set_scope"] assert "HERMES_HOME" in call_log["set_scope"] # Verify reset was called assert call_log.get("reset_called") is True def test_detached_worker_scope_installs_secret_scope(monkeypatch, tmp_path): """Inside detached worker scope, agent.secret_scope sees profile env, not process env.""" import threading import types base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("OPENROUTER_API_KEY", "process-default-key") # Inject fake agent.secret_scope that records calls call_log = {} def fake_set_secret_scope(scope_dict): call_log["set_scope"] = dict(scope_dict) return "fake_token" def fake_reset_secret_scope(token): call_log["reset_called"] = True fake_secret_scope = types.ModuleType("agent.secret_scope") fake_secret_scope.set_secret_scope = fake_set_secret_scope fake_secret_scope.reset_secret_scope = fake_reset_secret_scope prev_agent = sys.modules.get("agent") prev_ss = sys.modules.get("agent.secret_scope") sys.modules["agent.secret_scope"] = fake_secret_scope sys.modules["agent"] = types.ModuleType("agent") result = {"scope_was_set": False} def worker_body(): result["scope_was_set"] = "set_scope" in call_log # Capture the profile on the main thread profiles.set_request_profile("work") captured_profile = profiles.get_active_profile_name() try: with profiles.profile_scope_for_detached_worker(captured_profile): thread = threading.Thread(target=worker_body) thread.start() thread.join() finally: profiles.clear_request_profile() if prev_ss is not None: sys.modules["agent.secret_scope"] = prev_ss else: sys.modules.pop("agent.secret_scope", None) if prev_agent is not None: sys.modules["agent"] = prev_agent else: sys.modules.pop("agent", None) profiles._secret_scope_available = None # Verify the scope was set with profile env only assert "set_scope" in call_log assert "OPENROUTER_API_KEY" not in call_log["set_scope"] assert "HERMES_HOME" in call_log["set_scope"] # Verify reset was called assert call_log.get("reset_called") is True def test_account_usage_subprocess_env_strips_bedrock_keys(monkeypatch, tmp_path): """Quota probes must not inherit AWS/Bedrock keys when block_process_env_fallback is set.""" from api.providers import _account_usage_subprocess_env base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-key-id") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("quota probe"): env = _account_usage_subprocess_env(work_home, "bedrock", None) finally: profiles.clear_request_profile() assert env["HERMES_HOME"] == str(work_home) assert "AWS_ACCESS_KEY_ID" not in env assert "AWS_SECRET_ACCESS_KEY" not in env def test_account_usage_subprocess_env_strips_custom_key_env(monkeypatch, tmp_path): """Quota probes must strip custom provider key_env when block_process_env_fallback is set.""" from api.providers import _account_usage_subprocess_env base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) # Create a config.yaml with a custom provider that has key_env config_yaml = work_home / "config.yaml" config_yaml.write_text( """ custom_providers: - key_env: MY_CUSTOM_API_KEY """ ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("MY_CUSTOM_API_KEY", "custom-secret") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("quota probe"): env = _account_usage_subprocess_env(work_home, "openai", None) finally: profiles.clear_request_profile() assert env["HERMES_HOME"] == str(work_home) assert "MY_CUSTOM_API_KEY" not in env def test_account_usage_subprocess_env_strips_anthropic_token_aliases(monkeypatch, tmp_path): """Quota probes must not inherit the process-default Anthropic OAuth/token env vars (ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN) for an empty named profile. These are agent-runtime credential env vars absent from the WebUI's settable _PROVIDER_ENV_VAR map, so the strip set must derive them from the agent registry — otherwise the anthropic quota subprocess resolves them via resolve_anthropic_token() and leaks the server-process credential (#3961).""" from api.providers import _account_usage_subprocess_env base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ANTHROPIC_TOKEN", "process-default-anthropic-token") monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "process-default-oauth-token") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("quota probe"): env = _account_usage_subprocess_env(work_home, "anthropic", None) finally: profiles.clear_request_profile() assert env["HERMES_HOME"] == str(work_home) assert "ANTHROPIC_TOKEN" not in env assert "CLAUDE_CODE_OAUTH_TOKEN" not in env def test_detached_worker_scope_scrubs_anthropic_token_aliases(monkeypatch, tmp_path): """Detached/sync model-rebuild scope must scrub the process-default Anthropic OAuth/token env vars too — verified agent model code can resolve Anthropic models through raw os.getenv() of these names, so an empty named profile must not see the server-process token (#3961 detached-worker leak).""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("ANTHROPIC_TOKEN", "process-default-anthropic-token") monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "process-default-oauth-token") with profiles.profile_scope_for_detached_worker("work", "test-worker"): assert os.environ.get("ANTHROPIC_TOKEN") is None assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") is None assert config._thread_local_env_value("ANTHROPIC_TOKEN") == "" assert config._thread_local_env_value("CLAUDE_CODE_OAUTH_TOKEN") == "" assert os.environ.get("ANTHROPIC_TOKEN") == "process-default-anthropic-token" assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == "process-default-oauth-token" def test_account_usage_subprocess_env_strips_non_registry_agent_creds(monkeypatch, tmp_path): """Quota probes must not inherit process-default credential env vars the agent resolves via raw os.getenv() but that are NOT in the auth registry — the generic CUSTOM_API_KEY and the AWS/Bedrock credential family. Otherwise a custom/AWS-backed provider quota probe leaks the server-process credential to an empty named profile (#3961 residual leak class).""" from api.providers import _account_usage_subprocess_env base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("CUSTOM_API_KEY", "process-default-custom-key") monkeypatch.setenv("AWS_PROFILE", "process-default-aws-profile") monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "process-default-bedrock-token") monkeypatch.setenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", "http://169.254.170.2/creds") profiles.set_request_profile("work") try: with profiles.profile_env_for_active_request_readonly("quota probe"): env = _account_usage_subprocess_env(work_home, "openai", None) finally: profiles.clear_request_profile() assert env["HERMES_HOME"] == str(work_home) assert "CUSTOM_API_KEY" not in env assert "AWS_PROFILE" not in env assert "AWS_BEARER_TOKEN_BEDROCK" not in env assert "AWS_CONTAINER_CREDENTIALS_FULL_URI" not in env def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_path): """Detached/sync model-rebuild scope must scrub the non-registry agent credential env vars (CUSTOM_API_KEY, AWS/Bedrock family) too — the agent's custom-provider and bedrock-adapter paths resolve them via raw os.getenv(), so an empty named profile must not see the server-process value (#3961).""" base = tmp_path / ".hermes" work_home = base / "profiles" / "work" work_home.mkdir(parents=True) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) monkeypatch.setenv("CUSTOM_API_KEY", "process-default-custom-key") monkeypatch.setenv("AWS_PROFILE", "process-default-aws-profile") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "process-default-aws-secret") monkeypatch.setenv("AZURE_CLIENT_SECRET", "process-default-azure-secret") monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "process-default-foundry-key") monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254/msi") monkeypatch.setenv("MSI_ENDPOINT", "http://169.254.169.254/msi") with profiles.profile_scope_for_detached_worker("work", "test-worker"): assert os.environ.get("CUSTOM_API_KEY") is None assert os.environ.get("AWS_PROFILE") is None assert os.environ.get("AWS_SECRET_ACCESS_KEY") is None assert os.environ.get("AZURE_CLIENT_SECRET") is None assert os.environ.get("AZURE_FOUNDRY_API_KEY") is None assert os.environ.get("IDENTITY_ENDPOINT") is None assert os.environ.get("MSI_ENDPOINT") is None assert config._thread_local_env_value("CUSTOM_API_KEY") == "" assert os.environ.get("CUSTOM_API_KEY") == "process-default-custom-key" assert os.environ.get("AWS_PROFILE") == "process-default-aws-profile" assert os.environ.get("AWS_SECRET_ACCESS_KEY") == "process-default-aws-secret" assert os.environ.get("AZURE_CLIENT_SECRET") == "process-default-azure-secret" assert os.environ.get("AZURE_FOUNDRY_API_KEY") == "process-default-foundry-key" assert os.environ.get("IDENTITY_ENDPOINT") == "http://169.254.169.254/msi" assert os.environ.get("MSI_ENDPOINT") == "http://169.254.169.254/msi" def test_expand_env_vars_does_not_leak_process_env_under_block_scope(monkeypatch): """Config ${VAR} expansion must not reconstruct a server-process credential for a profile-scoped readonly/background read (#3961 config-template vector). A profile config.yaml of e.g. `api_key: ${ANTHROPIC_TOKEN}` previously expanded via raw os.environ in _expand_env_vars, so _get_provider_api_key could rebuild the process token and pass it through even when the scrub stripped the child env. The expansion now routes through the thread-local accessor and refuses the process-env fallback when block_process_env_fallback is set.""" monkeypatch.setenv("ANTHROPIC_TOKEN", "process-default-anthropic-token") # No active scope: normal behavior — expands from the process env. assert config._expand_env_vars({"api_key": "${ANTHROPIC_TOKEN}"}) == { "api_key": "process-default-anthropic-token" } # Profile-scoped readonly/background scope with no profile value for the var: # must NOT fall back to the process env (leaves the reference unexpanded). prev_block = getattr(config._thread_ctx, "block_process_env_fallback", False) prev_env = getattr(config._thread_ctx, "env", None) config._thread_ctx.block_process_env_fallback = True config._thread_ctx.env = {} try: assert config._expand_env_vars({"api_key": "${ANTHROPIC_TOKEN}"}) == { "api_key": "${ANTHROPIC_TOKEN}" } # A value present in the profile's thread-local env IS used (own value). config._thread_ctx.env = {"ANTHROPIC_TOKEN": "profile-own-token"} assert config._expand_env_vars({"api_key": "${ANTHROPIC_TOKEN}"}) == { "api_key": "profile-own-token" } finally: config._thread_ctx.block_process_env_fallback = prev_block if prev_env is None: config._thread_ctx.env = {} else: config._thread_ctx.env = prev_env