From 0b8e81996f756e8283571269692bd87a3bee5462 Mon Sep 17 00:00:00 2001 From: snav Date: Wed, 1 Jul 2026 22:44:46 +0530 Subject: [PATCH] fix(codex-app-server): honor approvals.mode/yolo for gateway-context approval routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On gateway/cron/non-CLI contexts the codex app-server runtime has no UI to surface codex's exec/apply_patch approval requests, so they fail closed (silently decline) — the bot appears responsive but cannot write files, with no approval prompt anywhere ("patch rejected by user"). When the user has explicitly opted out of Hermes approvals (approvals.mode: off, the /yolo session toggle, or HERMES_YOLO_MODE=1), collapse to codex's own sandbox permission profile (~/.codex/config.toml) as the policy gate by passing _ServerRequestRouting(auto_approve_exec=True, auto_approve_apply_patch=True) to the session. Defaults (manual/smart/unset) preserve the current fail-closed behavior — a no-op for users who have not opted out. Reads the mode via the canonical tools.approval._get_approval_mode() (which already normalizes the YAML-1.1 bare-'off'->False case) at session-build time, so a mid-session /yolo toggle is honored too. 5 integration tests: each opt-out mechanism (config off, YAML False, env var, session yolo) plus the default fail-closed regression guard. Closes #26530 Co-authored-by: snav --- agent/codex_runtime.py | 40 +++++- .../test_codex_app_server_integration.py | 127 ++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index d8f3f83e7..c38f97ce4 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -244,7 +244,10 @@ def run_codex_app_server_turn( Called from run_conversation() when agent.api_mode == "codex_app_server". Returns the same dict shape as the chat_completions path. """ - from agent.transports.codex_app_server_session import CodexAppServerSession + from agent.transports.codex_app_server_session import ( + CodexAppServerSession, + _ServerRequestRouting, + ) # Lazy session: one CodexAppServerSession per AIAgent instance. # Spawned on first turn, reused across turns, closed at AIAgent @@ -262,6 +265,37 @@ def run_codex_app_server_turn( except Exception: approval_callback = None + # Gateway / cron contexts have no UI to surface codex's approval + # requests through, so codex app-server exec / apply_patch requests + # fail closed (silently decline) by default. When the user has + # explicitly opted out of Hermes approvals — via `approvals.mode: off` + # in config, the /yolo session toggle, or HERMES_YOLO_MODE=1 — honor + # that and let codex's own sandbox permission profile + # (~/.codex/config.toml) be the policy gate instead of double-gating + # with a missing Hermes UI. Defaults (manual/smart/unset) preserve the + # current fail-closed behavior — this is a no-op for those users. + auto_approve_requests = False + try: + from tools.approval import ( + _get_approval_mode, + is_current_session_yolo_enabled, + ) + + auto_approve_requests = ( + _get_approval_mode() == "off" + or is_current_session_yolo_enabled() + ) + except Exception: + logger.debug( + "codex app-server: approval-mode lookup failed; " + "keeping fail-closed default", + exc_info=True, + ) + if not auto_approve_requests: + auto_approve_requests = os.getenv( + "HERMES_YOLO_MODE", "" + ).strip().lower() in {"1", "true", "yes", "on"} + def _on_codex_event(note: dict) -> None: # Bridge Codex app-server item/started notifications to Hermes # tool-progress so gateways show verbose "running X" breadcrumbs @@ -281,6 +315,10 @@ def run_codex_app_server_turn( agent._codex_session = CodexAppServerSession( cwd=cwd, approval_callback=approval_callback, + request_routing=_ServerRequestRouting( + auto_approve_exec=auto_approve_requests, + auto_approve_apply_patch=auto_approve_requests, + ), on_event=_on_codex_event, ) diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index 7c5ac4f83..05349398e 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -326,6 +326,133 @@ class TestRunConversationCodexPath: assert captured["cwd"] == str(tmp_path) + def _capture_routing_agent(self, monkeypatch): + """Build a codex agent with a CodexAppServerSession stub that captures + the request_routing passed at construction time, so we can assert how + the gateway-context approval routing was resolved.""" + captured: dict = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + self._thread_id = "thread-stub-1" + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult( + final_text="ok", + projected_messages=[{"role": "assistant", "content": "ok"}], + turn_id="turn-stub-1", + thread_id="thread-stub-1", + ) + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1" + ) + return captured + + def test_approvals_mode_off_auto_approves_codex_server_requests( + self, monkeypatch + ): + """When the user disables Hermes approvals, codex app-server approval + requests should not fail closed just because no interactive callback is + wired (the typical gateway path). Codex's own sandbox permission + profile remains the filesystem boundary.""" + captured = self._capture_routing_agent(monkeypatch) + with patch( + "hermes_cli.config.load_config", + return_value={"approvals": {"mode": "off"}}, + ): + agent = _make_codex_agent() + with patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("write something") + routing = captured["request_routing"] + assert routing.auto_approve_exec is True + assert routing.auto_approve_apply_patch is True + + def test_yaml_boolean_false_approval_mode_also_auto_approves( + self, monkeypatch + ): + """YAML 1.1 parses unquoted `off` as False; match the normal approval + subsystem's compatibility behavior for codex app-server routing too.""" + captured = self._capture_routing_agent(monkeypatch) + with patch( + "hermes_cli.config.load_config", + return_value={"approvals": {"mode": False}}, + ): + agent = _make_codex_agent() + with patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("write something") + routing = captured["request_routing"] + assert routing.auto_approve_exec is True + assert routing.auto_approve_apply_patch is True + + def test_manual_approvals_keep_codex_server_requests_fail_closed( + self, monkeypatch + ): + """Default (manual) approvals must preserve the fail-closed behavior — + this fix is a no-op for users who haven't opted out.""" + captured = self._capture_routing_agent(monkeypatch) + with patch( + "hermes_cli.config.load_config", + return_value={"approvals": {"mode": "manual"}}, + ): + agent = _make_codex_agent() + with patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("write something") + routing = captured["request_routing"] + assert routing.auto_approve_exec is False + assert routing.auto_approve_apply_patch is False + + def test_hermes_yolo_env_auto_approves_codex_server_requests( + self, monkeypatch + ): + """HERMES_YOLO_MODE should flow through to codex app-server routing so + gateway/cron contexts do not fail closed when the user explicitly + enabled yolo mode outside the CLI slash-command path.""" + captured = self._capture_routing_agent(monkeypatch) + monkeypatch.setenv("HERMES_YOLO_MODE", "1") + with patch( + "hermes_cli.config.load_config", + return_value={"approvals": {"mode": "manual"}}, + ): + agent = _make_codex_agent() + with patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("write something") + routing = captured["request_routing"] + assert routing.auto_approve_exec is True + assert routing.auto_approve_apply_patch is True + + def test_session_yolo_auto_approves_codex_server_requests( + self, monkeypatch + ): + """The /yolo session toggle should be honored at Codex session creation + time, independent of the startup-time approvals config.""" + captured = self._capture_routing_agent(monkeypatch) + with patch( + "hermes_cli.config.load_config", + return_value={"approvals": {"mode": "manual"}}, + ): + agent = _make_codex_agent() + with patch( + "tools.approval.is_current_session_yolo_enabled", + return_value=True, + ), patch.object( + agent, "_spawn_background_review", return_value=None + ): + agent.run_conversation("write something") + routing = captured["request_routing"] + assert routing.auto_approve_exec is True + assert routing.auto_approve_apply_patch is True + class TestReviewForkApiModeDowngrade: """When the parent agent runs on codex_app_server, the background