fix: keep api_server approval bridge and cron self-scheduling after presence strip

Widening the _presence() clearing from single-query to every unattended
context also cleared is_ask for platform=api_server. That surface answers
approvals through the /v1/runs bridge (approval.request ->
POST /v1/runs/{id}/approval), so a dangerous command that used to park in
waiting_for_approval became an instant BLOCK with no approval.request.
Restrict the clearing to single-query + cron, where nobody can answer.

Stripping HERMES_INTERACTIVE/HERMES_GATEWAY_SESSION/HERMES_EXEC_ASK from
the external worker env also made check_cronjob_requirements() False, so
the cronjob toolset vanished for every job on a managed-systemd gateway
even with cron.allow_agent_scheduling: true. Accept the existing
HERMES_CRON_SESSION marker (set by run_one_job's context) as well.

Review finding: _presence() over-widening broke the /v1/runs approval bridge; env strip hid the cronjob toolset in external workers.
This commit is contained in:
teknium1
2026-09-14 21:15:23 -07:00
committed by Teknium
parent b6b7802447
commit 04fcf9159c
4 changed files with 37 additions and 21 deletions

View File

@@ -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

View File

@@ -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"],

View File

@@ -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.<ctx>_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.<ctx>_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

View File

@@ -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", ""))
)