Skip to content

fix(credentials): extend prefer-dotenv policy to remaining resolve paths - #20929

Open
SandroHub013 wants to merge 1 commit into
NousResearch:mainfrom
SandroHub013:fix/credential-rotation-resolve-paths-followup
Open

fix(credentials): extend prefer-dotenv policy to remaining resolve paths#20929
SandroHub013 wants to merge 1 commit into
NousResearch:mainfrom
SandroHub013:fix/credential-rotation-resolve-paths-followup

Conversation

@SandroHub013

Copy link
Copy Markdown
Contributor

fix(credentials): extend prefer-dotenv policy to remaining resolve paths

What

Extends the prefer-dotenv lookup policy from PR #20602 to the credential-bearing call sites that PR leaves untouched:

  1. hermes_cli/runtime_provider.py::_resolve_azure_foundry_runtime — the Azure Foundry API key now reads via get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY") so a freshly rotated key in ~/.hermes/.env immediately wins over a stale shell-exported value.
  2. hermes_cli/nous_subscription.py — the module-level get_env_value import is aliased to get_env_value_prefer_dotenv. Every call site in that file is a credential probe (EXA, FIRECRAWL, PARALLEL, TAVILY, ELEVENLABS, BROWSER_USE, BROWSERBASE_API_KEY/PROJECT_ID, etc.) that gates subscription-feature availability, so the alias is the right unit of change — the 25 internal call sites stay textually identical.
  3. tools/tool_backend_helpers.py::fal_key_is_configured — switched to get_env_value_prefer_dotenv("FAL_KEY") for the same reason.

Display-only paths (hermes status, hermes doctor, hermes setup, hermes_cli/mcp_config.py) intentionally keep the old get_env_value semantics so diagnostic output continues to reflect what the user's shell sees.

Why

Issue #20591 — the credential pool reads stale os.environ values, so a freshly rotated .env key gets shadowed by a value the user exported in .bashrc. PR #20602 fixed the central hermes_cli/config.py helpers; this PR completes the migration on the credential-RESOLVE paths PR #20602 didn't reach.

Without this follow-up, an Azure Foundry user who rotates their key in ~/.hermes/.env would still hit "401 unauthorized" until they restart their shell, because _resolve_azure_foundry_runtime would keep handing the stale shell-exported key to the SDK on every request. Same shape of bug for every Nous-subscription tool probe.

Depends on

PR #20602 (fix/20591-resolve-prefers-dotenv). That PR introduces get_env_value_prefer_dotenv in hermes_cli/config.py with the same name and semantics this PR uses.

This PR vendors a copy of the helper inline (with a NOTE: comment marking it for reconciliation) so it is testable on its own. Rebase plan: once #20602 merges, drop the duplicate definition in hermes_cli/config.py, keep all call-site changes.

Tests

New, self-contained:

  • tests/hermes_cli/test_runtime_provider_dotenv_priority.py (4 tests) — Azure Foundry resolver: .env wins over stale os.environ, env-only fallback, dotenv-only path, blank .env value falls back to env.
  • tests/hermes_cli/test_nous_subscription_credential_probe.py (5 tests) — probes for web / browser / tts find dotenv-only credentials; "no credentials anywhere" yields all-False; module alias is pinned to the prefer-dotenv variant (regression guard against accidental revert during rebase).
pytest tests/hermes_cli/test_runtime_provider_dotenv_priority.py \
       tests/hermes_cli/test_nous_subscription_credential_probe.py -q
9 passed

Existing regression suite still green:

pytest tests/agent/test_credential_pool.py \
       tests/hermes_cli/test_config.py \
       tests/hermes_cli/test_anthropic_oauth_flow.py \
       tests/tools/test_credential_pool_env_fallback.py -q
103 passed, 1 pre-existing failure on main (unrelated; tracked separately)

ruff check on the changed tests/hermes_cli/* files: All checks passed!

Out of scope (intentional)

  • hermes_cli/setup.py, hermes_cli/status.py, hermes_cli/doctor.py, hermes_cli/mcp_config.py — these are diagnostic / display paths. Migrating them would make hermes status lie about what the running shell would see. Left untouched on purpose.

Extends the prefer-dotenv lookup policy from NousResearch#20602 to the
credential-bearing call sites that PR leaves untouched:

1. `hermes_cli/runtime_provider.py::_resolve_azure_foundry_runtime` —
   the Azure Foundry API key now reads via
   `get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY")` so a freshly
   rotated key in `~/.hermes/.env` immediately wins over a stale
   shell-exported value.
2. `hermes_cli/nous_subscription.py` — the module-level `get_env_value`
   import is aliased to `get_env_value_prefer_dotenv`. Every call site
   in that file is a credential probe (EXA, FIRECRAWL, PARALLEL,
   TAVILY, ELEVENLABS, BROWSER_USE, BROWSERBASE_*, etc.) that gates
   subscription-feature availability, so the alias is the right unit
   of change — the 25 internal call sites stay textually identical.
3. `tools/tool_backend_helpers.py::fal_key_is_configured` — switched
   to `get_env_value_prefer_dotenv("FAL_KEY")` for the same reason.

Display-only paths (`hermes status`, `doctor`, `setup`,
`hermes_cli/mcp_config.py`) intentionally keep the old `get_env_value`
semantics so diagnostic output continues to reflect what the user's
shell sees.

Without this follow-up, an Azure Foundry user who rotates their key
in `~/.hermes/.env` would still hit "401 unauthorized" until they
restart their shell, because `_resolve_azure_foundry_runtime` would
keep handing the stale shell-exported key to the SDK on every
request. Same shape of bug for every Nous-subscription tool probe.

Depends on NousResearch#20602 (`fix/20591-resolve-prefers-dotenv`). This PR
vendors a copy of `get_env_value_prefer_dotenv` in `hermes_cli/config.py`
with a NOTE: comment marking it for reconciliation, so the change is
testable on its own. Once NousResearch#20602 merges, drop the duplicate definition,
keep all call-site changes.

Closes part of NousResearch#20591.
@alt-glitch alt-glitch added P2 Medium — degraded but workaround exists type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard area/auth Authentication, OAuth, credential pools labels May 6, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the remaining credential-resolution paths. The Azure Foundry and FAL paths are still environment-first on current main (hermes_cli/runtime_provider.py:1327-1328; tools/tool_backend_helpers.py:172-179), so the core issue remains valid.

Problems

  • The module-wide nous_subscription alias also changes non-credential inputs: HERMES_LOCAL_STT_COMMAND (hermes_cli/nous_subscription.py:243,434) and URL/config probes such as FIRECRAWL_API_URL / SEARXNG_URL (:410-413). Please make the preference change per secret-bearing call site instead.
  • The same subscription flow still reads OpenAI audio credentials through resolve_openai_audio_api_key() (tools/tool_backend_helpers.py:141-146) and Modal tokens through has_direct_modal_credentials() (:90-99), both os.environ-first. Those paths need an explicit scope decision.

Suggested changes

  • Rebase the implementation concept on the existing scope-aware helper at hermes_cli/config.py:7743-7766; merged #55528 already added it.
  • Add stale-shell-versus-dotenv coverage for each adjusted path.

This is an automated hermes-sweeper review.

# stale shell-exported value would mask a freshly rotated .env key
# (issue #20591). Aliased to keep call sites unchanged.
from hermes_cli.config import ( # noqa: F401
get_env_value_prefer_dotenv as get_env_value,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module-wide alias also changes non-secret configuration reads such as HERMES_LOCAL_STT_COMMAND, FIRECRAWL_API_URL, and SEARXNG_URL. Please use the prefer-dotenv helper only at the secret credential call sites rather than changing every get_env_value call in the module.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
teknium1 pushed a commit that referenced this pull request Jul 28, 2026
…cope

`resolve_openai_audio_api_key()` reads the key that authenticates the audio
client straight from the process environment:

    return (
        os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
        or os.getenv("OPENAI_API_KEY", "")
    ).strip()

That value is not advisory. It flows through
`_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for
TTS, and through `transcription_tools` for voice-note STT — both on the
per-turn tool path, inside the profile secret scope the gateway installs.

`agent/vertex_adapter` states the contract this breaks:

    in a multiplex gateway serving several profiles from one process,
    os.environ reflects whichever profile's .env happened to be loaded at
    boot, not the profile the current turn belongs to. Reading it directly
    here would let one profile mint tokens from — and get billed against —
    a different profile's service-account file.

Reproduced with the real resolver, multiplexing on and profile A's scope
installed:

    scope-aware get_secret  -> sk-PROFILE-A-key
    voice/STT resolver      -> sk-PROFILE-B-key

So profile A's spoken reply and its users' voice notes are sent to OpenAI on
profile B's account, and billed there.

Route both reads through `agent.secret_scope.get_secret`, the same fix already
merged for the WeChat send path (#59662) and pending for QQ (#60420) — neither
covers the audio credential family. Under multiplexing the scope stays
authoritative, so a scope miss now yields no key instead of borrowing another
profile's; with multiplexing off `get_secret` falls through to `os.environ`
exactly as before, so single-profile deployments are untouched. The
VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged.

Deliberately narrow: `fal_key_is_configured()` and
`has_direct_modal_credentials()` in this file are presence checks, not
authentication, and the former is already being reworked in open PR #20929.

tests/tools/test_tool_backend_helpers.py: the scope wins over another
profile's `os.environ`; a scope miss does not borrow another profile's key;
voice-key precedence holds inside a scope; and a control proves the
single-profile path still reads `os.environ`. The three isolation tests fail
on main; the control passes there. 320 passed across the helper, secret-scope,
and consumer suites (the fluctuating voice_mode/voice_cli failures are
pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in
isolation on both main and this branch).
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…cope

`resolve_openai_audio_api_key()` reads the key that authenticates the audio
client straight from the process environment:

    return (
        os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
        or os.getenv("OPENAI_API_KEY", "")
    ).strip()

That value is not advisory. It flows through
`_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for
TTS, and through `transcription_tools` for voice-note STT — both on the
per-turn tool path, inside the profile secret scope the gateway installs.

`agent/vertex_adapter` states the contract this breaks:

    in a multiplex gateway serving several profiles from one process,
    os.environ reflects whichever profile's .env happened to be loaded at
    boot, not the profile the current turn belongs to. Reading it directly
    here would let one profile mint tokens from — and get billed against —
    a different profile's service-account file.

Reproduced with the real resolver, multiplexing on and profile A's scope
installed:

    scope-aware get_secret  -> sk-PROFILE-A-key
    voice/STT resolver      -> sk-PROFILE-B-key

So profile A's spoken reply and its users' voice notes are sent to OpenAI on
profile B's account, and billed there.

Route both reads through `agent.secret_scope.get_secret`, the same fix already
merged for the WeChat send path (NousResearch#59662) and pending for QQ (NousResearch#60420) — neither
covers the audio credential family. Under multiplexing the scope stays
authoritative, so a scope miss now yields no key instead of borrowing another
profile's; with multiplexing off `get_secret` falls through to `os.environ`
exactly as before, so single-profile deployments are untouched. The
VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged.

Deliberately narrow: `fal_key_is_configured()` and
`has_direct_modal_credentials()` in this file are presence checks, not
authentication, and the former is already being reworked in open PR NousResearch#20929.

tests/tools/test_tool_backend_helpers.py: the scope wins over another
profile's `os.environ`; a scope miss does not borrow another profile's key;
voice-key precedence holds inside a scope; and a control proves the
single-profile path still reads `os.environ`. The three isolation tests fail
on main; the control passes there. 320 passed across the helper, secret-scope,
and consumer suites (the fluctuating voice_mode/voice_cli failures are
pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in
isolation on both main and this branch).
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
…cope

`resolve_openai_audio_api_key()` reads the key that authenticates the audio
client straight from the process environment:

    return (
        os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
        or os.getenv("OPENAI_API_KEY", "")
    ).strip()

That value is not advisory. It flows through
`_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for
TTS, and through `transcription_tools` for voice-note STT — both on the
per-turn tool path, inside the profile secret scope the gateway installs.

`agent/vertex_adapter` states the contract this breaks:

    in a multiplex gateway serving several profiles from one process,
    os.environ reflects whichever profile's .env happened to be loaded at
    boot, not the profile the current turn belongs to. Reading it directly
    here would let one profile mint tokens from — and get billed against —
    a different profile's service-account file.

Reproduced with the real resolver, multiplexing on and profile A's scope
installed:

    scope-aware get_secret  -> sk-PROFILE-A-key
    voice/STT resolver      -> sk-PROFILE-B-key

So profile A's spoken reply and its users' voice notes are sent to OpenAI on
profile B's account, and billed there.

Route both reads through `agent.secret_scope.get_secret`, the same fix already
merged for the WeChat send path (NousResearch#59662) and pending for QQ (NousResearch#60420) — neither
covers the audio credential family. Under multiplexing the scope stays
authoritative, so a scope miss now yields no key instead of borrowing another
profile's; with multiplexing off `get_secret` falls through to `os.environ`
exactly as before, so single-profile deployments are untouched. The
VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged.

Deliberately narrow: `fal_key_is_configured()` and
`has_direct_modal_credentials()` in this file are presence checks, not
authentication, and the former is already being reworked in open PR NousResearch#20929.

tests/tools/test_tool_backend_helpers.py: the scope wins over another
profile's `os.environ`; a scope miss does not borrow another profile's key;
voice-key precedence holds inside a scope; and a control proves the
single-profile path still reads `os.environ`. The three isolation tests fail
on main; the control passes there. 320 passed across the helper, secret-scope,
and consumer suites (the fluctuating voice_mode/voice_cli failures are
pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in
isolation on both main and this branch).
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…cope

`resolve_openai_audio_api_key()` reads the key that authenticates the audio
client straight from the process environment:

    return (
        os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
        or os.getenv("OPENAI_API_KEY", "")
    ).strip()

That value is not advisory. It flows through
`_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for
TTS, and through `transcription_tools` for voice-note STT — both on the
per-turn tool path, inside the profile secret scope the gateway installs.

`agent/vertex_adapter` states the contract this breaks:

    in a multiplex gateway serving several profiles from one process,
    os.environ reflects whichever profile's .env happened to be loaded at
    boot, not the profile the current turn belongs to. Reading it directly
    here would let one profile mint tokens from — and get billed against —
    a different profile's service-account file.

Reproduced with the real resolver, multiplexing on and profile A's scope
installed:

    scope-aware get_secret  -> sk-PROFILE-A-key
    voice/STT resolver      -> sk-PROFILE-B-key

So profile A's spoken reply and its users' voice notes are sent to OpenAI on
profile B's account, and billed there.

Route both reads through `agent.secret_scope.get_secret`, the same fix already
merged for the WeChat send path (NousResearch#59662) and pending for QQ (NousResearch#60420) — neither
covers the audio credential family. Under multiplexing the scope stays
authoritative, so a scope miss now yields no key instead of borrowing another
profile's; with multiplexing off `get_secret` falls through to `os.environ`
exactly as before, so single-profile deployments are untouched. The
VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged.

Deliberately narrow: `fal_key_is_configured()` and
`has_direct_modal_credentials()` in this file are presence checks, not
authentication, and the former is already being reworked in open PR NousResearch#20929.

tests/tools/test_tool_backend_helpers.py: the scope wins over another
profile's `os.environ`; a scope miss does not borrow another profile's key;
voice-key precedence holds inside a scope; and a control proves the
single-profile path still reads `os.environ`. The three isolation tests fail
on main; the control passes there. 320 passed across the helper, secret-scope,
and consumer suites (the fluctuating voice_mode/voice_cli failures are
pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in
isolation on both main and this branch).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants