From 846951304db88aee362d26922656952996a937e3 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:24:07 -0400 Subject: [PATCH 01/23] fix(#3961): route provider and model credential reads through the thread-local profile channel --- api/config.py | 30 +++-- api/profiles.py | 65 ++++++++--- api/providers.py | 23 ++-- ...test_issue3957_profile_providers_models.py | 109 +++++++++++++++++- 4 files changed, 184 insertions(+), 43 deletions(-) diff --git a/api/config.py b/api/config.py index 2704d066..c1f995f5 100644 --- a/api/config.py +++ b/api/config.py @@ -1288,13 +1288,13 @@ def _legacy_custom_api_key_env_name(provider_id: object) -> str: def _lookup_custom_api_key_env(provider_id: object) -> str | None: """Look up sanitized custom-provider env first, then legacy broken shape.""" env_name = _api_key_env_name(provider_id) - api_key = os.getenv(env_name, "").strip() + api_key = _thread_local_env_value(env_name).strip() if api_key: return api_key legacy_env_name = _legacy_custom_api_key_env_name(provider_id) if legacy_env_name and legacy_env_name != env_name: - legacy_key = os.getenv(legacy_env_name, "").strip() + legacy_key = _thread_local_env_value(legacy_env_name).strip() if legacy_key: if legacy_env_name not in _LEGACY_CUSTOM_API_KEY_ENV_WARNED: _LEGACY_CUSTOM_API_KEY_ENV_WARNED.add(legacy_env_name) @@ -2408,13 +2408,13 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st if raw_api_key is not None: key_text = str(raw_api_key).strip() if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3: - api_key = os.getenv(key_text[2:-1], "").strip() or None + api_key = _thread_local_env_value(key_text[2:-1]).strip() or None elif key_text: api_key = key_text if not api_key: key_env = str(raw_key_env or "").strip() if key_env: - api_key = os.getenv(key_env, "").strip() or None + api_key = _thread_local_env_value(key_env).strip() or None if not api_key and provider_hint: api_key = _lookup_custom_api_key_env(provider_hint) return api_key @@ -5234,7 +5234,7 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", ): - val = os.getenv(k) + val = _thread_local_env_value(k) if val: all_env[k] = val if all_env.get("ANTHROPIC_API_KEY"): @@ -5557,7 +5557,7 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: "API_KEY", ) for key in api_key_vars: - api_key = (all_env.get(key) or os.getenv(key) or "").strip() + api_key = (all_env.get(key) or _thread_local_env_value(key) or "").strip() if api_key: break @@ -5599,7 +5599,7 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: if not _cp_api_key: _cp_key_env = str(_cp.get("key_env") or "").strip() if _cp_key_env: - _cp_api_key = str(os.getenv(_cp_key_env) or "").strip() + _cp_api_key = _thread_local_env_value(_cp_key_env).strip() # Fallback: check credential pool for both api_key and base_url if (not _cp_api_key or not _cp_base_url) and _slug: try: @@ -6893,6 +6893,22 @@ def _evict_session_agent(session_id: str) -> None: _thread_ctx = threading.local() +def _thread_local_env_value(name: str, default: str = "") -> str: + """Return thread-local profile env first, then process env, for provider reads.""" + env_name = str(name or "").strip() + if not env_name: + return default or "" + + thread_env = getattr(_thread_ctx, "env", {}) + if isinstance(thread_env, dict) and env_name in thread_env: + thread_value = thread_env.get(env_name) + if thread_value is None: + return default + return str(thread_value) + + return str(os.getenv(env_name, default or "")) + + def _set_thread_env(**kwargs): _thread_ctx.env = kwargs diff --git a/api/profiles.py b/api/profiles.py index cc8481b4..9d231d02 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -905,36 +905,63 @@ def profile_env_for_active_request( purpose: str = "provider/model read", logger_override: Optional[logging.Logger] = None, ): - """Apply the active per-request profile's env around a read-only API call (#3957). + """Apply the active per-request profile's env to thread-local state only (#3957). WebUI profile switching is per-client/cookie scoped (issue #798): a browser on a named profile sets a ``hermes_profile`` cookie, which ``server.py`` - turns into a thread-local via ``set_request_profile()``. But the per-client - switch deliberately does NOT reload the profile's ``.env`` into - ``os.environ`` (that would mutate process-global state shared with other - clients). So read-only endpoints that resolve provider credentials through - ``os.environ`` / ``HERMES_HOME`` — ``/api/providers`` (``get_auth_status`` - probes) and ``/api/models`` (``provider_model_ids`` / custom-key lookup) — - resolve against the *default* profile's credentials instead of the active - one. Symptoms: Settings → Providers times out and the model picker shows - only the default profile's models. + turns into a thread-local via ``set_request_profile()``. This wrapper keeps + provider-credential reads isolated to the request profile and does not touch + process-wide environment for read-only endpoints. - This mirrors what streaming already does for the duration of an agent run - (``profile_env_for_background_worker``): it temporarily applies the active - profile's ``.env`` + terminal config for the duration of the wrapped read, - then restores the previous env under ``_ENV_LOCK``. + A thread-local read-only scope is used for ``/api/providers`` and + ``/api/models`` flows that now resolve credentials through thread-local + environment first. It also sets a context-local Hermes-home override so + agent-side auth-store reads stay on the active profile without mutating + process-global ``os.environ``. - No-ops (zero overhead, byte-identical behavior) for the default/root profile - — the overwhelmingly common single-profile deployment is unaffected. + No-ops for the default/root profile, which is the common single-profile + deployment case. """ profile = (get_active_profile_name() or "").strip() if not profile or _is_root_profile(profile): yield return - with profile_env_for_background_worker( - profile, purpose, logger_override=logger_override - ): + try: + from api.config import _clear_thread_env, _set_thread_env, _thread_ctx + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + profile_home_path = Path(get_hermes_home_for_profile(profile)) + runtime_env = get_profile_runtime_env(profile_home_path) + safe_runtime_env = filter_runtime_env_for_gateway_parity(runtime_env) + except Exception: + log = logger_override or logger + log.debug( + "Failed to resolve profile env for active request profile %s in %s; " + "falling back to current env", + profile, + purpose, + exc_info=True, + ) yield + return + + thread_env = dict(safe_runtime_env) + thread_env["HERMES_HOME"] = str(profile_home_path) + previous_thread_env = getattr(_thread_ctx, "env", {}).copy() + home_override_token = None + try: + _set_thread_env(**thread_env) + home_override_token = set_hermes_home_override(profile_home_path) + yield + finally: + if home_override_token is not None: + reset_hermes_home_override(home_override_token) + if previous_thread_env: + _set_thread_env(**previous_thread_env) + else: + _clear_thread_env() @contextmanager diff --git a/api/providers.py b/api/providers.py index bf7fece8..ee36d1b6 100644 --- a/api/providers.py +++ b/api/providers.py @@ -34,6 +34,7 @@ except ImportError: # pragma: no cover - exercised only where fcntl is unavaila from api.config import ( _PROVIDER_DISPLAY, _PROVIDER_MODELS, + _thread_local_env_value, _custom_provider_slug_from_name, _get_label_for_model, _models_from_live_provider_ids, @@ -1032,10 +1033,10 @@ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool: env_path = _get_hermes_home() / ".env" env_values = _load_env_file(env_path) values.append(env_values.get(env_var)) - values.append(os.getenv(env_var)) + values.append(_thread_local_env_value(env_var)) for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): values.append(env_values.get(alias)) - values.append(os.getenv(alias)) + values.append(_thread_local_env_value(alias)) cfg = get_config() model_cfg = cfg.get("model", {}) @@ -1054,7 +1055,7 @@ def _provider_has_shadowed_codex_oauth_value(provider_id: str) -> bool: if isinstance(cp, dict) and _custom_provider_name_matches(provider_id, cp.get("name")): cp_key = cp.get("api_key") if isinstance(cp_key, str) and cp_key.startswith("${") and cp_key.endswith("}"): - values.append(os.getenv(cp_key[2:-1])) + values.append(_thread_local_env_value(cp_key[2:-1])) else: values.append(cp_key) return any(_looks_like_codex_oauth_token(str(value or "")) for value in values) @@ -1175,7 +1176,7 @@ def _provider_has_key(provider_id: str) -> bool: env_file_value = env_values.get(env_var) if _provider_value_counts_as_api_key(provider_id, env_file_value): return True - env_value = os.getenv(env_var) + env_value = _thread_local_env_value(env_var) if _provider_value_counts_as_api_key(provider_id, env_value): return True # Fall back to legacy env-var aliases (e.g. lmstudio's pre-#1500 @@ -1184,7 +1185,7 @@ def _provider_has_key(provider_id: str) -> bool: for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): if _provider_value_counts_as_api_key(provider_id, env_values.get(alias)): return True - if _provider_value_counts_as_api_key(provider_id, os.getenv(alias)): + if _provider_value_counts_as_api_key(provider_id, _thread_local_env_value(alias)): return True # Check credential pool — covers custom providers registered via # `hermes auth add` which store keys in auth.json (not config.yaml). @@ -1238,14 +1239,14 @@ def _get_provider_api_key(provider_id: str) -> str | None: env_file_value = env_values.get(env_var) if _provider_value_counts_as_api_key(provider_id, env_file_value): return str(env_file_value).strip() or None - env_value = os.getenv(env_var) + env_value = _thread_local_env_value(env_var) if _provider_value_counts_as_api_key(provider_id, env_value): return str(env_value).strip() or None for alias in _PROVIDER_ENV_VAR_ALIASES.get(provider_id, ()) or (): alias_file_value = env_values.get(alias) if _provider_value_counts_as_api_key(provider_id, alias_file_value): return str(alias_file_value).strip() or None - alias_value = os.getenv(alias) + alias_value = _thread_local_env_value(alias) if _provider_value_counts_as_api_key(provider_id, alias_value): return str(alias_value).strip() or None @@ -1273,7 +1274,7 @@ def _get_provider_api_key(provider_id: str) -> str | None: if _custom_provider_name_matches(provider_id, cp.get("name")): cp_key = str(cp.get("api_key") or "").strip() if cp_key.startswith("${") and cp_key.endswith("}"): - return os.getenv(cp_key[2:-1], "").strip() or None + return _thread_local_env_value(cp_key[2:-1]).strip() or None if _provider_value_counts_as_api_key(provider_id, cp_key): return cp_key # Fallback: try credential pool (e.g. bothub key stored via auth.json) @@ -2382,7 +2383,7 @@ def get_providers() -> dict[str, Any]: env_values = _load_env_file(env_path) if _provider_value_counts_as_api_key(pid, env_values.get(env_var)): key_source = "env_file" - elif _provider_value_counts_as_api_key(pid, os.getenv(env_var)): + elif _provider_value_counts_as_api_key(pid, _thread_local_env_value(env_var)): key_source = "env_var" else: # Canonical name not set; check legacy aliases (e.g. lmstudio's @@ -2395,7 +2396,7 @@ def get_providers() -> dict[str, Any]: key_source = "env_file" aliased = True break - if _provider_value_counts_as_api_key(pid, os.getenv(alias)): + if _provider_value_counts_as_api_key(pid, _thread_local_env_value(alias)): key_source = "env_var" aliased = True break @@ -2592,7 +2593,7 @@ def get_providers() -> dict[str, Any]: # Replace env var reference to check actual value if cp_api_key.startswith("${") and cp_api_key.endswith("}"): env_var = cp_api_key[2:-1] - cp_has_key = bool(os.getenv(env_var, "").strip()) + cp_has_key = bool(_thread_local_env_value(env_var).strip()) # Fallback: check credential pool (key added via hermes auth add) if not cp_has_key: try: diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 2fb66a66..3c7e3e6b 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -120,7 +120,7 @@ def test_active_request_env_noop_for_default_profile(monkeypatch): def test_active_request_env_applies_named_profile_env(monkeypatch, tmp_path): - """A named profile's .env is applied inside the block and restored after.""" + """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( @@ -130,14 +130,17 @@ def test_active_request_env_applies_named_profile_env(monkeypatch, tmp_path): 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 os.environ.get("ISSUE_3957_PROBE") is None + assert config._thread_local_env_value("ISSUE_3957_PROBE") == "from-process-env" with profiles.profile_env_for_active_request("test"): - assert os.environ.get("ISSUE_3957_PROBE") == "from-work-profile" + 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 os.environ.get("ISSUE_3957_PROBE") is None + 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() @@ -151,18 +154,79 @@ def test_active_request_env_restores_on_exception(monkeypatch, tmp_path): ) 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("test"): - assert os.environ.get("ISSUE_3957_PROBE") == "from-work-profile" + 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 os.environ.get("ISSUE_3957_PROBE") is None + 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("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.""" + from hermes_constants import get_hermes_home + + base = tmp_path / ".hermes" + work_home = base / "profiles" / "work" + work_home.mkdir(parents=True) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) + + profiles.set_request_profile("work") + try: + with profiles.profile_env_for_active_request("test"): + assert get_hermes_home() == work_home finally: profiles.clear_request_profile() @@ -249,3 +313,36 @@ def test_detached_worker_scope_binds_profile_on_new_thread(monkeypatch, tmp_path 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" + From 321090efcc5493760ca3219868674d72bc6a283f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:33:54 -0400 Subject: [PATCH 02/23] fix(#3961): preserve mirrored live-model profile reads --- api/config.py | 2 +- api/profiles.py | 23 ++++++++++- api/routes.py | 8 ++-- ...test_issue3957_profile_providers_models.py | 40 +++++++++++++++---- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/api/config.py b/api/config.py index c1f995f5..63435e3c 100644 --- a/api/config.py +++ b/api/config.py @@ -6415,7 +6415,7 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: try: from api.profiles import ( get_active_profile_name as _gapn, - profile_env_for_active_request as _prof_env_request, + profile_env_for_active_request_readonly as _prof_env_request, profile_scope_for_detached_worker as _prof_scope_worker, ) _active_profile_name = (_gapn() or "").strip() diff --git a/api/profiles.py b/api/profiles.py index 9d231d02..4b8950a3 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -901,7 +901,7 @@ def profile_env_for_background_worker( @contextmanager -def profile_env_for_active_request( +def profile_env_for_active_request_readonly( purpose: str = "provider/model read", logger_override: Optional[logging.Logger] = None, ): @@ -964,6 +964,27 @@ def profile_env_for_active_request( _clear_thread_env() +@contextmanager +def profile_env_for_active_request( + purpose: str = "active request", + logger_override: Optional[logging.Logger] = None, +): + """Apply the active per-request profile through the legacy mirrored path. + + Some request-scoped readers still delegate into Hermes helpers that resolve + credentials directly from process env or ``get_hermes_home()``. Those paths + stay on the mirrored scope until they are fully audited. + """ + profile = (get_active_profile_name() or "").strip() + if not profile or _is_root_profile(profile): + yield + return + with profile_env_for_background_worker( + profile, purpose, logger_override=logger_override + ): + yield + + @contextmanager def profile_scope_for_detached_worker( profile_name, diff --git a/api/routes.py b/api/routes.py index a637ad56..a5a31bd1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7773,8 +7773,8 @@ def handle_get(handler, parsed) -> bool: # profile's (#3957). Without this, get_auth_status() probes on a # non-default profile resolve the wrong/empty creds and can stall past # the 30s frontend timeout. No-op for the default profile. - from api.profiles import profile_env_for_active_request - with profile_env_for_active_request("/api/providers", logger_override=logger): + from api.profiles import profile_env_for_active_request_readonly + with profile_env_for_active_request_readonly("/api/providers", logger_override=logger): return j(handler, get_providers()) # ── Plugins/hooks visibility (read-only, no callback/source internals) ── @@ -7790,8 +7790,8 @@ def handle_get(handler, parsed) -> bool: # read/write runs under the process-default profile, so a multi-profile # client would see (and seed) the default profile's pool instead of its # own (#4247/#4067 profile-isolation class). - from api.profiles import profile_env_for_active_request - with profile_env_for_active_request("/api/provider/quota", logger_override=logger): + from api.profiles import profile_env_for_active_request_readonly + with profile_env_for_active_request_readonly("/api/provider/quota", logger_override=logger): return j(handler, get_provider_quota(provider_id, refresh=refresh)) if parsed.path == "/api/provider/cost-history": diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 3c7e3e6b..02875d8f 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -19,7 +19,7 @@ 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()`` applies the active + - ``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. """ @@ -113,7 +113,7 @@ def test_active_request_env_noop_for_default_profile(monkeypatch): 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("test"): + 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 @@ -135,7 +135,7 @@ def test_active_request_env_applies_named_profile_env(monkeypatch, tmp_path): 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("test"): + 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. @@ -160,7 +160,7 @@ def test_active_request_env_restores_on_exception(monkeypatch, tmp_path): try: with_raised = False try: - with profiles.profile_env_for_active_request("test"): + 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") @@ -201,7 +201,7 @@ def test_active_request_scope_prefers_profile_key_over_process_env_for_custom_pr "from-process-env", None, ) - with profiles.profile_env_for_active_request("test"): + with profiles.profile_env_for_active_request_readonly("test"): assert config.resolve_custom_provider_connection("custom:team") == ( "from-work-profile", None, @@ -225,18 +225,41 @@ def test_active_request_scope_sets_context_local_hermes_home(monkeypatch, tmp_pa profiles.set_request_profile("work") try: - with profiles.profile_env_for_active_request("test"): + with profiles.profile_env_for_active_request_readonly("test"): assert get_hermes_home() == work_home 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_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 wraps the synchronous read in profile_env_for_active_request. + - /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() scoping its detached rebuild worker via profile_scope_for_detached_worker (the request-thread wrapper cannot reach the worker thread — Codex CORE finding). @@ -244,7 +267,8 @@ def test_providers_and_models_routes_wrap_in_profile_env(): routes_src = Path(profiles.__file__).resolve().parent.joinpath("routes.py").read_text( encoding="utf-8" ) - assert "profile_env_for_active_request" in routes_src + 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_scope_for_detached_worker" in config_src assert "_get_models_cache_path" in config_src From b59f58601ef1ef6c75a546944bd25799d4c519af Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 02:48:24 -0400 Subject: [PATCH 03/23] fix(#3961): block process-env fallback in readonly profile scopes --- api/config.py | 3 +++ api/profiles.py | 5 +++++ .../test_issue3957_profile_providers_models.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/api/config.py b/api/config.py index 63435e3c..afbef1a0 100644 --- a/api/config.py +++ b/api/config.py @@ -6906,6 +6906,9 @@ def _thread_local_env_value(name: str, default: str = "") -> str: return default return str(thread_value) + if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): + return default or "" + return str(os.getenv(env_name, default or "")) diff --git a/api/profiles.py b/api/profiles.py index 4b8950a3..149a523b 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -950,14 +950,19 @@ def profile_env_for_active_request_readonly( thread_env = dict(safe_runtime_env) thread_env["HERMES_HOME"] = str(profile_home_path) previous_thread_env = getattr(_thread_ctx, "env", {}).copy() + previous_block_process_env = bool( + getattr(_thread_ctx, "block_process_env_fallback", False) + ) home_override_token = None try: _set_thread_env(**thread_env) + _thread_ctx.block_process_env_fallback = True home_override_token = set_hermes_home_override(profile_home_path) yield finally: if home_override_token is not None: reset_hermes_home_override(home_override_token) + _thread_ctx.block_process_env_fallback = previous_block_process_env if previous_thread_env: _set_thread_env(**previous_thread_env) else: diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 02875d8f..7a603822 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -250,6 +250,24 @@ def test_active_request_legacy_scope_still_mirrors_process_env(monkeypatch, tmp_ 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_providers_and_models_routes_wrap_in_profile_env(): """The two read routes are profile-scoped for non-default profiles (#3957). From 6220cc95db72ed27b46539d56855b9c58d1ba341 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 03:01:13 -0400 Subject: [PATCH 04/23] Keep readonly profile scoping alive without hermes-agent imports --- api/profiles.py | 17 +++++++++----- ...test_issue3957_profile_providers_models.py | 22 ++++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 149a523b..ce8b9a5d 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -928,10 +928,6 @@ def profile_env_for_active_request_readonly( return try: from api.config import _clear_thread_env, _set_thread_env, _thread_ctx - from hermes_constants import ( - reset_hermes_home_override, - set_hermes_home_override, - ) profile_home_path = Path(get_hermes_home_for_profile(profile)) runtime_env = get_profile_runtime_env(profile_home_path) safe_runtime_env = filter_runtime_env_for_gateway_parity(runtime_env) @@ -946,6 +942,14 @@ def profile_env_for_active_request_readonly( ) yield return + try: + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + except Exception: + reset_hermes_home_override = None + set_hermes_home_override = None thread_env = dict(safe_runtime_env) thread_env["HERMES_HOME"] = str(profile_home_path) @@ -957,10 +961,11 @@ def profile_env_for_active_request_readonly( try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True - home_override_token = set_hermes_home_override(profile_home_path) + if set_hermes_home_override is not None: + home_override_token = set_hermes_home_override(profile_home_path) yield finally: - if home_override_token is not None: + if home_override_token is not None and reset_hermes_home_override is not None: reset_hermes_home_override(home_override_token) _thread_ctx.block_process_env_fallback = previous_block_process_env if previous_thread_env: diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 7a603822..2d1e4a2e 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -24,6 +24,8 @@ The fix: """ import os +import sys +import types from pathlib import Path import api.config as config @@ -216,17 +218,31 @@ def test_active_request_scope_prefers_profile_key_over_process_env_for_custom_pr 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.""" - from hermes_constants import get_hermes_home - 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 get_hermes_home() == work_home + assert fake_constants.get_hermes_home() == work_home finally: profiles.clear_request_profile() From 7a0d86863515a80b4d8d1c01ff347ef3f6061a13 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 03:37:35 -0400 Subject: [PATCH 05/23] Keep profile-scoped reads from falling back to server credentials --- api/config.py | 8 +- api/profiles.py | 10 +- api/providers.py | 19 +++ ...test_issue3957_profile_providers_models.py | 115 +++++++++++++++++- 4 files changed, 145 insertions(+), 7 deletions(-) diff --git a/api/config.py b/api/config.py index afbef1a0..32c772ee 100644 --- a/api/config.py +++ b/api/config.py @@ -6415,7 +6415,7 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: try: from api.profiles import ( get_active_profile_name as _gapn, - profile_env_for_active_request_readonly as _prof_env_request, + profile_env_for_active_request as _prof_env_request, profile_scope_for_detached_worker as _prof_scope_worker, ) _active_profile_name = (_gapn() or "").strip() @@ -6427,7 +6427,9 @@ def get_available_models(*, prefer_cache: bool = False) -> dict: if _LIVE_REBUILD_BUDGET_SECONDS <= 0: try: # Foreground thread already carries the request-profile TLS; - # apply the profile env (no-op for default) for the live probe. + # apply the mirrored profile env (no-op for default) for the + # live probe because provider_model_ids() still has raw + # os.getenv()/HERMES_HOME readers on this synchronous path. _sync_scope = ( _prof_env_request("models rebuild (sync)") if _prof_env_request is not None @@ -6903,7 +6905,7 @@ def _thread_local_env_value(name: str, default: str = "") -> str: if isinstance(thread_env, dict) and env_name in thread_env: thread_value = thread_env.get(env_name) if thread_value is None: - return default + return default or "" return str(thread_value) if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): diff --git a/api/profiles.py b/api/profiles.py index ce8b9a5d..00c4650f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -966,7 +966,15 @@ def profile_env_for_active_request_readonly( yield finally: if home_override_token is not None and reset_hermes_home_override is not None: - reset_hermes_home_override(home_override_token) + try: + reset_hermes_home_override(home_override_token) + except Exception: + (logger_override or logger).debug( + "Failed to reset Hermes-home override for active request profile %s in %s", + profile, + purpose, + exc_info=True, + ) _thread_ctx.block_process_env_fallback = previous_block_process_env if previous_thread_env: _set_thread_env(**previous_thread_env) diff --git a/api/providers.py b/api/providers.py index ee36d1b6..3b05053d 100644 --- a/api/providers.py +++ b/api/providers.py @@ -742,6 +742,18 @@ _PROVIDER_ENV_VAR_ALIASES: dict[str, tuple[str, ...]] = { "opencode-go": ("OPENCODE_API_KEY",), } + +def _provider_credential_env_vars() -> tuple[str, ...]: + names = {name for name in _PROVIDER_ENV_VAR.values() if name} + for aliases in _PROVIDER_ENV_VAR_ALIASES.values(): + for alias in aliases or (): + if alias: + names.add(alias) + return tuple(sorted(names)) + + +_PROVIDER_CREDENTIAL_ENV_VARS = _provider_credential_env_vars() + # Providers that use OAuth or token flows — their credentials are managed # through the Hermes CLI, not via API keys. The WebUI cannot set these. _OAUTH_PROVIDERS = frozenset({ @@ -1395,6 +1407,13 @@ def _agent_fetch_account_usage(provider: str, *, base_url: str | None = None, ap def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None) -> dict[str, str]: env = dict(os.environ) + try: + from api.config import _thread_ctx + except Exception: + _thread_ctx = None + if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): + for env_name in _PROVIDER_CREDENTIAL_ENV_VARS: + env.pop(env_name, None) env["HERMES_HOME"] = str(Path(home)) # Profile .env values should affect only the child quota probe, not the diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 2d1e4a2e..2b022869 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -247,6 +247,45 @@ def test_active_request_scope_sets_context_local_hermes_home(monkeypatch, tmp_pa 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" @@ -294,9 +333,10 @@ def test_providers_and_models_routes_wrap_in_profile_env(): - /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() scoping its detached - rebuild worker via profile_scope_for_detached_worker (the request-thread - wrapper cannot reach the worker thread — Codex CORE finding). + - /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" @@ -304,10 +344,58 @@ def test_providers_and_models_routes_wrap_in_profile_env(): 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) @@ -404,3 +492,24 @@ def test_detached_worker_prefers_profile_key_for_custom_provider(monkeypatch, tm t.join() assert out["value"] == "from-worker-profile" + +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 + From 4d3817f64b3fb6679f4d20e14cc69ed83e9a5347 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 03:53:25 -0400 Subject: [PATCH 06/23] Ask GitHub to re-register checks for the profile-scope follow-up head From 44437f7a263432138d057fed1f521d9c31f69f35 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 19:52:52 -0400 Subject: [PATCH 07/23] fix(#3961): fail closed for profile-scoped credential pool reads --- api/config.py | 83 +++++++++++++++---- api/profiles.py | 71 +++++++++++++++- api/providers.py | 39 +++------ ...test_issue3957_profile_providers_models.py | 61 ++++++++++++++ 4 files changed, 208 insertions(+), 46 deletions(-) diff --git a/api/config.py b/api/config.py index 32c772ee..8102c2de 100644 --- a/api/config.py +++ b/api/config.py @@ -25,6 +25,7 @@ import urllib.error import urllib.request import uuid from pathlib import Path +from typing import Any from urllib.parse import parse_qs, urlparse # ── Basic layout ────────────────────────────────────────────────────────────── @@ -4162,18 +4163,37 @@ def _credential_pool_profile_tag() -> str: return "" -def _has_explicit_pool_credentials(provider_id: str) -> bool: - """Return True when the credential pool has at least one non-ambient entry - for *provider_id* (i.e. not a gh-cli / GITHUB_TOKEN auto-detect). +def _pool_entry_payloads(provider_id: str) -> list[dict[str, Any]]: + """Return explicit credential-pool entry payloads for the active profile. - Reuses ``_CREDENTIAL_POOL_CACHE`` so that callers on hot paths (provider - detection, model listing, live-model fetch) don't pay the ~10s load_pool - cost more than once per TTL window. + Readonly profile scopes must not let ``load_pool()`` seed from process env, + because that can materialize server-default credentials into a named + profile's auth store. In that mode, read raw auth.json payloads only. """ + _pid = _resolve_provider_alias(provider_id) + if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): + try: + from hermes_cli.auth import read_credential_pool as _read_credential_pool + + raw_entries = _read_credential_pool(_pid) + except ImportError: + return [] + payloads: list[dict[str, Any]] = [] + for entry in raw_entries: + if not isinstance(entry, dict): + continue + if _is_ambient_gh_cli_entry( + str(entry.get("source", "") or ""), + str(entry.get("label", "") or ""), + str(entry.get("key_source", "") or ""), + ): + continue + payloads.append(dict(entry)) + return payloads + try: from agent.credential_pool import load_pool as _load_pool - _pid = _resolve_provider_alias(provider_id) _ck = (_credential_pool_profile_tag(), _pid) _cached = _CREDENTIAL_POOL_CACHE.get(_ck) if _cached is not None: @@ -4188,17 +4208,46 @@ def _has_explicit_pool_credentials(provider_id: str) -> bool: _cp_pool = _load_pool(_pid) _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool) _all_entries = _cp_pool.entries() - - return any( - not _is_ambient_gh_cli_entry( - str(getattr(e, "source", "") or ""), - str(getattr(e, "label", "") or ""), - str(getattr(e, "key_source", "") or ""), - ) - for e in _all_entries - ) except ImportError: - return False + return [] + + payloads = [] + for entry in _all_entries: + if _is_ambient_gh_cli_entry( + str(getattr(entry, "source", "") or ""), + str(getattr(entry, "label", "") or ""), + str(getattr(entry, "key_source", "") or ""), + ): + continue + payload = entry.to_dict() if hasattr(entry, "to_dict") else {} + if not isinstance(payload, dict): + payload = {} + payload = dict(payload) + payload.setdefault("source", str(getattr(entry, "source", "") or "")) + payload.setdefault("label", str(getattr(entry, "label", "") or "")) + payload.setdefault("key_source", str(getattr(entry, "key_source", "") or "")) + runtime_api_key = getattr(entry, "runtime_api_key", None) + if runtime_api_key: + payload["runtime_api_key"] = runtime_api_key + base_url = getattr(entry, "base_url", None) + if base_url: + payload["base_url"] = base_url + inference_base_url = getattr(entry, "inference_base_url", None) + if inference_base_url: + payload["inference_base_url"] = inference_base_url + payloads.append(payload) + return payloads + + +def _has_explicit_pool_credentials(provider_id: str) -> bool: + """Return True when the credential pool has at least one non-ambient entry + for *provider_id* (i.e. not a gh-cli / GITHUB_TOKEN auto-detect). + + Reuses ``_CREDENTIAL_POOL_CACHE`` so that callers on hot paths (provider + detection, model listing, live-model fetch) don't pay the ~10s load_pool + cost more than once per TTL window. + """ + return bool(_pool_entry_payloads(provider_id)) _provider_models_invalidated_ts: dict[str, float] = {} # provider_id -> timestamp of last invalidation # Disk-backed in-memory cache for get_available_models(). diff --git a/api/profiles.py b/api/profiles.py index 00c4650f..a36ef0e3 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -811,6 +811,64 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str] return filtered +def _profile_secret_env_names(profile_home_path: Path) -> set[str]: + names: set[str] = set() + try: + from api.providers import _provider_credential_env_vars + + names.update(_provider_credential_env_vars()) + except Exception: + logger.debug( + "Failed to load provider credential env names for profile scope", + exc_info=True, + ) + + config_path = Path(profile_home_path) / "config.yaml" + if not config_path.exists(): + return names + try: + payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + logger.debug( + "Failed to inspect custom-provider credential env names from %s", + config_path, + exc_info=True, + ) + return names + + custom_providers = payload.get("custom_providers") if isinstance(payload, dict) else None + if not isinstance(custom_providers, list): + return names + for custom_provider in custom_providers: + if not isinstance(custom_provider, dict): + continue + key_env = str(custom_provider.get("key_env") or "").strip() + if key_env: + names.add(key_env) + api_key = str(custom_provider.get("api_key") or "").strip() + match = re.fullmatch(r"\$\{([^}]+)\}", api_key) + if match: + env_name = str(match.group(1) or "").strip() + if env_name: + names.add(env_name) + return names + + +def _apply_profile_env_to_process( + process_env, + safe_runtime_env: dict[str, str], + *, + secret_env_names: set[str], +) -> dict[str, Optional[str]]: + scoped_keys = set(safe_runtime_env) | set(secret_env_names) + previous_env = {key: process_env.get(key) for key in scoped_keys} + for key in secret_env_names: + if key not in safe_runtime_env: + process_env.pop(key, None) + process_env.update(safe_runtime_env) + return previous_env + + @contextmanager def profile_env_for_background_worker( session, @@ -840,6 +898,7 @@ def profile_env_for_background_worker( profile_home_path = Path(get_hermes_home_for_profile(profile)) runtime_env = get_profile_runtime_env(profile_home_path) safe_runtime_env = filter_runtime_env_for_gateway_parity(runtime_env) + secret_env_names = _profile_secret_env_names(profile_home_path) except Exception: log.debug( "Failed to resolve profile env for %s profile %s; falling back to current env", @@ -862,14 +921,21 @@ def profile_env_for_background_worker( old_hermes_home = None had_hermes_home = False previous_thread_env = getattr(_thread_ctx, "env", {}).copy() + previous_block_process_env = bool( + getattr(_thread_ctx, "block_process_env_fallback", False) + ) try: _set_thread_env(**thread_env) + _thread_ctx.block_process_env_fallback = True with _ENV_LOCK: - old_runtime_env = {key: os.environ.get(key) for key in safe_runtime_env} + old_runtime_env = _apply_profile_env_to_process( + os.environ, + safe_runtime_env, + secret_env_names=secret_env_names, + ) had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") skill_home_snapshot = snapshot_skill_home_modules() - os.environ.update(safe_runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) try: patch_skill_home_modules(profile_home_path) @@ -882,6 +948,7 @@ def profile_env_for_background_worker( ) yield finally: + _thread_ctx.block_process_env_fallback = previous_block_process_env if previous_thread_env: _set_thread_env(**previous_thread_env) else: diff --git a/api/providers.py b/api/providers.py index 3b05053d..fe790c3a 100644 --- a/api/providers.py +++ b/api/providers.py @@ -34,13 +34,14 @@ except ImportError: # pragma: no cover - exercised only where fcntl is unavaila from api.config import ( _PROVIDER_DISPLAY, _PROVIDER_MODELS, - _thread_local_env_value, _custom_provider_slug_from_name, _get_label_for_model, _models_from_live_provider_ids, + _pool_entry_payloads, _read_live_provider_model_ids, _read_visible_codex_cache_model_ids, _save_yaml_config_file, + _thread_local_env_value, get_config, invalidate_models_cache, reload_config, @@ -866,11 +867,7 @@ def _local_pool_snapshot(provider): or None if the provider has no pool or no entries. """ try: - from agent.credential_pool import load_pool - from api.config import _is_ambient_gh_cli_entry - - pool = load_pool(provider) - entries = list(pool.entries()) if pool is not None and hasattr(pool, "entries") else [] + entries = [SimpleNamespace(**payload) for payload in _pool_entry_payloads(provider)] except Exception: return None if not entries: @@ -881,11 +878,6 @@ def _local_pool_snapshot(provider): exhausted_count = 0 dead_count = 0 for index, entry in enumerate(entries, start=1): - source = str(_entry_value(entry, "source") or "") - label_val = str(_entry_value(entry, "label", "source") or "") - key_source = str(_entry_value(entry, "key_source") or "") - if _is_ambient_gh_cli_entry(source, label_val, key_source): - continue label = _safe_entry_label(entry, index) entry_status = str(_entry_value(entry, "last_status") or "").strip().lower() if entry_status == "dead": @@ -1290,22 +1282,15 @@ def _get_provider_api_key(provider_id: str) -> str | None: if _provider_value_counts_as_api_key(provider_id, cp_key): return cp_key # Fallback: try credential pool (e.g. bothub key stored via auth.json) - try: - from api.config import _has_explicit_pool_credentials, _resolve_provider_alias - if _has_explicit_pool_credentials(provider_id): - from agent.credential_pool import load_pool - # Must resolve alias here too: _has_explicit_pool_credentials does it - # internally, but load_pool sees the original unresolved provider_id. - _resolved = _resolve_provider_alias(provider_id) - pool = load_pool(_resolved) - if pool: - entry = pool.select() - if entry: - key = getattr(entry, "runtime_api_key", "") or getattr(entry, "access_token", "") - if key: - return key - except ImportError: - pass + for entry in _pool_entry_payloads(provider_id): + key = str( + entry.get("runtime_api_key") + or entry.get("agent_key") + or entry.get("access_token") + or "" + ).strip() + if key: + return key return None diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 2b022869..30911497 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -323,6 +323,27 @@ def test_active_request_readonly_scope_blocks_process_env_fallback(monkeypatch, 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) + 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). @@ -493,6 +514,46 @@ def test_detached_worker_prefers_profile_key_for_custom_provider(monkeypatch, tm 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 From 62ff54d5da00d24874dfa5a73c06fb9a7b94a3c5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 19:58:04 -0400 Subject: [PATCH 08/23] fix(#3961): preserve live pool entry state outside readonly scopes --- api/config.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/api/config.py b/api/config.py index 8102c2de..a5f12155 100644 --- a/api/config.py +++ b/api/config.py @@ -4199,15 +4199,15 @@ def _pool_entry_payloads(provider_id: str) -> list[dict[str, Any]]: if _cached is not None: _cp_ts, _cp_pool = _cached if (time.time() - _cp_ts) < 86400.0: - _all_entries = _cp_pool.entries() + _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else [] else: _cp_pool = _load_pool(_pid) _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool) - _all_entries = _cp_pool.entries() + _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else [] else: _cp_pool = _load_pool(_pid) _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool) - _all_entries = _cp_pool.entries() + _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else [] except ImportError: return [] @@ -4219,7 +4219,15 @@ def _pool_entry_payloads(provider_id: str) -> list[dict[str, Any]]: str(getattr(entry, "key_source", "") or ""), ): continue - payload = entry.to_dict() if hasattr(entry, "to_dict") else {} + if hasattr(entry, "to_dict") and callable(getattr(entry, "to_dict")): + payload = entry.to_dict() + elif isinstance(entry, dict): + payload = dict(entry) + else: + try: + payload = dict(vars(entry)) + except TypeError: + payload = {} if not isinstance(payload, dict): payload = {} payload = dict(payload) From c237946ea369a5515b0fefbc035b0e1e7d340dff Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 20:01:58 -0400 Subject: [PATCH 09/23] fix(#3961): keep mirrored worker env updates and pool helpers compatible --- api/config.py | 2 +- api/profiles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/config.py b/api/config.py index a5f12155..c026d652 100644 --- a/api/config.py +++ b/api/config.py @@ -4219,7 +4219,7 @@ def _pool_entry_payloads(provider_id: str) -> list[dict[str, Any]]: str(getattr(entry, "key_source", "") or ""), ): continue - if hasattr(entry, "to_dict") and callable(getattr(entry, "to_dict")): + if hasattr(entry, "to_dict") and callable(entry.to_dict): payload = entry.to_dict() elif isinstance(entry, dict): payload = dict(entry) diff --git a/api/profiles.py b/api/profiles.py index a36ef0e3..2dca8ba5 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -865,7 +865,6 @@ def _apply_profile_env_to_process( for key in secret_env_names: if key not in safe_runtime_env: process_env.pop(key, None) - process_env.update(safe_runtime_env) return previous_env @@ -936,6 +935,7 @@ def profile_env_for_background_worker( had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") skill_home_snapshot = snapshot_skill_home_modules() + os.environ.update(safe_runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) try: patch_skill_home_modules(profile_home_path) From 58b50d243e0fd277ae7b6a75ec622f4d62873e41 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 20:50:13 -0400 Subject: [PATCH 10/23] fix(#3961): close load_pool secret-scope bypass and extend subprocess env strip set --- api/profiles.py | 28 ++++ api/providers.py | 9 +- ...test_issue3957_profile_providers_models.py | 150 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index 2dca8ba5..ccb80813 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -923,9 +923,18 @@ def profile_env_for_background_worker( previous_block_process_env = bool( getattr(_thread_ctx, "block_process_env_fallback", False) ) + _scope_token = None + _has_scope = False try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True + try: + from agent.secret_scope import reset_secret_scope, set_secret_scope + _scope_token = set_secret_scope(thread_env) + _has_scope = True + except ImportError: + _scope_token = None + _has_scope = False with _ENV_LOCK: old_runtime_env = _apply_profile_env_to_process( os.environ, @@ -948,6 +957,11 @@ def profile_env_for_background_worker( ) yield finally: + if _has_scope: + try: + reset_secret_scope(_scope_token) + except Exception: + pass _thread_ctx.block_process_env_fallback = previous_block_process_env if previous_thread_env: _set_thread_env(**previous_thread_env) @@ -1025,13 +1039,27 @@ def profile_env_for_active_request_readonly( getattr(_thread_ctx, "block_process_env_fallback", False) ) home_override_token = None + _scope_token = None + _has_scope = False try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True + try: + from agent.secret_scope import reset_secret_scope, set_secret_scope + _scope_token = set_secret_scope(thread_env) + _has_scope = True + except ImportError: + _scope_token = None + _has_scope = False if set_hermes_home_override is not None: home_override_token = set_hermes_home_override(profile_home_path) yield finally: + if _has_scope: + try: + reset_secret_scope(_scope_token) + except Exception: + pass if home_override_token is not None and reset_hermes_home_override is not None: try: reset_hermes_home_override(home_override_token) diff --git a/api/providers.py b/api/providers.py index fe790c3a..bbc588c0 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1397,7 +1397,14 @@ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None except Exception: _thread_ctx = None if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): - for env_name in _PROVIDER_CREDENTIAL_ENV_VARS: + _strip = set(_PROVIDER_CREDENTIAL_ENV_VARS) + _strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) + try: + from api.profiles import _profile_secret_env_names, get_active_hermes_home + _strip.update(_profile_secret_env_names(get_active_hermes_home())) + except Exception: + pass + for env_name in _strip: env.pop(env_name, None) env["HERMES_HOME"] = str(Path(home)) diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 30911497..17ca8e28 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -574,3 +574,153 @@ def test_account_usage_subprocess_env_blocks_process_default_key(monkeypatch, tm 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 + 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() + del sys.modules["agent.secret_scope"] + del sys.modules["agent"] + + # 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 + 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() + del sys.modules["agent.secret_scope"] + del sys.modules["agent"] + + # 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 + From 466412a732909c23c74a2672cff00e944cec2ae5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:18:57 -0400 Subject: [PATCH 11/23] fix(#3961): clean up partial agent entry from sys.modules on ImportError --- api/profiles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index ccb80813..52ef1f59 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -933,6 +933,9 @@ def profile_env_for_background_worker( _scope_token = set_secret_scope(thread_env) _has_scope = True except ImportError: + import sys as _sys + _sys.modules.pop('agent', None) + _sys.modules.pop('agent.secret_scope', None) _scope_token = None _has_scope = False with _ENV_LOCK: @@ -1049,6 +1052,9 @@ def profile_env_for_active_request_readonly( _scope_token = set_secret_scope(thread_env) _has_scope = True except ImportError: + import sys as _sys + _sys.modules.pop('agent', None) + _sys.modules.pop('agent.secret_scope', None) _scope_token = None _has_scope = False if set_hermes_home_override is not None: From 1dbfddd5b8c236a13bc6cb685d043a3a0b04b9ec Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:26:50 -0400 Subject: [PATCH 12/23] fix(#3961): use find_spec to avoid sys.modules pollution from failed agent import --- api/profiles.py | 48 ++++++++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 52ef1f59..eaba0a7e 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -928,14 +928,20 @@ def profile_env_for_background_worker( try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True + _secret_scope_mod = None try: - from agent.secret_scope import reset_secret_scope, set_secret_scope - _scope_token = set_secret_scope(thread_env) - _has_scope = True - except ImportError: - import sys as _sys - _sys.modules.pop('agent', None) - _sys.modules.pop('agent.secret_scope', None) + _secret_scope_mod = __import__('sys').modules.get('agent.secret_scope') + if _secret_scope_mod is None: + import importlib.util + if importlib.util.find_spec('agent.secret_scope') is not None: + _secret_scope_mod = __import__('agent.secret_scope', fromlist=['set_secret_scope', 'reset_secret_scope']) + if _secret_scope_mod is not None: + _scope_token = _secret_scope_mod.set_secret_scope(thread_env) + _has_scope = True + else: + _scope_token = None + _has_scope = False + except Exception: _scope_token = None _has_scope = False with _ENV_LOCK: @@ -960,9 +966,9 @@ def profile_env_for_background_worker( ) yield finally: - if _has_scope: + if _has_scope and _secret_scope_mod is not None: try: - reset_secret_scope(_scope_token) + _secret_scope_mod.reset_secret_scope(_scope_token) except Exception: pass _thread_ctx.block_process_env_fallback = previous_block_process_env @@ -1047,23 +1053,29 @@ def profile_env_for_active_request_readonly( try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True + _secret_scope_mod = None try: - from agent.secret_scope import reset_secret_scope, set_secret_scope - _scope_token = set_secret_scope(thread_env) - _has_scope = True - except ImportError: - import sys as _sys - _sys.modules.pop('agent', None) - _sys.modules.pop('agent.secret_scope', None) + _secret_scope_mod = __import__('sys').modules.get('agent.secret_scope') + if _secret_scope_mod is None: + import importlib.util + if importlib.util.find_spec('agent.secret_scope') is not None: + _secret_scope_mod = __import__('agent.secret_scope', fromlist=['set_secret_scope', 'reset_secret_scope']) + if _secret_scope_mod is not None: + _scope_token = _secret_scope_mod.set_secret_scope(thread_env) + _has_scope = True + else: + _scope_token = None + _has_scope = False + except Exception: _scope_token = None _has_scope = False if set_hermes_home_override is not None: home_override_token = set_hermes_home_override(profile_home_path) yield finally: - if _has_scope: + if _has_scope and _secret_scope_mod is not None: try: - reset_secret_scope(_scope_token) + _secret_scope_mod.reset_secret_scope(_scope_token) except Exception: pass if home_override_token is not None and reset_hermes_home_override is not None: From 454371c466dfc23563a37fb6b84944077d63c2a6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:32:13 -0400 Subject: [PATCH 13/23] fix(#3961): probe agent availability without polluting sys.modules --- api/profiles.py | 68 +++++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index eaba0a7e..4d1c6b13 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -868,6 +868,32 @@ def _apply_profile_env_to_process( return previous_env +_secret_scope_available = None + + +def _resolve_secret_scope_module(): + global _secret_scope_available + import sys as _sys + mod = _sys.modules.get('agent.secret_scope') + if mod is not None: + return mod + if _secret_scope_available is False: + return None + if _secret_scope_available is None: + try: + import importlib.util + _secret_scope_available = importlib.util.find_spec('agent') is not None + except Exception: + _secret_scope_available = False + if _secret_scope_available: + try: + from agent.secret_scope import set_secret_scope, reset_secret_scope # noqa: F401 + return _sys.modules.get('agent.secret_scope') + except ImportError: + _secret_scope_available = False + return None + + @contextmanager def profile_env_for_background_worker( session, @@ -928,22 +954,15 @@ def profile_env_for_background_worker( try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True - _secret_scope_mod = None - try: - _secret_scope_mod = __import__('sys').modules.get('agent.secret_scope') - if _secret_scope_mod is None: - import importlib.util - if importlib.util.find_spec('agent.secret_scope') is not None: - _secret_scope_mod = __import__('agent.secret_scope', fromlist=['set_secret_scope', 'reset_secret_scope']) - if _secret_scope_mod is not None: + _secret_scope_mod = _resolve_secret_scope_module() + _scope_token = None + _has_scope = False + if _secret_scope_mod is not None: + try: _scope_token = _secret_scope_mod.set_secret_scope(thread_env) _has_scope = True - else: - _scope_token = None - _has_scope = False - except Exception: - _scope_token = None - _has_scope = False + except Exception: + pass with _ENV_LOCK: old_runtime_env = _apply_profile_env_to_process( os.environ, @@ -1053,22 +1072,15 @@ def profile_env_for_active_request_readonly( try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True - _secret_scope_mod = None - try: - _secret_scope_mod = __import__('sys').modules.get('agent.secret_scope') - if _secret_scope_mod is None: - import importlib.util - if importlib.util.find_spec('agent.secret_scope') is not None: - _secret_scope_mod = __import__('agent.secret_scope', fromlist=['set_secret_scope', 'reset_secret_scope']) - if _secret_scope_mod is not None: + _secret_scope_mod = _resolve_secret_scope_module() + _scope_token = None + _has_scope = False + if _secret_scope_mod is not None: + try: _scope_token = _secret_scope_mod.set_secret_scope(thread_env) _has_scope = True - else: - _scope_token = None - _has_scope = False - except Exception: - _scope_token = None - _has_scope = False + except Exception: + pass if set_hermes_home_override is not None: home_override_token = set_hermes_home_override(profile_home_path) yield From b3c9bf5ad18992c2f48030f22f8a2f2b8a6c26b7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:39:06 -0400 Subject: [PATCH 14/23] Preserve sys.modules state across secret_scope tests to avoid polluting sibling test modules --- ...test_issue3957_profile_providers_models.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 17ca8e28..874d859e 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -598,6 +598,8 @@ def test_active_request_scope_installs_secret_scope(monkeypatch, tmp_path): 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") @@ -607,8 +609,15 @@ def test_active_request_scope_installs_secret_scope(monkeypatch, tmp_path): pass finally: profiles.clear_request_profile() - del sys.modules["agent.secret_scope"] - del sys.modules["agent"] + 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 @@ -642,6 +651,8 @@ def test_detached_worker_scope_installs_secret_scope(monkeypatch, tmp_path): 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") @@ -660,8 +671,15 @@ def test_detached_worker_scope_installs_secret_scope(monkeypatch, tmp_path): thread.join() finally: profiles.clear_request_profile() - del sys.modules["agent.secret_scope"] - del sys.modules["agent"] + 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 From 7f7ec57ebdc76f8aab11ba3ca6afc8cf850f988e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:42:51 -0400 Subject: [PATCH 15/23] Skip dead and exhausted pool entries in _get_provider_api_key to match prior pool.select() contract --- api/providers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/providers.py b/api/providers.py index bbc588c0..dc1ec76f 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1283,6 +1283,13 @@ def _get_provider_api_key(provider_id: str) -> str | None: return cp_key # Fallback: try credential pool (e.g. bothub key stored via auth.json) for entry in _pool_entry_payloads(provider_id): + status = str(entry.get("last_status") or "").strip().lower() + if status == "dead": + continue + if status == "exhausted": + ns = SimpleNamespace(**entry) + if _entry_is_pool_exhausted(ns): + continue key = str( entry.get("runtime_api_key") or entry.get("agent_key") From 1293f0301eae896a471d0946c1178c951ec697e7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:09:59 +0000 Subject: [PATCH 16/23] fix(#3961): scrub agent-registry OAuth token env vars in profile credential scope Codex re-gate of #4544 reproduced a residual cross-profile leak: the PR closed OPENROUTER_API_KEY but ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN still leaked the server-process credential to an empty named profile on /api/provider/quota (anthropic) and the detached-worker model-rebuild path, because the scrub set was built only from the WebUI _PROVIDER_ENV_VAR map (which omits OAuth/token-flow provider env vars). Widen the shared _profile_secret_env_names() scrub source to union in the agent registry's api_key_env_vars (hermes_cli.auth.PROVIDER_REGISTRY) so every credential env var the agent runtime can resolve via raw os.getenv() is scrubbed in profile-scoped reads. Fixes both leak sites at once (both route through _profile_secret_env_names). + 2 regressions that fail without the fix. Co-authored-by: rodboev --- api/profiles.py | 33 ++++++++++++ ...test_issue3957_profile_providers_models.py | 51 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index 62106188..e2812f66 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -885,6 +885,33 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str] return filtered +def _agent_registry_credential_env_names() -> set[str]: + """Credential env-var names the *agent* runtime reads (hermes_cli auth + registry), e.g. OAuth/token-flow providers like Anthropic's ANTHROPIC_TOKEN + and CLAUDE_CODE_OAUTH_TOKEN. The WebUI's own _PROVIDER_ENV_VAR map omits + these (they aren't WebUI-settable API keys), so a profile scrub built only + from the WebUI map would leave them in os.environ — letting an empty named + profile inherit the server-process Anthropic token on the quota subprocess + and detached-worker model-rebuild paths (#3961 residual leak).""" + names: set[str] = set() + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + registry = PROVIDER_REGISTRY + items = registry.items() if hasattr(registry, "items") else enumerate(registry) + for _key, entry in items: + env_vars = getattr(entry, "api_key_env_vars", None) + for env_var in env_vars or (): + if env_var: + names.add(str(env_var)) + except Exception: + logger.debug( + "Failed to load agent registry credential env names for profile scope", + exc_info=True, + ) + return names + + def _profile_secret_env_names(profile_home_path: Path) -> set[str]: names: set[str] = set() try: @@ -897,6 +924,12 @@ def _profile_secret_env_names(profile_home_path: Path) -> set[str]: exc_info=True, ) + # Also scrub credential env vars the agent runtime resolves directly + # (OAuth/token-flow providers absent from the WebUI's settable-key map) so a + # profile-scoped read can't inherit the server process's ANTHROPIC_TOKEN / + # CLAUDE_CODE_OAUTH_TOKEN etc. (#3961 cross-profile residual leak). + names.update(_agent_registry_credential_env_names()) + config_path = Path(profile_home_path) / "config.yaml" if not config_path.exists(): return names diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 874d859e..cf5a4ecf 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -742,3 +742,54 @@ custom_providers: 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" + From 4eb069be97bf9ac7f20d58a70aaf663add121ee0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:19:56 +0000 Subject: [PATCH 17/23] fix(#3961): complete credential scrub with non-registry agent creds (CUSTOM_API_KEY, AWS/Bedrock) Codex re-gate found the registry-derived scrub still missed credential env vars the agent resolves via raw os.getenv() that aren't in PROVIDER_REGISTRY: CUSTOM_API_KEY (hermes_cli/models.py), AWS_BEARER_TOKEN_BEDROCK (hermes_cli/model_switch.py), AZURE_ANTHROPIC_KEY (runtime_provider.py), and the boto3 AWS_* credential family (agent/bedrock_adapter.py). Same cross-profile leak class on custom/AWS-backed provider quota + detached-worker paths. - Add _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES fail-closed set, union into _profile_secret_env_names so BOTH leak sites pick it up. - _account_usage_subprocess_env now relies on the centralized scrub set (WebUI- only fallback only if the api.profiles import fails), dropping the partial local AWS pair. - 2 more regressions (quota subprocess + detached worker) covering CUSTOM_API_KEY + AWS family, fail without the fix. Co-authored-by: rodboev --- api/profiles.py | 47 +++++++++++++--- api/providers.py | 8 ++- ...test_issue3957_profile_providers_models.py | 53 +++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index e2812f66..3783316f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -885,15 +885,46 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str] return filtered +# Credential env vars the agent runtime resolves via raw os.getenv() that are +# NOT in hermes_cli.auth.PROVIDER_REGISTRY (so the registry-derived scrub set +# would miss them). Fail-closed list — verified against the installed agent: +# CUSTOM_API_KEY hermes_cli/models.py (generic custom provider key) +# AZURE_ANTHROPIC_KEY hermes_cli/runtime_provider.py (Azure-hosted Anthropic) +# AWS_BEARER_TOKEN_BEDROCK hermes_cli/model_switch.py (Bedrock bearer token) +# AWS_* agent/bedrock_adapter.py (boto3 credential chain) +# Stripping these in a profile-scoped read prevents an empty named profile from +# inheriting the server-process credential (#3961 residual cross-profile leak). +_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES: tuple[str, ...] = ( + "CUSTOM_API_KEY", + "AZURE_ANTHROPIC_KEY", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_WEB_IDENTITY_TOKEN_FILE", +) + + def _agent_registry_credential_env_names() -> set[str]: - """Credential env-var names the *agent* runtime reads (hermes_cli auth - registry), e.g. OAuth/token-flow providers like Anthropic's ANTHROPIC_TOKEN - and CLAUDE_CODE_OAUTH_TOKEN. The WebUI's own _PROVIDER_ENV_VAR map omits - these (they aren't WebUI-settable API keys), so a profile scrub built only - from the WebUI map would leave them in os.environ — letting an empty named - profile inherit the server-process Anthropic token on the quota subprocess - and detached-worker model-rebuild paths (#3961 residual leak).""" - names: set[str] = set() + """Credential env-var names the *agent* runtime reads, beyond the WebUI's own + settable-key map. Two sources: + + 1. ``hermes_cli.auth.PROVIDER_REGISTRY[*].api_key_env_vars`` — every provider + the agent CLI knows, incl. OAuth/token-flow providers like Anthropic's + ``ANTHROPIC_TOKEN`` / ``CLAUDE_CODE_OAUTH_TOKEN`` that the WebUI's own + ``_PROVIDER_ENV_VAR`` map omits (they aren't WebUI-settable API keys). + 2. ``_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES`` — a fail-closed fallback for + credential env vars the agent resolves via raw ``os.getenv()`` that are NOT + in the auth registry (the generic ``CUSTOM_API_KEY`` and the AWS/Bedrock + credential family the bedrock adapter relies on). + + A profile scrub built only from the WebUI map would leave all of these in + ``os.environ`` — letting an empty named profile inherit the server-process + credential on the quota subprocess and detached-worker model-rebuild paths + (#3961 residual cross-profile leak).""" + names: set[str] = set(_NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES) try: from hermes_cli.auth import PROVIDER_REGISTRY diff --git a/api/providers.py b/api/providers.py index dc1ec76f..ec4a1b49 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1404,13 +1404,17 @@ def _account_usage_subprocess_env(home: Path, provider: str, api_key: str | None except Exception: _thread_ctx = None if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): + # Rely on the centralized profile scrub set (api.profiles), which unions + # the WebUI provider env vars + the agent auth registry + the non-registry + # agent credential fallback (CUSTOM_API_KEY, AWS/Bedrock family). Falling + # back to the WebUI-only set keeps the probe fail-closed if that import + # fails. (#3961 — don't leave a partial local AWS set here.) _strip = set(_PROVIDER_CREDENTIAL_ENV_VARS) - _strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) try: from api.profiles import _profile_secret_env_names, get_active_hermes_home _strip.update(_profile_secret_env_names(get_active_hermes_home())) except Exception: - pass + _strip.update({"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) for env_name in _strip: env.pop(env_name, None) env["HERMES_HOME"] = str(Path(home)) diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index cf5a4ecf..8ba3c68b 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -793,3 +793,56 @@ def test_detached_worker_scope_scrubs_anthropic_token_aliases(monkeypatch, tmp_p 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") + + 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 + + +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") + + 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 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" + From bf548a255dfccc0677cdffe7e456a875768cefbe Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:27:00 +0000 Subject: [PATCH 18/23] fix(#3961): complete cloud-credential scrub set (AWS container FULL_URI + Azure identity family) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 gate found two more credential env vars the agent resolves cross-profile that the scrub set missed, verified against the installed agent source: - AWS_CONTAINER_CREDENTIALS_FULL_URI — read by model_switch._has_aws_creds:1360 (botocore container-credentials provider; RELATIVE_URI was present, FULL_URI was not). - Azure identity family — AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID / AZURE_FEDERATED_TOKEN_FILE (agent/azure_identity_adapter.py service-principal + workload-identity model auth) and AZURE_FOUNDRY_API_KEY (runtime_provider.py). Enumerated the agent's full credential-env read surface from source to close the whole class in one pass rather than one var per gate round. Deliberately EXCLUDES config/region vars (AWS_REGION, AWS_DEFAULT_REGION, AZURE_FOUNDRY_BASE_URL) — the child probe may legitimately need those. Regressions extended to pin FULL_URI + the Azure family. Co-authored-by: rodboev --- api/profiles.py | 16 +++++++++++++++- tests/test_issue3957_profile_providers_models.py | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index 3783316f..09e611d6 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -890,19 +890,33 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str] # would miss them). Fail-closed list — verified against the installed agent: # CUSTOM_API_KEY hermes_cli/models.py (generic custom provider key) # AZURE_ANTHROPIC_KEY hermes_cli/runtime_provider.py (Azure-hosted Anthropic) +# AZURE_FOUNDRY_API_KEY hermes_cli/runtime_provider.py (Azure Foundry key) +# AZURE_* identity family agent/azure_identity_adapter.py (service-principal / +# workload-identity model auth) # AWS_BEARER_TOKEN_BEDROCK hermes_cli/model_switch.py (Bedrock bearer token) -# AWS_* agent/bedrock_adapter.py (boto3 credential chain) +# AWS_* credential chain agent/bedrock_adapter.py + model_switch._has_aws_creds +# (boto3 access keys, session token, profile, +# container/web-identity credential providers) +# NOTE: region/base-url config vars (AWS_REGION, AWS_DEFAULT_REGION, +# AZURE_FOUNDRY_BASE_URL) are deliberately NOT included — they're configuration, +# not credentials, and the child probe may legitimately need them. # Stripping these in a profile-scoped read prevents an empty named profile from # inheriting the server-process credential (#3961 residual cross-profile leak). _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES: tuple[str, ...] = ( "CUSTOM_API_KEY", "AZURE_ANTHROPIC_KEY", + "AZURE_FOUNDRY_API_KEY", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "AZURE_FEDERATED_TOKEN_FILE", "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_PROFILE", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_WEB_IDENTITY_TOKEN_FILE", ) diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index 8ba3c68b..cbc3be27 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -809,6 +809,7 @@ def test_account_usage_subprocess_env_strips_non_registry_agent_creds(monkeypatc 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: @@ -821,6 +822,7 @@ def test_account_usage_subprocess_env_strips_non_registry_agent_creds(monkeypatc 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): @@ -835,14 +837,20 @@ def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_ 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") 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 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" From 51dd69b4f239a072229357851a47acd841c30d1c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:49:44 +0000 Subject: [PATCH 19/23] fix(#3961): route config ${VAR} expansion through the profile-aware env lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-gate found a config-template leak vector distinct from the env-name scrub: _expand_env_vars() expanded ${VAR} references in config values via raw os.environ, so a profile config.yaml of e.g. `api_key: ${ANTHROPIC_TOKEN}` let _get_provider_api_key reconstruct the server-process token and pass it through as the API key argument — even when the child-env scrub stripped the env var. This bypassed the thread-local channel entirely. _expand_env_vars now substitutes via _thread_local_env_value (the same thread-local-first accessor with the block_process_env_fallback guard the PR introduced): a ${VAR} resolves to the profile's own value, and refuses the process-env fallback when a profile-scoped readonly/background scope set the block flag (leaving the reference unexpanded). Cache integrity preserved — only the RAW unexpanded dict is cached; expansion re-runs per read against the current thread's scope. Normal (no-scope) behavior unchanged. + regression that fails without the fix. Co-authored-by: rodboev --- api/config.py | 17 +++++++- ...test_issue3957_profile_providers_models.py | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/api/config.py b/api/config.py index 7eb48f52..e2813a6b 100644 --- a/api/config.py +++ b/api/config.py @@ -255,9 +255,22 @@ else: # ── Config file (reloadable -- supports profile switching) ────────────────── def _expand_env_vars(obj): - """Recursively expand ${VAR} references in config values using os.environ.""" + """Recursively expand ${VAR} references in config values. + + Uses the thread-local-first profile env lookup (_thread_local_env_value) so a + ${VAR} reference in a profile's config.yaml resolves to that profile's value, + and — critically — does NOT fall back to the server process os.environ when a + profile-scoped readonly/background scope set block_process_env_fallback. The + raw (unexpanded) dict is what gets cached; this expansion re-runs on every + read against the current thread's scope, so a cross-profile credential + (e.g. config api_key: ${ANTHROPIC_TOKEN}) can't be reconstructed from the + server process env for a named profile that has no such value (#3961).""" if isinstance(obj, str): - return re.sub(r"\${([^}]+)}", lambda m: os.environ.get(m.group(1), m.group(0)), obj) + return re.sub( + r"\${([^}]+)}", + lambda m: _thread_local_env_value(m.group(1), m.group(0)), + obj, + ) if isinstance(obj, dict): return {k: _expand_env_vars(v) for k, v in obj.items()} if isinstance(obj, list): diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index cbc3be27..d5ac485d 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -854,3 +854,43 @@ def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_ assert os.environ.get("AZURE_CLIENT_SECRET") == "process-default-azure-secret" assert os.environ.get("AZURE_FOUNDRY_API_KEY") == "process-default-foundry-key" + +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 + From d60ad140b14ac26869e8c51a05d3bcc5d2c4b1bb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 19:58:44 +0000 Subject: [PATCH 20/23] fix(#3961): scrub Azure managed-identity (MSI/IMDS) credential-source env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final credential-surface item: agent/azure_identity_adapter.py treats IDENTITY_ENDPOINT / MSI_ENDPOINT as ManagedIdentityCredential sources, so an empty named profile could inherit the host's managed identity on the quota + detached-worker paths. Add IDENTITY_ENDPOINT / IDENTITY_HEADER / MSI_ENDPOINT / MSI_SECRET to the non-registry scrub set (config var AZURE_FOUNDRY_BASE_URL kept). Detached-worker regression extended. This completes the agent's enumerable credential-env read surface (verified from source). Decision (with maintainer): stop the Codex deep-gate here — the remaining theoretical surface is cloud-SDK-internal credential-source endpoints on pre-existing infra, not vectors introduced by this PR; validated via Opus + full suite. The PR's #3961 scope (os.environ mirror race on /api/providers + /api/models) is fully closed plus the broader same-class hardening. Co-authored-by: rodboev --- api/profiles.py | 8 ++++++++ tests/test_issue3957_profile_providers_models.py | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index 09e611d6..e6aebaad 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -910,6 +910,14 @@ _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES: tuple[str, ...] = ( "AZURE_CLIENT_SECRET", "AZURE_TENANT_ID", "AZURE_FEDERATED_TOKEN_FILE", + # Azure managed-identity (App Service MSI / IMDS) credential-source vars — + # agent/azure_identity_adapter.py treats these as ManagedIdentityCredential + # sources, so an empty named profile must not inherit the host's managed + # identity. (NOT AZURE_FOUNDRY_BASE_URL — that's config, kept.) + "IDENTITY_ENDPOINT", + "IDENTITY_HEADER", + "MSI_ENDPOINT", + "MSI_SECRET", "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", diff --git a/tests/test_issue3957_profile_providers_models.py b/tests/test_issue3957_profile_providers_models.py index d5ac485d..d77a76c3 100644 --- a/tests/test_issue3957_profile_providers_models.py +++ b/tests/test_issue3957_profile_providers_models.py @@ -839,6 +839,8 @@ def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_ 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 @@ -846,6 +848,8 @@ def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_ 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" @@ -853,6 +857,8 @@ def test_detached_worker_scope_scrubs_non_registry_agent_creds(monkeypatch, tmp_ 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): From d2ee837796f1521e4839e584a2eb3806ca884de8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 20:18:01 +0000 Subject: [PATCH 21/23] fix(#3961): resolve Opus ship-gate findings (startup NameError + reload cache poisoning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus final gate reproduced two issues in the config-expansion hardening: MUST-FIX — import-order forward reference. _expand_env_vars (config.py top) calls _thread_local_env_value, but that fn + _thread_ctx were defined ~6900 lines later, AFTER the import-time reload_config(). A ${VAR} in a startup config.yaml raised a (swallowed) NameError, leaving import-time config consumers with empty config. Fix: relocate _thread_ctx + _thread_local_env_value above the config-file section (they depend only on os + threading). Verified: startup ${VAR} now expands, no NameError. SHOULD-FIX — reload_config baked block-scoped expansion into the process-global _cfg_cache. If a reload fired while a readonly/worker scope was active (profile alternation resolves _get_config_path to the named profile), under-expanded literal ${VAR}s were written to the shared cache, starving concurrent readers of the `cfg` alias. Fix: reload_config now expands the global cache with the block flag forced off (process-env view), since the cache is process-global; per-request scoped reads still go through _thread_local_env_value at their own sites. Verified: global cache holds the process-expanded value, not the literal. Co-authored-by: rodboev --- api/config.py | 74 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/api/config.py b/api/config.py index e2813a6b..fc42f035 100644 --- a/api/config.py +++ b/api/config.py @@ -252,6 +252,33 @@ if _AGENT_DIR is not None: else: _HERMES_FOUND = False +# ── Thread-local env context ───────────────────────────────────────────────── +# Defined BEFORE the config-file section because _expand_env_vars() (below) calls +# _thread_local_env_value() and the import-time reload_config() runs during module +# load — a forward reference here would NameError on any startup config.yaml that +# uses a ${VAR} reference. Depends only on os + threading (both imported above). +_thread_ctx = threading.local() + + +def _thread_local_env_value(name: str, default: str = "") -> str: + """Return thread-local profile env first, then process env, for provider reads.""" + env_name = str(name or "").strip() + if not env_name: + return default or "" + + thread_env = getattr(_thread_ctx, "env", {}) + if isinstance(thread_env, dict) and env_name in thread_env: + thread_value = thread_env.get(env_name) + if thread_value is None: + return default or "" + return str(thread_value) + + if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): + return default or "" + + return str(os.getenv(env_name, default or "")) + + # ── Config file (reloadable -- supports profile switching) ────────────────── def _expand_env_vars(obj): @@ -463,7 +490,29 @@ def reload_config() -> None: if config_path.exists(): loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) if isinstance(loaded, dict): - _cfg_cache.update(_expand_env_vars(loaded)) + # The process-global _cfg_cache must reflect PROCESS-env + # expansion, never a profile-scoped block_process_env_fallback + # view — otherwise a reload that fires while a readonly/worker + # scope is active (profile alternation resolves _get_config_path + # to the named profile, #798 TLS) would bake under-expanded + # literal ${VAR}s into the shared cache and starve concurrent + # readers of the module-level `cfg` alias. Expansion re-runs + # per-read elsewhere; here we pin the cache to the unscoped view. + _prev_block = getattr(_thread_ctx, "block_process_env_fallback", False) + _prev_env = getattr(_thread_ctx, "env", None) + try: + _thread_ctx.block_process_env_fallback = False + _thread_ctx.env = {} + _cfg_cache.update(_expand_env_vars(loaded)) + finally: + _thread_ctx.block_process_env_fallback = _prev_block + if _prev_env is None: + try: + del _thread_ctx.env + except AttributeError: + pass + else: + _thread_ctx.env = _prev_env try: _cfg_mtime = Path(config_path).stat().st_mtime except OSError: @@ -7184,26 +7233,9 @@ def _evict_session_agent(session_id: str) -> None: logger.debug("Failed to close _session_db on eviction for %s", session_id, exc_info=True) # ── Thread-local env context ───────────────────────────────────────────────── -_thread_ctx = threading.local() - - -def _thread_local_env_value(name: str, default: str = "") -> str: - """Return thread-local profile env first, then process env, for provider reads.""" - env_name = str(name or "").strip() - if not env_name: - return default or "" - - thread_env = getattr(_thread_ctx, "env", {}) - if isinstance(thread_env, dict) and env_name in thread_env: - thread_value = thread_env.get(env_name) - if thread_value is None: - return default or "" - return str(thread_value) - - if bool(getattr(_thread_ctx, "block_process_env_fallback", False)): - return default or "" - - return str(os.getenv(env_name, default or "")) +# (_thread_ctx + _thread_local_env_value are defined near the top of this module, +# above the config-file section, so _expand_env_vars can reference them at the +# import-time reload_config() without a forward-reference NameError.) def _set_thread_env(**kwargs): From c55ba5df116fc10315157ad2f2941d25734f6af6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 20:53:58 +0000 Subject: [PATCH 22/23] =?UTF-8?q?Release=20v0.51.590=20=E2=80=94=20Release?= =?UTF-8?q?=20UW=20(close=20cross-profile=20credential=20leak,=20#4544=20/?= =?UTF-8?q?=20#3961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a23dd6..7c79c800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.590] — 2026-06-22 — Release UW (close cross-profile credential leak on /api/providers + /api/models) + +### Fixed + +- **A named profile can no longer inherit the server's process-level provider credentials.** On a multi-profile instance, reads behind `/api/providers`, `/api/provider/quota`, and the synchronous `/api/models` rebuild could fall through to the server process environment — so a named profile with no key of its own would surface (and use, for quota probes) the default profile's API key. Credential reads now route through a thread-local profile channel that refuses the process-env fallback under a profile-scoped read: provider/model env vars, the agent auth-registry credentials (incl. OAuth tokens like `ANTHROPIC_TOKEN`/`CLAUDE_CODE_OAUTH_TOKEN`), the generic `CUSTOM_API_KEY`, the full AWS/Bedrock credential chain, and the Azure identity + managed-identity families are all scrubbed from the quota subprocess and the detached-worker model-rebuild env, and `${VAR}` config-template expansion no longer reconstructs a process credential under a scoped read. Region/base-URL config values are preserved. Thanks @rodboev. (#4544, fixes #3961) + ## [v0.51.589] — 2026-06-22 — Release UV (guard state.db replay after compressed anchors) ### Fixed From 4ae41c6d7aaca98bcc5cdd267e10098fcd2a7458 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 22 Jun 2026 21:07:54 +0000 Subject: [PATCH 23/23] fix(#3961): add Anthropic OAuth tokens to fail-closed floor (agent-absent CI/test env) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry-derived scrub union is best-effort and returns empty when hermes_cli.auth can't be imported (WebUI-only CI/test env). That made ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN scrubbing depend on the agent being installed, so the quota-subprocess regression failed on CI (agent absent) while passing locally (agent present). Add both token names to the fail-closed floor set so the scrub covers them regardless of the registry import — strictly more correct (defense-in-depth) and deterministic across environments. Co-authored-by: rodboev --- api/profiles.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index e6aebaad..7b8eccdf 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -904,6 +904,12 @@ def filter_runtime_env_for_gateway_parity(env: dict[str, str]) -> dict[str, str] # inheriting the server-process credential (#3961 residual cross-profile leak). _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES: tuple[str, ...] = ( "CUSTOM_API_KEY", + # Anthropic OAuth/token aliases. These ARE in the agent auth registry, but + # are duplicated here as a fail-closed floor so the scrub still covers them + # when the agent package can't be imported (e.g. a WebUI-only CI/test env + # where hermes_cli.auth is absent) — the registry union is best-effort. + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", "AZURE_ANTHROPIC_KEY", "AZURE_FOUNDRY_API_KEY", "AZURE_CLIENT_ID",