fix: allow authorized Harness sandbox fallback

This commit is contained in:
KOKO\Mimi
2026-08-12 18:36:37 +09:00
parent b7b72a6bcc
commit 0d50625a81
4 changed files with 115 additions and 4 deletions
+15
View File
@@ -26,6 +26,21 @@ python scripts/execute.py <phase-name>
python scripts/execute.py <phase-name> --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 <phase-name>
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에 설치하지 않고
+8 -1
View File
@@ -238,12 +238,19 @@ Executor는 각 Step을 다음 형태의 독립 프로세스로 실행한다.
```text
codex exec
--json
--sandbox workspace-write
--sandbox <workspace-write|danger-full-access>
--dangerously-bypass-hook-trust
--cd <repository-root>
-
```
기본값은 `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
+16 -3
View File
@@ -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 인증에 실패했습니다.",
+76
View File
@@ -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 "EulerBernoulli 보" 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):