From b866a48b016bef620d3795583dd96ca4c8e2b0c6 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 8 Aug 2026 09:46:10 +0900 Subject: [PATCH] =?UTF-8?q?G7=20=EC=84=A0=ED=96=89=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EB=B0=9C=EA=B2=AC=20=E2=80=94=20=EA=B3=B5=EA=B0=9C=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=97=90=20admin/voice-runtime=20=EC=9D=B4?= =?UTF-8?q?=20=EC=97=86=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측으로 찾았다. 공개 런타임은 119 paths 구버전이고 /admin/voice-runtime 이 배포돼 있지 않다. capture-g7-runtime-evidence.py 는 정확히 그 경로만 부르므로 artifact 2 를 만들 수 없다. 즉 마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 실패한다. 현재 소스에는 있다(admin.py:1699, 프리뷰 126 paths). 오케스트레이터가 시작 전에 이걸 검사하고 admin_voice_runtime_not_deployed 로 즉시 멈춘다. 공개 런타임 대상 --rehearse 가 exit 2 로 몇 초 만에 차단됐다. 판정 방식을 한 번 고쳤다. 처음엔 미인증 404/401 상태로 짰는데, Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는 경로까지 포함해 모든 경로를 403(error code 1010)으로 돌려주는 것을 확인했다. 그 신호로는 "배포 누락"과 "edge 차단"을 구분할 수 없어 작동하는 것처럼 보이지만 무의미한 검사였다. OpenAPI spec 의 paths 만 authoritative 하게 쓰도록 바꾸고 그 이유를 테스트로 남겼다. G7 실행 순서가 이렇게 확정된다. 1. current source 를 공개 런타임에 승격(비-secure origin crypto.randomUUID 수정 포함) 2. --rehearse 로 배관 확인 3. 동의 하 물리 마이크 50분 + human pack 으로 실제 실행 검증: 오케스트레이터 25/25, SSOT FAIL 0, dashboard E2E 10/10, ruff clean. --- docs/TODO.md | 6 ++ docs/dev_dashboard.html | 3 +- ...-g7-external-proof-readiness-2026-08-07.md | 27 ++++++++ scripts/run-g7-external-proof-window.py | 64 ++++++++++++++++++- scripts/test_run_g7_external_proof_window.py | 58 ++++++++++++++++- 5 files changed, 153 insertions(+), 5 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 5769d4f..8810636 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -98,6 +98,12 @@ (1) **명시 동의 하 물리 마이크 50분 양방향 soak** — 소유자가 실제로 50분 발화해야 한다. (2) **독립 blind human voice-gain pack** — 참가자 30명 / 50회기 / 150 paired axis / 독립 평가자 2인 / ICC(A,1) ≥ 0.75 · κ ≥ 0.70 · gain ≥ 0.01 · participant-cluster bootstrap 10,000회 95% CI lower > 0. + **선행 조건(2026-08-08 발견):** 공개 런타임은 119 paths 구버전이라 `/admin/voice-runtime`이 + 배포돼 있지 않다. 마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 artifact 2에서 실패한다. + current source를 공개에 먼저 승격해야 한다(비-secure origin `crypto.randomUUID` 수정도 함께 나간다). + 오케스트레이터가 시작 전에 `admin_voice_runtime_not_deployed`로 막는다 — 실측 exit 2. + 이 판정은 미인증 HTTP 상태가 아니라 OpenAPI `paths`로 한다. Cloudflare가 자동화 클라이언트에 + 모든 경로를 403(error 1010)으로 주기 때문에 상태 코드로는 배포 누락을 알 수 없다. 실행 전 `--rehearse`로 배관을 먼저 검증한다. fail-closed 경계 4종(동의 없음·pack 없음·50분 미만·host 불일치)은 CLI로 실증했다. 상세: `ops/outcome-os-g7-external-proof-readiness-2026-08-07.md`. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 3b7c33c..3bfd204 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -634,6 +634,7 @@

179차 적용(2026-08-07): G7 내부 hardening release-only SHA a8c27b0aadcb9dacea86c4be5e62e19277b9cc2678ebca51aa02998d2c2d1a0d를 격리 NAS vignette-preview-20260807에 승격했다. 첫 release-agent 실행은 새 동의 모달을 따르지 않던 DB-backed mic fixture 1건을 23/24에서 fail-closed로 차단하고 원격을 변경하지 않았다. fixture를 마이크→동의 원장→synthetic capture 순서로 고친 뒤 focused 1/1, clean candidate session E2E 24/24, API 전체, API contract·typecheck·build를 통과했다. 최종 API/Web 이미지 79ec…4450/c530…2f28, health ok·db/engine true, OpenAPI 124·auth 401·G0~G8, readiness 3회, NAS browser SSE→DB review를 확인했다. 물리 마이크는 사용하지 않았고 G7 외부 four-artifact gate는 계속 BUILD다.

180차 적용(2026-08-07): current source release-only SHA 6030a677af7e…c611를 격리 NAS vignette-preview-20260807에 승격했다. 최초 전체 gate는 108개 중 5개를 fixture drift 2종과 runtime-critical shell.css 분류 누락으로 차단해 원격을 바꾸지 않았고, G5→G4 무호출·StrictMode retry·learner 모바일 4열 packaging을 focused 6/6과 manifest 회귀로 고정했다. 최종 candidate browser E2E 108/108, postdeploy 실제 SSE→DB review 32.437s, health 3/3, OpenAPI 126·auth 401·G0~G8 route를 통과했다. API/Web 이미지는 52e0…8b2d/6fdb…f215이며 named DB volume과 배포 전 dump를 보존했다. 이 승격은 current runtime 증거이고 G8 receipt-bound 실제 image rollback은 별도 GATE다.

181차 적용(2026-08-07): G8의 마지막 게이트인 실제 receipt-bound image rollback을 격리 NAS에서 실행해 DONE으로 승격했다. 먼저 성립 불가능한 Gate6 계약을 정정했다. 감사 대상 current API 이미지 자체가 com.docker.compose.project=vignette-preview-20260807·service=api image label을 갖고 있어 "helper의 compose label 0개"는 감사되지 않은 다른 이미지를 쓰지 않는 한 성립하지 않는다. 그래서 계약을 key 부재가 아니라 소속으로 바꿔 scripts/launch-nas-preview-g8-helpers.py에 고정했다. image 상속 label을 baseline으로 읽고, container의 모든 compose label이 baseline과 같거나 선언된 격리 override인지 검사하며, 최종 project는 vignette-g8-helper-*·service는 g8-rollback-*이어야 하고, docker run argv에 target project/service label을 주입하면 fake-runner 테스트가 먼저 깨진다(37/37). 실행은 executor 127.0.0.1:18149(host network·read-only rootfs·cap-drop ALL·no-new-privileges·uid1028/gid100·group101)와 별도 lifecycle의 control-plane API 127.0.0.1:8018로 구성했다. 계획했던 Windows SSH 터널은 NAS sshd가 administratively prohibited로 direct-tcpip를 거부해 사용할 수 없었고, sshd 설정을 바꾸는 대신 같은 격리 계약의 NAS-side probe 컨테이너에서 실행해 control-plane(8018)·preview(8088) origin 분리를 그대로 유지했다. 결과는 rollback-old receipt nas-g8-723eeef2…, restore-current receipt nas-g8-2738846c…로 실제 이미지가 52e0/6fdb79ec/c53052e0/6fdb로 왕복했고, release gate·approval 2회 멱등, DB audit.ci_lifecycle_event rollback/executed 2건과 receipt id 결속, silent auto-promotion 0, HMAC journal 6-record 체인 검증, health 3/3·OpenAPI 126·auth 401·Web 200, helper 0·listener 0 복귀와 비밀 env 파기를 확인했다. down·volume rm·prune은 실행하지 않았고 공개 런타임은 건드리지 않았다. G7은 Deepgram live·인증 WSS·명시 동의 물리 마이크 50분·독립 라벨 human pack이 남아 외부 GATE로 유지한다.

+

182차 적용(2026-08-08): 회기 전 자기점검(pre alliance gate) 화면을 데스크톱·모바일 전 폭에서 다시 짰다. 계측이 먼저였다. gate 상태에서 카드가 align-self:stretch + grid-template-rows: auto minmax(0,1fr) auto auto인데 base flex의 align-items:center가 살아 있어 3축 블록이 1fr 행 한가운데 떠 있었다. 1920×1080 기준 카드의 66.5%가 빈 공간이고 축 위·아래로 각각 277px의 죽은 여백이 생겼다. 척도 라벨은 0.54rem+nowrap+ellipsis라 901~1279px에서 최대 12개가 "전허 아…"로 잘렸고, ≤620px에서는 display:none으로 15개 전부 사라져 숫자 1~5만 남았다. 320×568에서는 @media (max-width:420px) and (max-height:620px)의 collapsed 전용 40px 고정 행이 gate에도 걸려 카드가 40px로 눌리고 헤더와 축이 겹쳐 화면이 사실상 깨져 있었다. 수정은 네 가지다. (1) gate 카드는 행 높이만큼 늘리지 않고 align-self:center+max-height:100%+내부 스크롤로 화면 중앙에 서게 했다. (2) 3열 격자를 버리고 축마다 질문 한 줄 + 전폭 5칸 척도 세로 스택으로 바꿔 어느 폭에서도 라벨을 자르거나 숨기지 않는다(word-break:keep-all+overflow-wrap:normal로 어절 경계만 줄바꿈). (3) 정보가 없던 3 AXES 모노 배지를 제출 버튼이 왜 잠겨 있는지 답하는 0/3 축 선택함 진행 표시(aria-live)로 교체했다. (4) 40px 행 규칙에 :not(.sx-alliance-gated)를 걸어 collapsed mid 전용으로 되돌렸다. 선택 상태 글자색도 반투명 --surface에서 --text-on-accent로 고쳤다(라이트 4.85:1 · 다크 6.36:1, AA). 15개 뷰포트 재계측 결과 빈 공간 비율 0.665→0.127(1920), 라벨 잘림 12→0, 숨김 15→0, 최소 탭 타깃 38×38→46×60, 문서 가로 오버플로 전 구간 0이고, 320×568에서는 카드 내부 스크롤로 제출까지 도달해 실제 잠금·발화 입력 활성까지 확인했다. 인라인 mid 카드는 ≥1080px에서만 3열로 눕혀 축어록을 밀지 않게 두었고 높이는 오히려 약 55px 줄었다. 무회귀: typecheck 통과, session-layout 8/8, alliance-pulse 8/8, pre/mid 잠금 E2E 2/2, layout-visual-gate 22/23(실패 1건 settings는 변경 전 baseline에서도 동일 실패로 확인된 기존 회귀). session-mvp 음성 4건 실패도 baseline 동일이라 이 변경과 무관하다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -1122,7 +1123,7 @@ Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLPassed: exact-pinned API requirements, live coaching data/kb source pack, env template keys, and DB readiness (current_user=vignette). DB mode can additionally check app-role DSN with --require-app-role and now verifies session read-model columns including app.turns voice metadata columns(audio_ref/silence_ms/speech_rate/barge_in/provider_events) plus worksheet review columns. Fresh compose smokedocker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthPassed with dummy production-safe env: API healthy, DB healthy, web/proxy up, http://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run. 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매일 04:30 KST / 배포 증거 / 기계 판독 증거current SHA 6030a677…c611을 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. browser E2E 108/108, health 3/3, OpenAPI 126·auth 401·G0~G8, postdeploy SSE→DB review를 통과했고 predeploy dump와 이전 exact images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다. - G7 외부 증거 3-artifact 동시 시간창 오케스트레이터scripts/run-g7-external-proof-window.py · 준비 문서soak·runtime·topology 세 캡처는 같은 host의 겹치는 시간창이어야 하는데 지금까지는 운영자가 세 명령을 손으로 맞춰야 했다. 한 명령으로 동시에 띄우고 checker까지 잇도록 묶었다. 게이트는 약화시키지 않았다 — CLI 실증 fail-closed 4종: 동의·장치 없음 physical_microphone_consent_required, human pack 없음 human_voice_gain_pack_required, 50분 미만 production_window_too_short, host 불일치 hosts_must_match, 전부 exit 2. --rehearse는 마이크를 열지 않고 배관만 확인하며 gate_closed는 항상 false다. 회귀 20/20. 남은 것은 물리 마이크 50분독립 human voice-gain pack 둘뿐이다. + G7 외부 증거 3-artifact 동시 시간창 오케스트레이터scripts/run-g7-external-proof-window.py · 준비 문서soak·runtime·topology 세 캡처는 같은 host의 겹치는 시간창이어야 하는데 지금까지는 운영자가 세 명령을 손으로 맞춰야 했다. 한 명령으로 동시에 띄우고 checker까지 잇도록 묶었다. 게이트는 약화시키지 않았다 — CLI 실증 fail-closed 4종: 동의·장치 없음 physical_microphone_consent_required, human pack 없음 human_voice_gain_pack_required, 50분 미만 production_window_too_short, host 불일치 hosts_must_match, 전부 exit 2. --rehearse는 마이크를 열지 않고 배관만 확인하며 gate_closed는 항상 false다. 회귀 20/20. 선행 조건 발견: 공개 런타임은 119 paths 구버전이라 /admin/voice-runtime이 배포돼 있지 않아 마이크·사람을 준비해도 artifact 2 에서 실패한다. current source 를 공개에 먼저 승격해야 하며, 오케스트레이터가 시작 전 admin_voice_runtime_not_deployed로 막는다(실측 exit 2). 이 판정은 Cloudflare 가 모든 경로를 403(error 1010)으로 주기 때문에 미인증 상태 코드가 아니라 OpenAPI paths로 한다. 그 뒤 남는 것은 물리 마이크 50분독립 human voice-gain pack 둘뿐이다. 노트북 로컬 음성 스택 — faster-whisper STT + MeloTTS TTSscripts/local-whisper-stt-server.py · scripts/start-local-whisper-stt.ps1 · voice_stt_provider=local_whisper2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주 로컬 모델로 정했다. TTS는 처음 Higgs로 잡았으나 연구/비상업 라이선스라 config.py가 운영에서 차단하고 있었고 그 가드를 푸는 건 법적 판단이라, 상업 사용이 허용된 MeloTTS Korean(MIT)으로 바꿔 가드 자체를 불필요하게 만들었다. Kokoro-82M은 Apache 2.0이지만 공식 VOICES.md한국어가 없어 탈락했다. 이전에는 로컬 STT 경로가 코드에 없었고 interim/final 스트리밍이 Deepgram 전용이었다. Higgs와 같은 loopback 사이드카를 추가해 오디오가 호스트를 벗어나지 않고 외부 STT 키도 필요 없다. 실측: 저장소 합성 시드 8.72초에서 interim 9·final 5·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 37/37, API 전체 908 passed, gateway 58, G7 checker 23, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 네이티브 크래시라 디바이스 확인을 자식 프로세스(--self-check)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. MeloTTS는 CPU 정상 상태 RTF 0.27~0.28(실시간 3.6배), POST /tts 200·WAV 350,566 bytes·3.61s, 빈 텍스트 422·미지 경로 404이며, 합성음을 로컬 STT가 완전 일치 전사하는 왕복 검증을 통과했다(사이드카 16/16, API 914 passed). G7 체커의 deepgram 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다. 비-secure origin 회기 리뷰 크래시 수정apps/web/src/lib/uuid.ts · apps/web/e2e/insecure-context-uuid.spec.ts격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가 crypto.randomUUID is not a function으로 error boundary에 잡혔다. 이 API는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 RuptureRepairCard는 렌더 시점 호출이라 화면 전체가 죽었다. 릴리스 게이트는 localhost 후보 스택에서만 돌아 미검출. randomUuid()로 통일하고 fallback도 getRandomValues 우선으로 예측 불가능성을 유지했다. 회귀 6/6(직접 호출 0건 검사 포함), typecheck·build 통과. NAS 프리뷰에는 아직 미배포이며 배포된 SHA 6030a677…c611은 여전히 결함 빌드다. 공개 워치독 engine readiness 사각지대(해결)scripts/watch-public-runtime.ps1 · scripts/start-public-runtime.ps1 · apps/api/engine_gateway/gateway.py2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 /ready?force=true 200 ok:true·detail:OK, API 8001과 공개 api-vignette.chanpaca.net 모두 status:ok·engine:true·engine_detail:OK다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 EngineSession 재현이 "OK"를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 /health(프로세스 liveness)에서 /ready(실제 생성)로 바꾸고 API 판정에 engine을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 claude -p의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 exit·stderr를 붙인다 — 실증: empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz'). (3) 기동 시 게이트웨이 로그 회전, Stop-UvicornByPortName -like python* 추가(호출자 자기 자신 종료 방지). 검증: pytest engine_gateway 58/58, 워치독 -CheckOnly healthy 5/5, 장애 재현(9299·CLAUDE_BIN 부재)에서 /healthok:true로 통과하고 /ready는 503으로 검출되어 워치독이 unhealthy (1/3) + 503 본문을 남겼다. NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다. diff --git a/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md b/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md index 075cfa9..41021d4 100644 --- a/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md +++ b/docs/ops/outcome-os-g7-external-proof-readiness-2026-08-07.md @@ -95,5 +95,32 @@ DONE으로 표시하지 않는다. 열지 않고, 인자·경로·산출 파일까지 실제로 검증한다. 보고서의 `gate_closed`는 rehearse에서 **항상 false**다. 실제 50분 실행 전에 이걸로 먼저 실패를 뽑아내라. +### ⚠ 선행 조건 — 공개 런타임을 먼저 올려야 한다 (2026-08-08 발견) + +**마이크와 사람을 다 준비해도 오늘 실행하면 50분을 버리고 artifact 2에서 실패한다.** + +공개 런타임은 **119 paths 구버전**이고 `/admin/voice-runtime`이 **배포돼 있지 않다**. +`capture-g7-runtime-evidence.py`는 정확히 그 경로만 부르므로 artifact 2를 만들 수 없다. +현재 소스에는 있다(`apps/api/app/routes/admin.py:1699`, NAS 프리뷰 기준 126 paths). + +``` +공개 OpenAPI 119 paths · voice 경로 = /users/me/voice-presets, /voice/health, /voice/speech +has /admin/voice-runtime → False +``` + +그래서 오케스트레이터는 시작 전에 이걸 검사하고 `admin_voice_runtime_not_deployed`로 즉시 멈춘다. +실측: 공개 런타임 대상 `--rehearse` 실행이 exit 2로 몇 초 만에 차단됐다. + +**판정을 미인증 HTTP 상태로 하지 않는다.** Cloudflare 앞단이 자동화 클라이언트에게 존재하지 않는 +경로까지 포함해 **모든 경로를 403(error code 1010)** 으로 돌려주기 때문에, 상태 코드로는 "배포 누락"과 +"edge 차단"을 구분할 수 없다. 처음엔 404/401 판정으로 짰다가 이 사실을 확인하고 **OpenAPI spec의 +`paths`만 authoritative하게 쓰도록** 고쳤다. 회귀에 그 이유를 테스트로 남겼다. + +따라서 G7 실행 순서는 이렇다. + +1. current source를 공개 런타임에 승격한다(비-secure origin `crypto.randomUUID` 수정도 여기 포함된다). +2. `--rehearse`로 배관을 확인한다. +3. 동의 하 물리 마이크 50분 + human pack으로 실제 실행한다. + 기대 provider 기본값은 2026-08-08 결정에 맞춰 `local_whisper` / `melotts`다 (근거: `../decisions/local-voice-stack.md`). 회귀는 `scripts/test_run_g7_external_proof_window.py` 20/20. diff --git a/scripts/run-g7-external-proof-window.py b/scripts/run-g7-external-proof-window.py index 9850e2d..63262da 100644 --- a/scripts/run-g7-external-proof-window.py +++ b/scripts/run-g7-external-proof-window.py @@ -175,8 +175,70 @@ def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg: return Leg("topology", argv, output) -def plan_legs(args: argparse.Namespace, out_dir: Path) -> list[Leg]: +ADMIN_RUNTIME_PATH = "/admin/voice-runtime" +# Cloudflare 앞단은 자동화 클라이언트에 error code 1010 으로 **모든 경로**에 403 을 준다. +# 그래서 미인증 상태 코드로는 "endpoint 없음"과 "edge 차단"을 구분할 수 없다. +# 배포 여부는 OpenAPI spec 의 paths 로만 판정한다. +_BROWSER_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/140.0 Safari/537.36" +) + + +def assert_admin_endpoint_deployed( + url: str, *, paths_probe: Callable[[str], Sequence[str]] | None = None +) -> str: + """관리자 runtime endpoint 가 그 배포에 실제로 존재하는지 먼저 본다. + + 2026-08-08 확인: 공개 런타임은 119 paths 구버전이라 `/admin/voice-runtime` 이 + 아예 없었다. 이 상태로 50분 창을 돌리면 마이크와 사람을 다 준비하고도 artifact 2 + 에서 실패한다. 그래서 시작 전에 막는다. + + 미인증 HTTP 상태로 판정하지 않는다. Cloudflare 가 자동화 클라이언트에 모든 경로를 + 403(error code 1010)으로 돌려주므로 그 신호로는 배포 누락을 알 수 없다. OpenAPI + spec 의 `paths` 만 authoritative 하게 쓴다. + """ + + from urllib.parse import urlsplit + + parsed = urlsplit(url) + if parsed.path.rstrip("/") != ADMIN_RUNTIME_PATH: + raise WindowError("admin_runtime_url_unexpected_path") + origin = f"{parsed.scheme}://{parsed.netloc}" + paths = (paths_probe or _openapi_paths)(origin) + if ADMIN_RUNTIME_PATH not in set(paths): + raise WindowError("admin_voice_runtime_not_deployed") + return origin + + +def _openapi_paths(origin: str) -> Sequence[str]: # pragma: no cover - 네트워크 I/O + import urllib.error + import urllib.request + + request = urllib.request.Request( + f"{origin}/openapi.json", + headers={"User-Agent": _BROWSER_UA, "Accept": "application/json"}, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + document = json.loads(response.read(8 * 1024 * 1024)) + except (urllib.error.URLError, OSError, ValueError) as exc: + raise WindowError("openapi_unreachable") from exc + paths = document.get("paths") if isinstance(document, dict) else None + if not isinstance(paths, dict): + raise WindowError("openapi_shape_invalid") + return list(paths) + + +def plan_legs( + args: argparse.Namespace, + out_dir: Path, + *, + admin_probe: Callable[[str], Sequence[str]] | None = None, +) -> list[Leg]: assert_single_host(args.wss_url, args.origin, args.admin_runtime_url) + assert_admin_endpoint_deployed(args.admin_runtime_url, paths_probe=admin_probe) return [ build_soak_leg(args, out_dir / "voice-soak.json"), build_runtime_leg(args, out_dir / "runtime.json"), diff --git a/scripts/test_run_g7_external_proof_window.py b/scripts/test_run_g7_external_proof_window.py index e53d346..407a5a5 100644 --- a/scripts/test_run_g7_external_proof_window.py +++ b/scripts/test_run_g7_external_proof_window.py @@ -70,6 +70,7 @@ class HostAlignmentTest(unittest.TestCase): args(admin_runtime_url="https://elsewhere.test/admin/voice-runtime", microphone_device="mic", confirm_physical_capture=True), Path("out"), + admin_probe=lambda origin: ["/admin/voice-runtime"], ) @@ -146,7 +147,8 @@ class LegCompositionTest(unittest.TestCase): def test_all_three_legs_share_one_window_length(self) -> None: legs = MODULE.plan_legs( - args(rehearse=True, duration_seconds=3_000.0), Path("out") + args(rehearse=True, duration_seconds=3_000.0), Path("out"), + admin_probe=lambda origin: ["/admin/voice-runtime"], ) self.assertEqual([leg.name for leg in legs][1:], ["runtime", "topology"]) for leg in legs[1:]: @@ -163,7 +165,7 @@ class WindowExecutionTest(unittest.TestCase): started.append(argv[3]) return FakeCompleted(0) - legs = MODULE.plan_legs(args(rehearse=True), Path("out")) + legs = MODULE.plan_legs(args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/admin/voice-runtime"]) results = MODULE.run_window(legs, timeout=60, runner=runner) self.assertEqual(len(started), 3) self.assertTrue(all(result.ok for result in results)) @@ -172,13 +174,63 @@ class WindowExecutionTest(unittest.TestCase): def runner(argv, **kwargs): return FakeCompleted(1 if "capture-g7-runtime-evidence.py" in argv[3] else 0) - legs = MODULE.plan_legs(args(rehearse=True), Path("out")) + legs = MODULE.plan_legs(args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/admin/voice-runtime"]) results = MODULE.run_window(legs, timeout=60, runner=runner) self.assertEqual(len(results), 3) failed = [r.name for r in results if not r.ok] self.assertEqual(failed, ["runtime"]) +class AdminEndpointPreflightTest(unittest.TestCase): + """공개 런타임이 구버전이라 endpoint 가 없으면 50분을 버리기 전에 막는다.""" + + ADMIN_URL = "https://api.example.test/admin/voice-runtime" + + def test_missing_endpoint_is_rejected_before_the_window_starts(self) -> None: + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.assert_admin_endpoint_deployed( + self.ADMIN_URL, paths_probe=lambda origin: ["/health", "/voice/speech"] + ) + self.assertEqual(str(ctx.exception), "admin_voice_runtime_not_deployed") + + def test_deployed_endpoint_is_accepted(self) -> None: + origin = MODULE.assert_admin_endpoint_deployed( + self.ADMIN_URL, + paths_probe=lambda origin: ["/health", "/admin/voice-runtime"], + ) + self.assertEqual(origin, "https://api.example.test") + + def test_unauthenticated_status_is_never_used_as_the_signal(self) -> None: + """Cloudflare 는 자동화 클라이언트에 모든 경로를 403 으로 준다. + + 상태 코드로 판정하면 배포 누락을 통과시킨다. spec 만 본다. + """ + + probed: list[str] = [] + + def probe(origin: str): + probed.append(origin) + return ["/admin/voice-runtime"] + + MODULE.assert_admin_endpoint_deployed(self.ADMIN_URL, paths_probe=probe) + self.assertEqual(probed, ["https://api.example.test"]) + + def test_wrong_admin_url_path_fails_closed(self) -> None: + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.assert_admin_endpoint_deployed( + "https://api.example.test/admin/something-else", + paths_probe=lambda origin: ["/admin/voice-runtime"], + ) + self.assertEqual(str(ctx.exception), "admin_runtime_url_unexpected_path") + + def test_plan_refuses_a_deployment_without_the_endpoint(self) -> None: + with self.assertRaises(MODULE.WindowError) as ctx: + MODULE.plan_legs( + args(rehearse=True), Path("out"), admin_probe=lambda origin: ["/health"] + ) + self.assertEqual(str(ctx.exception), "admin_voice_runtime_not_deployed") + + class ReportTest(unittest.TestCase): def _results(self, codes=(0, 0, 0)): names = ("voice_soak", "runtime", "topology")