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
|
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 검증
|
## Harness Python 검증
|
||||||
|
|
||||||
이 저장소의 테스트와 최종 acceptance 검증은 pytest를 시스템 Python에 설치하지 않고
|
이 저장소의 테스트와 최종 acceptance 검증은 pytest를 시스템 Python에 설치하지 않고
|
||||||
|
|||||||
@@ -238,12 +238,19 @@ Executor는 각 Step을 다음 형태의 독립 프로세스로 실행한다.
|
|||||||
```text
|
```text
|
||||||
codex exec
|
codex exec
|
||||||
--json
|
--json
|
||||||
--sandbox workspace-write
|
--sandbox <workspace-write|danger-full-access>
|
||||||
--dangerously-bypass-hook-trust
|
--dangerously-bypass-hook-trust
|
||||||
--cd <repository-root>
|
--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에 전달하는 프롬프트는 다음 내용의 조합이다.
|
Codex에 전달하는 프롬프트는 다음 내용의 조합이다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+16
-3
@@ -66,6 +66,8 @@ class StepExecutor:
|
|||||||
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
|
"""Phase 디렉토리 안의 step들을 순차 실행하는 하네스."""
|
||||||
|
|
||||||
MAX_RETRIES = 3
|
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}"
|
FEAT_MSG = "feat({phase}): step {num} - {name}"
|
||||||
CHORE_MSG = "chore({phase}): step {num} output"
|
CHORE_MSG = "chore({phase}): step {num} output"
|
||||||
TZ = timezone(timedelta(hours=9))
|
TZ = timezone(timedelta(hours=9))
|
||||||
@@ -242,6 +244,15 @@ class StepExecutor:
|
|||||||
|
|
||||||
# --- Codex 호출 ---
|
# --- 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:
|
def _invoke_codex(self, step: dict, preamble: str) -> dict:
|
||||||
step_num, step_name = step["step"], step["name"]
|
step_num, step_name = step["step"], step["name"]
|
||||||
step_file = self._phase_dir / f"step{step_num}.md"
|
step_file = self._phase_dir / f"step{step_num}.md"
|
||||||
@@ -256,7 +267,7 @@ class StepExecutor:
|
|||||||
"exec",
|
"exec",
|
||||||
"--json",
|
"--json",
|
||||||
"--sandbox",
|
"--sandbox",
|
||||||
"workspace-write",
|
self._codex_sandbox_mode(),
|
||||||
"--dangerously-bypass-hook-trust",
|
"--dangerously-bypass-hook-trust",
|
||||||
"--cd",
|
"--cd",
|
||||||
self._root,
|
self._root,
|
||||||
@@ -294,11 +305,13 @@ class StepExecutor:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _codex_environment_failure(output: dict) -> Optional[str]:
|
def _codex_environment_failure(output: dict) -> Optional[str]:
|
||||||
if output.get("exitCode", 0) == 0:
|
|
||||||
return None
|
|
||||||
diagnostic = (
|
diagnostic = (
|
||||||
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
|
f"{output.get('stderr', '')}\n{output.get('stdout', '')}"
|
||||||
).lower()
|
).lower()
|
||||||
|
if "orchestrator_helper_launch_failed" in diagnostic:
|
||||||
|
return "Codex Windows sandbox helper를 실행할 수 없습니다."
|
||||||
|
if output.get("exitCode", 0) == 0:
|
||||||
|
return None
|
||||||
markers = {
|
markers = {
|
||||||
"not logged in": "Codex 인증이 필요합니다.",
|
"not logged in": "Codex 인증이 필요합니다.",
|
||||||
"authentication": "Codex 인증에 실패했습니다.",
|
"authentication": "Codex 인증에 실패했습니다.",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from scripts import execute
|
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 "Euler–Bernoulli 보" in invocation["kwargs"]["input"]
|
||||||
assert invocation["kwargs"]["encoding"] == "utf-8"
|
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):
|
def test_run_git_decodes_output_as_utf8(tmp_path, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user