156 lines
5.1 KiB
Python
156 lines
5.1 KiB
Python
"""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)
|