import importlib.util import json import os import subprocess import sys import tempfile import unittest from pathlib import Path from unittest.mock import patch def load_execute(): module_path = Path(__file__).resolve().parent / "execute.py" spec = importlib.util.spec_from_file_location("execute", module_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def write_phase(root: Path, phase_dir: str = "0-mvp", phase_name: str = "0-mvp", steps=None): phase_path = root / "phases" / phase_dir phase_path.mkdir(parents=True) if steps is None: steps = [ { "step": 1, "name": "Docs", "status": "pending", "summary": "", "allowed_paths": ["docs/*.md"], } ] (phase_path / "index.json").write_text( json.dumps({"project": "FESA", "phase": phase_name, "steps": steps}, indent=2), encoding="utf-8", ) (phase_path / "step1.md").write_text("# Step 1\n", encoding="utf-8") return phase_path def make_executor(execute, root: Path, phase_dir: str = "0-mvp"): with patch.object(execute, "ROOT", root): return execute.StepExecutor(phase_dir) class ExecuteRunnerSafetyTests(unittest.TestCase): def test_scaffold_loads_execute_module(self): execute = load_execute() self.assertTrue(hasattr(execute, "StepExecutor")) def test_branch_name_uses_codex_prefix_and_sanitized_phase(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, phase_name="linear truss/1d") executor = make_executor(execute, root) self.assertEqual(executor._branch_name(), "codex/linear-truss-1d") def test_finalize_push_uses_codex_branch_name(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, phase_name="0-mvp") executor = make_executor(execute, root) executor._auto_push = True calls = [] def fake_git(*args): calls.append(args) if args == ("diff", "--cached", "--quiet"): return subprocess.CompletedProcess(args, 0, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit", create=True): with patch("builtins.print"): executor._finalize() self.assertIn(("push", "-u", "origin", "codex/0-mvp"), calls) def test_finalize_stages_only_phase_indexes(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) (root / "phases" / "index.json").write_text('{"phases":[]}', encoding="utf-8") executor = make_executor(execute, root) calls = [] def fake_git(*args): calls.append(args) if args == ("diff", "--cached", "--quiet"): return subprocess.CompletedProcess(args, 1, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit"): with patch("builtins.print"): executor._finalize() self.assertNotIn(("add", "-A"), calls) self.assertIn(("add", "--", "phases/0-mvp/index.json", "phases/index.json"), calls) def test_assert_clean_worktree_exits_when_git_status_has_changes(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) with patch.object( executor, "_run_git", return_value=subprocess.CompletedProcess([], 0, " M AGENTS.md\n?? scratch.txt\n", ""), ): with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._assert_clean_worktree("before checkout") self.assertEqual(cm.exception.code, 1) def test_run_checks_clean_worktree_before_checkout(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) calls = [] def record(name): def inner(*args, **kwargs): calls.append(name) return inner with patch.object(executor, "_assert_clean_worktree", side_effect=record("clean")): with patch.object(executor, "_checkout_branch", side_effect=record("checkout")): with patch.object(executor, "_print_header"): with patch.object(executor, "_check_blockers"): with patch.object(executor, "_load_guardrails", return_value=""): with patch.object(executor, "_ensure_created_at"): with patch.object(executor, "_execute_all_steps"): with patch.object(executor, "_finalize"): executor.run() self.assertLess(calls.index("clean"), calls.index("checkout")) def test_step_allowlist_accepts_exact_prefix_and_glob_paths(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) patterns = ["AGENTS.md", "docs/", "scripts/*.py"] self.assertTrue(executor._path_allowed("AGENTS.md", patterns)) self.assertTrue(executor._path_allowed("docs/PRD.md", patterns)) self.assertTrue(executor._path_allowed("scripts/execute.py", patterns)) self.assertFalse(executor._path_allowed(".codex/hooks.json", patterns)) def test_step_without_allowed_paths_is_rejected_before_codex_invocation(self): execute = load_execute() steps = [{"step": 1, "name": "Unsafe", "status": "pending", "summary": ""}] with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root, steps=steps) executor = make_executor(execute, root) with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._validate_step_allowlist(steps[0]) self.assertEqual(cm.exception.code, 1) def test_classify_step_changes_splits_allowed_housekeeping_and_disallowed_paths(self): execute = load_execute() step = { "step": 1, "name": "Docs", "status": "completed", "summary": "", "allowed_paths": ["docs/*.md"], } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) changed = [ "docs/PRD.md", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json", "scripts/execute.py", ] allowed, housekeeping, disallowed = executor._classify_step_changes(1, step, changed) self.assertEqual(allowed, ["docs/PRD.md"]) self.assertEqual(housekeeping, ["phases/0-mvp/index.json", "phases/0-mvp/step1-output.json"]) self.assertEqual(disallowed, ["scripts/execute.py"]) def test_commit_step_stages_only_explicit_allowed_and_housekeeping_paths(self): execute = load_execute() step = { "step": 1, "name": "Docs", "status": "completed", "summary": "", "allowed_paths": ["docs/*.md"], } with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) calls = [] def fake_git(*args): calls.append(args) if args in { ("diff", "--quiet", "--cached", "--"), ("diff", "--cached", "--quiet"), }: return subprocess.CompletedProcess(args, 1, "", "") return subprocess.CompletedProcess(args, 0, "", "") with patch.object( executor, "_changed_paths", return_value=[ "docs/PRD.md", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json", ], ): with patch.object(executor, "_run_git", side_effect=fake_git): with patch.object(executor, "_validate_before_commit", create=True): with patch("builtins.print"): executor._commit_step(step, "Docs") self.assertNotIn(("add", "-A"), calls) self.assertIn(("add", "--", "docs/PRD.md"), calls) self.assertIn(("add", "--", "phases/0-mvp/index.json", "phases/0-mvp/step1-output.json"), calls) def test_validate_before_commit_runs_python_selftest_then_workspace_validation(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) commands = [] def fake_run(cmd, **kwargs): commands.append(cmd) return subprocess.CompletedProcess(cmd, 0, "ok", "") with patch.object(execute.subprocess, "run", side_effect=fake_run): with patch("builtins.print"): executor._validate_before_commit("feat(0-mvp): step 1") self.assertEqual( commands, [ [sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py"], [sys.executable, "scripts/validate_workspace.py"], ], ) def test_validate_before_commit_exits_before_commit_when_validation_fails(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess(cmd, 1, "bad", "failed") with patch.object(execute.subprocess, "run", side_effect=fake_run): with patch("builtins.print"): with self.assertRaises(SystemExit) as cm: executor._validate_before_commit("feat(0-mvp): step 1") self.assertEqual(cm.exception.code, 1) def test_invoke_codex_passes_prompt_through_stdin(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) step = {"step": 1, "name": "Docs"} long_preamble = "x" * 40000 def fake_run(cmd, **kwargs): return subprocess.CompletedProcess(cmd, 0, '{"event":"done"}\n', "") with patch.object(execute.subprocess, "run", side_effect=fake_run) as run_mock: with patch.object(executor, "_codex_command", return_value="codex.cmd"): executor._invoke_codex(step, long_preamble) cmd = run_mock.call_args.args[0] kwargs = run_mock.call_args.kwargs self.assertEqual( cmd, ["codex.cmd", "exec", "--dangerously-bypass-approvals-and-sandbox", "--json", "-"], ) self.assertEqual(kwargs["input"], long_preamble + "# Step 1\n") self.assertEqual(kwargs["cwd"], str(root)) self.assertEqual(kwargs["encoding"], "utf-8") self.assertEqual(kwargs["errors"], "replace") def test_codex_command_prefers_windows_cmd_or_exe_shim(self): execute = load_execute() def fake_which(name): return { "codex.cmd": "C:/tools/codex.cmd", "codex.exe": "C:/tools/codex.exe", "codex": "C:/tools/codex", }.get(name) with patch.object(execute.shutil, "which", side_effect=fake_which): self.assertEqual(execute.StepExecutor._codex_command(), "C:/tools/codex.cmd") def test_codex_exec_command_uses_model_env_override(self): execute = load_execute() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) write_phase(root) executor = make_executor(execute, root) with patch.object(executor, "_codex_command", return_value="codex.cmd"): with patch.dict(os.environ, {"HARNESS_CODEX_MODEL": "gpt-5"}): self.assertEqual( executor._codex_exec_command(), [ "codex.cmd", "exec", "-m", "gpt-5", "--dangerously-bypass-approvals-and-sandbox", "--json", "-", ], ) def test_configure_output_encoding_sets_utf8_replace(self): execute = load_execute() class Stream: def __init__(self): self.calls = [] def reconfigure(self, **kwargs): self.calls.append(kwargs) stdout = Stream() stderr = Stream() with patch.object(execute.sys, "stdout", stdout): with patch.object(execute.sys, "stderr", stderr): execute.configure_output_encoding() self.assertEqual(stdout.calls, [{"encoding": "utf-8", "errors": "replace"}]) self.assertEqual(stderr.calls, [{"encoding": "utf-8", "errors": "replace"}]) if __name__ == "__main__": unittest.main()