diff --git a/tests/tools/test_cron_not_interactive.py b/tests/tools/test_cron_not_interactive.py index e00579bf43..0228c4ea48 100644 --- a/tests/tools/test_cron_not_interactive.py +++ b/tests/tools/test_cron_not_interactive.py @@ -1,9 +1,10 @@ """Unattended approval contexts never resolve as interactive (#110932). A gateway sets HERMES_EXEC_ASK=1 at startup and hands its environ to every external cron -worker; interactive launches export HERMES_INTERACTIVE=1. Inside cron (or a programmatic -platform session) nobody can answer the card, so ``_presence()`` must clear the trio and let -the gate resolve from ``approvals.cron_mode`` / ``approvals.unattended_mode``. +worker; interactive launches export HERMES_INTERACTIVE=1. Inside cron nobody can answer the +card, so ``_presence()`` must clear the trio and let the gate resolve from +``approvals.cron_mode``. Unattended platforms are NOT cleared: api_server answers via the +``/v1/runs`` approval bridge, which needs ``is_ask`` intact. """ import pytest @@ -21,14 +22,8 @@ def leaked_presence(monkeypatch): monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) -@pytest.mark.parametrize( - "unattended_env", - [{"HERMES_CRON_SESSION": "1"}, {"HERMES_SESSION_PLATFORM": "webhook"}], - ids=["cron", "webhook"], -) -def test_unattended_context_clears_leaked_presence(monkeypatch, leaked_presence, unattended_env): - for key, value in unattended_env.items(): - monkeypatch.setenv(key, value) +def test_cron_context_clears_leaked_presence(monkeypatch, leaked_presence): + monkeypatch.setenv("HERMES_CRON_SESSION", "1") _, is_cli, is_gateway, is_ask = approval_mod._presence() assert (is_cli, is_gateway, is_ask) == (False, False, False) @@ -36,3 +31,11 @@ def test_unattended_context_clears_leaked_presence(monkeypatch, leaked_presence, def test_interactive_session_keeps_presence(monkeypatch, leaked_presence): _, is_cli, is_gateway, is_ask = approval_mod._presence() assert (is_cli, is_gateway, is_ask) == (True, True, True) + + +def test_api_server_platform_keeps_exec_ask_for_runs_approval_bridge(monkeypatch, leaked_presence): + """api_server resolves approvals via ``approval.request`` → ``POST /v1/runs/{id}/approval``; + clearing ``is_ask`` there would turn every dangerous command into an instant BLOCK.""" + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "api_server") + _, _, _, is_ask = approval_mod._presence() + assert is_ask is True diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 32dc66a37b..b80dd0e7e5 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -207,6 +207,15 @@ class TestCronjobRequirements: assert check_cronjob_requirements() is True + def test_accepts_external_cron_worker_with_presence_vars_stripped(self, monkeypatch): + """``_launch_external_cron_worker`` strips the presence trio from the worker env; the + cron session marker alone must keep ``cron.allow_agent_scheduling: true`` effective.""" + for v in ("HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK"): + monkeypatch.delenv(v, raising=False) + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + + assert check_cronjob_requirements() is True + @pytest.mark.parametrize( "var_name", ["HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK"], diff --git a/tools/approval.py b/tools/approval.py index 3a3761be6c..f5082238d9 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -869,16 +869,16 @@ def _human_decision(spec: _GateSpec, *, command: str, description: str, def _presence(approval_callback=None) -> tuple: """``(approval_callback, is_cli, is_gateway, is_ask)`` for the current context. - Every unattended context (single-query ``-q``, cron, programmatic platforms) clears the - presence trio: ``hermes chat -q`` exports HERMES_INTERACTIVE=1 for sudo prompts, a gateway - sets HERMES_EXEC_ASK=1 at startup and passes its environ to every external cron worker - (#110932), and a webhook session inherits that same HERMES_EXEC_ASK — in none of them can a - human answer the card, so the gate must resolve from ``approvals._mode`` instead of - parking on a pending approval.""" + Single-query ``-q`` and cron clear the presence trio: ``hermes chat -q`` exports + HERMES_INTERACTIVE=1 for sudo prompts, and a gateway sets HERMES_EXEC_ASK=1 at startup and + passes its environ to every external cron worker (#110932) — in neither can a human answer + the card, so the gate must resolve from ``approvals._mode`` instead of parking on a + pending approval. Unattended *platforms* keep ``is_ask``: api_server relies on it for the + ``/v1/runs`` approval bridge (``approval.request`` → ``POST /v1/runs/{id}/approval``).""" approval_callback = _resolve_cli_approval_callback(approval_callback) is_cli, is_gateway = _is_interactive_cli(), _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") - if _unattended_contexts(): + if _is_single_query_approval_context() or _is_cron_approval_context(): is_cli = is_gateway = is_ask = False return approval_callback, is_cli, is_gateway, is_ask diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 79048541b8..d9073638b5 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1094,13 +1094,17 @@ Jobs run in a fresh session with no current-chat context, so prompts must be sel def check_cronjob_requirements() -> bool: - """Available in interactive CLI mode and gateway/messaging platforms (the scheduler is - internal; no crontab needed). Flags must be explicitly truthy via ``env_var_enabled``.""" - from utils import env_var_enabled + """Available in interactive CLI mode, gateway/messaging platforms, and cron runs (the + scheduler is internal; no crontab needed). Flags must be explicitly truthy via + ``env_var_enabled``. An external cron worker has the presence vars stripped from its env, so + the cron session marker keeps ``cron.allow_agent_scheduling`` meaningful there.""" + from gateway.session_context import get_session_env + from utils import env_var_enabled, is_truthy_value return ( env_var_enabled("HERMES_INTERACTIVE") or env_var_enabled("HERMES_GATEWAY_SESSION") or env_var_enabled("HERMES_EXEC_ASK") + or is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")) )