From fc49f7619dcef5ce60a3f950e6d9400175b98cba Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:14:32 -0700 Subject: [PATCH] fix(cron): a missing-credential preflight verdict names the profile and HERMES_HOME it read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocked_config reason for a missing provider credential now carries "[profile '', HERMES_HOME ]" — the home the scheduler actually read auth.json/.env from — under the ticker's profile scope, so a multiplexed satellite profile reports its own home, not the gateway's launch home. Why: #116213 reports an openai-codex cron job blocked with "No Codex credentials stored" while an interactive session under "the same" HERMES_HOME resolves the credential. A 5-shape x 5-scope live matrix (singleton, expired+refreshable, pool-only, ~/.codex only, none; root, named profile, root-only auth, multiplex default/named) on origin/main and on the reporter's build 345cd2b0 shows interactive and cron preflight agree in every cell — both call the same resolve_runtime_provider ladder and read the same store. The remaining explanation is a scheduler process reading a different home than the shell (Docker HOME vs HERMES_HOME, a service unit without the shell's env, a satellite profile), which the bare verdict could not reveal. Naming the store the verdict judged makes that mismatch visible in the one alert the user receives. Part of #116213 --- cron/scheduler_preflight.py | 17 ++++++- ...preflight_credential_verdict_names_home.py | 46 +++++++++++++++++++ website/docs/user-guide/features/cron.md | 8 ++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/cron/test_preflight_credential_verdict_names_home.py diff --git a/cron/scheduler_preflight.py b/cron/scheduler_preflight.py index 5aa058c31c..d0f8ca7f3c 100644 --- a/cron/scheduler_preflight.py +++ b/cron/scheduler_preflight.py @@ -111,8 +111,8 @@ def _preflight_check_provider_key(job: dict, cfg: dict) -> Optional[str]: # the job through the provider's window (cron/quota_hold.py, #89376). return None return ( - f"provider credential missing: {exc}. " - "Set the provider API key in .env (or `hermes setup`), or pin a " + f"provider credential missing: {exc} {_credential_store_scope_label()}. " + "Set the provider API key in .env (or `hermes setup`) for that home, or pin a " "working provider via `hermes cron edit " f"{job.get('id')} --provider

`." ) @@ -121,6 +121,19 @@ def _preflight_check_provider_key(job: dict, cfg: dict) -> Optional[str]: return None +def _credential_store_scope_label() -> str: + """``[profile '', HERMES_HOME ]`` for the home this preflight read credentials from. + + The verdict must name the store it judged: a scheduler process whose home differs from the + shell where "the same credential works" (Docker HOME vs HERMES_HOME, a multiplexed satellite + profile, a gateway launched without the shell's env) otherwise reports a bare "No credentials + stored" that cannot be told apart from a real login gap (#116213). + """ + from hermes_cli.profiles import get_active_profile_name + from hermes_constants import get_hermes_home + return f"[profile '{get_active_profile_name() or 'default'}', HERMES_HOME {get_hermes_home()}]" + + def _primary_profile_routes_for_current_home() -> list: """Primary gateway ``profile_routes`` targeting the profile being served; ``[]`` if this IS the primary home. Satellite crons are ticked and delivered by the primary gateway (a satellite diff --git a/tests/cron/test_preflight_credential_verdict_names_home.py b/tests/cron/test_preflight_credential_verdict_names_home.py new file mode 100644 index 0000000000..24780b66e2 --- /dev/null +++ b/tests/cron/test_preflight_credential_verdict_names_home.py @@ -0,0 +1,46 @@ +"""A ``blocked_config`` credential verdict names the profile + HERMES_HOME the scheduler actually +read (#116213): "No Codex credentials stored" from a gateway whose home differs from the shell that +works is otherwise indistinguishable from a genuine login gap.""" + +import json +import re + +import pytest + +from cron.scheduler_preflight import _preflight_check_provider_key +from cron.scheduler_provider import _profile_cron_scope + +JOB = {"id": "7a6ae427c1d8", "name": "radar", "provider": "openai-codex", "model": "gpt-5.6-sol"} + + +@pytest.fixture +def two_homes(tmp_path, monkeypatch): + root = tmp_path / "root" + alpha = root / "profiles" / "alpha" + alpha.mkdir(parents=True) + for home in (root, alpha): + (home / "config.yaml").write_text("model:\n default: gpt-5.6-sol\n provider: openai-codex\n") + (root / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("HERMES_PROFILE", raising=False) + return root, alpha + + +def _scope(reason: str) -> tuple: + match = re.search(r"\[profile '([^']+)', HERMES_HOME (.+?)\]", reason) + assert match, reason + return match.group(1), match.group(2) + + +def test_missing_codex_credential_verdict_names_the_home_it_read(two_homes): + root, alpha = two_homes + + reason = _preflight_check_provider_key(JOB, {"cron": {}}) + assert reason and "No Codex credentials stored" in reason + assert _scope(reason) == ("default", str(root)) + + # Multiplex tick of a satellite profile: the verdict names alpha, not the gateway's launch home. + with _profile_cron_scope(alpha): + reason = _preflight_check_provider_key(JOB, {"cron": {}}) + assert reason and _scope(reason) == ("alpha", str(alpha)) diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 006d346c16..bbc9e6678f 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -99,6 +99,14 @@ alert is delivered (it is not repeated every tick), and **no LLM call is made** — a misconfigured job never spends tokens. The next healthy run clears the blocked state so a future configuration break alerts again. +A missing-credential verdict names the profile and `HERMES_HOME` the scheduler +read, e.g. `provider credential missing: No Codex credentials stored … [profile +'default', HERMES_HOME /opt/data]`. When an interactive session with "the same" +credential works, compare that path with the shell's `HERMES_HOME`: a gateway +started without the shell's environment (Docker `HOME` vs `HERMES_HOME`, a +service unit) or a multiplexed satellite profile reads a different `auth.json` +and `.env` than the shell does. + To disable the validation and restore the old behavior (the run proceeds and fails during execution):