fix: extend corrupt-config fail-closed guard to gateway, serve, and cron surfaces
Follow-up to the salvaged #81988 CLI guard (issue #81952): - gateway/run.py::main() refuses startup (exit 2) on unparseable config.yaml - hermes serve headless path (cmd_dashboard) gets the same guard - cron run_job() fails the job with the guard error before AIAgent construction (no_agent script jobs exempt — no token spend) - HERMES_IGNORE_USER_CONFIG=1 / --ignore-user-config escape hatch honored on every surface
This commit is contained in:
@@ -5509,6 +5509,24 @@ def run_job(
|
||||
job_id = job["id"]
|
||||
job_name = str(job.get("name") or job.get("prompt") or job_id or "cron job")
|
||||
|
||||
# Fail closed on a corrupt config.yaml before any agent-driven work
|
||||
# (issue #81952): a cron fire is fully non-interactive, and continuing
|
||||
# on built-in defaults lets provider auto-detection adopt .env
|
||||
# credentials the config never named, billing a provider the user did
|
||||
# not choose. no_agent script jobs are exempt — they never construct an
|
||||
# AIAgent or spend tokens. Escape hatch: HERMES_IGNORE_USER_CONFIG=1.
|
||||
if not job.get("no_agent"):
|
||||
from hermes_cli.config import (
|
||||
InvalidUserConfigError,
|
||||
require_parseable_user_config,
|
||||
)
|
||||
|
||||
try:
|
||||
require_parseable_user_config()
|
||||
except InvalidUserConfigError as exc:
|
||||
logger.error("Job '%s': refusing to run — %s", job_id, exc)
|
||||
return (False, f"# Cron Job: {job_name}\n\nError: {exc}\n", "", str(exc))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# no_agent short-circuit — the script IS the job, no LLM involvement.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -33429,8 +33429,34 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
return True
|
||||
|
||||
|
||||
def _guard_corrupt_user_config() -> None:
|
||||
"""Fail closed when the active profile's config.yaml cannot be parsed.
|
||||
|
||||
The gateway is a fully non-interactive surface: nobody is present to
|
||||
repair a corrupt ``config.yaml``, and silently continuing on built-in
|
||||
defaults lets provider auto-detection adopt credentials from ``.env``
|
||||
that the config never named (issue #81952). Same policy and escape
|
||||
hatch (``HERMES_IGNORE_USER_CONFIG=1``) as the non-interactive CLI
|
||||
guard in ``hermes_cli/main.py``.
|
||||
"""
|
||||
from hermes_cli.config import (
|
||||
InvalidUserConfigError,
|
||||
require_parseable_user_config,
|
||||
)
|
||||
|
||||
try:
|
||||
require_parseable_user_config()
|
||||
except InvalidUserConfigError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for the gateway."""
|
||||
# Refuse to start on a corrupt config.yaml — before any config-dependent
|
||||
# startup (watchdog, DB opens, provider resolution). See _guard docstring.
|
||||
_guard_corrupt_user_config()
|
||||
|
||||
# Advertise the agent harness to child processes (AI_AGENT is the
|
||||
# cross-agent standard; HERMES_AGENT the Hermes-specific marker — see
|
||||
# _advertise_agent_env in hermes_cli/main.py, kept inline here to avoid
|
||||
|
||||
@@ -12103,6 +12103,23 @@ def cmd_dashboard(args):
|
||||
# ready sentinel. Resolved once and threaded through the re-exec, the
|
||||
# build gate, and start_server.
|
||||
_headless_backend = getattr(args, "headless_backend", False)
|
||||
# `hermes serve` is headless/non-interactive: fail closed on a corrupt
|
||||
# config.yaml instead of silently starting on defaults where provider
|
||||
# auto-detection can adopt unnamed .env credentials (issue #81952).
|
||||
# Same policy + escape hatch as _guard_noninteractive_user_config.
|
||||
if _headless_backend:
|
||||
from hermes_cli.config import (
|
||||
InvalidUserConfigError,
|
||||
require_parseable_user_config,
|
||||
)
|
||||
|
||||
try:
|
||||
require_parseable_user_config(
|
||||
ignore_user_config=bool(getattr(args, "ignore_user_config", False))
|
||||
)
|
||||
except InvalidUserConfigError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2) from exc
|
||||
_ssh_owner_nonce = getattr(args, "ssh_owner_nonce", None)
|
||||
if _ssh_owner_nonce and not re.fullmatch(r"[0-9a-f]{16}", _ssh_owner_nonce):
|
||||
raise SystemExit("--ssh-owner-nonce must be 16 lowercase hex characters")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Fail-closed corrupt-config guards on gateway, serve, and cron surfaces.
|
||||
|
||||
Companion to test_noninteractive_config_guard.py (PR #81988): issue #81952
|
||||
extended to every non-interactive startup surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_config_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
||||
yield
|
||||
os.environ.pop("HERMES_IGNORE_USER_CONFIG", None)
|
||||
|
||||
|
||||
def _write_corrupt_config(tmp_path):
|
||||
path = tmp_path / "config.yaml"
|
||||
path.write_text("model: [unterminated\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestGatewayGuard:
|
||||
def test_gateway_refuses_corrupt_config(self, tmp_path, capsys):
|
||||
from gateway.run import _guard_corrupt_user_config
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_guard_corrupt_user_config()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "Refusing non-interactive startup" in capsys.readouterr().err
|
||||
|
||||
def test_gateway_allows_valid_config(self, tmp_path):
|
||||
from gateway.run import _guard_corrupt_user_config
|
||||
|
||||
(tmp_path / "config.yaml").write_text("model:\n default: local/test\n")
|
||||
_guard_corrupt_user_config() # must not raise
|
||||
|
||||
def test_gateway_allows_missing_config(self, tmp_path):
|
||||
from gateway.run import _guard_corrupt_user_config
|
||||
|
||||
_guard_corrupt_user_config() # first-run state: must not raise
|
||||
|
||||
def test_gateway_escape_hatch(self, monkeypatch, tmp_path):
|
||||
from gateway.run import _guard_corrupt_user_config
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
|
||||
_guard_corrupt_user_config() # must not raise
|
||||
|
||||
|
||||
class TestCronRunJobGuard:
|
||||
def _job(self, **overrides):
|
||||
job = {"id": "job-test-1", "name": "guard test", "prompt": "hi"}
|
||||
job.update(overrides)
|
||||
return job
|
||||
|
||||
def test_run_job_fails_closed_on_corrupt_config(self, tmp_path):
|
||||
from cron.scheduler import run_job
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
|
||||
success, output_doc, final_response, error = run_job(self._job())
|
||||
|
||||
assert success is False
|
||||
assert error is not None
|
||||
assert "Refusing non-interactive startup" in error
|
||||
assert "config.yaml" in error
|
||||
assert final_response == ""
|
||||
|
||||
def test_run_job_escape_hatch(self, monkeypatch, tmp_path):
|
||||
from cron.scheduler import run_job
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
|
||||
|
||||
# With the escape hatch active the guard must not trip. The job then
|
||||
# proceeds into normal execution; a missing provider/model in the
|
||||
# empty temp HERMES_HOME may fail later, but never with the guard's
|
||||
# refusal message.
|
||||
success, output_doc, final_response, error = run_job(
|
||||
self._job(no_agent=True, script="true", deliver="none")
|
||||
)
|
||||
assert "Refusing non-interactive startup" not in (error or "")
|
||||
|
||||
def test_run_job_no_agent_exempt(self, tmp_path):
|
||||
from cron.scheduler import run_job
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
|
||||
success, output_doc, final_response, error = run_job(
|
||||
self._job(no_agent=True, script="true", deliver="none")
|
||||
)
|
||||
assert "Refusing non-interactive startup" not in (error or "")
|
||||
|
||||
|
||||
class TestServeGuard:
|
||||
def test_serve_headless_refuses_corrupt_config(self, tmp_path, capsys):
|
||||
"""The `hermes serve` headless path fails closed before startup."""
|
||||
from argparse import Namespace
|
||||
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
args = Namespace(
|
||||
headless_backend=True,
|
||||
ignore_user_config=False,
|
||||
ssh_session_token_file=None,
|
||||
ssh_owner_nonce=None,
|
||||
status=False,
|
||||
stop=False,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main_mod.cmd_dashboard(args)
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "Refusing non-interactive startup" in capsys.readouterr().err
|
||||
|
||||
def test_serve_escape_hatch_passes_guard(self, tmp_path, monkeypatch):
|
||||
"""--ignore-user-config lets serve get past the corrupt-config guard."""
|
||||
from argparse import Namespace
|
||||
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
_write_corrupt_config(tmp_path)
|
||||
args = Namespace(
|
||||
headless_backend=True,
|
||||
ignore_user_config=True,
|
||||
ssh_session_token_file=None,
|
||||
ssh_owner_nonce=None,
|
||||
status=False,
|
||||
stop=False,
|
||||
)
|
||||
|
||||
# Stop execution right after the guard: the next thing cmd_dashboard
|
||||
# touches on the headless path is the nonce regex via `re`.
|
||||
sentinel = RuntimeError("passed-guard")
|
||||
|
||||
class _ReStop:
|
||||
def fullmatch(self, *a, **k):
|
||||
raise sentinel
|
||||
|
||||
monkeypatch.setattr(main_mod, "re", _ReStop())
|
||||
args.ssh_owner_nonce = "0123456789abcdef"
|
||||
|
||||
with pytest.raises(RuntimeError, match="passed-guard"):
|
||||
main_mod.cmd_dashboard(args)
|
||||
Reference in New Issue
Block a user