Skip to content

fix(tools): the provider selected in hermes tools is what runs — credential presence never reroutes - #90317

Merged
teknium1 merged 8 commits into
mainfrom
fix/strict-tool-provider-selection
Aug 19, 2026
Merged

teknium1 merged 8 commits into
mainfrom
fix/strict-tool-provider-selection

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

The provider you pick in hermes tools (or the desktop GUI) is now what runs — always. Runtime tool dispatch no longer chooses backends by sniffing which API keys or entitlements happen to exist: pick Nous Portal → FAL → gpt-image-2 and it uses the managed gateway even if a FAL_KEY sits in .env; pick BYOK FAL and it uses your key or fails with an error that names your selection — never a silent reroute to another route or account.

This kills the class behind the repeated "wrong provider ran" incidents (most recently: an exhausted personal FAL_KEY hijacking image gen from the managed subscription). A pre-PR audit found the same if <key present> → direct, else → managed pattern at 10 call sites across all six tool categories; all are replaced.

The model (one string, no booleans): each picker row writes one provider value — provider: nous for the managed Nous Subscription row, the vendor name (fal, openai, firecrawl, browser-use, camofox, …) for BYOK rows. Runtime is a switch on that string. use_gateway is never written again; old configs are interpreted at read time (use_gateway: truenous), nothing rewritten on disk. Never-configured categories keep today's autodetect, unchanged.

Changes

  • tools/tool_backend_helpers.py: read_selection() / selection_exists() / selection_error() — the one shared resolver (folds in the legacy use_gateway shim; treats the previously-seeded stt.provider: local as no-selection).
  • image (_resolve_managed_fal_gateway, krea interception) + video FAL: strict switch on the stored selection; krea model-interception only fires with no stored provider.
  • TTS/STT OpenAI-audio resolvers: route on the stored selection (the STT one previously never read the stored intent at all).
  • web: _get_capability_backend no longer silently swaps an unavailable selected backend; firecrawl client no longer silently falls to the managed gateway.
  • browser: unknown stored cloud_provider = honest error (was warn + autodetect); camofox is now a real selection (CAMOFOX_URL is just the address); nous routes via the managed browser-use gateway.
  • hermes_cli/tools_config.py: every row persists its provider string (BYOK image FAL included), legacy use_gateway keys popped on write, plugin-row clear-gap fixed.
  • hermes_cli/nous_subscription.py: status/hermes status mirrors + setup writers follow the same strict precedence.
  • Error contract everywhere: <category> is configured to use <selection> (set via hermes tools), but <failure>. Run 'hermes tools' to change it.

Validation

Result
New strict-selection suite (per category: managed-wins-over-key, selection-naming error with no managed call, autodetect-unchanged) 33/33
Touched suites (strict + image env + managed gateways + tools_config) re-run by reviewer 91/91
Sabotage check (strict switch reverted → managed-selection tests fail, restored) verified
Real-import E2E vs temp HERMES_HOME: provider: nous+FAL_KEY⇒nous · provider: fal+no key⇒error naming fal/FAL_KEY/hermes tools · legacy use_gateway: true⇒nous · never-configured⇒autodetect 6/6
ruff on touched files clean

Infographic

Not included: FAL image account for this box is the exhausted direct key this PR exists to stop silently routing around. Will backfill via gh pr edit.

…ols selection

Add read_selection()/selection_exists()/selection_error() to
tool_backend_helpers: one provider string per category ('nous' = managed
Nous Tool Gateway, vendor name = direct with the user's own credentials,
no key ever written = legacy credential autodetect). Legacy configs are
interpreted at read time only (use_gateway: true => nous); nothing is
migrated on disk, and the DEFAULT_CONFIG-seeded stt.provider: local is
treated as never-configured.

_resolve_managed_fal_gateway / _resolve_managed_fal_video_gateway now
switch on that string: 'nous' routes managed only (unentitled => error
naming the selection), a stored vendor routes direct only (missing
FAL_KEY => error naming FAL_KEY and the selection, no silent managed
reroute), and FAL_KEY presence no longer selects the route. Krea's
model-driven managed interception now requires no stored provider (or
the managed selection) instead of merely provider != krea, and the
image/video registries map the 'nous' selection to the FAL plugin.
…waps

_get_backend returns the stored web.backend verbatim (mapping the managed
'nous' selection to the firecrawl provider) — unknown names surface the
honest selection-naming error at dispatch instead of silently rerouting
through the credential ladder, which now runs only on never-configured
installs. _get_capability_backend no longer discards an explicit
search/extract backend when its availability probe fails. The firecrawl
client resolves strictly: 'nous' => managed gateway only (unavailable =>
selection-naming error), stored vendor => direct only (no FIRECRAWL key
=> error, never a silent managed fallback billed to Nous).
…redentials

Both _resolve_openai_audio_client_config resolvers now switch on the
stored provider string: 'nous' (or legacy use_gateway: true) => managed
openai-audio gateway only, erroring by selection name when unentitled —
the STT twin previously never read the stored gateway intent at all, so
a direct OPENAI_API_KEY silently overrode the Nous Subscription pick;
stored vendor => direct credentials only with a selection-naming error
on missing keys (no silent managed fallback); never-configured keeps the
legacy ladder. DEFAULT_CONFIG stops seeding stt.provider: local, and the
seeded value on existing configs is treated as no-selection so autodetect
keeps working for that installed base.
…ction

An explicitly stored browser.cloud_provider that names no registered
plugin now raises the honest selection-naming error instead of warning
and silently auto-detecting; the auto-detect walk (including the managed
gateway entitlement probe) runs only when no cloud_provider key was ever
written. The 'nous' selection routes to the Browser Use provider, whose
config resolver is now a strict switch: 'nous' => managed only, stored
vendor => direct BROWSER_USE_API_KEY only with a selection-naming error
when missing. Camofox is selected via browser.cloud_provider: camofox;
CAMOFOX_URL stays the server ADDRESS only and can no longer override an
explicit different selection (never-configured installs keep the legacy
env-var activation).
…outing in status

Every hermes tools row now writes exactly one selection value per
category — managed 'Nous Subscription' rows write 'nous', BYOK rows the
vendor name (including the historically-unset BYOK-FAL image row) — and
use_gateway is no longer written; fresh picks drop any legacy key so the
read-time shim cannot override them. The non-managed clear now resolves
the category from the row's own markers, covering plugin-injected rows
the TOOL_CATEGORIES loop missed. Setup-flow writers (managed defaults,
gateway enablement) store 'nous', and the feature-state mirrors in
nous_subscription.py compute per-category selections with the same
legacy interpretation so hermes status matches runtime: a stored vendor
selection pins direct (managed availability no longer lights it up) and
an explicit non-camofox selection beats a stray CAMOFOX_URL.
New tests/tools/test_strict_provider_selection.py covers read_selection
semantics (legacy use_gateway interpretation, seeded stt local, empty
strings, browser.backend vs cloud_provider) and the three strict
behaviors per category: managed 'nous' selection wins over present
direct keys, a vendor selection with missing credentials raises the
selection-naming error with NO managed call, and never-configured
installs keep today's autodetect. Updated the tests that pinned the old
credential-first precedence (TTS resolver gateway override, STT silent
managed fallback, web invalid-backend reroute, video_gen picker writes).
Sabotage-verified: reverting the image FAL strict switch makes the new
managed-selection tests fail.
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on be6a8bd — test(tools): repin selector/picker tests to the provider-str

⚠️ Warnings

CI timings · View report · View job

Wall time 12m28s vs 3m31s (+254.5%). 16 job(s) slower, 6 faster, 2 unchanged.

  • Python tests / Run tests slice 11/12: +38.0s
  • Python tests / Run tests slice 1/12: +35.0s
  • Python tests / Run tests slice 12/12: +17.0s
  • OS-specific tests / Windows-only tests: -12.0s
  • Python tests / Run tests slice 5/12: +10.0s

OSV vulnerability scan · View job

7 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins tool/browser Browser automation (CDP, Playwright) tool/vision Vision analysis and image generation tool/tts Text-to-speech and transcription tool/web Web search and extraction area/config Config system, migrations, profiles P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 19, 2026
…er provider-string migration

Two real gaps the CI-red sibling tests exposed:

- read_selection() treated EVERY raw stt.provider: local as the legacy
  DEFAULT_CONFIG seed and reported no-selection — but the seed never
  reached config.yaml (save_config strips schema defaults), so a
  picker- or hand-written local pick was silently discarded and the
  autodetect ladder could route an explicit local user to cloud STT.
  A raw 'local' is now a genuine selection; the merged-view ambiguity
  note replaces the over-broad shim (mirror comment updated in
  nous_subscription._selected_provider and _get_provider).

- _reconfigure_provider was half-migrated: the tts/stt/browser/web
  branches and the managed-category fallthrough still wrote
  use_gateway flags and vendor names for managed rows. They now write
  the single provider string ('nous' for managed rows) and pop the
  legacy key, matching _write_provider_config.
Update the sibling tests that pinned the old use_gateway-writing
contract: image/video selector and reconfigure rows now assert the
single provider string ('nous' managed / 'fal' BYOK) plus legacy-key
popping, the stt/video picker writes drop the use_gateway expectation,
the web_server managed-browser select asserts the persisted 'nous'
cloud_provider, and explicit-local STT pins no-cloud-fallback against
a stored raw-config selection.
@teknium1
teknium1 merged commit 19113b3 into main Aug 19, 2026
47 checks passed
@teknium1
teknium1 deleted the fix/strict-tool-provider-selection branch August 19, 2026 23:10

@andrexibiza andrexibiza 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.

Reviewed exact head be6a8bdbf92207131eca58242e705550b9508a64 against base 7b25941b0ecd1a2d367edc7b6ef89a0958c10822. Current main is 9ec5750aca33bf6e977cfde468868b0f0d66e339, three unrelated commits ahead of the merge base; there is no direct file overlap with this tool-routing change. There were no prior human reviews or inline threads on this head. Exact-head CI, Docker, and Nix are all green.

The class-level direction is right: one persisted provider identity, a read-only legacy use_gateway compatibility shim, and no credential-presence rerouting after an explicit choice. The cross-category sweep and the selected-broken-provider error contract are materially stronger than the previous per-call-site fixes. I found three authority/transition blockers before this can safely become the canonical rule.

1. The new runtime authority bypasses the administrator-managed config layer

tools.tool_backend_helpers.read_selection() and selection_exists() read only hermes_cli.config.read_raw_config_readonly(). That API intentionally returns the user's raw ~/.hermes/config.yaml without defaults or the managed-scope overlay. But hermes_cli.managed_scope defines /etc/hermes/config.yaml as IT-owned, user-immutable policy that wins per leaf key, and normal load_config()/status paths apply it.

Concrete failure: an administrator pins image_gen.provider: nous, the user file contains image_gen.provider: fal, and FAL_KEY is present. Status/effective config says nous; the new runtime selector returns the user-owned fal and bills the personal account. If the raw user file has no selection, the same managed pin is reduced to “never configured” and ambient autodetect is allowed.

Please give selection its own explicit-effective read seam: raw user values plus the managed overlay, but still without schema defaults. It should copy before overlaying rather than mutate the identity-cached read-only raw dict. Add a real conflict witness for managed nous vs user fal + direct key and prove both runtime dispatch and status honor the managed owner.

2. “Never configured” is still conflated with “selection source unavailable”

read_raw_config() returns {} both when the file is absent and when it cannot be parsed, and read_selection() additionally catches every read exception and returns None. Every category then treats None as permission to enter the legacy credential/entitlement autodetect ladder.

That means a partial write, malformed YAML, transient read error, or permissions failure can silently change the account/provider used by the next tool call. This is the other side of the same wrong-provider class: the persisted choice did not change, but loss of evidence is interpreted as affirmative absence.

Please preserve a tri-state boundary—selected, genuinely absent, and unavailable/invalid—or retain the last known-good effective selection. unavailable/invalid must fail closed before any vendor/gateway call; it cannot be the same value as a never-configured install. Add corrupt/unreadable-config regressions with competing direct and managed credentials and assert that neither route is invoked.

3. A whole-web-provider selection does not supersede stale per-capability selections

Web dispatch deliberately resolves web.search_backend / web.extract_backend before shared web.backend. The desktop endpoint writes those capability keys when capability is supplied, while the whole-provider path calls apply_provider_selection(). _write_provider_config() then writes only web.backend; it never clears either capability override.

So a user can select split providers, later select a whole-category provider such as Nous Subscription, and continue running the old search/extract providers. Example: search_backend: tavily, extract_backend: firecrawl, then whole-provider nous leaves both old authorities intact; neither capability runs the provider the user just selected.

The whole-provider transition should atomically clear both capability overrides (or the UI/API must be explicitly redefined as “change shared fallback only,” which conflicts with this PR's promise and current picker copy). Add a split → whole-provider transition test covering both search and extract.

Provenance and merge topology

  • Merged #86919 and #87562 by @teknium1 are the narrow image/FAL predecessors; this PR is a legitimate class-level superseder, not duplicate work.
  • Merged #35109 by @alt-glitch is the first-install managed image/video config-writer precursor. Its setup-path contribution remains part of this lineage even though the persisted representation changes from vendor + boolean to provider: nous.
  • Open #90313 by @teknium1 is complementary keyless-web fallback work on the same config_defaults.py / tools_config.py / web_tools.py seam. Its keyless tier must run only after a genuinely absent, valid selection—not after managed-policy loss or config-read failure. Whichever lands second needs a semantic composition rebase and transition tests, not just conflict resolution.

Re-review gate: an explicit-effective selection resolver that honors managed scope and preserves absence-vs-unavailable provenance; atomic reconciliation of web capability overrides on a whole-provider choice; the three regression families above; rebase onto current main; and a fresh exact-head matrix.

Rmohid pushed a commit to Rmohid/hermes-agent that referenced this pull request Aug 25, 2026
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/browser Browser automation (CDP, Playwright) tool/tts Text-to-speech and transcription tool/vision Vision analysis and image generation tool/web Web search and extraction type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants