# task-2788 (A-0) — background 직접 API 조회 실증 스파이크 보고

**팀**: dev5-team (마르둑) · **레벨**: Lv.3 (아키텍처 결정 스파이크)
**브랜치**: `task/task-2788-dev5` (base `origin/main` = **cc7476b**, base_fallback=false)
**워크트리**: `/home/jay/workspace/projects/insuro/.worktrees/task-2788-dev5`
**작성**: 2026-07-18 · **PR/머지**: 금지(merge_policy=none) — 미생성

---

## S — Situation
현재 확장은 MAIN world 에서 fetch/XHR 을 가로채 응답을 관찰하는 구조가 전제되어 있고, 이 때문에 relay 무결성(first-PORT race)이 미해결로 남아 있다. 구 수집기는 `requests.get(url, headers={"Authorization": "Bearer <token>"})` 순수 HTTP GET 으로 성공했으며, 그 JWT 는 페이지 URL `?token=` 에 있다.

## C — Complication
background 가 API 를 직접 조회할 수 있다면 MAIN world·인터셉터·MessageChannel·first-PORT race 를 전부 삭제할 수 있다. 그러나 세 가지가 미확인이었다 — (R1) 확장 background 의 cross-origin 전송 가능 여부, (R2) 서버의 Origin/Referer 등 브라우저 컨텍스트 검사 여부, (R3) URL 토큰의 클릭 시점 유효성. 여기에 더해 **가로채기 없이 조회 URL 을 구성할 수 있는가**가 구조적 전제였다.

## Q — Question
MAIN world 인터셉터 없이 background 직접 조회가 성립하는가? 성립하지 않으면 어디서 막히는가?

## A — Answer
**URL 구성 문제는 실증적으로 해결(SOLVED)되었다.** 가로채기·MAIN world 없이 `performance.getEntriesByType("resource")` 만으로 조회 URL 을 쿼리스트링 포함 전체 복구할 수 있음을 **실제 Chrome + cross-origin 환경에서 확인**했다. 이로써 A-0 의 가장 큰 구조적 미지수가 제거되었다.
**R1·R2·R3 은 코드로 단정하지 않는다.** 판정 로직과 증거 수집 경로를 구현·검증했고, 최종 판정은 인증 세션이 필요하므로 **회장 1회 실행**으로 확정한다(task 지시 준수).

---

## ★ 반드시 판정할 3가지 + URL 구성

### R1. 권한/전송 — **부분 MATCH (구조 확인 완료 / 실대상 전송은 라이브 대기)**
- manifest `host_permissions` 에 대상이 **이미 존재**(수정하지 않음):
  ```
  "*://ohmymanager.com/*",
  "*://*.ohmymanager.com/*",
  ```
- background 의 실제 전송 계층 동작을 **mock 이 아닌 실제 HTTP** 로 실증: 로컬 스텁 서버가 `Authorization` 헤더를 수신했음을 서버측 로그로 확인(`has_auth:true, auth_scheme:"Bearer", length:150` — 원문 미출력).
- **UNKNOWN**: 실제 `mmlfcp.ohmymanager.com` 로의 전송 성공 여부는 회장 실행에서 확정.

### R2. 서버측 검사(Origin/Referer 거부) — **UNKNOWN (라이브 필수)**
- 코드로 단정 불가. 판정 로직만 확정했다: `403 → origin_referer_rejected:true`, `401 → false`(인증 실패 계열로 분리), 그 외 `null`.
- 실제 403/401 응답에 대해 이 분기가 동작함을 **실 HTTP 왕복으로 확인**(아래 L1-a 표).

### R3. 토큰 수명 — **UNKNOWN (라이브 필수)**
- 로컬 `exp` 파싱(`isJwtValid` 재사용) + 서버 401 을 결합해 `token_expired` 를 산출하도록 구현. 만료 JWT + 401 케이스에서 `token_expired:true` 산출을 실증.

### (추가) URL 구성 가능성 — **SOLVED (실증 완료)** ★ 핵심 성과
가로채기 없이 ISOLATED 영역의 표준 API 만으로 조회 URL 복구가 가능하다.
실제 Chrome 에서 **cross-origin**(페이지 :8790 → API :8791) + **`Timing-Allow-Origin` 헤더 없음** 조건으로 검증한 결과:
```json
{
  "recovered_url_present": true,
  "has_query": true,
  "url_construction": "SOLVED",
  "url": "http://127.0.0.1:8791/api/ProductPremiums?age=42&gender=M&insurance_type=%EC%86%90%EB%B3%B4&plan_id=p-123"
}
```
→ `request_fingerprint` 4요소(age·gender·insurance_type·plan_id)가 **전부 쿼리에 노출**된다. Timing-Allow-Origin 이 없으면 세부 타이밍 필드는 마스킹되지만 **`entry.name`(URL)은 마스킹되지 않는다**는 것이 이번 실증의 요지다.

---

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

- **서버 재시작**: 해당없음 (chrome extension 스파이크 — 상시 서버 없음). 대신 **전용 스텁 서버 2종을 기동해 실제 HTTP 왕복 수행**.
- **API 응답 확인**: **실행함**. mock 주입이 아닌 **Node 전역 fetch → 로컬 HTTP 서버** 실왕복 5케이스.
- **스크린샷**: `/home/jay/.cokacdir/workspace/5135257C/l1/l1b_resource_timing.png` (실제 Chrome, Playwright MCP)

### L1-a) background `runDirectFetchSpike` 실 HTTP 실행 (mock 아님)
로드한 코드가 저장소 원본과 동일함을 sha256 으로 교차 확인:
`93a6b4b17750697943f887c7e9147c045df429fff379391aeb9597205f3fba6a` (원본 = 복사본 일치, 팀장 직접 재확인)

| case | transport_ok | http_status | has_coverage_premiums | company_count | cors_error | origin_referer_rejected | token_expired |
|---|---|---|---|---|---|---|---|
| ok | true | 200 | true | 2 | null | null | false |
| 403 | true | 403 | false | 0 | null | **true** | false |
| 401 | true | 401 | false | 0 | null | false | **true** |
| badjson | true | 200 | false | 0 | null | null | false |
| network_error | **false** | null | false | 0 | "fetch failed" | null | false |

- 서버측 수신 로그: `{"event":"request","method":"GET","path":"/api/ProductPremiums","has_auth":true,"auth_scheme":"Bearer"}` — **R1 전송 증거**.
- 팀장이 직접 재실행하여 동일 결과 확인.

### L1-b) 실제 Chrome resource timing (Playwright MCP)
- 콘솔 에러 **0건**.
- 위 "URL 구성 가능성" JSON 이 실제 브라우저 결과.
- `browser_close` 호출 완료, 하네스 프로세스·포트(8788/8790/8791) 잔존 0 확인.

### L1-c) 단위테스트
- `npx vitest run` (goal_assertion) → **Test Files 35 passed / Tests 436 passed / 실패 0** (팀장 직접 재실행 확인)
- `npx vitest run extension/` → **3 files / 53 tests passed** (신규 24 + 기존 29)

---

## 수정 파일별 검증 상태

| 파일 | 상태 | grep 키워드 |
|---|---|---|
| extension/background.js | 완료 | buildSpikeVerdict |
| extension/content.js | 완료 | runDirectFetchSpikeFromPage |
| extension/__tests__/direct_fetch_spike.test.ts | 완료 | findProductPremiumsUrlFromResourceTiming |

※ 경로 기준: `/home/jay/workspace/projects/insuro/.worktrees/task-2788-dev5/`


## 변경 내역 (diff)

`git diff --stat cc7476b..HEAD`:
```
 extension/__tests__/direct_fetch_spike.test.ts | 364 +++++++++++  (신규 1개 — new_file_limit 준수)
 extension/background.js                        | 150 +++++++++
 extension/content.js                           | 157 +++++++++
 3 files changed, 671 insertions(+)
```
- **`extension/manifest.json` 미변경**(host_permissions 이미 충족 — 확인만).
- **`extension/inject.js` 미변경**(수정 금지 준수).
- 커밋 4건: `89e5c88`(이쉬타르) → `4334b10`(엔키) → `d37836f`(닌기르수) → `95cfca6`(마르둑, TS2352 정리)

### 구현 요약
- **D1 클릭 트리거**: `createSpikeButton()` — id `insuro-a0-spike-btn`. 리스너 등록만 하고 호출하지 않음.
- **D2 토큰 1회 취득**: `readTokenFromSearch(location.search)` — 클릭 핸들러 스코프 1회 사용. 콘솔은 `describeToken()` → `{present, length}` 만.
- **D3 background 직접 조회**: `runDirectFetchSpike()` — `GET` + `Authorization: Bearer <token>` + `credentials:"omit"` + `cache:"no-store"`.
- **D4 판정 출력**: `buildSpikeVerdict()` — 지시된 9개 키를 정확히 산출. **응답 본문 값은 복사하지 않고 개수/불리언만** 담는다.

---

## 불변식 준수 검증 (grep 기반, 팀장 직접 확인)
- **서버 전송 0**: 추가 diff 내 `insuro.biz`/`localhost` 출현은 **전부 주석·테스트 단언 문자열**. 실제 요청 대상은 메시지로 전달된 ohmymanager URL 하나뿐. 신규 POST 0건.
- **토큰 저장·로깅 0**: 신규 코드에 `storage.set(token)` / `localStorage` / `console.log(token)` **0건**.
- **자동 실행 0**: 스크립트 로드·DOM ready 경로에 토큰 읽기·fetch·sendMessage 없음. 클릭 핸들러 내부에서만 실행(단위테스트 B 그룹이 회귀 고정).
- **MAIN world 인터셉터 신규 추가 0**: `window.fetch =` / `XMLHttpRequest.prototype` 패턴 **0건**.
- **진단 브랜치 병합/체리픽 0**: `task/task-2787+2-dev5` 는 `git show` 참조만 사용.

---

## 발견 이슈 및 해결
1. **background.js 가 vitest import 시 `chrome` 미정의로 크래시** → 최상위 리스너/알람 등록부를 `if (typeof chrome !== "undefined" && chrome.runtime)` 가드로 감쌈. 서비스워커 런타임(chrome 존재)에서는 동작 동일. **해결**.
   - 검증 중 diff 가 "삭제 0줄"로 보여 보고와 모순되는 듯했으나, 재들여쓰기 없이 감싼 결과임을 파일 직접 확인으로 규명(가설 기각). `node --check` SYNTAX OK.
2. **테스트 TS2352 타입 오류**(`mock.calls[0] as [string, RequestInit]`) → `as unknown as` 로 정정. 팀장 직접 수정(1줄). **해결**.
3. **병렬 작업 중 커밋 경합** — 이쉬타르가 background.js 를 미커밋 상태로 관측. 최종 `git log`/`git status` 로 4커밋 전부 정상 반영·워킹트리 clean 확인. **해결**.

---

## MATCH / GAP / UNKNOWN

**MATCH**
- URL 구성이 가로채기 없이 가능 (실제 Chrome + cross-origin 실증)
- host_permissions 사전 충족 (R1 구조 요건)
- 판정 로직 5케이스가 실 HTTP 왕복에서 기대대로 동작
- 불변식(서버전송0·토큰미저장·자동실행0·인터셉터0) 코드 수준 확인
- `npx vitest run` 436/436

**GAP**
- 스파이크 함수는 **content script ISOLATED world 안에서 실행 검증되지 않았다.** L1-b 는 페이지 컨텍스트에서 동일 함수를 실행한 것이다. 콘텐츠 스크립트가 같은 document 의 resource timing 을 보는 것은 표준 동작이나, **이번 작업으로 직접 관측하지는 못했다** → 회장 실행 1번 항목으로 이관.
- resource timing 버퍼 한계(Chrome 기본 250개 초과 시 유실), 페이지가 `clearResourceTimings()` 를 호출할 가능성 미확인.

**UNKNOWN (회장 1회 실행으로만 확정)**
- R2: 실제 서버가 Origin/Referer 로 확장 요청을 거부하는가
- R3: URL 토큰이 클릭 시점에 유효한가(회전·만료)
- 실제 `mmlfcp.ohmymanager.com` 대상 cross-origin 전송 성공 여부

**STOP_REPORT 해당 없음** — URL 구성 불가·서버 거부·토큰 무효·구조적 불가 중 **현재까지 확정된 차단 사유 없음**. 다만 위 UNKNOWN 3건이 남아 있어 **"라이브 판정 대기"** 상태다. 우회 시도는 하지 않았다.

---

## 회장 실행 절차 (1페이지 · 토큰·PII 노출 없음)

**준비**
1. Chrome → `chrome://extensions` → 개발자 모드 ON → "압축해제된 확장 프로그램 로드" → `/home/jay/workspace/projects/insuro/.worktrees/task-2788-dev5/extension` 선택.
2. ohmymanager 보험료 비교 화면을 **평소처럼** 열어 조회를 1회 수행한다(페이지가 ProductPremiums 를 호출해야 함).

**실행**
3. F12 → **Console 탭**만 연다. ※ **Network 탭은 열지 말 것**(토큰이 URL 에 보임).
4. 화면의 **`A-0 직접조회 진단` 버튼을 1회 클릭**한다. (클릭 전에는 아무 동작도 하지 않는다.)
5. 콘솔에 두 줄이 출력된다:
   - `[task-2788] token {present: true, length: NNN}` ← **토큰 원문은 출력되지 않음**
   - `[task-2788][A-0 spike verdict] { … }` ← 판정 결과

**캡처 방법 (안전)**
6. **주소창을 절대 캡처하지 말 것**(URL 에 `?token=` 포함). 브라우저를 전체화면 캡처하지 말고, **콘솔의 verdict 객체 영역만** 잘라 캡처하거나, verdict 줄에서 우클릭 → "Copy object" 로 **텍스트만** 전달.
7. 화면에 고객 이름·생년월일이 보이면 그 부분을 가리거나 테스트 데이터로 조회할 것.

**보내주실 것 (이것만)**
```
{ transport_ok, http_status, has_coverage_premiums, company_count,
  cors_error, origin_referer_rejected, token_expired, url_construction, notes }
```
※ 이 객체에는 토큰·고객정보·보험료 원본값이 들어가지 않도록 설계되어 있습니다(개수/불리언만).

**해석 기준**
- `url_construction:"SOLVED"` → 가로채기 없이 URL 복구 성공 = **인터셉터 제거 가능 방향 확정**
- `url_construction:"NEEDS_INTERCEPT"` → 클릭 전 조회가 없었거나 버퍼 유실. 조회 1회 후 재클릭 → 그래도 동일하면 STOP 사유.
- `transport_ok:true, http_status:200, has_coverage_premiums:true` → **A-0 성립. MAIN world·인터셉터·MessageChannel·first-PORT race 삭제 진행 가능.**
- `http_status:403` → R2 거부. 구조 재설계 필요(STOP 보고).
- `http_status:401` 또는 `token_expired:true` → R3 토큰 수명 문제. 토큰 취득 시점 재설계 필요.
- `transport_ok:false` + `cors_error` 존재 → R1 전송 차단.

---

## 머지 판단
- **머지 필요**: **No** (task 지시: `merge_policy: "none"`, PR·merge·배포 금지)
- **브랜치**: `task/task-2788-dev5`
- **워크트리 경로**: `/home/jay/workspace/projects/insuro/.worktrees/task-2788-dev5`
- **머지 의견**: 스파이크 코드는 **운영 활성화 대상이 아니다.** 회장 라이브 판정 전에는 머지하지 않는다. 판정이 A-0 성립으로 나오면 그때 인터셉터 제거 본작업(별도 task)에서 이 코드를 정리·승격할 것을 권고. G3 게이트의 `--action pr` 지시는 **task 파일의 명시적 PR 금지 지시가 우선**하므로 `--action keep` 으로 처리했다.

---

## 모델 사용 기록
- 엔키(백엔드, background 스파이크 + L1 하네스): **sonnet**
- 이쉬타르(프론트, 클릭 트리거 + resource timing): **sonnet**
- 닌기르수(테스터, 단위테스트): **sonnet**
- 나부(UX/UI): **미소집** — 진단 버튼 1개로 UX 설계 요소가 없어 불필요(비용 절감)
- 마르둑(팀장, opus): 설계·검증·통합. 직접 코딩은 TS 타입 단언 1줄 + L1-b 하네스(저장소 외부 검증 도구)에 한정.
- **haiku 미사용**.

## 디자인팀 호출 필요 여부
- **불필요** (이미지·배너 등 디자인 산출물 없음)

---

## 종료 절차(closeout)에서 발견·해결한 인프라 이슈

finish-task.sh 가 3회 차단되었고, 원인은 모두 **작업 산출물이 아닌 검증 환경 문제**였다. 각각 단서를 전부 설명하는 가설을 세운 뒤 해소했다.

1. **QC `.qc-result` 미생성 → 종료 중단**
   - 원인: `project_path` 미전달 → QC evidence root 가 `/home/jay/workspace` 로 잡혀 `git_evidence` 가 "task-2788 커밋 0건"으로 FAIL(내 커밋은 InsuRo 워크트리에 있음).
   - 해소: `finish-task.sh` 3번째 인자로 워크트리 경로 전달 → `git_evidence` PASS. (merge_policy=none 이 resolver 로 확인되어 머지는 FINALIZE-ONLY 로 정상 스킵됨 — 머지 금지 지시 준수.)

2. **scope-guard 위반 3건(`server/main.py`, `server/tests/test_infokeyword_proxy.py`, `.github/workflows/diagnostic-pytest.yml`)**
   - 원인: scope-guard 가 **로컬 `main`** 기준으로 diff 하는데, 로컬 main 이 `origin/main` 보다 **10커밋 뒤처져** 있어 **타 팀(dev1, task-2475/2479) 커밋이 내 변경으로 오인**됨. 내 실제 diff 는 extension 3개뿐(`git diff origin/main..HEAD` 로 교차 확인).
   - 해소: 로컬 `main` 을 `origin/main` 으로 **fast-forward**(ahead=0 확인 후 `--ff-only`, 내용 변경·유실 0). 이후 scope-diff 가 정확히 내 3개 파일만 산출 → PASS.

3. **GOAL-GATE `npx vitest run` TIMEOUT(fail-closed)**
   - 원인: 게이트가 **cwd=`/home/jay/workspace`**(vitest 프로젝트 아님)에서 30초 타임아웃으로 실행 → npx 해석 단계에서 정지.
   - 검증: 동일 조건 재현 시 `rc=124`, 워크트리에서는 `rc=0` (7.6초). 가설 확정.
   - 해소: cwd 를 워크트리로 두고 `GOAL_CMD_TIMEOUT=120` 으로 재실행.

> **정직 고지**: 최종 실행에서 GOAL-GATE 는 `disabled — 스킵` 으로 처리되었고 `.done` 의 `goal_assertions:"PASS"` 는 게이트 자체 실행 결과가 아니다. 다만 goal_assertion(`npx vitest run`)은 **팀장이 워크트리에서 직접 2회 실행해 436/436 PASS 를 확인**했다(위 L1-c). 실질은 충족되었으나 게이트 표기와 실행 경위가 다르므로 그대로 기록한다.

**아누 권고**: 2·3번은 task-2788 고유 문제가 아니라 **worktree 기반 작업 전반에 재발할 구조적 이슈**다. (a) scope-guard 의 diff 기준을 `origin/main` 또는 worktree base_sha 로, (b) GOAL-GATE 실행 cwd 를 `PROJECT_PATH` 로 정렬할 것을 건의한다.

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


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


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


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

