# task-3006 — 확장 파일럿 빌드 결함 + 거짓 성공 문구 제거

- **레벨**: Lv.2 · **팀**: dev4-team (비슈누)
- **PR**: https://github.com/Jeon-Jonghyuk/InsuRo/pull/251 (#251, state=open, merged=false)
- **브랜치**: task/task-3006-dev4 · **base**: a0bf0c9 · **head**: 5b2f276
- **PR 작성자**: jeon-jonghyuk-taskctl-bot[bot] (봇 토큰 주입 확인 — 회장 PAT 미사용)
- **워크트리**: /home/jay/projects/InsuRo/.worktrees/task-3006-dev4
- ★ **머지 금지 — PR 생성까지가 범위. 머지는 ANU 가 한다.**

---

## S — 상황 (Situation)

회장이 오마이매니저에서 복합설계를 캡처하고 저장 버튼을 눌렀을 때 "저장됐어요" 메시지는 떴으나
인슈로에서 조회되지 않는 상태가 약 4주간 지속됐다. `consultation_history_v1` 마지막 저장 이후
25일간 유입 0, 백엔드 로그에 POST 0건이었다. ANU 가 `__tests__` 를 제외한 zip 을 수동 생성해
전달한 뒤 정상 복구됐다. 이 태스크는 **그 수동 조치를 코드로 고정하는 것**이다.

## C — 문제 (Complication)

근인 3개가 겹쳤다.

1. **파일럿 빌드가 애초에 설치 불가능했다** — `scripts/build-pilot-extension.mjs` 가 `extension/`
   전체를 복사하면서 `__tests__/` 가 산출물에 포함됐다. Chrome/Edge 는 `_` 로 시작하는
   파일·디렉토리를 예약어로 거부한다. 게다가 설치용 zip 자체를 만들지 않았다.
2. **파일럿 빌드 재생성이 자동화돼 있지 않았다** — 확장을 고칠 때마다 수동으로 다시 만들어야
   했고 아무도 챙기지 않았다. 릴리스 워크플로우에도 파일럿 산출 단계가 없었다.
3. **거짓 성공 문구가 사고를 숨겼다** — 전송이 차단된 상태인데 사용자에게 "저장됐어요" 라고
   알렸다. 회장은 저장된 줄 알고 4주를 보냈다.

## Q — 핵심 질문 (Question)

설치 가능한 산출물을 **기계가 스스로 보증**하게 만들 수 있는가, 그리고 실패를 성공으로 알리는
경로를 **회귀 테스트로 봉인**할 수 있는가.

## A — 해결 (Answer)

### 1) 빌드 스크립트 — 설치 가능한 산출물 + fail-closed 자체검증

- `fs.cpSync` 의 `filter` 로 basename 이 `_` 로 시작하는 항목을 제외한다. `cpSync` 는 filter 가
  false 인 디렉토리에 재귀 진입하지 않으므로 하위 전체가 함께 빠진다(실측 확인).
- **순수 Node ZIP writer** 를 직접 구현했다(`node:zlib` deflateRawSync + 자체 CRC-32 테이블,
  local header/central directory/EOCD, UTF-8 flag bit 11, DOS 시각 고정으로 재현성 확보).
  ★ 이 환경에 `zip` 바이너리가 **없다**(`command not found` 실측). 외부 바이너리에 의존했다면
  로컬에서 돌지 않는 스크립트가 됐을 것이다.
- `manifest.json` 이 **zip 최상위**에 오도록 staged extension 디렉토리 기준 상대경로로 엔트리를 쓴다.
- **fail-closed 자체검증 4종** — 하나라도 실패하면 `process.exit(1)`:
  (a) staged 트리 재귀 순회 시 `_` 접두 항목 0건
  (b) staged `manifest.json` 존재
  (c) **작성한 zip 을 디스크에서 다시 읽어 central directory 를 파싱**해 엔트리명 검증
      (자기가 넣은 배열이 아니라 실제 바이트를 재확인한다)
  (d) 패치 후 flag=true 리터럴 존재
- `--out <dir>` 옵션 추가 — 테스트가 레포(`dist-pilot/`, gitignore 안 됨)를 오염시키지 않게 했다.
- flag 치환은 현행 유지. **소스 원본은 `false` 불변**(실측 재확인).

### 2) 거짓 성공 문구 제거

`"저장됐어요."` → `"인슈로에 저장되지 않았어요. 지금은 저장 기능을 사용할 수 없어요."`

flag OFF 인 한 재시도해도 전송되지 않는다는 확인된 사실 때문에, 성공을 보장·암시하는 표현
("다시 시도하면 됩니다" 등)은 쓰지 않았다. 기술용어·에러코드는 노출하지 않았다.

### 3) 자동화 (근인 2 봉인)

릴리스 워크플로우에 파일럿 빌드 스텝을 추가하되 **기존 릴리스 skip 조건과 무관하게 매번 실행**한다.
manifest 버전을 올리지 않은 확장 수정(= 이번 사고와 동일한 시나리오)에서도 최신 파일럿 산출물이
나와야 하기 때문이다. `if-no-files-found: error` 로 "빌드는 초록인데 아티팩트가 없다"는 조용한
실패를 차단했다. ★ ANU 가 따로 드롭할 수 있도록 **별도 커밋**(18a5c40)으로 분리했다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-3006-dev4/scripts/build-pilot-extension.mjs | _ 접두 제외 + 루트 manifest zip + fail-closed 자체검증 4종 + --out 옵션 | grep "base.startsWith(\"_\")" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3006-dev4/extension/content.js | FLAG_DISABLED_STORED 거짓 성공 문구 제거 | grep "인슈로에 저장되지 않았어요" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3006-dev4/.github/workflows/extension-release.yml | 파일럿 빌드 산출 자동화 (릴리스 skip 무관 매번 실행) | grep "build-pilot-extension.mjs" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3006-dev4/extension/__tests__/task-3006-pilot-build-honest-copy.test.ts | 회귀 봉인 테스트 25건 (빌드 산출물 7 + 문구 18) | grep "underscore" OK | verified |

---

## 검증 실측

### 1. 재현 (수정 전, 명세 요구 1번)

현행 스크립트로 빌드한 산출물을 실측했다.

```
dist-pilot/extension/__tests__        ← 존재
_ 로 시작하는 항목: 1건
__tests__ 내부 파일: 25개
총 파일 수: 40
zip 산출물: 0건 (애초에 만들지 않음)
```

### 2. 수정 후 (명세 요구 2번)

```
copied files    : 15
excluded items  : 1 (underscore-prefixed)
zip entries     : 15
zip path        : <out>/insuro-helper-pilot-v0.4.1.zip
staged _ 시작 항목 : 0건
manifest.json 루트  : 존재
```

`unzip` 으로 **독립 교차검증**(스크립트 자체 파서를 쓰지 않음):

```
unzip -l → 15 files, Name 컬럼 최상위에 manifest.json (경로 접두 없음)
_ 시작 엔트리: 0건
압축 해제 후 background/ingest.js 의 flag=true : 1건
소스 원본 extension/background/ingest.js:38 : = false (불변)
```

### 3. 실제 브라우저 로드 확인 (명세 요구 3번) — ★ 정직 고지

**시도했고, 이 환경에서는 브라우저 측 거부를 재현하지 못했다.**

Playwright Chromium 141.0.7390.37 로 `--load-extension` 을 써서 (A) 신규 zip 압축 해제본과
(B) 구 산출물 재현본(`__tests__` 포함)을 각각 로드했다. **둘 다 로드됐다**(양쪽 모두
service worker `chrome-extension://.../background.js` 기동). Chrome stderr 에 경고도 없었다.

즉 `--load-extension` CLI 경로는 `_` 예약어 규칙을 강제하지 않는다. 따라서
**"브라우저에 로드됐다"를 이번 수정의 근거로 삼지 않는다.**

거부에 대한 권위 있는 근거는 ANU 가 실브라우저에서 재현한 오류
(`Cannot load extension with file or directory name __tests__`) 와,
`__tests__` 제외 zip 설치 후 정상 복구 확인(POST 5건 · `consultation_history_v1` 6→7행)이다.
이번 PR 의 산출물 보증은 **정적 검증 + fail-closed 자체검증 + 회귀 테스트**로 대체한다.

### 4. 응답코드 ↔ 문구 ↔ ok 대조표 (명세 요구 4번)

`userMessageForConsentSaveCode` (content.js:2463)

| code | ok | 문구 | 성공처럼 읽히나 | 근거 |
|---|---|---|---|---|
| INGEST_ACK | true | 인슈로에 저장됐어요. 감사합니다. | 아니오 (실제 성공) | ingest.js:369 |
| FLAG_DISABLED_STORED | 혼재 (SAVE=true / RETRY=false) | 인슈로에 저장되지 않았어요. 지금은 저장 기능을 사용할 수 없어요. | **아니오 (이번에 수정)** | background.js:253 / ingest.js:340 |
| CONSENT_ABSENT | false | 동의가 필요해요. 다시 시도해주세요. | 아니오 | background.js:236 |
| NO_PENDING | false | 재시도할 내용이 없어요. 다시 미리보기 후 저장해주세요. | 아니오 | background.js:283 |
| JWT_ABSENT | false | 인증이 만료됐어요. 다시 로그인해주세요. | 아니오 | ingest.js:346 |
| HTTP_401_UNAUTHORIZED | false | 인증이 만료됐어요. 다시 로그인해주세요. | 아니오 | ingest.js:371 |
| HTTP_403_FORBIDDEN | false | 인증이 만료됐어요. 다시 로그인해주세요. | 아니오 | ingest.js:372 |
| ASSEMBLE_FAILED | false | 지금은 저장할 수 없어요. 잠시 후 다시 시도해주세요. | 아니오 | background.js:245 |
| STORED_BLOB_CORRUPT | false | 지금은 저장할 수 없어요. 잠시 후 다시 시도해주세요. | 아니오 | ingest.js:353 |
| SENDER_NOT_ALLOWED | false | 지금은 저장할 수 없어요. 잠시 후 다시 시도해주세요. | 아니오 | background.js:229 외 |
| TRANSPORT_ERROR | false | 지금은 저장할 수 없어요. 잠시 후 다시 시도해주세요. | 아니오 | ingest.js:373 |
| URL_NOT_ALLOWED / 계약 외 code | false | (default) 지금은 저장할 수 없어요. 잠시 후 다시 시도해주세요. | 아니오 | ingest.js — default 폴백 |

`userMessageForOhmyPpdCode` (content.js:1186) — 전 항목 실패·안내 문구. 이 함수는 구조적으로
`response.ok !== true` 일 때만 호출된다(content.js:1552). 거짓 성공 문구 위험 없음.

**결론: 수정 후 `ok:false` 인데 성공처럼 읽히는 항목은 0건.**

### 5. 변이로 봉인 실증 (명세 요구 5번) — 팀장이 독립 재현

| 변이 | 결과 | 원복 |
|---|---|---|
| 빌드 스크립트의 `_` 제외 필터 무력화 | 스크립트가 fail-closed 로 **exit 1** — `staged tree still contains 28 underscore-prefixed entr(y/ies): __tests__, ...` / 테스트 4건 FAIL | git checkout 후 **byte-identical** 확인 |
| FLAG_DISABLED_STORED 문구를 "저장됐어요." 로 원복 | 테스트 **2건 FAIL** — `expected '저장됐어요.' not to contain '저장됐어요'` · `not to match /저장(됐|되었|完)|저장 완료|완료됐어요|성공/` | git checkout 후 **byte-identical** 확인 |

두 변이 모두 하누만이 1차 실증했고, **팀장이 독립적으로 재현**했다(자기증명 신뢰 금지 원칙).
원복 후 워킹트리 clean, 회귀 530/530 PASS 복귀 확인.

### 6. 회귀 (명세 요구 6번)

**base `a0bf0c9` 에서 재측정한 기준선**(명세 수치 아님):

```
수정 전 : 24 files / 505 tests PASS
수정 후 : 25 files / 530 tests PASS   (신규 25건 추가, 기존 505건 전량 유지)
회귀 실패: 0건
```

★ 한계: `vitest.config.ts` 가 `tests/e2e/**` 를 제외한다. vitest 전량 PASS 가 E2E 무결성을
보증하지는 않는다 — CI 결과를 별도로 확인해야 한다.

---

## L1 스모크테스트

- **서버 재시작**: 해당없음 (서버 코드 무변경. `server/**` 는 forbidden path)
- **API 응답 확인**: 해당없음 (API 무변경)
- **실행 검증(대체)**: 빌드 스크립트를 **실제로 실행**해 산출물을 디스크에 만들고,
  `unzip` 으로 독립 압축 해제하여 루트 `manifest.json` 존재와 `flag=true` 를 확인했다.
  (pytest/vitest PASS 가 아니라 실제 산출물 검사임)
- **브라우저 로드**: 실행함. 단 **양성 판정 실패** — 위 "검증 실측 3번" 참조.
  구 산출물도 로드되어 이 환경에서는 거부를 재현하지 못했다. 추정으로 "로드 성공했다"고 쓰지 않는다.
- **스크린샷**: 없음 (헤드리스 로드 검증이 양성 신호를 주지 못해 스크린샷은 증거 가치가 없다고 판단)

---

## trip-wire 5종 (실측)

| 항목 | 실측 | 측정 방법 |
|---|---|---|
| Critical7 | 0 | red-team-auto-review scan → risk_level=low, vulnerabilities=0 |
| PII net-new | 0 | diff 추가줄(+) 대상 주민번호/전화/이메일/토큰 패턴 스캔 → 0건 |
| 회귀 실패 | 0 | base 재측정 505 → 530 전량 PASS |
| forbidden_paths 침범 | 0 | git diff --name-only a0bf0c9..HEAD 를 forbidden 패턴과 대조 → 0건 |
| nonce | task-3006 | 발사 task_id 와 일치 |

변경 파일 전수(4건) — 모두 allowed_resources 내:
`scripts/build-pilot-extension.mjs` · `extension/content.js` ·
`extension/__tests__/task-3006-pilot-build-honest-copy.test.ts` · `.github/workflows/extension-release.yml`

---

## ★ ANU 판단 필요 (명세와 실측 차이 · 임의 판단하지 않음)

### 1. 명세의 `ok:false` 기술과 실제 사용자 경로가 다르다

명세는 `ingest.js:339` 가 `{ok:false, code:"FLAG_DISABLED_STORED"}` 를 준다고 기술했다.
실측 결과 **SAVE 최초 클릭 경로는 `background.js:253` 에서 `{ok:true, code:"FLAG_DISABLED_STORED"}`
로 먼저 short-circuit** 되어 `sendIngest` 에 도달하지 않는다. 명세의 `ok:false` 는 RETRY 경로에서만
성립한다(`ingest.js:340` → `background.js:293` relay).

즉 **거짓 성공은 문구 한 겹이 아니라 `ok:true` 라는 한 겹이 더 있다.** `background.js` 는
forbidden path 이므로 손대지 않았다. `content.js` 의 매핑이 `code` 만으로 분기하므로 이번 수정으로
두 경로 모두 정직한 문구가 나가지만, **`ok:true` 자체를 고칠지는 ANU 판단**이 필요하다.

### 2. 재시도 버튼이 계속 노출된다

`isConsentSaveTerminal` 이 `ok===true && code==="INGEST_ACK"` 만 terminal 로 보므로
flag OFF 에서도 재시도 버튼이 뜬다. 그런데 flag OFF 인 한 재시도해도 전송되지 않는다.
"저장할 수 없다" 문구 + 눌러도 안 되는 재시도 버튼의 조합은 어색하다. 로직 변경은 명세 범위 밖이라
하지 않았다. **판단 필요.**

### 3. 플래그를 정식으로 켤지

파일럿 빌드로 우회하는 현 구조 자체가 불안정하다는 지적이 사고 조사 때 나왔다. 이번 태스크는
flag 를 소스에서 켜는 것이 명시적으로 금지돼 있어 하지 않았다. **회장/ANU 결정 사항.**

### 4. 도구 오탐 2건 (조치 불필요, 기록용)

- `code-validator.py` 의 documentation/code_structure FAIL — 검사 정규식이 Python 전용
  (`"""` docstring, `def`/`class`)이다. 대상은 JavaScript(`//` 주석, `function`)라 **언어 불일치
  오탐**이다. security/dependencies/execution 은 PASS, red-team risk=low.
- LSP 진단 "Unreachable code at build-pilot-extension.mjs:86" — 실행 출력이
  `excluded items : 1` 로 해당 줄이 실제 실행됨을 보여준다. **오탐.**
- content.js 의 미사용 변수 진단 2건(`_lastMmlfcpTrace`, `snapshot`)은 base `a0bf0c9` 에도
  존재하는 **선재 항목**이다(우리 diff 는 5줄).

---

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 카르티케야 | 백엔드 | sonnet | 빌드 스크립트 재작성(MT-1) · 릴리스 워크플로우 자동화(MT-2) |
| 사라스바티 | 프론트엔드 | sonnet | content.js 문구 수정 + 매핑 테이블 전수 조사(MT-3) |
| 하누만 | 테스트/QA | sonnet | 회귀 봉인 테스트 + 변이 실증(MT-4) |
| 비슈누 | 팀장 | opus | 재현·설계·분배·독립 검증·변이 재현·PR |

haiku 미사용. 락슈미(UX/UI)는 이번 작업에 UI 설계 요소가 없어 미소집(문구 1건은 프론트 담당).

## 3 Step Why

- **1st Why**: 왜 저장이 안 됐나 → 파일럿 빌드가 설치되지 않아 사용자가 flag OFF 빌드를 쓰고 있었다.
- **2nd Why**: 왜 4주간 몰랐나 → 실패(`FLAG_DISABLED_STORED`)를 "저장됐어요" 로 알려서 사용자와
  개발자 모두 정상으로 착각했다.
- **3rd Why**: 왜 그런 산출물이 나왔나 → 빌드 스크립트가 자기 산출물이 설치 가능한지 **스스로
  검증하지 않았고**, 확장이 바뀌어도 파일럿 빌드를 다시 만드는 절차가 사람 손에 맡겨져 있었다.
  → 이번 수정은 (a) 스크립트가 스스로 fail-closed 검증하게 하고 (b) 재생성을 CI 로 옮기고
  (c) 거짓 성공 문구를 회귀 테스트로 봉인해 세 지점을 모두 막았다.

## 완료 조건 대조

| 완료 조건 | 결과 |
|---|---|
| 빌드 스크립트가 설치 가능한 산출물 생성(_ 제외 + 루트 manifest + 자체 검증 fail-closed) | 충족 — 실측 zip 15엔트리 / _ 0건 / 루트 manifest |
| 거짓 성공 문구 제거 | 충족 |
| 응답코드↔문구 대조표 | 충족 (위 4번) |
| 변이로 봉인 실증 | 충족 — 2종 변이 각각 FAIL 확인 후 byte-identical 원복 |
| 회귀 유지 | 충족 — 505 → 530 PASS, 회귀 0건 |

## 세션 통계
- 총 도구 호출: 0회

