diff --git a/run_agent.py b/run_agent.py index a4df87497772..774c1b7828aa 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1950,6 +1950,19 @@ def __init__( _agent_cfg = _load_agent_config() except Exception: _agent_cfg = {} + _approvals_cfg = _agent_cfg.get("approvals", {}) + if not isinstance(_approvals_cfg, dict): + _approvals_cfg = {} + try: + from tools.approval import _normalize_approval_mode + _approval_mode = _normalize_approval_mode( + _approvals_cfg.get("mode", "manual") + ) + except Exception: + _approval_mode = str( + _approvals_cfg.get("mode", "manual") + ).lower() + self._codex_app_server_auto_approve_requests = _approval_mode == "off" try: self._tool_guardrails = ToolCallGuardrailController( ToolCallGuardrailConfig.from_mapping( @@ -15748,7 +15761,10 @@ def _run_codex_app_server_turn( Called from run_conversation() when self.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 @@ -15763,9 +15779,36 @@ def _run_codex_app_server_turn( approval_callback = _get_approval_callback() except Exception: approval_callback = None + # Gateway 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 be the policy gate instead of + # double-gating with a missing Hermes UI. + _auto_approve_requests = bool( + self._codex_app_server_auto_approve_requests + ) + try: + from tools.approval import is_current_session_yolo_enabled + _auto_approve_requests = ( + _auto_approve_requests + or is_current_session_yolo_enabled() + ) + except Exception: + pass + _auto_approve_requests = ( + _auto_approve_requests + or os.getenv("HERMES_YOLO_MODE", "").lower() in {"1", "true", "yes", "on"} + ) self._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, + ), ) # NOTE: the user message is ALREADY appended to messages by the diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index 46e47bae13e3..68feb1a159e5 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -232,6 +232,168 @@ def test_chat_completions_loop_is_not_entered(self, fake_session): agent.run_conversation("hi") assert not client_mock.chat.completions.create.called + 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 (typical gateway path). Codex's own sandbox permission profile + remains the filesystem boundary. + """ + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult(final_text="ok", thread_id="th", turn_id="tu") + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th" + ) + + 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 parses unquoted `off` as False; match the normal approval + subsystem's compatibility behavior for codex app-server routing too. + """ + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr( + CodexAppServerSession, + "run_turn", + lambda self, user_input, **kwargs: TurnResult( + final_text="ok", thread_id="th", turn_id="tu" + ), + ) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th" + ) + + 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): + captured = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult(final_text="ok", thread_id="th", turn_id="tu") + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th" + ) + + 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 = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult(final_text="ok", thread_id="th", turn_id="tu") + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th" + ) + 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 = {} + + def fake_init(self, **kwargs): + captured.update(kwargs) + + def fake_run_turn(self, user_input: str, **kwargs): + return TurnResult(final_text="ok", thread_id="th", turn_id="tu") + + monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init) + monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn) + monkeypatch.setattr( + CodexAppServerSession, "ensure_started", lambda self: "th" + ) + + 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