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):