diff --git a/docs/HARNESS.md b/docs/HARNESS.md index a7cf917..afc5a28 100644 --- a/docs/HARNESS.md +++ b/docs/HARNESS.md @@ -26,6 +26,21 @@ python scripts/execute.py python scripts/execute.py --push ``` +Executor가 시작하는 Codex 세션은 기본적으로 `workspace-write` sandbox를 사용한다. +Windows native sandbox에서 MSVC compiler-id의 `cl.exe`가 정지하는 것이 재현되고 같은 +명령이 sandbox 밖에서 정상 완료되는 경우에만, 사용자 승인을 받은 격리된 clean +worktree에서 다음처럼 해당 실행에 한정해 fallback을 지정할 수 있다. + +```powershell +$env:FESA_HARNESS_CODEX_SANDBOX = "danger-full-access" +python scripts/execute.py +Remove-Item Env:FESA_HARNESS_CODEX_SANDBOX +``` + +허용값은 `workspace-write`와 `danger-full-access`뿐이다. 후자는 Codex Step에 workspace +밖의 파일 접근 권한도 부여하므로 일반 기본값으로 설정하지 않는다. 어느 모드에서도 +`.codex/hooks.json`의 PreToolUse와 Stop hook은 자동으로 실행된다. + ## Harness Python 검증 이 저장소의 테스트와 최종 acceptance 검증은 pytest를 시스템 Python에 설치하지 않고 diff --git a/docs/HARNESS_WORKFLOW.md b/docs/HARNESS_WORKFLOW.md index 134412a..feb3b2f 100644 --- a/docs/HARNESS_WORKFLOW.md +++ b/docs/HARNESS_WORKFLOW.md @@ -238,12 +238,19 @@ Executor는 각 Step을 다음 형태의 독립 프로세스로 실행한다. ```text codex exec --json - --sandbox workspace-write + --sandbox --dangerously-bypass-hook-trust --cd - ``` +기본값은 `workspace-write`다. `FESA_HARNESS_CODEX_SANDBOX` 환경 변수는 +`workspace-write` 또는 `danger-full-access`만 허용한다. Windows native sandbox에서 +MSVC compiler-id의 `cl.exe` 정지가 재현되고 동일 명령이 sandbox 밖에서 통과하는 +환경에서는, 사용자 승인을 받은 격리된 clean worktree 실행에 한해서 +`danger-full-access` fallback을 사용할 수 있다. 이 override는 hook trust 또는 hook +등록을 끄지 않으며 PreToolUse와 Stop 검증은 동일하게 실행된다. + Codex에 전달하는 프롬프트는 다음 내용의 조합이다. ```text diff --git a/scripts/execute.py b/scripts/execute.py index e5219c2..451e511 100644 --- a/scripts/execute.py +++ b/scripts/execute.py @@ -66,6 +66,8 @@ class StepExecutor: """Phase 디렉토리 안의 step들을 순차 실행하는 하네스.""" MAX_RETRIES = 3 + CODEX_SANDBOX_ENV = "FESA_HARNESS_CODEX_SANDBOX" + CODEX_SANDBOX_MODES = frozenset({"workspace-write", "danger-full-access"}) FEAT_MSG = "feat({phase}): step {num} - {name}" CHORE_MSG = "chore({phase}): step {num} output" TZ = timezone(timedelta(hours=9)) @@ -242,6 +244,15 @@ class StepExecutor: # --- Codex 호출 --- + def _codex_sandbox_mode(self) -> str: + mode = os.environ.get(self.CODEX_SANDBOX_ENV, "workspace-write") + if mode not in self.CODEX_SANDBOX_MODES: + allowed = ", ".join(sorted(self.CODEX_SANDBOX_MODES)) + raise CodexEnvironmentError( + f"{self.CODEX_SANDBOX_ENV} must be one of: {allowed}" + ) + return mode + def _invoke_codex(self, step: dict, preamble: str) -> dict: step_num, step_name = step["step"], step["name"] step_file = self._phase_dir / f"step{step_num}.md" @@ -256,7 +267,7 @@ class StepExecutor: "exec", "--json", "--sandbox", - "workspace-write", + self._codex_sandbox_mode(), "--dangerously-bypass-hook-trust", "--cd", self._root, @@ -294,11 +305,13 @@ class StepExecutor: @staticmethod def _codex_environment_failure(output: dict) -> Optional[str]: - if output.get("exitCode", 0) == 0: - return None diagnostic = ( f"{output.get('stderr', '')}\n{output.get('stdout', '')}" ).lower() + if "orchestrator_helper_launch_failed" in diagnostic: + return "Codex Windows sandbox helper를 실행할 수 없습니다." + if output.get("exitCode", 0) == 0: + return None markers = { "not logged in": "Codex 인증이 필요합니다.", "authentication": "Codex 인증에 실패했습니다.", diff --git a/tests/test_execute.py b/tests/test_execute.py index 108354b..6eaac37 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -1,6 +1,8 @@ import json import subprocess +import pytest + from scripts import execute @@ -34,6 +36,80 @@ def test_invoke_codex_uses_utf8_for_unicode_prompt(tmp_path, monkeypatch): assert "Euler–Bernoulli 보" in invocation["kwargs"]["input"] assert invocation["kwargs"]["encoding"] == "utf-8" + sandbox_index = invocation["command"].index("--sandbox") + assert invocation["command"][sandbox_index + 1] == "workspace-write" + + +def test_invoke_codex_accepts_explicit_danger_full_access_override( + tmp_path, monkeypatch +): + phase_dir = tmp_path / "phases" / "sandbox-phase" + phase_dir.mkdir(parents=True) + (phase_dir / "index.json").write_text( + json.dumps( + { + "project": "FESA Structural Solver", + "phase": "sandbox-phase", + "steps": [{"step": 0, "name": "sandbox", "status": "pending"}], + } + ), + encoding="utf-8", + ) + (phase_dir / "step0.md").write_text("sandbox override", encoding="utf-8") + + invocation = {} + + def capture_run(command, **kwargs): + invocation["command"] = command + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + monkeypatch.setattr(execute, "ROOT", tmp_path) + monkeypatch.setattr(execute.subprocess, "run", capture_run) + monkeypatch.setenv("FESA_HARNESS_CODEX_SANDBOX", "danger-full-access") + + executor = execute.StepExecutor("sandbox-phase") + executor._invoke_codex({"step": 0, "name": "sandbox"}, "") + + sandbox_index = invocation["command"].index("--sandbox") + assert invocation["command"][sandbox_index + 1] == "danger-full-access" + + +def test_invoke_codex_rejects_unknown_sandbox_override(tmp_path, monkeypatch): + phase_dir = tmp_path / "phases" / "sandbox-phase" + phase_dir.mkdir(parents=True) + (phase_dir / "index.json").write_text( + json.dumps( + { + "project": "FESA Structural Solver", + "phase": "sandbox-phase", + "steps": [{"step": 0, "name": "sandbox", "status": "pending"}], + } + ), + encoding="utf-8", + ) + (phase_dir / "step0.md").write_text("sandbox override", encoding="utf-8") + + monkeypatch.setattr(execute, "ROOT", tmp_path) + monkeypatch.setenv("FESA_HARNESS_CODEX_SANDBOX", "read-only") + + executor = execute.StepExecutor("sandbox-phase") + with pytest.raises(execute.CodexEnvironmentError, match="FESA_HARNESS_CODEX_SANDBOX"): + executor._invoke_codex({"step": 0, "name": "sandbox"}, "") + + +def test_codex_environment_failure_detects_missing_sandbox_helper_on_zero_exit(): + output = { + "exitCode": 0, + "stdout": "", + "stderr": ( + "windows sandbox: orchestrator_helper_launch_failed: " + "helper=codex-windows-sandbox-setup.exe, error=program not found" + ), + } + + assert execute.StepExecutor._codex_environment_failure(output) == ( + "Codex Windows sandbox helper를 실행할 수 없습니다." + ) def test_run_git_decodes_output_as_utf8(tmp_path, monkeypatch):