fix(auth): enforce complete fallback routes

Skip provider-only setup fallbacks, keep fallback selection explicit for resumed sessions, preserve configured primary identity for cron drift checks, and make the auth lost-update regression deterministic.
fix/verification-admin-route-recovery
Dan Schnurbusch 2026-07-15 18:36:18 -05:00 committed by Teknium
parent f68fd80f41
commit 679487b807
5 changed files with 123 additions and 44 deletions

View File

@ -2935,6 +2935,7 @@ def run_job(
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing # Load config.yaml for model, reasoning, prefill, toolsets, provider routing
_cfg = {} _cfg = {}
_model_cfg = {}
try: try:
import yaml import yaml
_cfg_path = str(_get_hermes_home() / "config.yaml") _cfg_path = str(_get_hermes_home() / "config.yaml")
@ -3037,7 +3038,16 @@ def run_job(
_guard_job_credential_exfil(job) _guard_job_credential_exfil(job)
primary_model_for_drift = model primary_model_for_drift = model
primary_provider_for_drift = (job.get("provider") or "").strip().lower() or None configured_provider_for_drift = (
str(_model_cfg.get("provider") or "").strip().lower()
if isinstance(_model_cfg, dict)
else ""
)
primary_provider_for_drift = (
str(job.get("provider") or "").strip().lower()
or configured_provider_for_drift
or None
)
try: try:
# Do not inject HERMES_INFERENCE_PROVIDER here. resolve_runtime_provider() # Do not inject HERMES_INFERENCE_PROVIDER here. resolve_runtime_provider()
# already prefers persisted config over stale shell/env overrides when # already prefers persisted config over stale shell/env overrides when
@ -3070,9 +3080,9 @@ def run_job(
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
fb_provider = str(entry.get("provider") or "").strip() fb_provider = str(entry.get("provider") or "").strip()
if not fb_provider: fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue continue
fb_model = str(entry.get("model") or "").strip() or None
try: try:
fb_kwargs = { fb_kwargs = {
"requested": fb_provider, "requested": fb_provider,
@ -3083,13 +3093,12 @@ def run_job(
if entry.get("api_key"): if entry.get("api_key"):
fb_kwargs["explicit_api_key"] = entry["api_key"] fb_kwargs["explicit_api_key"] = entry["api_key"]
runtime = resolve_runtime_provider(**fb_kwargs) runtime = resolve_runtime_provider(**fb_kwargs)
if fb_model: model = fb_model
model = fb_model
logger.info( logger.info(
"Job '%s': fallback resolved to %s%s", "Job '%s': fallback resolved to %s model %s",
job_id, job_id,
runtime.get("provider"), runtime.get("provider"),
f" model {fb_model}" if fb_model else "", fb_model,
) )
break break
except Exception as fb_exc: except Exception as fb_exc:

View File

@ -213,6 +213,7 @@ def test_global_write_through_preserves_concurrent_root_update(
) )
helper_loaded = threading.Event() helper_loaded = threading.Event()
helper_has_target_lock = threading.Event()
allow_helper_save = threading.Event() allow_helper_save = threading.Event()
writer_started = threading.Event() writer_started = threading.Event()
writer_done = threading.Event() writer_done = threading.Event()
@ -221,11 +222,17 @@ def test_global_write_through_preserves_concurrent_root_update(
def paused_helper_load(path=None): def paused_helper_load(path=None):
store = real_auth_load(path) store = real_auth_load(path)
if threading.current_thread().name == "profile-write-through": if threading.current_thread().name == "profile-write-through":
target_holder = A._auth_lock_holder_for(root_path)
if getattr(target_holder, "depth", 0) > 0:
helper_has_target_lock.set()
helper_loaded.set() helper_loaded.set()
assert allow_helper_save.wait(timeout=5) assert allow_helper_save.wait(timeout=5)
return store return store
monkeypatch.setattr(A, "_load_auth_store", paused_helper_load) monkeypatch.setattr(A, "_load_auth_store", paused_helper_load)
# The pre-fix implementation imported the loader directly; patch both
# bindings so reverting the safe helper still exercises the stale ordering.
monkeypatch.setattr(CP, "_load_auth_store", paused_helper_load)
def profile_write_through(): def profile_write_through():
CP._write_through_provider_state_to_global_root( CP._write_through_provider_state_to_global_root(
@ -255,9 +262,12 @@ def test_global_write_through_preserves_concurrent_root_update(
writer = threading.Thread(target=concurrent_codex_login, name="concurrent-login") writer = threading.Thread(target=concurrent_codex_login, name="concurrent-login")
writer.start() writer.start()
assert writer_started.wait(timeout=5) assert writer_started.wait(timeout=5)
# Before the fix the writer completes while the stale helper is paused. # A fixed helper already owns the target lock, so the writer will merge
# After the fix it blocks on the root lock until the helper saves and exits. # after release. A reverted unlocked helper must first let the competing
writer_done.wait(timeout=0.2) # login finish; only then do we release its stale save. This makes the
# losing pre-fix ordering deterministic rather than scheduler-dependent.
if not helper_has_target_lock.is_set():
assert writer_done.wait(timeout=5)
allow_helper_save.set() allow_helper_save.set()
helper.join(timeout=5) helper.join(timeout=5)
writer.join(timeout=5) writer.join(timeout=5)

View File

@ -2011,6 +2011,7 @@ class TestRunJobConfigEnvVarExpansion:
" default: gpt-5.6-sol\n" " default: gpt-5.6-sol\n"
" provider: openai-codex\n" " provider: openai-codex\n"
"fallback_providers:\n" "fallback_providers:\n"
" - provider: anthropic\n"
" - provider: openrouter\n" " - provider: openrouter\n"
" model: z-ai/glm-5.2\n", " model: z-ai/glm-5.2\n",
encoding="utf-8", encoding="utf-8",
@ -2023,12 +2024,14 @@ class TestRunJobConfigEnvVarExpansion:
"model_snapshot": "gpt-5.6-sol", "model_snapshot": "gpt-5.6-sol",
} }
fake_db = MagicMock() fake_db = MagicMock()
requested = []
def resolve_runtime(**kwargs): def resolve_runtime(**kwargs):
requested.append(kwargs.get("requested"))
if kwargs.get("requested") in (None, "openai-codex"): if kwargs.get("requested") in (None, "openai-codex"):
raise AuthError( # Cron must retain the configured primary provider for drift
"No Codex credentials stored", provider="openai-codex" # comparison even when older/custom AuthError sites omit it.
) raise AuthError("No Codex credentials stored")
assert kwargs["requested"] == "openrouter" assert kwargs["requested"] == "openrouter"
assert kwargs["target_model"] == "z-ai/glm-5.2" assert kwargs["target_model"] == "z-ai/glm-5.2"
return {**self._RUNTIME, "provider": "openrouter"} return {**self._RUNTIME, "provider": "openrouter"}
@ -2049,6 +2052,7 @@ class TestRunJobConfigEnvVarExpansion:
assert success is True assert success is True
assert error is None assert error is None
assert requested == [None, "openrouter"]
kwargs = mock_agent_cls.call_args.kwargs kwargs = mock_agent_cls.call_args.kwargs
assert kwargs["provider"] == "openrouter" assert kwargs["provider"] == "openrouter"
assert kwargs["model"] == "z-ai/glm-5.2" assert kwargs["model"] == "z-ai/glm-5.2"

View File

@ -9528,11 +9528,12 @@ class TestResolveRuntimeWithFallback:
"hermes_cli.runtime_provider.resolve_runtime_provider", "hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: expected, lambda **kw: expected,
) )
runtime, fallback_model = server._resolve_runtime_with_fallback( resolution = server._resolve_runtime_with_fallback(
{"requested": "openai"} {"requested": "openai"}
) )
assert runtime == expected assert resolution.runtime == expected
assert fallback_model is None assert resolution.selected_model is None
assert resolution.used_fallback is False
def test_auth_error_tries_fallback_chain(self, monkeypatch): def test_auth_error_tries_fallback_chain(self, monkeypatch):
"""On AuthError from primary, walk fallback_providers chain.""" """On AuthError from primary, walk fallback_providers chain."""
@ -9554,11 +9555,47 @@ class TestResolveRuntimeWithFallback:
"_load_fallback_model", "_load_fallback_model",
lambda: [{"provider": "deepseek", "model": "deepseek-v4-pro"}], lambda: [{"provider": "deepseek", "model": "deepseek-v4-pro"}],
) )
runtime, fallback_model = server._resolve_runtime_with_fallback( resolution = server._resolve_runtime_with_fallback(
{"requested": "openai-codex"}, {"requested": "openai-codex"},
) )
assert runtime == fallback_runtime assert resolution.runtime == fallback_runtime
assert fallback_model == "deepseek-v4-pro" assert resolution.selected_model == "deepseek-v4-pro"
assert resolution.used_fallback is True
def test_auth_error_skips_provider_only_fallback(self, monkeypatch):
"""Auth fallback requires one complete provider/model pair."""
from hermes_cli.auth import AuthError
requested = []
fallback_runtime = {"provider": "openrouter", "api_key": "fb-tok"}
def fake_resolve(**kwargs):
requested.append(kwargs.get("requested"))
if kwargs.get("requested") == "openai-codex":
raise AuthError("No Codex credentials stored")
return fallback_runtime
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve,
)
monkeypatch.setattr(
server,
"_load_fallback_model",
lambda: [
{"provider": "anthropic"},
{"provider": "openrouter", "model": "z-ai/glm-5.2"},
],
)
resolution = server._resolve_runtime_with_fallback(
{"requested": "openai-codex"}
)
assert requested == ["openai-codex", "openrouter"]
assert resolution.runtime == fallback_runtime
assert resolution.selected_model == "z-ai/glm-5.2"
assert resolution.used_fallback is True
def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch): def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch):
"""When all fallbacks also fail, re-raise the original AuthError.""" """When all fallbacks also fail, re-raise the original AuthError."""
@ -9606,11 +9643,12 @@ class TestResolveRuntimeWithFallback:
{"provider": "anthropic", "model": "claude-sonnet-4-6"}, {"provider": "anthropic", "model": "claude-sonnet-4-6"},
], ],
) )
runtime, fallback_model = server._resolve_runtime_with_fallback( resolution = server._resolve_runtime_with_fallback(
{"requested": "openai-codex"}, {"requested": "openai-codex"},
) )
assert runtime == fallback_runtime assert resolution.runtime == fallback_runtime
assert fallback_model == "claude-sonnet-4-6" assert resolution.selected_model == "claude-sonnet-4-6"
assert resolution.used_fallback is True
def test_make_agent_uses_fallback_on_auth_error(self, monkeypatch): def test_make_agent_uses_fallback_on_auth_error(self, monkeypatch):
"""Integration: _make_agent falls back to configured fallback """Integration: _make_agent falls back to configured fallback

View File

@ -15,7 +15,7 @@ import time
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, NamedTuple, Optional
from hermes_constants import ( from hermes_constants import (
get_hermes_home, get_hermes_home,
@ -4471,34 +4471,46 @@ def _schedule_mcp_late_refresh(sid: str, agent) -> None:
).start() ).start()
class _RuntimeFallbackResolution(NamedTuple):
runtime: dict
selected_model: str | None
used_fallback: bool
def _resolve_runtime_with_fallback( def _resolve_runtime_with_fallback(
resolve_kwargs: dict | None = None, resolve_kwargs: dict | None = None,
) -> tuple[dict, str | None]: ) -> _RuntimeFallbackResolution:
"""Resolve a runtime and the fallback model selected after auth failure. """Resolve the primary runtime or one complete provider/model fallback.
A fallback entry is one provider/model pair. Returning the model alongside Setup-time auth fallback only accepts entries with both fields. Provider-
the runtime prevents callers from accidentally pairing the fallback only entries are skipped so the unavailable primary model can never leak
provider with the unavailable primary model. into a different runtime. ``used_fallback`` remains explicit rather than
overloading a nullable model as control flow.
""" """
from hermes_cli.auth import AuthError from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import resolve_runtime_provider from hermes_cli.runtime_provider import resolve_runtime_provider
kwargs = resolve_kwargs or {} kwargs = resolve_kwargs or {}
try: try:
return resolve_runtime_provider(**kwargs), None return _RuntimeFallbackResolution(
resolve_runtime_provider(**kwargs),
None,
False,
)
except AuthError as primary_exc: except AuthError as primary_exc:
fb_chain = _load_fallback_model() or [] fb_chain = _load_fallback_model() or []
for entry in fb_chain: for entry in fb_chain:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
fb_provider = (entry.get("provider") or "").strip() fb_provider = str(entry.get("provider") or "").strip()
if not fb_provider: fb_model = str(entry.get("model") or "").strip()
if not fb_provider or not fb_model:
continue continue
fb_model = (entry.get("model") or "").strip() or None
try: try:
fb_kwargs: dict = {"requested": fb_provider} fb_kwargs: dict = {
if fb_model: "requested": fb_provider,
fb_kwargs["target_model"] = fb_model "target_model": fb_model,
}
if entry.get("base_url"): if entry.get("base_url"):
fb_kwargs["explicit_base_url"] = entry["base_url"] fb_kwargs["explicit_base_url"] = entry["base_url"]
if entry.get("api_key"): if entry.get("api_key"):
@ -4507,12 +4519,12 @@ def _resolve_runtime_with_fallback(
import logging import logging
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"Primary auth failed (%s), falling back to %s%s", "Primary auth failed (%s), falling back to %s model %s",
primary_exc, primary_exc,
fb_provider, fb_provider,
f" model {fb_model}" if fb_model else "", fb_model,
) )
return runtime, fb_model return _RuntimeFallbackResolution(runtime, fb_model, True)
except Exception: except Exception:
continue continue
raise raise
@ -4613,9 +4625,12 @@ def _make_agent(
resolve_kwargs["explicit_base_url"] = override_base_url resolve_kwargs["explicit_base_url"] = override_base_url
resolve_kwargs["requested"] = requested_provider resolve_kwargs["requested"] = requested_provider
resolve_kwargs["target_model"] = model or None resolve_kwargs["target_model"] = model or None
runtime, auth_fallback_model = _resolve_runtime_with_fallback(resolve_kwargs) resolution = _resolve_runtime_with_fallback(resolve_kwargs)
if auth_fallback_model: runtime = resolution.runtime
model = auth_fallback_model if resolution.used_fallback:
if not resolution.selected_model:
raise RuntimeError("Auth fallback resolved without a model")
model = resolution.selected_model
else: else:
# The switch already resolved concrete credentials/endpoint; honor # The switch already resolved concrete credentials/endpoint; honor
# persisted overrides only while using that original runtime. They # persisted overrides only while using that original runtime. They
@ -4632,12 +4647,15 @@ def _make_agent(
model = model_override model = model_override
if provider_override: if provider_override:
requested_provider = provider_override requested_provider = provider_override
runtime, auth_fallback_model = _resolve_runtime_with_fallback({ resolution = _resolve_runtime_with_fallback({
"requested": requested_provider, "requested": requested_provider,
"target_model": model or None, "target_model": model or None,
}) })
if auth_fallback_model: runtime = resolution.runtime
model = auth_fallback_model if resolution.used_fallback:
if not resolution.selected_model:
raise RuntimeError("Auth fallback resolved without a model")
model = resolution.selected_model
_pr = _load_provider_routing() _pr = _load_provider_routing()
return AIAgent( return AIAgent(
model=model, model=model,