# task-2774+10 보고서 — PR #253 same-PR micro-fix: subprocess `text=True` 인코딩 fail-closed

- 작업 ID: task-2774+10
- 팀: dev2-team (오딘/토르/헤임달)
- 대상 PR: #253 (branch `task/task-2774+8-dev2`)
- 이전 head: `6dba16a656b0983eb24e1c892e05212652cf949f`
- **새 head: `615ed83936e1503c0cb6efcd23af6b736938d8cf`**
- PR 상태: **OPEN 유지 (merge 0)** · mergeable=MERGEABLE
- 회장 승인: A안 same-PR micro-fix 1회 (2026-06-25)

---

## S (Situation)

PR #253 (D-2 fire wiring) fresh head `6dba16a6` 에 Gemini 재리뷰 결과 신규 MEDIUM 1건이 발생했다.
`build_oneshot_cokacdir_fire_fn` 의 `subprocess.run(..., text=True)` 가 **시스템 기본 인코딩**으로
stdout 을 디코딩하기 때문에, UTF-8 이 아닌 로케일이나 디코딩 불가 바이트가 들어오면
`UnicodeDecodeError` 로 fire_fn 실행 자체가 깨질 수 있는 플랫폼 의존 취약점이다.

## C (Complication)

`_parse_cokacdir_cron_stdout` 는 이미 bytes + `errors="replace"` fail-closed 디코딩을 갖추고 있다
(라인 1847-1848). 그러나 호출부가 `text=True` 로 stdout 을 **str 로 미리 디코딩**해 버리므로,
parser 의 안전 디코딩 경로가 우회되어 시스템 인코딩 의존이 남아 있었다. 승인 범위는 PR #253
same-PR micro-fix 1회 + 수정 허용 파일 2개로 엄격히 제한된다.

## Q (Question)

`text=True` 의존 인코딩 깨짐을, expected_files(2개) 밖을 건드리지 않고, 기존 fail-closed 회귀를
무손상으로 유지한 채 어떻게 플랫폼 독립적으로 닫는가?

## A (Answer)

`subprocess.run` 에서 **`text=True` 만 제거**하여 stdout/stderr 를 **raw bytes 로 캡처**하고
(`capture_output=True` 유지), 디코딩을 이미 bytes 를 안전 처리하는
`_parse_cokacdir_cron_stdout`(bytes + `errors="replace"`)에 **위임**한다. 이로써 디코딩 불가
바이트가 와도 `UnicodeDecodeError` 가 fire_fn 을 깨뜨리지 못하고, 실패 시 기존 fail-closed
(`FIRE_REGISTRATION_FAILED`) 경로로 수렴한다.

### 변경 내용 (diff 5줄, 정확히 1지점)

`dispatch/anu_result_pickup_runner.py` (build_oneshot_cokacdir_fire_fn 내부 `_fire`, 라인 1924 부근):

```diff
                 import subprocess  # noqa: PLC0415 — approved 1-shot real-fire subprocess
+                # text=True 제거(task-2774+10): stdout 을 raw bytes 로 캡처해 디코딩을
+                # _parse_cokacdir_cron_stdout(bytes + errors="replace" fail-closed)에 위임한다.
+                # 시스템 기본 인코딩 의존 UnicodeDecodeError 로 fire_fn 이 깨지지 않게(플랫폼 독립).
                 proc = subprocess.run(
-                    argv_list, capture_output=True, text=True,
+                    argv_list, capture_output=True,
                     timeout=timeout_sec, check=False,
                 )
                 raw = _parse_cokacdir_cron_stdout(proc.stdout)
```

- `capture_output=True` / `timeout=timeout_sec` / `check=False` 유지.
- try/except fail-closed 구조 유지. 다른 함수/로직 무변경.

### 테스트 보강 (`tests/regression/test_d2_fire_wiring_2774p8.py`)

신규 클래스 `Test14_StdoutBytesEncodingFailClosed` (순수 추가, 기존 31 tests 무수정) — 7종:

1. `test_bytes_stdout_valid_id_parses` — bytes stdout 정상 id 파싱 성공.
2. `test_bytes_stdout_invalid_utf8_no_decode_error` — `b'\xff\xfe\n' + 유효 JSON` → `UnicodeDecodeError` 없이 `registered=True`.
3. `test_non_json_bytes_fail_closed` — 비JSON bytes → `FIRE_REGISTRATION_FAILED`.
4. `test_empty_bytes_fail_closed` — 빈 bytes → `FIRE_REGISTRATION_FAILED`.
5. `test_bytes_id_absent_registered_false` — bytes + id 부재 → `FIRE_BLOCKED_NO_CRON_ID`, `registered=False`.
6. `test_fire_fn_with_bytes_runner_no_real_subprocess` — fire_fn bytes 위임 경로, `subprocess.run/Popen` `assert_not_called()`, runner 1회, `registered=True`.
7. `test_stderr_bytes_no_raw_key_leak` — bytes 내 키 유사 토큰 미노출(raw key 0) + fail-closed.

---

## 구현 요구 대조 (10항 전수)

1. `text=True` 제거 — ✅
2. raw bytes 캡처(`capture_output=True`) — ✅
3. stdout 파싱 `_parse_cokacdir_cron_stdout` 위임 — ✅
4. `UnicodeDecodeError` 가 fire_fn 전체를 깨지 않음 — ✅ (L1-2 입증)
5. 디코딩 불가 바이트 `errors="replace"` 안전 처리 — ✅ (parser 기존 경로)
6. parsing 실패 → `FIRE_REGISTRATION_FAILED` — ✅ (Test14-3/4)
7. id 없음/빈값/공백/None → `FIRE_BLOCKED_NO_CRON_ID` 유지 — ✅ (기존 Test13 + Test14-5)
8. 정상 id 일 때만 `cron_registered=True` — ✅ (Test14-1/6)
9. `--cron --once` 강제 유지(recurring 금지) — ✅ (무변경, 기존 Test12)
10. raw key argv/log/audit 노출 0 / 실 cokacdir 호출 0 — ✅ (Test14-7, mock only)

## 수정 금지(회귀 무손상) 대조

- owner-proof / governor / G2 / G3 / D-1 / `build_callback_launch_fn` / `attempt_owner_fire` — **무변경**.
- target 조건 / registered fail-closed 로직 완화 — **없음**.

---

## L1 스모크테스트 결과 (필수 기록)

실제 subprocess 가 비-UTF8 바이트를 emit 하는 end-to-end 스모크(실 cokacdir 호출 0):

- **서버 재시작**: 해당없음 (server 미기동 모듈 단위 fix — subprocess/정제 작업 유형)
- **API 응답 확인**: 해당없음 (HTTP 엔드포인트 아님)
- **subprocess 실동작 확인** (workflow 4.8 subprocess 유형):
  - L1-1: 실 `subprocess.run([...], capture_output=True)` 가 `b'\xff\xfe garbage\n{"status":"ok","id":"sched-smoke-1"}'` emit → `proc.stdout` 타입 **bytes** 확인 → parser 위임 → `cron_registered=True`, `schedule_id="sched-smoke-1"` (**UnicodeDecodeError 0**). PASS
  - L1-2: `_parse_cokacdir_cron_stdout(b"\xff\xfe\x80\x81 not json")` → 예외 0 + `cron_registered=False` + `FIRE_REGISTRATION_FAILED`. PASS
  - L1-3: `build_oneshot_cokacdir_fire_fn(runner=...)` bytes 위임 경로 → `cron_registered=True`, `schedule_id="sched-smoke-3"`, 실 cokacdir 호출 0. PASS
- **스크린샷**: 해당없음 (프론트 작업 아님)
- 결과: **L1 SMOKE ALL PASS** (3/3 실제 실행·통과)

## 테스트 결과

- `tests/regression/test_d2_fire_wiring_2774p8.py`: **38 passed** (기존 31 + 신규 7), 실패 0, 0.18s.
- `py_compile`: 소스/테스트 양쪽 PASS.

## 검증 (봇 보고 필수)

- PR head 새 sha: `615ed83936e1503c0cb6efcd23af6b736938d8cf` (보고).
- diff 가 expected_files 안인지: **YES** — `dispatch/anu_result_pickup_runner.py`(+4/-1),
  `tests/regression/test_d2_fire_wiring_2774p8.py`(+87) 2개만. expected_files 밖 0.
- raw key scan: **0** (diff 의 `SECRET_BYTES_TOKEN_ZZZ` 는 비노출 입증용 테스트 가짜 토큰;
  실 ANU key `c119085…` diff 미포함).
- real fire: **0** / callback 자가발사: **0** / ACTIVE=false: **유지** (gate 무변경).
- 신규+기존 D-2 테스트: 38 PASS.

## 발견 이슈 및 해결

- 이슈 없음. micro-fix 범위 내에서 모든 구현 요구·금지 항목 충족. 잔여 이슈 0.

## 머지 판단

- **머지 필요**: No (본 task 는 merge 금지 — 회장 명시 승인 전까지 PR #253 OPEN 유지)
- **브랜치**: `task/task-2774+8-dev2`
- **워크트리 경로**: `/home/jay/workspace/.worktrees/task-2774+8-dev2`
- **머지 의견**: 변경은 surgical(diff 5줄), 38 회귀 PASS, L1 실동작 PASS, raw key 0, real fire 0.
  품질상 MERGE_CANDIDATE 이나, **ANU 가 독립검증 → fresh-head Gemini 재리뷰 → CI 확인 후**
  회장 승인하에만 머지. dev2 는 머지하지 않음.

## 모델 사용 기록

- 토르(백엔드, **sonnet**): 소스 micro-fix (`text=True` 제거 1지점). 일반 코딩 → sonnet.
- 헤임달(테스터, **sonnet**): Test14 회귀 7종 추가. 테스트 코딩 → sonnet.
- 오딘(팀장, opus): 설계/분배/직접 검증(diff·grep·pytest·L1 스모크)/통합. 직접 코딩 0.
- haiku 미사용.

## 완료 처리

- 보고서: 본 파일 (`memory/reports/task-2774+10.md`).
- result.json: `memory/events/task-2774+10.result.json` (callback_schedule_created=false,
  relay_hints={} → green deterministic closeout).
- ANU callback: finish-task.sh 의 ANU-owned launcher 게이트 경유 (owner_key=ANU,
  self-key 차단). executor 자가발사 0.
- `.done`: finish-task.sh 가 생성 (수동 생성 금지).
