From 16150e907a9c541a393df02f7544ed4b91bf9a3c Mon Sep 17 00:00:00 2001 From: Hina Chen Date: Wed, 15 Jul 2026 13:58:39 +0800 Subject: [PATCH 1/2] fix(cron): scope cron approval context per session --- acp_adapter/server.py | 2 +- cron/scheduler.py | 8 +- gateway/platforms/api_server.py | 1 + gateway/run.py | 1 + gateway/session_context.py | 14 ++ .../test_scheduler_cron_session_isolation.py | 154 ++++++++++++++++++ tests/gateway/test_session_env.py | 35 ++++ tests/tools/test_cron_approval_mode.py | 62 +++++++ .../test_execute_code_approval_cluster.py | 36 ++++ tools/approval.py | 24 ++- tui_gateway/server.py | 1 + 11 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 tests/cron/test_scheduler_cron_session_isolation.py diff --git a/acp_adapter/server.py b/acp_adapter/server.py index d86e406518693..20daba63a1358 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1478,7 +1478,7 @@ def _run_agent() -> dict: clear_session_vars, set_session_vars, ) - session_tokens = set_session_vars(session_key=session_id) + session_tokens = set_session_vars(session_key=session_id, cron_session="") except Exception: session_tokens = None clear_session_vars = None # type: ignore[assignment] diff --git a/cron/scheduler.py b/cron/scheduler.py index c5957070021e7..309121e0d647c 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2914,10 +2914,9 @@ def run_job( agent = None - # Mark this as a cron session so the approval system can apply cron_mode. - # This env var is process-wide and persists for the lifetime of the - # scheduler process — every job this process runs is a cron job. - os.environ["HERMES_CRON_SESSION"] = "1" + # Mark this as a cron session in the per-job ContextVar binding so the + # approval system can apply cron_mode without leaking a process-global env + # flag into later non-cron gateway/API/TUI turns in the same process. # Use ContextVars for per-job session/delivery state so parallel jobs # don't clobber each other's targets (os.environ is process-global). @@ -2962,6 +2961,7 @@ def run_job( # inline/synchronous path, so results return within the job's own turn. # See declare_stateless_channel(). Upstream: #53027, #63142. async_delivery=False, + cron_session="1", ) _cron_delivery_vars = ( "HERMES_CRON_AUTO_DELIVER_PLATFORM", diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 65d5b324cc459..5635f81e823e6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -4617,6 +4617,7 @@ def _bind_api_server_session( session_key=session_key, session_id=session_id, async_delivery=False, + cron_session="", ) async def _run_agent( diff --git a/gateway/run.py b/gateway/run.py index f59e006f6249a..065fc32de402f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16383,6 +16383,7 @@ def _set_session_env(self, context: SessionContext) -> list: message_id=str(context.source.message_id) if context.source.message_id else "", profile=getattr(context.source, "profile", "") or "", async_delivery=_async_delivery, + cron_session="", ) def _clear_session_env(self, tokens: list) -> None: diff --git a/gateway/session_context.py b/gateway/session_context.py index a8e7c027fecb1..5895c07909e26 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -93,6 +93,12 @@ def session_context_engaged() -> bool: _SESSION_PROFILE: ContextVar = ContextVar("HERMES_SESSION_PROFILE", default=_UNSET) +# Per-session cron marker. Unlike the process-global legacy env var, this is +# scoped to one cron job / inbound session. _UNSET preserves the legacy env +# fallback for CLI/tests; "1" marks cron; "" explicitly marks non-cron and +# masks any leaked process env value. +_CRON_SESSION: ContextVar = ContextVar("HERMES_CRON_SESSION", default=_UNSET) + # Whether the current session's delivery channel can route an ASYNC completion # back to the agent AFTER the current turn ends (i.e. wake a fresh turn). # @@ -133,6 +139,7 @@ def session_context_engaged() -> bool: "HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID, "HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID, "HERMES_SESSION_PROFILE": _SESSION_PROFILE, + "HERMES_CRON_SESSION": _CRON_SESSION, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID, "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID, @@ -169,6 +176,7 @@ def set_session_vars( cwd: str = "", async_delivery: bool = True, ui_session_id: str = "", + cron_session: Any = _UNSET, ) -> list: """Set all session context variables and return reset tokens. @@ -184,6 +192,10 @@ def set_session_vars( background completion back to the agent after the turn ends (see ``_SESSION_ASYNC_DELIVERY`` / ``async_delivery_supported``). Stateless request/response adapters (the API server) pass ``False``. + + ``cron_session`` is tri-state: ``_UNSET`` preserves legacy + ``os.environ["HERMES_CRON_SESSION"]`` fallback, ``"1"`` marks a cron job, + and ``""`` explicitly marks a non-cron session while masking leaked env. """ # Mark the session-context machinery engaged for this process. The # subprocess-env bridge uses this to switch from "os.environ fallback" to @@ -203,6 +215,7 @@ def set_session_vars( _SESSION_UI_SESSION_ID.set(ui_session_id), _SESSION_MESSAGE_ID.set(message_id), _SESSION_PROFILE.set(profile), + _CRON_SESSION.set(cron_session), _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)), ] try: @@ -238,6 +251,7 @@ def clear_session_vars(tokens: list) -> None: _SESSION_UI_SESSION_ID, _SESSION_MESSAGE_ID, _SESSION_PROFILE, + _CRON_SESSION, ): var.set("") # Reset async-delivery capability to the "never set" sentinel rather than a diff --git a/tests/cron/test_scheduler_cron_session_isolation.py b/tests/cron/test_scheduler_cron_session_isolation.py new file mode 100644 index 0000000000000..341bae46e3911 --- /dev/null +++ b/tests/cron/test_scheduler_cron_session_isolation.py @@ -0,0 +1,154 @@ +"""Regression test for cron-session approval isolation. + +A cron job must use ``approvals.cron_mode`` for its own ``execute_code`` call, +without leaving process-global state that changes a later interactive gateway +turn handled by the same Python process. +""" + +from __future__ import annotations + +import os + +import pytest + +import cron.scheduler as cron_scheduler +from gateway.session_context import ( + clear_session_vars, + get_session_env, + reset_session_vars, + set_session_vars, +) +from tools import approval as approval_module + + +class _DummySessionDB: + def set_session_title(self, *args, **kwargs): + pass + + def end_session(self, *args, **kwargs): + pass + + def close(self): + pass + + +class _FakeCronAgent: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + def run_conversation(self, prompt): + result = approval_module.check_execute_code_guard( + "import os; print(1)", "local" + ) + assert result["approved"] is False + assert result["outcome"] == "blocked" + assert get_session_env("HERMES_CRON_SESSION") == "1" + return { + "completed": True, + "failed": False, + "final_response": "cron execute_code blocked", + "turn_exit_reason": "", + } + + def close(self): + pass + + +@pytest.fixture(autouse=True) +def _clear_approval_state(monkeypatch): + reset_session_vars() + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + approval_module._permanent_approved.clear() + approval_module.clear_session("default") + approval_module.clear_session("cron-isolation-session") + yield + approval_module._permanent_approved.clear() + approval_module.clear_session("default") + approval_module.clear_session("cron-isolation-session") + reset_session_vars() + + +def _register_gateway_auto_approve(session_key: str) -> None: + def _notify(_approval_data): + with approval_module._lock: + entries = approval_module._gateway_queues.get(session_key, []) + if entries: + entry = entries[-1] + entry.result = "once" + entry.event.set() + + with approval_module._lock: + approval_module._gateway_notify_cbs[session_key] = _notify + + +def test_run_job_cron_execute_code_deny_does_not_pollute_later_gateway_execute_code( + monkeypatch, tmp_path +): + """Cron deny stays scoped; a later gateway approval still reaches its user.""" + monkeypatch.setenv("HERMES_MODEL", "test-model") + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") + monkeypatch.setattr("hermes_state.SessionDB", _DummySessionDB) + monkeypatch.setattr("run_agent.AIAgent", _FakeCronAgent) + monkeypatch.setattr( + "hermes_constants.resolve_reasoning_config", lambda *_args, **_kwargs: None + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "test-key", + "base_url": None, + "provider": "test-provider", + "api_mode": None, + "command": None, + "args": None, + }, + ) + monkeypatch.setattr("tools.mcp_tool.discover_mcp_tools", lambda: []) + monkeypatch.setattr(cron_scheduler, "_get_hermes_home", lambda: tmp_path) + monkeypatch.setattr(cron_scheduler, "get_fallback_chain", lambda _cfg: []) + monkeypatch.setattr( + cron_scheduler, "_guard_job_credential_exfil", lambda _job: None + ) + + success, _output, final_response, error = cron_scheduler.run_job( + { + "id": "ctx-isolation", + "name": "Context Isolation", + "prompt": "Run safely", + "schedule_display": "manual", + } + ) + + assert success is True + assert error is None + assert final_response == "cron execute_code blocked" + assert os.environ.get("HERMES_CRON_SESSION") is None + assert get_session_env("HERMES_CRON_SESSION") == "" + + session_key = "cron-isolation-session" + key_token = approval_module.set_current_session_key(session_key) + session_tokens = set_session_vars( + platform="discord", + chat_id="123", + session_key=session_key, + cron_session="", + ) + monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") + try: + _register_gateway_auto_approve(session_key) + result = approval_module.check_execute_code_guard( + "import os; print(2)", "local" + ) + assert result["approved"] is True + assert result.get("user_approved") is True + finally: + clear_session_vars(session_tokens) + approval_module.reset_current_session_key(key_token) + with approval_module._lock: + approval_module._gateway_queues.pop(session_key, None) + approval_module._gateway_notify_cbs.pop(session_key, None) diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index f5392ab2c2209..0c0f5c6eb092d 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -10,6 +10,7 @@ get_session_env, set_session_vars, clear_session_vars, + reset_session_vars, _VAR_MAP, _UNSET, ) @@ -327,6 +328,40 @@ async def test_run_in_executor_with_context_preserves_session_env(monkeypatch): } +def test_cron_session_contextvar_preserves_legacy_env_fallback(monkeypatch): + """Unset cron ContextVar keeps old env-only cron callers working.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + + assert get_session_env("HERMES_CRON_SESSION") == "1" + + +def test_cron_session_explicit_blank_masks_leaked_env(monkeypatch): + """Non-cron session bindings must override a stale process cron env flag.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + + tokens = set_session_vars(platform="api_server", cron_session="") + try: + assert get_session_env("HERMES_CRON_SESSION") == "" + finally: + clear_session_vars(tokens) + + assert get_session_env("HERMES_CRON_SESSION") == "" + + +def test_cron_session_set_clear_and_reset_tristate(monkeypatch): + """Cron marker supports _UNSET fallback, '1' cron, and '' explicit clear.""" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + + tokens = set_session_vars(cron_session="1") + assert get_session_env("HERMES_CRON_SESSION") == "1" + + clear_session_vars(tokens) + assert get_session_env("HERMES_CRON_SESSION") == "" + + reset_session_vars() + assert get_session_env("HERMES_CRON_SESSION") == "1" + + @pytest.mark.asyncio async def test_run_in_executor_with_context_forwards_args(): """_run_in_executor_with_context should forward *args to the callable.""" diff --git a/tests/tools/test_cron_approval_mode.py b/tests/tools/test_cron_approval_mode.py index 9264d108cffa9..cccf197ecbf77 100644 --- a/tests/tools/test_cron_approval_mode.py +++ b/tests/tools/test_cron_approval_mode.py @@ -3,6 +3,7 @@ import pytest import tools.approval as approval_module +from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars from tools.approval import ( _get_cron_approval_mode, check_all_command_guards, @@ -16,10 +17,12 @@ def _clear_approval_state(): approval_module._permanent_approved.clear() approval_module.clear_session("default") approval_module.clear_session("test-session") + reset_session_vars() yield approval_module._permanent_approved.clear() approval_module.clear_session("default") approval_module.clear_session("test-session") + reset_session_vars() # --------------------------------------------------------------------------- @@ -83,6 +86,65 @@ def test_yaml_boolean_false_maps_to_deny(self): assert _get_cron_approval_mode() == "deny" +# --------------------------------------------------------------------------- +# ContextVar cron detection +# --------------------------------------------------------------------------- + +class TestCronContextVarDetection: + def test_legacy_env_fallback_still_marks_cron(self, monkeypatch): + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + assert approval_module._is_cron_approval_context() is True + + def test_explicit_blank_masks_leaked_cron_env_for_gateway_classification(self, monkeypatch): + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") + tokens = set_session_vars(platform="api_server", cron_session="") + try: + assert approval_module._is_cron_approval_context() is False + assert approval_module._is_gateway_approval_context() is True + finally: + clear_session_vars(tokens) + + def test_scoped_cron_deny_for_dangerous_all_and_execute_code(self, monkeypatch): + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") + + tokens = set_session_vars(cron_session="1") + try: + dangerous = check_dangerous_command("rm -rf /tmp/stuff", "local") + combined = check_all_command_guards("rm -rf /tmp/stuff", "local") + code = approval_module.check_execute_code_guard("import os", "local") + finally: + clear_session_vars(tokens) + + assert dangerous["approved"] is False + assert combined["approved"] is False + assert code["approved"] is False + assert code["outcome"] == "blocked" + + def test_non_cron_blank_context_keeps_headless_execute_code_legacy_approved(self, monkeypatch): + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") + + tokens = set_session_vars(cron_session="") + try: + result = approval_module.check_execute_code_guard("import os", "local") + finally: + clear_session_vars(tokens) + + assert result["approved"] is True + + # --------------------------------------------------------------------------- # check_dangerous_command() with cron session # --------------------------------------------------------------------------- diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index 7ea74a53b4381..3907ee0ed5bac 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -24,6 +24,7 @@ from tools import approval as A from tools.thread_context import propagate_context_to_thread +from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars # --------------------------------------------------------------------------- @@ -122,6 +123,8 @@ def gw_session(monkeypatch): with A._lock: A._gateway_queues.pop(session_key, None) A._gateway_notify_cbs.pop(session_key, None) + A._permanent_approved.discard("execute_code") + A._session_approved.get(session_key, set()).discard("execute_code") try: yield session_key finally: @@ -178,6 +181,39 @@ def test_guard_headless_local_approved(monkeypatch): def test_guard_cron_deny_blocks(monkeypatch): + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "deny") + tokens = set_session_vars(cron_session="1") + try: + res = A.check_execute_code_guard("import os", "local") + finally: + clear_session_vars(tokens) + assert res["approved"] is False + assert res["outcome"] == "blocked" + + +def test_guard_explicit_non_cron_masks_leaked_env(monkeypatch): + monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "deny") + tokens = set_session_vars(cron_session="") + try: + res = A.check_execute_code_guard("import os", "local") + finally: + clear_session_vars(tokens) + reset_session_vars() + assert res["approved"] is True + + +def test_guard_legacy_env_cron_still_blocks(monkeypatch): + reset_session_vars() monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False) monkeypatch.setenv("HERMES_CRON_SESSION", "1") monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) diff --git a/tools/approval.py b/tools/approval.py index ea3bb82690697..750cd086f4e60 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -224,6 +224,22 @@ def _get_session_platform() -> str: return os.getenv("HERMES_SESSION_PLATFORM", "") or "" +def _is_cron_approval_context() -> bool: + """True when the current approval decision is running inside cron. + + Prefer the session ContextVar so one cron job cannot taint unrelated + gateway/API/TUI turns in the same process. If the session context layer is + not engaged or unavailable, fall back to the legacy process env var for CLI + tests and older entrypoints. + """ + try: + from gateway.session_context import get_session_env + + return is_truthy_value(get_session_env("HERMES_CRON_SESSION", "")) + except Exception: + return env_var_enabled("HERMES_CRON_SESSION") + + def _is_gateway_approval_context() -> bool: """True when this call is inside a gateway/API session. @@ -238,7 +254,7 @@ def _is_gateway_approval_context() -> bool: fall through to the gateway branch would submit a pending approval with no listener and block the job indefinitely. """ - if env_var_enabled("HERMES_CRON_SESSION"): + if _is_cron_approval_context(): return False if env_var_enabled("HERMES_GATEWAY_SESSION"): return True @@ -2708,7 +2724,7 @@ def _run_approval_gate( if not is_cli and not is_gateway: # Cron sessions: respect cron_mode config - if env_var_enabled("HERMES_CRON_SESSION"): + if _is_cron_approval_context(): if _get_cron_approval_mode() == "deny": return { "approved": False, @@ -3235,7 +3251,7 @@ def check_all_command_guards(command: str, env_type: str, # flows, we do not block on approvals and we skip external guard work. if not is_cli and not is_gateway and not is_ask: # Cron sessions: respect cron_mode config - if env_var_enabled("HERMES_CRON_SESSION"): + if _is_cron_approval_context(): if _get_cron_approval_mode() == "deny": # Run detection to get a description for the block message is_dangerous, _pk, description = detect_dangerous_command(command) @@ -3656,7 +3672,7 @@ def check_execute_code_guard(code: str, env_type: str, is_ask = env_var_enabled("HERMES_EXEC_ASK") # Cron: no user is present to approve arbitrary code. - if env_var_enabled("HERMES_CRON_SESSION"): + if _is_cron_approval_context(): if _get_cron_approval_mode() == "deny": return { "approved": False, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index bdf1f4d672c54..c494a30019caa 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2317,6 +2317,7 @@ def _set_session_context( source=source, cwd=resolved, ui_session_id=ui_session_id, + cron_session="", ) except Exception: return [] From a7322d5331c4904fcc8e8abc812731e229de8617 Mon Sep 17 00:00:00 2001 From: Hina Chen Date: Tue, 21 Jul 2026 00:17:37 +0800 Subject: [PATCH 2/2] fix(cron): preserve env fallback after scoped jobs Restore the cron ContextVar token after each job, update current-main approval test seams, and keep ordered cron/approval suites context-isolated. --- cron/scheduler.py | 14 ++++++++----- tests/cron/conftest.py | 20 +++++++++++++++++++ .../test_scheduler_cron_session_isolation.py | 7 +++++++ tests/tools/test_request_tool_approval.py | 8 +++----- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 309121e0d647c..6cd5159bacbda 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2914,10 +2914,6 @@ def run_job( agent = None - # Mark this as a cron session in the per-job ContextVar binding so the - # approval system can apply cron_mode without leaking a process-global env - # flag into later non-cron gateway/API/TUI turns in the same process. - # Use ContextVars for per-job session/delivery state so parallel jobs # don't clobber each other's targets (os.environ is process-global). from gateway.session_context import set_session_vars, clear_session_vars, _VAR_MAP @@ -2961,7 +2957,6 @@ def run_job( # inline/synchronous path, so results return within the job's own turn. # See declare_stateless_channel(). Upstream: #53027, #63142. async_delivery=False, - cron_session="1", ) _cron_delivery_vars = ( "HERMES_CRON_AUTO_DELIVER_PLATFORM", @@ -3014,7 +3009,14 @@ def run_job( # statement raises. A leaked writer would deadlock the whole scheduler # (every future job blocks on acquire_*); a leaked reader blocks all # future writers. Acquire itself can't leak (it either blocks or returns). + _cron_session_var = _VAR_MAP["HERMES_CRON_SESSION"] + _cron_session_token = None try: + # Scope cron approval policy to this job. Keep the token so the finally + # restores the pre-job state instead of pinning an explicit empty value, + # which would suppress the legacy os.environ fallback used by standalone + # cron entrypoints and tests. + _cron_session_token = _cron_session_var.set("1") if _job_workdir: os.environ["TERMINAL_CWD"] = _job_workdir logger.info("Job '%s': using workdir %s", job_id, _job_workdir) @@ -3624,6 +3626,8 @@ def _heartbeat_run_claim_if_due(): _terminal_cwd_lock.release_read() # Clean up ContextVar session/delivery state for this job. clear_session_vars(_ctx_tokens) + if _cron_session_token is not None: + _cron_session_var.reset(_cron_session_token) for _var_name in _cron_delivery_vars: _VAR_MAP[_var_name].set("") if _session_db: diff --git a/tests/cron/conftest.py b/tests/cron/conftest.py index caaec4559487c..48380a0929c5d 100644 --- a/tests/cron/conftest.py +++ b/tests/cron/conftest.py @@ -19,3 +19,23 @@ def _default_cron_test_model(monkeypatch): """Pin a default HERMES_MODEL so cron run_job tests have a resolvable model.""" monkeypatch.setenv("HERMES_MODEL", "test-cron-default-model") yield + + +@pytest.fixture(autouse=True) +def _reset_session_context_vars(): + """Restore session ContextVars around cron tests that call run_job directly. + + Production confines each cron run to a copied context, but direct unit tests + share the pytest context. ``run_job`` intentionally clears ordinary session + variables to explicit empty values, which would otherwise shadow legacy env + fallbacks used by later approval tests in the same process. + """ + from gateway.session_context import _UNSET, _VAR_MAP + + def _reset_all(): + for var in _VAR_MAP.values(): + var.set(_UNSET) + + _reset_all() + yield + _reset_all() diff --git a/tests/cron/test_scheduler_cron_session_isolation.py b/tests/cron/test_scheduler_cron_session_isolation.py index 341bae46e3911..70237407545d2 100644 --- a/tests/cron/test_scheduler_cron_session_isolation.py +++ b/tests/cron/test_scheduler_cron_session_isolation.py @@ -130,6 +130,13 @@ def test_run_job_cron_execute_code_deny_does_not_pollute_later_gateway_execute_c assert os.environ.get("HERMES_CRON_SESSION") is None assert get_session_env("HERMES_CRON_SESSION") == "" + # A completed in-process job must restore the truly-unset ContextVar state, + # not leave an explicit empty value that shadows the standalone cron env + # fallback in this reused context. + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + assert get_session_env("HERMES_CRON_SESSION") == "1" + monkeypatch.delenv("HERMES_CRON_SESSION") + session_key = "cron-isolation-session" key_token = approval_module.set_current_session_key(session_key) session_tokens = set_session_vars( diff --git a/tests/tools/test_request_tool_approval.py b/tests/tools/test_request_tool_approval.py index 16414fc054c55..2e48d1d75e7d6 100644 --- a/tests/tools/test_request_tool_approval.py +++ b/tests/tools/test_request_tool_approval.py @@ -103,8 +103,7 @@ def test_gateway_path_submits_pending_and_defers(self, monkeypatch): def test_cron_deny_mode_blocks(self, monkeypatch): monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) - monkeypatch.setattr(approval, "env_var_enabled", - lambda v: v == "HERMES_CRON_SESSION") + monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: True) monkeypatch.setattr(approval, "_get_cron_approval_mode", lambda: "deny") res = request_tool_approval("terminal", "smtp send") assert res["approved"] is False @@ -113,8 +112,7 @@ def test_cron_deny_mode_blocks(self, monkeypatch): def test_cron_approve_mode_allows(self, monkeypatch): monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) - monkeypatch.setattr(approval, "env_var_enabled", - lambda v: v == "HERMES_CRON_SESSION") + monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: True) monkeypatch.setattr(approval, "_get_cron_approval_mode", lambda: "approve") res = request_tool_approval("terminal", "smtp send") assert res["approved"] is True @@ -150,7 +148,7 @@ def test_no_human_non_cron_fails_closed(self, monkeypatch): — a plugin-flagged action never runs ungated without a human.""" monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False) monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False) - monkeypatch.setattr(approval, "env_var_enabled", lambda v: False) # not cron + monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: False) res = request_tool_approval("terminal", "smtp send") assert res["approved"] is False assert "no interactive user or gateway" in res["message"].lower()