fix: allow authorized Harness sandbox fallback
This commit is contained in:
@@ -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에 설치하지 않고
|
||||
|
||||
@@ -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
@@ -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 인증에 실패했습니다.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user