| from __future__ import annotations |
|
|
| import contextlib |
| import io |
| import os |
| import shlex |
| import stat |
| import tempfile |
| import unittest |
| from pathlib import Path |
| from unittest import mock |
|
|
| import codex_auto_run as app |
|
|
|
|
| COMMAND_APPROVAL = """ |
| |
| Would you like to run the following command? |
| |
| Reason: tests need to run |
| |
| $ python -m unittest |
| |
| › 1. Yes, proceed (y) |
| 2. Yes, and don't ask again for commands that start with `python` (p) |
| 3. No, and tell Codex what to do differently (esc) |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| EDIT_APPROVAL = """ |
| Would you like to make the following edits? |
| |
| Reason: update the implementation |
| |
| › 1. Yes, proceed (y) |
| 2. Yes, and don't ask again for these files (a) |
| 3. No, and tell Codex what to do differently (esc) |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| PERMISSIONS_APPROVAL = """ |
| Would you like to grant these permissions? |
| |
| Permission rule: network; write `/tmp/output` |
| |
| › 1. Yes, grant these permissions for this turn (y) |
| 2. Yes, grant for this turn with strict auto review (r) |
| 3. Yes, grant these permissions for this session (a) |
| 4. No, continue without permissions (d) |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| NETWORK_APPROVAL = """ |
| Do you want to approve network access to "example.com"? |
| |
| Reason: fetch a dependency |
| |
| › 1. Yes, just this once (y) |
| 2. Yes, and allow this host for this conversation (a) |
| 3. Yes, and allow this host in the future (p) |
| 4. No, and tell Codex what to do differently (esc) |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| MCP_APPROVAL = """ |
| github needs your approval. |
| |
| Server: github |
| |
| Create an issue comment |
| |
| › 1. Yes, provide the requested info (y) |
| 2. No, but continue without it (n) |
| 3. Cancel this request (esc) |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| MCP_TOOL_APPROVAL = """ |
| Field 1/1 |
| Allow Calendar to create an event |
| |
| Calendar: primary |
| Title: Roadmap review |
| |
| › 1. Allow Run the tool and continue. |
| 2. Cancel Cancel this tool call |
| |
| enter to submit | esc to cancel |
| """ |
|
|
| TRUST_DIRECTORY = """ |
| > You are in /workspace/project |
| |
| Do you trust the contents of this directory? Working with untrusted |
| contents comes with higher risk of prompt injection. |
| |
| › 1. Yes, continue |
| 2. No, quit |
| |
| Press enter to continue |
| """ |
|
|
| FULL_ACCESS = """ |
| Enable full access? |
| |
| When Codex runs with full access, it can edit any file. |
| |
| › 1. Yes, continue anyway |
| 2. Yes, and don't ask again |
| 3. Cancel |
| |
| Press enter to confirm or esc to cancel |
| """ |
|
|
| REQUEST_USER_INPUT = """ |
| Questions 1/1 |
| Which deployment target should be used? |
| |
| › 1. Staging |
| 2. Production |
| |
| enter to submit | esc to cancel |
| """ |
|
|
|
|
| def rows(value: str) -> list[str]: |
| return value.strip("\n").splitlines() |
|
|
|
|
| def config( |
| runtime_dir: Path, |
| *, |
| approve_mcp: bool = False, |
| auto_trust_directory: bool = False, |
| stability_polls: int = 2, |
| ) -> app.Config: |
| return app.Config( |
| session_prefix="codex-auto-test", |
| runtime_dir=runtime_dir, |
| poll_interval=0.1, |
| cooldown=0.5, |
| stability_polls=stability_polls, |
| rearm_interval=2.0, |
| idle_exit_seconds=2.0, |
| approve_mcp=approve_mcp, |
| auto_trust_directory=auto_trust_directory, |
| keep_dead_session=False, |
| ) |
|
|
|
|
| class DetectorTests(unittest.TestCase): |
| def setUp(self) -> None: |
| self.temp = tempfile.TemporaryDirectory() |
| self.runtime = Path(self.temp.name) |
|
|
| def tearDown(self) -> None: |
| self.temp.cleanup() |
|
|
| def test_standard_approval_types(self) -> None: |
| cfg = config(self.runtime) |
| expected = { |
| COMMAND_APPROVAL: "command", |
| EDIT_APPROVAL: "edit", |
| PERMISSIONS_APPROVAL: "permissions", |
| NETWORK_APPROVAL: "network", |
| } |
| for fixture, kind in expected.items(): |
| with self.subTest(kind=kind): |
| candidate = app.detect_candidate(rows(fixture), cfg) |
| self.assertIsNotNone(candidate) |
| self.assertEqual(kind, candidate.kind) |
|
|
| def test_mcp_requires_explicit_opt_in(self) -> None: |
| safe = config(self.runtime) |
| enabled = config(self.runtime, approve_mcp=True) |
| for fixture in (MCP_APPROVAL, MCP_TOOL_APPROVAL): |
| with self.subTest(fixture=fixture[:30]): |
| self.assertIsNone(app.detect_candidate(rows(fixture), safe)) |
| candidate = app.detect_candidate(rows(fixture), enabled) |
| self.assertIsNotNone(candidate) |
| self.assertIn(candidate.kind, {"mcp", "mcp_tool"}) |
|
|
| def test_directory_trust_requires_explicit_opt_in(self) -> None: |
| self.assertIsNone(app.detect_candidate(rows(TRUST_DIRECTORY), config(self.runtime))) |
| candidate = app.detect_candidate( |
| rows(TRUST_DIRECTORY), |
| config(self.runtime, auto_trust_directory=True), |
| ) |
| self.assertIsNotNone(candidate) |
| self.assertEqual("trust_directory", candidate.kind) |
|
|
| def test_unrelated_interactive_surfaces_are_not_approved(self) -> None: |
| cfg = config(self.runtime, approve_mcp=True, auto_trust_directory=True) |
| for fixture in (FULL_ACCESS, REQUEST_USER_INPUT): |
| with self.subTest(fixture=fixture[:30]): |
| self.assertIsNone(app.detect_candidate(rows(fixture), cfg)) |
|
|
| def test_historical_approval_text_above_composer_is_not_approved(self) -> None: |
| transcript = COMMAND_APPROVAL + "\n\n• Command completed\n\n› write a follow-up" |
| self.assertIsNone(app.detect_candidate(rows(transcript), config(self.runtime))) |
|
|
| def test_selection_must_be_first_one_shot_option(self) -> None: |
| unsafe_selection = COMMAND_APPROVAL.replace( |
| "› 1. Yes, proceed (y)", |
| " 1. Yes, proceed (y)", |
| ).replace( |
| " 2. Yes, and don't ask again", |
| "› 2. Yes, and don't ask again", |
| ) |
| self.assertIsNone(app.detect_candidate(rows(unsafe_selection), config(self.runtime))) |
|
|
| def test_soft_wrapped_mcp_description_still_matches(self) -> None: |
| wrapped = MCP_TOOL_APPROVAL.replace( |
| "› 1. Allow Run the tool and continue.", |
| "› 1. Allow\n Run the tool and continue.", |
| ) |
| candidate = app.detect_candidate( |
| rows(wrapped), |
| config(self.runtime, approve_mcp=True), |
| ) |
| self.assertIsNotNone(candidate) |
| self.assertEqual("mcp_tool", candidate.kind) |
|
|
| def test_long_mcp_parameter_block_still_matches(self) -> None: |
| params = "\n".join(f" Parameter {idx}: value" for idx in range(30)) |
| expanded = MCP_TOOL_APPROVAL.replace( |
| " Calendar: primary", |
| f" Calendar: primary\n{params}", |
| ) |
| candidate = app.detect_candidate( |
| rows(expanded), |
| config(self.runtime, approve_mcp=True), |
| ) |
| self.assertIsNotNone(candidate) |
| self.assertEqual("mcp_tool", candidate.kind) |
|
|
| def test_wrapped_network_title_and_footer_still_match(self) -> None: |
| wrapped = NETWORK_APPROVAL.replace( |
| 'network access to "example.com"?', |
| 'network access to\n "example.com"?', |
| ).replace( |
| "Press enter to confirm or esc to cancel", |
| "Press enter to confirm or esc to\n cancel", |
| ) |
| candidate = app.detect_candidate(rows(wrapped), config(self.runtime)) |
| self.assertIsNotNone(candidate) |
| self.assertEqual("network", candidate.kind) |
|
|
|
|
| class SessionStateTests(unittest.TestCase): |
| def setUp(self) -> None: |
| self.temp = tempfile.TemporaryDirectory() |
| self.cfg = config(Path(self.temp.name)) |
| self.candidate = app.PromptCandidate("command", "command:abc") |
|
|
| def tearDown(self) -> None: |
| self.temp.cleanup() |
|
|
| def test_stability_and_rearm(self) -> None: |
| state = app.SessionState() |
| self.assertFalse(state.ready(self.candidate, 10.0, self.cfg)) |
| self.assertTrue(state.ready(self.candidate, 10.1, self.cfg)) |
| state.mark_approved(self.candidate, 10.1) |
| self.assertFalse(state.ready(self.candidate, 11.0, self.cfg)) |
| self.assertFalse(state.ready(self.candidate, 12.2, self.cfg)) |
| self.assertTrue(state.ready(self.candidate, 12.3, self.cfg)) |
|
|
| def test_screen_clear_rearms_immediately_after_cooldown(self) -> None: |
| state = app.SessionState() |
| state.mark_approved(self.candidate, 20.0) |
| self.assertTrue(state.clear_candidate()) |
| self.assertFalse(state.ready(self.candidate, 20.6, self.cfg)) |
| self.assertTrue(state.ready(self.candidate, 20.7, self.cfg)) |
|
|
|
|
| class ArgumentTests(unittest.TestCase): |
| def test_prompt_and_passthrough(self) -> None: |
| _, args = app.parse_args(["-p", "hello world", "--", "--model", "gpt-5.4"]) |
| self.assertEqual("hello world", args.prompt) |
| self.assertEqual(["--model", "gpt-5.4"], args.codex_args) |
|
|
| def test_resume_modes_parse_without_changing_passthrough(self) -> None: |
| _, exact = app.parse_args( |
| ["--resume", "019f-test", "-p", "continue", "--", "--search"] |
| ) |
| self.assertEqual("019f-test", exact.resume) |
| self.assertFalse(exact.resume_last) |
| self.assertEqual(["--search"], exact.codex_args) |
|
|
| _, latest = app.parse_args(["--resume-last", "-C", "/tmp/project"]) |
| self.assertIsNone(latest.resume) |
| self.assertTrue(latest.resume_last) |
|
|
| _, picker = app.parse_args(["--resume", "-C", "/tmp/project"]) |
| self.assertEqual("", picker.resume) |
| self.assertFalse(picker.resume_last) |
|
|
| def test_resume_picker_rejects_initial_prompt(self) -> None: |
| with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): |
| app.parse_args(["--resume", "-p", "continue"]) |
|
|
| def test_resume_selection_passthrough_is_mode_checked(self) -> None: |
| for argv in ( |
| ["--resume", "019f-test", "--", "--last"], |
| ["--", "--all"], |
| ["--resume", "019f-test", "--", "--include-non-interactive"], |
| ): |
| with self.subTest(argv=argv): |
| with contextlib.redirect_stderr(io.StringIO()), self.assertRaises( |
| SystemExit |
| ): |
| app.parse_args(argv) |
|
|
| _, args = app.parse_args( |
| ["--resume-last", "--", "--all", "--include-non-interactive"] |
| ) |
| self.assertEqual( |
| ["--all", "--include-non-interactive"], |
| args.codex_args, |
| ) |
|
|
| def test_wrapper_rejects_conflicting_codex_options(self) -> None: |
| with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): |
| app.parse_args(["-p", "hello", "--", "-a", "never"]) |
|
|
| def test_wrapper_rejects_subcommand_after_global_option(self) -> None: |
| with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): |
| app.parse_args( |
| ["-p", "hello", "--", "--model", "gpt-5.4", "exec"] |
| ) |
|
|
| def test_build_codex_argv_preserves_prompt_as_one_argument(self) -> None: |
| prompt = "quoted ' prompt\nwith multiple lines" |
| argv = app.build_codex_argv( |
| "/opt/codex", |
| prompt, |
| Path("/tmp/project with spaces"), |
| ["--model", "gpt-5.4"], |
| "work", |
| "workspace-write", |
| False, |
| ) |
| self.assertEqual(argv, shlex.split(shlex.join(argv))) |
| self.assertEqual(["--", prompt], argv[-2:]) |
| self.assertIn('approvals_reviewer="user"', argv) |
| self.assertNotIn("exec", argv) |
|
|
| def test_bypass_is_explicit_and_omits_approval_settings(self) -> None: |
| argv = app.build_codex_argv( |
| "/opt/codex", |
| "go", |
| Path("/tmp/project"), |
| [], |
| None, |
| "workspace-write", |
| True, |
| ) |
| self.assertIn("--dangerously-bypass-approvals-and-sandbox", argv) |
| self.assertNotIn("on-request", argv) |
|
|
| def test_build_exact_resume_argv(self) -> None: |
| argv = app.build_codex_argv( |
| "/opt/codex", |
| "continue safely", |
| Path("/tmp/project"), |
| ["--search"], |
| None, |
| "workspace-write", |
| False, |
| resume_session="019f-session", |
| ) |
| self.assertEqual("resume", argv[1]) |
| self.assertIn("--search", argv) |
| self.assertEqual(["--", "019f-session", "continue safely"], argv[-3:]) |
|
|
| def test_build_resume_last_and_picker_argv(self) -> None: |
| latest = app.build_codex_argv( |
| "/opt/codex", |
| "continue", |
| Path("/tmp/project"), |
| [], |
| None, |
| "workspace-write", |
| False, |
| resume_last=True, |
| ) |
| self.assertEqual("resume", latest[1]) |
| self.assertIn("--last", latest) |
| self.assertEqual(["--", "continue"], latest[-2:]) |
|
|
| picker = app.build_codex_argv( |
| "/opt/codex", |
| None, |
| Path("/tmp/project"), |
| [], |
| None, |
| "workspace-write", |
| False, |
| resume_session="", |
| ) |
| self.assertEqual("resume", picker[1]) |
| self.assertNotIn("--last", picker) |
| self.assertNotIn("--", picker) |
|
|
| def test_build_resume_rejects_ambiguous_internal_combinations(self) -> None: |
| with self.assertRaises(ValueError): |
| app.build_codex_argv( |
| "/opt/codex", |
| None, |
| Path("/tmp/project"), |
| [], |
| None, |
| "workspace-write", |
| False, |
| resume_session="019f-session", |
| resume_last=True, |
| ) |
| with self.assertRaises(ValueError): |
| app.build_codex_argv( |
| "/opt/codex", |
| "ambiguous prompt", |
| Path("/tmp/project"), |
| [], |
| None, |
| "workspace-write", |
| False, |
| resume_session="", |
| ) |
|
|
|
|
| class BinaryAndTmuxTests(unittest.TestCase): |
| def test_codex_probe(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| binary = Path(directory) / "codex" |
| binary.write_text("#!/bin/sh\necho 'codex-cli 9.9.9'\n", encoding="utf-8") |
| binary.chmod(binary.stat().st_mode | stat.S_IXUSR) |
| self.assertTrue(app.probe_codex_binary(binary)) |
|
|
| def test_explicit_codex_path_does_not_fall_back(self) -> None: |
| self.assertIsNone(app.find_codex("/definitely/missing/codex")) |
|
|
| def test_tmux_prefix_filter_is_delimited(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| cfg = config(Path(directory)) |
| app.register_session(cfg, "codex-auto-test-123") |
|
|
| class FakeTmux(app.TmuxClient): |
| def run(self, *args: str, timeout: float = 5.0): |
| del args, timeout |
| return ( |
| 0, |
| "codex-auto-test-123\t%1\t0\n" |
| "codex-auto-testing\t%2\t0\n" |
| "other\t%3\t0\n", |
| "", |
| ) |
|
|
| panes = FakeTmux(cfg).list_panes() |
| self.assertEqual(["%1"], [pane.pane_id for pane in panes]) |
|
|
| def test_confirm_selected_choice_uses_rendered_enter_binding(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| cfg = config(Path(directory)) |
| calls: list[tuple[str, ...]] = [] |
|
|
| class FakeTmux(app.TmuxClient): |
| def run(self, *args: str, timeout: float = 5.0): |
| del timeout |
| calls.append(args) |
| return 0, "", "" |
|
|
| self.assertTrue(FakeTmux(cfg).confirm_selected_choice("%7")) |
| self.assertEqual(("send-keys", "-t", "%7", "Enter"), calls[0]) |
|
|
| def test_session_exists_uses_target_session(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| cfg = config(Path(directory)) |
| calls: list[tuple[str, ...]] = [] |
|
|
| class FakeTmux(app.TmuxClient): |
| def run(self, *args: str, timeout: float = 5.0): |
| del timeout |
| calls.append(args) |
| return 0, "", "" |
|
|
| self.assertTrue(FakeTmux(cfg).session_exists("test-session")) |
| self.assertEqual( |
| ("has-session", "-t", "=test-session"), |
| calls[0], |
| ) |
|
|
| def test_new_session_sets_working_directory_and_quotes_argv(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| cfg = config(Path(directory)) |
| calls: list[tuple[str, ...]] = [] |
|
|
| class FakeTmux(app.TmuxClient): |
| def run(self, *args: str, timeout: float = 5.0): |
| del timeout |
| calls.append(args) |
| return 0, "", "" |
|
|
| argv = ["/opt/codex", "--", "hello ' world\nsecond prompt line"] |
| ok, _ = FakeTmux(cfg).new_session( |
| "codex-auto-test-1", |
| argv, |
| Path("/tmp/project with spaces"), |
| 120, |
| 40, |
| ) |
| self.assertTrue(ok) |
| new_session = calls[0] |
| self.assertIn("-c", new_session) |
| self.assertIn("/tmp/project with spaces", new_session) |
| self.assertEqual(tuple(argv), new_session[-len(argv) :]) |
| self.assertEqual("set-window-option", calls[1][0]) |
| self.assertEqual("=codex-auto-test-1:", calls[1][2]) |
|
|
|
|
| class DaemonLifecycleTests(unittest.TestCase): |
| def test_stop_timeout_preserves_identity_record(self) -> None: |
| with tempfile.TemporaryDirectory() as directory: |
| cfg = config(Path(directory)) |
| record = app.DaemonRecord( |
| pid=12345, |
| token="token", |
| script=str(Path(app.__file__).resolve()), |
| fingerprint=cfg.daemon_fingerprint(), |
| ) |
| app._write_daemon_record(cfg, record) |
| with ( |
| mock.patch.object( |
| app, |
| "daemon_status", |
| return_value=(True, record, None), |
| ), |
| mock.patch.object(app, "_process_matches", return_value=True), |
| mock.patch.object(app.os, "kill"), |
| mock.patch.object(app.time, "monotonic", side_effect=[0.0, 6.0]), |
| ): |
| self.assertEqual("timeout", app.stop_daemon(cfg)) |
| self.assertTrue(cfg.pid_path.exists()) |
|
|
|
|
| if __name__ == "__main__": |
| unittest.main() |
|
|