From db962fb762dbc7970c8321222c277baad7a12d3b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 00:09:04 +0900 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20.codexclaw=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C=20=E2=80=94=20gitignore=20=EC=9B=90?= =?UTF-8?q?=EB=9E=98=20=EC=9D=98=EB=8F=84=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .codexclaw/는 9d4f58e8부터 .gitignore 대상이었으나 68f88fb3에서 강제 add로 추적이 시작돼 이후 커밋과 main 프로모션까지 타고 갔다. gitignore는 미추적 파일에만 적용되므로 한번 추적되면 묵시적으로 계속 따라간다. git rm -r --cached로 추적만 해제. 로컬 goalplan 상태 파일은 그대로 유지되고(cxc orchestrate가 계속 갱신), ignore가 다시 적용돼 앞으로는 커밋되지 않는다. --- .../goalplan.json | 263 ------------------ .../ledger.jsonl | 12 - 2 files changed, 275 deletions(-) delete mode 100644 .codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/goalplan.json delete mode 100644 .codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/ledger.jsonl diff --git a/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/goalplan.json b/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/goalplan.json deleted file mode 100644 index b1feeb1602..0000000000 --- a/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/goalplan.json +++ /dev/null @@ -1,263 +0,0 @@ -{ - "objective": "opencodex 워크트리 codex/260725-bug-sweep에서 GUI·enhancement 제외 미해결 버그(#433 quota cooldown 고착, #432 Windows 서비스 stale 오탐, #422 remote compaction v2 fatal, #404 Grok per-model adapter, #373 Cursor 재시작 context 0)를 다중 PABCD 사이클로 직접 수정하고 각 work-phase를 로컬 커밋으로 쌓는다. WP0는 docs-only 로드맵 사이클.", - "slug": "opencodex-codex-260725-bug-sweep-gui-enhancement", - "createdAt": "2026-07-25T08:12:13.026Z", - "updatedAt": "2026-07-25T12:50:38.598Z", - "activeWorkPhaseId": null, - "workPhases": [ - { - "id": "wp0", - "title": "WP0 docs-only 로드맵 사이클 — 260725_bug_sweep 유닛 + 이슈별 decade 문서 diff-level 작성", - "status": "done", - "tasks": [ - { - "id": "wp0-t1", - "title": "5개 이슈 근본원인을 코드 대조로 확정 (sol high 4기 병렬 조사)", - "status": "done" - }, - { - "id": "wp0-t2", - "title": "외부 근거 원문 확인 (MS XSD, xAI, OpenAI Compaction, upstream codex)", - "status": "done" - }, - { - "id": "wp0-t3", - "title": "독립 리뷰어 3라운드 감사로 설계 결함 제거", - "status": "done" - }, - { - "id": "wp0-t4", - "title": "통합 유닛 구조가 DIFFLEVEL-ROADMAP-01과 충돌함을 확인, 이슈별 단일 work-phase 유닛으로 재구성 결정", - "status": "done" - } - ], - "criteriaIds": [ - "c-roadmap" - ] - }, - { - "id": "wp-restructure", - "title": "P-phase 개정 — 통합 유닛을 이슈별 단일 work-phase 유닛으로 분리 (LOOP-UNIT-CHAIN-01)", - "status": "done", - "tasks": [ - { - "id": "wpr-t1", - "title": "DIFFLEVEL-ROADMAP-01은 2+ work-phase 유닛에만 적용됨을 규약 원문으로 확인", - "status": "done" - }, - { - "id": "wpr-t2", - "title": "이슈별 독립 유닛 devlog/_plan/260725_issue_/ 구조로 전환", - "status": "done" - } - ], - "criteriaIds": [] - }, - { - "id": "wp1", - "title": "WP1 #433 — 먼 미래 resetAt로 인한 quota cooldown 고착 수정", - "status": "done", - "tasks": [ - { - "id": "wp1-t1", - "title": "260725_issue_433 유닛 P — 실제 파일 열어 정확한 diff 작성", - "status": "done" - }, - { - "id": "wp1-t2", - "title": "cooldown 상한 분리 + probe lease 구현 (lease id 전달 경로 포함)", - "status": "done" - }, - { - "id": "wp1-t3", - "title": "회귀 테스트 추가 + typecheck/test 통과 + 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-433" - ] - }, - { - "id": "wp2", - "title": "WP2 #432 — Windows Task Scheduler XML 기본값 생략 시 stale 오탐 수정", - "status": "done", - "tasks": [ - { - "id": "wp2-t1", - "title": "260725_issue_432 유닛 P — 실제 파일 열어 정확한 diff 작성", - "status": "done" - }, - { - "id": "wp2-t2", - "title": "생략형 XML을 기본값으로 해석 + diagnoseService wiring 통합", - "status": "done" - }, - { - "id": "wp2-t3", - "title": "회귀 테스트 추가 + typecheck/test 통과 + 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-432" - ] - }, - { - "id": "wp3", - "title": "WP3 #422 — API-key 기반 openai-responses에서 remote compaction v2 fatal 수정", - "status": "done", - "tasks": [ - { - "id": "wp3-t1", - "title": "030 문서 stale 체크 후 compaction 분기 경로 확정", - "status": "done" - }, - { - "id": "wp3-t2", - "title": "authMode별 compaction 지원 판정 구현", - "status": "done" - }, - { - "id": "wp3-t3", - "title": "회귀 테스트 추가 + typecheck/test 통과 + 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-422" - ] - }, - { - "id": "wp4", - "title": "WP4 #373 — Cursor 재시작 후 context usage 0 보고 수정", - "status": "done", - "tasks": [ - { - "id": "wp4-t1", - "title": "050 문서 stale 체크 후 checkpoint tracker 경로 확정", - "status": "done" - }, - { - "id": "wp4-t2", - "title": "재시작 시드 복구 또는 request-local 추정 구현", - "status": "done" - }, - { - "id": "wp4-t3", - "title": "회귀 테스트 추가 + typecheck/test 통과 + 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-373" - ] - }, - { - "id": "wp5", - "title": "WP5 #404 — Grok 4.5 per-model adapter override (혼합 게이트웨이 web_search 소실)", - "status": "done", - "tasks": [ - { - "id": "wp5-t1", - "title": "040 문서 stale 체크 후 adapter-resolve 경로 확정", - "status": "done" - }, - { - "id": "wp5-t2", - "title": "모델별 wire override 구현 또는 BLOCKED 증거 기록", - "status": "done" - }, - { - "id": "wp5-t3", - "title": "회귀 테스트 추가 + typecheck/test 통과 + 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-404" - ] - }, - { - "id": "wp6", - "title": "WP6 최종 통합 검증 — 전체 스위트 회귀 확인", - "status": "done", - "tasks": [ - { - "id": "wp6-t1", - "title": "bun run typecheck 전체 통과", - "status": "done" - }, - { - "id": "wp6-t2", - "title": "bun run test 전체 스위트 실행 및 회귀 없음 확인", - "status": "done" - }, - { - "id": "wp6-t3", - "title": "060 스윕 종료 요약 문서 + 최종 커밋", - "status": "done" - } - ], - "criteriaIds": [ - "c-suite" - ] - } - ], - "criteria": [ - { - "id": "c-roadmap", - "scenario": "5개 이슈의 근본 원인이 파일:라인 코드 대조로 확정되고, 외부 근거가 원문 확인되며, 독립 리뷰어 감사로 설계 결함이 제거된 상태로 조사 산출물이 커밋된다.", - "expectedEvidence": "devlog 커밋 SHA + 리뷰어 감사 결과 + 근본원인별 파일:라인", - "capturedEvidence": "커밋 cddc86f9(r1), 961fa431(r2), 86de6069(r3). 독립 리뷰어 3라운드 감사(Anscombe 2회, Lagrange 1회)로 근본원인 확정: #433 routing.ts:474 하드 cooldown이 재시작 외 해제 불가(auth-context.ts:132 선차단으로 자기완결 교착), 기존 테스트 codex-routing:312/:806이 2xx 보존을 고정. #432 service.ts:471-493 + 953-958 두 곳 독립 오탐, MS XSD상 생략 Enabled/RunLevel은 true/LeastPrivilege. #422 core.ts:973이 adapter.passthrough만 검사, isCanonicalOpenAiForwardProvider(openai-tiers.ts:32)가 정확한 판정자, core.ts:1828 non-stream 400. #373 protobuf-events.ts:48 tracker가 process-local, protobuf-events.ts:474가 inputTokens:0 복사. #404 adapter-resolve.ts:13 pin에 grok 없음, modelAdapters 성립하나 canonical forward에는 금지 필요. 기준선 typecheck 0 / routing 59 / responses 46 / service 34 / cursor 112 / server-auth 54 / privacy scan 통과.", - "status": "met" - }, - { - "id": "c-433", - "scenario": "먼 미래 resetAt를 받은 계정이 상한을 넘겨 고착되지 않고, 회복된 계정이 다시 라우팅 대상이 된다. 수정 전 실패하고 수정 후 통과하는 회귀 테스트가 이 동작을 고정한다.", - "expectedEvidence": "신규 테스트 파일 경로 + bun test 통과 출력 tail + 커밋 SHA", - "capturedEvidence": "커밋 c71defc4. tests/codex-routing.test.ts 72 pass/0 fail (신규 13개: far-future resetAt 상한, Retry-After 보존, retry-after 미probe, interval당 lease 1개, leased success 해제, unleased 2xx 보존, 불일치 lease 미소비, 실패 probe interval 재시작, stale-generation 차단, credential 종료, retry-after source 보존, reset-derived probe 가능, in-flight lease 생존). tests/codex-auth-context.test.ts 17 pass/0 fail (통합: 차단→interval 후 probe 허용→성공→정상 복귀). 기존 codex-routing:312/:806 무수정 통과. 전체 bun run test 4093 pass, 기준선 4079 대비 +14, 실패 2건은 양쪽 동일한 GUI react/jsx-dev-runtime 미해결로 무관. typecheck exit 0, privacy scan 통과.", - "status": "met" - }, - { - "id": "c-432", - "scenario": "Enabled/RunLevel이 생략된 Task Scheduler XML을 정상(healthy)으로 판정하고, 실제로 비활성화된 서비스는 여전히 stale로 판정한다.", - "expectedEvidence": "신규 테스트 파일 경로 + bun test 통과 출력 tail + 커밋 SHA", - "capturedEvidence": "커밋 c00b9c0c. tests/service.test.ts 40 pass/0 fail (기존 34 무회귀 + 신규 6: canonicalized XML 수용, 명시적 unsafe 거부, Data/주석 decoy 거부, prefixed 값 거부, Data shadowing 3종 거부, 중복 요소 거부). 전체 bun run test 4099 pass (기준선 4079 대비 +20). typecheck exit 0, privacy scan 통과. wiring은 WindowsServiceDiagnosticInputs 타입 변경으로 강제.", - "status": "met" - }, - { - "id": "c-422", - "scenario": "authMode=key인 openai-responses provider에서 remote compaction 요청이 fatal 없이 처리되거나 명확히 미지원으로 저하(degrade)된다.", - "expectedEvidence": "신규 테스트 파일 경로 + bun test 통과 출력 tail + 커밋 SHA", - "capturedEvidence": "커밋 e2626eb6. 신규 tests/responses-compaction-routing.test.ts 10 pass/0 fail (helper matrix, wire rewrite 검사, additional_tools 제거, input_image 재귀 치환, noncanonical forward rewrite, upstream failure, incomplete 차단, output_text.done fallback, completed snapshot fallback). 기존 responses-compaction+passthrough 46 pass 무회귀. 전체 bun run test 4109 pass (기준선 4079 대비 +30). typecheck exit 0, privacy scan 통과.", - "status": "met" - }, - { - "id": "c-373", - "scenario": "프록시 재시작 후 checkpoint 없는 Cursor 요청이 inputTokens=0이 아닌 실제 컨텍스트 규모를 보고한다.", - "expectedEvidence": "신규 테스트 파일 경로 + bun test 통과 출력 tail + 커밋 SHA", - "capturedEvidence": "커밋 cb22b7ee. 신규 10개 테스트(cursor-blob 4, cursor-protobuf-events 4, cursor-live-transport 2). cursor+token-estimate 7파일 132 pass/0 fail. 뮤테이션 검증: live-transport estimatedInputTokens 배선 제거 시 transport 테스트 2개가 정확히 실패. 전체 bun run test 4119 pass (기준선 4079 대비 +40). typecheck exit 0, privacy scan 통과. 리뷰어는 kimi/k3(Curie) 3라운드 감사 PASS.", - "status": "met" - }, - { - "id": "c-404", - "scenario": "혼합 게이트웨이에서 특정 모델만 responses wire를 쓰도록 override할 수 있거나, 구현 불가 사유가 코드 근거와 함께 문서화된다.", - "expectedEvidence": "신규 테스트 파일 경로 + bun test 통과 출력 tail + 커밋 SHA, 또는 BLOCKED 증거 문서 경로", - "capturedEvidence": "커밋 359e2ecd. 신규 13개(adapter-resolve 7, config 3, management 1, chat-completions-endpoint 3). 증상 회귀: mock dual-wire upstream으로 grok이 /responses에 web_search를 실은 채 도달, sibling은 /chat/completions, inbound도 effective adapter 사용을 검증. 전체 bun run test 4132 pass (기준선 4079 대비 +53). typecheck 0, privacy scan 통과. 리뷰어 kimi/k3 + gpt-5.6-sol medium priority 4라운드 PASS.", - "status": "met" - }, - { - "id": "c-suite", - "scenario": "모든 수정을 쌓은 최종 트리에서 bun run typecheck와 bun run test가 기존 대비 신규 실패 없이 완료된다.", - "expectedEvidence": "bun run typecheck 종료코드 0 + bun run test 요약 출력 tail", - "capturedEvidence": "bun run typecheck exit 0. bun run test 4132 pass / 2 fail / 2 errors / 21258 expect() calls. 기준선(변경분 stash 후 동일 명령) 4079 pass / 2 fail / 2 errors — 증가분 +53은 전부 신규 회귀 테스트이고 실패 2건은 gui/src react/jsx-dev-runtime 미해결로 변경 전후 동일. bun run privacy:scan 통과. 교차 영향 직접 실행 확인: canonical forward가 override 후에도 canonical 유지, key 게이트웨이는 canonical 오인 없음, #433 lease가 429->획득->success->해제까지 동작.", - "status": "met" - } - ], - "host": { - "armed": true, - "armedAt": null, - "source": "freeze" - } -} \ No newline at end of file diff --git a/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/ledger.jsonl b/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/ledger.jsonl deleted file mode 100644 index 5d29e958c9..0000000000 --- a/.codexclaw/goalplans/opencodex-codex-260725-bug-sweep-gui-enhancement/ledger.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"ts":"2026-07-25T08:12:13.027Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"created","detail":"init objective=\"opencodex 워크트리 codex/260725-bug-sweep에서 GUI·enhancement 제외 미해결 버그(#433 quota cooldown 고착, #432 Windows 서비스 stale 오탐, #422 remote compaction v2 fatal, #404 Grok per-model adapter, #373 Cursor 재시작 context 0)를 다중 PABCD 사이클로 직접 수정하고 각 work-phase를 로컬 커밋으로 쌓는다. WP0는 docs-only 로드맵 사이클.\" criteria=0"} -{"ts":"2026-07-25T10:25:10.974Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp1"} -{"ts":"2026-07-25T10:25:10.974Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_started","detail":"started wp2"} -{"ts":"2026-07-25T10:52:31.760Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp2"} -{"ts":"2026-07-25T10:52:31.760Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_started","detail":"started wp3"} -{"ts":"2026-07-25T11:24:06.677Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp3"} -{"ts":"2026-07-25T11:24:06.677Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_started","detail":"started wp4"} -{"ts":"2026-07-25T12:12:21.536Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp4"} -{"ts":"2026-07-25T12:12:21.537Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_started","detail":"started wp5"} -{"ts":"2026-07-25T12:48:30.899Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp5"} -{"ts":"2026-07-25T12:48:30.899Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_started","detail":"started wp6"} -{"ts":"2026-07-25T12:50:38.598Z","slug":"opencodex-codex-260725-bug-sweep-gui-enhancement","event":"workphase_done","detail":"closed wp6"} From a8c14fc1f25850d542d9c999a05b6f96fa27cc39 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 19:42:01 +0900 Subject: [PATCH 02/22] docs(devlog): grok-build bridge roadmap (a5727c5 pull, smoke matrix, wp1/wp2 plan) --- .../260723_grok_build_bridge/000_plan.md | 64 +++++++++++++++++++ .../001_sol_source_analysis.md | 46 +++++++++++++ .../010_usage_details_always.md | 39 +++++++++++ .../020_docs_and_residual_smoke.md | 27 ++++++++ 4 files changed, 176 insertions(+) create mode 100644 devlog/_plan/260723_grok_build_bridge/000_plan.md create mode 100644 devlog/_plan/260723_grok_build_bridge/001_sol_source_analysis.md create mode 100644 devlog/_plan/260723_grok_build_bridge/010_usage_details_always.md create mode 100644 devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md diff --git a/devlog/_plan/260723_grok_build_bridge/000_plan.md b/devlog/_plan/260723_grok_build_bridge/000_plan.md new file mode 100644 index 0000000000..d5cbf7386e --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/000_plan.md @@ -0,0 +1,64 @@ +# 000 — Grok Build 최신화 + OpenCodex 브리지 마감 로드맵 + +Date: 2026-07-23 +Session: 019f8e74-906b-7430-be5d-5598745922b6 (cxc-loop, docs-first roadmap cycle) + +## Loop-spec +- Loop archetype: spec-satisfaction (verifier = Grok headless exit 0 + full test suite green) +- Trigger: user asked to pull `~/Developer/codex/180_grok-build` to latest and stand up a devlog for the bridge follow-through +- Goal: OpenCodex를 로컬 서버로 쓰는 Grok Build 사용자가 **routed 모델 포함** 깨끗한 exit 0을 받는 상태 +- Non-goals: push (user approval 필요), Grok Build 소스 수정, `dev` 브랜치 직접 작업 +- Verifier: live `grok -p` smoke (exit code + stdout), `bun run typecheck`, `bun run test` + +## State refresh (2026-07-23, live) + +### grok-build tree +- `origin/main` force-pushed: `b189869` ("Publish harness and TUI open-source") → `a5727c5` ("Synced from monorepo" 연쇄, 상류가 히스토리 재작성) +- 로컬 조치: `backup/pre-260723-pull` 태그로 옛 tip 보존 → `git checkout -B main origin/main`. 로컬 커밋 없음, untracked `analysis/`/`.codexclaw/`는 그대로 보존됨 +- `SOURCE_REV`: `30192d2eef5d91a8fff0e53957de5bd05b43398c` +- 로컬 grok 바이너리: `0.2.101 (5bc4b5dfadcf) [stable]` (7/16 스모크 당시 0.2.99) +- user-guide diff (구→신): 21 files, +602/−634 — custom-models 계약 재검증 필요 (Sol 분석 010에 fold) + +### OpenCodex 런타임 +- 사용자가 재시작 완료: `healthz` → `opencodex 2.7.35`, port 10100 +- `/v1/chat/completions` 인바운드 **live 확인**: routed(`cursor/grok-4.5`)·native(`gpt-5.4-mini`) 모두 200 + 정답 텍스트 + +## Live smoke matrix (2026-07-23, grok 0.2.101 → ocx 2.7.35) + +격리 `GROK_HOME=/tmp/grok-ocx-smoke-260723`, `XAI_API_KEY=dummy-loopback` (grok가 비어있지 않은 키를 요구; loopback ocx는 무시). + +| grok model | configured backend | ocx model | text | exit | error | +|---|---|---|---|---|---| +| ocx-chat | chat_completions | cursor/grok-4.5 | (없음) | 1 | `missing field 'input_tokens_details'` | +| ocx-native-chat | chat_completions | gpt-5.4-mini | **OCX_NATIVE_OK** | **0** | — | +| ocx-resp | responses | cursor/grok-4.5 | OCX_RESP_OK | 1 | `missing field 'input_tokens_details'` | + +### 해석 +1. 7/16 블로커 ①(chat/completions 404)은 v2.7.35에서 해소 — 기본 백엔드로 wire가 뚫림. +2. 7/16 블로커 ②(native system 거부)도 chat 인바운드의 instructions 폴딩으로 해소 — native가 이번엔 **유일한 완전 성공**. +3. 남은 단일 블로커는 ③ usage details. 정정(리뷰 반영): grok의 **Chat Completions 디코더 자체는 usage optional로 관대**하다(001 참조). 실패한 ocx-chat 런은 *configured* backend가 chat이었는데도 실제로는 **Responses 이벤트를 소비하다** required `input_tokens_details`에서 죽은 것 — stderr raw_data가 `response.completed`인 것으로 확증. 즉 strict한 것은 Responses 디코더이고, grok 하니스가 chat 설정에서도 Responses 경로를 태우는 조건이 잔여 조사 항목이다. +4. native가 통과한 이유: ChatGPT 상류가 details를 항상 포함하고 ocx가 이를 보존 (`prompt_tokens_details`/`completion_tokens_details` 응답 확인). routed는 upstream이 details를 안 주면 ocx가 필드를 **생략**한다: + - `src/bridge.ts` `responsesUsage()` — cached/reasoning이 undefined면 `*_tokens_details` 생략 + - `src/chat/outbound.ts` `chatCompletionsUsage()` — 동일하게 조건부 생략 + - live 확인: `POST /v1/responses` (cursor/grok-4.5) usage = `{input_tokens, output_tokens, total_tokens}` 뿐 + +## Work-phases (decade map) + +| id | decade | title | verifier | +|----|--------|-------|----------| +| wp0 | 000-00x | 이 로드맵 + Sol 소스 분석 fold-in | 문서 존재 + FSM D | +| wp1 | 010 | usage details 상시 방출 (bridge.ts + chat/outbound.ts) + 회귀 테스트 | 신규 테스트 + typecheck + full test + live grok exit 0 (3-way matrix) | +| wp2 | 020 | Grok Build 연동 문서화 (docs-site 또는 devlog receipt) + 잔여 스모크 (tool-call turn) | docs 빌드/검증 + smoke receipt | + +wp1이 유일한 코드 변경. 예상 diff: `responsesUsage()`가 `input_tokens_details`(cached_tokens=0 기본)와 `output_tokens_details`(reasoning_tokens=0 기본)를 **항상** 포함, `chatCompletionsUsage()`도 `prompt_tokens_details`/`completion_tokens_details` 상시 포함. 위험: 기존 소비자(Codex CLI/App, Claude inbound)는 필드 추가에 관대(additive) — 회귀는 스냅샷 테스트로 커버. + +## Residuals / open questions (Sol 분석 대기) +- grok 0.2.101 custom-models 계약 변경점 (신규 필드/기본값) +- usage struct가 정확히 어떤 필드를 required로 두는지 (cache_write? reasoning?) +- models_base_url 카탈로그 fetch의 Bearer 요구가 여전한지 +- tool-call turn (function calling) 왕복이 grok chat 백엔드에서 도는지 — wp2 스모크 + +## Evidence +- 스모크 로그: `/tmp/grok-smoke-{chat,native,resp}.{out,err}` +- grok config: `/tmp/grok-ocx-smoke-260723/config.toml` +- 직전 판정: `devlog/_fin/260716_grok_build_connect/030_feasibility_verdict.md`, `040_live_smoke.md` diff --git a/devlog/_plan/260723_grok_build_bridge/001_sol_source_analysis.md b/devlog/_plan/260723_grok_build_bridge/001_sol_source_analysis.md new file mode 100644 index 0000000000..356b0c6951 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/001_sol_source_analysis.md @@ -0,0 +1,46 @@ +# 001 — Sol 서브에이전트 소스 분석 fold-in (grok-build a5727c5) + +Date: 2026-07-23 +Source: Sol(medium) 서브에이전트 read-only 분석, 전문은 `/tmp/grok-build-a5727c5-analysis.md` +Tree: `/Users/jun/Developer/codex/180_grok-build` @ `a5727c5960452e7527a154b25cb5bf00cda0545e` (SOURCE_REV `30192d2eef5d`) + +## 핵심 판정 (Sol) + +1. **Responses usage details는 여전히 필수(non-Option).** pinned `async-openai` fork(`95b52ebd`)의 `ResponseUsage`가 `input_tokens_details`/`output_tokens_details`를 required struct로 정의 — `#[serde(default)]` 없음. 중첩 필드(`cached_tokens`, `reasoning_tokens`)도 required. + - `crates/codegen/xai-grok-sampler/src/client.rs:99-129` (SSE 역직렬화, 실패 시 SamplingError) + - `crates/codegen/xai-grok-sampler/src/stream/responses.rs:319-327, 482-488` +2. **Chat Completions 클라이언트는 관대.** chunk의 `usage`는 Optional, details도 Optional, 중첩은 zero-default (`xai-grok-sampling-types/src/types.rs:535-587`). +3. **system 메시지는 chat 백엔드에서 정상 role로 전송** (`conversation.rs:1781-1784`) — ocx chat 인바운드가 instructions로 폴딩하므로 문제 없음. +4. **카탈로그 fetch는 loopback 예외 없이 Bearer 필수** (`remote/client.rs:686-738`). 빈 키면 `No API key for custom models endpoint`. `{data:[...]}` 형태, id는 `model|modelId|id|_meta.*`, `context_window` 없으면 256k 기본. +5. **신규 config 표면**: `[model_providers.]` 재사용 블록 + `[model.] auth_provider`(동적 Bearer 헬퍼). 백엔드 enum은 3종 그대로(chat_completions 기본). +6. custom-models 가이드(11-custom-models.md)는 구/신 revision에서 blob 동일 — 계약 변경 없음. + +## 로컬 스모크와의 교차 검증 (main agent, 2026-07-23) + +`/tmp/grok-smoke-chat.err`의 raw_data가 결정적: + +``` +Failed to deserialize ResponseStreamEvent … missing field `input_tokens_details` +raw_data={"type":"response.completed", … "usage":{"input_tokens":0,"output_tokens":22,"total_tokens":22}} +``` + +- `api_backend = "chat_completions"`로 설정한 `ocx-chat`(cursor/grok-4.5)조차 **실제 와이어는 Responses 이벤트를 역직렬화하다 실패**했다. grok 0.2.101 하니스가 이 turn을 Responses 클라이언트로 태운 것 (설정과 무관하게 harness 내부 경로가 Responses vocab을 쓰는 표면 존재 — 정확한 트리거는 잔여 조사 항목). +- native `gpt-5.4-mini`가 exit 0인 이유: ChatGPT 상류가 details를 항상 포함하고 ocx가 보존 → 역직렬화 통과. +- routed가 죽는 이유: ocx `src/bridge.ts` `responsesUsage()`가 cached/reasoning 미보고 upstream에서 `*_tokens_details`를 **생략**. + +### 수렴 결론 + +Grok 쪽 3개 백엔드/신규 표면 무엇을 쓰든, **ocx가 usage details를 상시 방출하면 전 매트릭스가 뚫린다.** Sol 권고(chat 기본 브리지)와 로컬 스모크(사실상 Responses 경로 강제)를 합치면 wp1은 두 인코더 모두 커버해야 한다: + +1. `src/bridge.ts` `responsesUsage()` — `input_tokens_details.cached_tokens`(기본 0), `output_tokens_details.reasoning_tokens`(기본 0) 상시 포함. usage undefined인 기본 반환값에도 포함. +2. `src/chat/outbound.ts` `chatCompletionsUsage()` — `prompt_tokens_details`/`completion_tokens_details` 상시 포함 (grok chat 클라이언트엔 optional이지만 대칭성+향후 strict 클라이언트 대비). + +## Grok-side 신규 기회 (문서화 대상, wp2) + +- `[model_providers.opencodex]` 블록 하나로 base_url/backend를 공유하고 모델별 alias만 얇게 추가하는 권장 config. +- 카탈로그 연동: `GROK_MODELS_BASE_URL=http://127.0.0.1:10100/v1` + 아무 non-empty `XAI_API_KEY` (loopback ocx는 admission key 무시). ocx `/v1/models`는 이미 `{data:[{id,…}]}` 반환 — `api_backend`/`context_window` per-model 필드를 ocx가 실어주면 zero-config에 근접(선택 과제, out of scope 표기). + +## 잔여 + +- grok 0.2.101이 `api_backend="chat_completions"` custom model에서 Responses 와이어를 태운 정확한 조건 (config 파싱? 하니스 goal-tracker 사이드카?) — wp1 검증 시 grok 디버그 로그로 재확인. +- tool-call 왕복 스모크는 wp2. diff --git a/devlog/_plan/260723_grok_build_bridge/010_usage_details_always.md b/devlog/_plan/260723_grok_build_bridge/010_usage_details_always.md new file mode 100644 index 0000000000..871440410e --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/010_usage_details_always.md @@ -0,0 +1,39 @@ +# 010 — wp1: usage details 상시 방출 (diff-level plan) + +Goal: routed 모델의 `response.completed` / chat `usage`에 token-detail 오브젝트를 항상 포함시켜 Grok Build(및 여타 strict Responses 클라이언트)의 exit 1을 제거. + +## Why (evidence) +- grok 0.2.101 pinned `async-openai` `ResponseUsage`: `input_tokens_details`/`output_tokens_details` required (001 참조). +- live: routed `cursor/grok-4.5` usage = `{input_tokens, output_tokens, total_tokens}`만 방출 → grok `serialization error: missing field 'input_tokens_details'` exit 1 (3-way 매트릭스 000 참조). +- native ChatGPT 경로는 상류가 details를 항상 실어 exit 0 — 목표는 routed를 native와 동일 계약으로. + +## Diff plan + +### 1. `src/bridge.ts` — `responsesUsage()` +- usage 없음 분기: `{ input_tokens: 0, output_tokens: 0, total_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } }` 반환. +- 본 분기: `inputDetails.cached_tokens`를 `usage.cachedInputTokens ?? 0`으로 항상 설정 (cache_write_tokens는 기존처럼 있을 때만), `out.input_tokens_details = inputDetails` 무조건 대입. `out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 }` 무조건 대입. +- 주석: grok-build pinned async-openai가 두 details를 required로 역직렬화한다는 근거 명시 (`response_usage.rs:18-28` @ 95b52ebd). + +### 2. `src/chat/outbound.ts` — `chatCompletionsUsage()` +- `prompt_tokens_details = { cached_tokens: cached ?? 0 }` 상시 포함. +- `completion_tokens_details = { reasoning_tokens: reasoning ?? 0 }` 상시 포함. + +### 3. 회귀 테스트 +- bridge 쪽: `responsesUsage()`는 private — 공개 표면인 `bridgeToResponsesSSE()` / `buildResponseJSON()` 경유로 검증. 배치는 `tests/bridge.test.ts` (reviewer 확인: 인접 표면 맞음). 케이스 3종: usage 부재 / usage 있으나 cached·reasoning undefined / 값 있음 — 스트리밍·논스트리밍 모두에서 두 details 키 존재+숫자 확인. +- chat 쪽: `tests/chat-completions-endpoint.test.ts`에 non-stream+stream usage frame의 details 존재 어서션 추가. + +### 4. 리스크 점검 +- 소비자: Codex CLI/App(관대), Claude inbound(`src/claude/outbound.ts`는 자체 변환 — 영향 경로 typecheck로 확인), request-log 파서(`src/server/request-log.ts:352-375`는 optional 읽기라 무해). +- `cached_tokens: 0` 상시 방출이 GUI 비용 계산에 미치는 영향: 0은 no-op이므로 없음 (확인: `usageDisplayTotalTokens` 경로). + +## Verifier +1. `bun run typecheck` +2. `bun run test` (full) +3. **런타임 프로비넌스 (reviewer blocker 반영):** 수정된 체크아웃으로 :10100 프록시를 명시적으로 재기동(`ocx stop` → `ocx start`, 사용자 확인 필요 — 이 프록시가 현재 세션을 서빙 중)하고, `/healthz` version+pid+uptime으로 새 프로세스가 수정 코드를 서빙함을 기록한 뒤에만 매트릭스 실행. stale 프로세스 결과는 무효. +4. live 3-way 매트릭스 재실행 (grok 0.2.101, `GROK_HOME=/tmp/grok-ocx-smoke-260723`): ocx-chat / ocx-native-chat / ocx-resp 모두 **exit 0** + 정답 텍스트. +5. **endpoint 프로비넌스 (reviewer 제안 6 반영):** grok의 ocx-chat 런이 Responses 디코더를 탄 사실이 있으므로, 매트릭스만으로는 `chatCompletionsUsage()` 실행 증명이 안 됨 — 직접 `curl /v1/chat/completions` (stream+non-stream)로 details 상시 존재를 별도 확인. +6. 스모크 receipt는 `011_receipt.md`에 **명령·타임스탬프·stdout·stderr·exit code를 한 세트로** 기록 (reviewer 제안 5). + +## Out of scope +- ocx `/v1/models`에 `api_backend`/`context_window` 필드 추가 (zero-config 카탈로그) — 후속 결정 사항. +- push / release (사용자 승인 필요). diff --git a/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md b/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md new file mode 100644 index 0000000000..b9a14f7335 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md @@ -0,0 +1,27 @@ +# 020 — wp2: Grok Build 연동 문서화 + 잔여 스모크 + +Precondition: wp1 (usage details 상시 방출) 완료, 3-way 매트릭스 exit 0. + +## Deliverables + +### 1. 사용자 문서 (docs-site) +- 위치 후보: `docs-site` 내 클라이언트 연동 섹션 (기존 Claude Code / Copilot 인바운드 문서 형식 답습 — P에서 실제 경로 확인). +- 내용: + - 권장 config: `[model_providers.opencodex]` 블록 + 얇은 `[model.]` 목록 (Sol 분석의 신규 표면 활용, grok 0.2.101+). + - 백엔드 선택: `responses` 권장 (wp1 이후 완전 동작; harness가 내부적으로 Responses vocab을 쓰는 경로 존재), `chat_completions`도 동작. + - 인증: loopback은 아무 non-empty `XAI_API_KEY`/api_key로 충분(ocx가 무시), non-loopback은 ocx admission token. + - 카탈로그: `GROK_MODELS_BASE_URL=http://127.0.0.1:10100/v1`. + - 한계: grok 하니스 시스템 프롬프트는 xAI 톤 전제, 모델별 tool 동작 차이. +- 번역 로케일 모순 없는지 확인 (AGENTS.md Docs sync 규칙). + +### 2. 잔여 스모크 (021_receipt) +- tool-call 왕복: grok headless로 `run_terminal_cmd` 허용 1턴 (`echo` 정도) — function_call/function_call_output 왕복이 routed 모델에서 도는지. +- 카탈로그 fetch: `GROK_MODELS_BASE_URL` 설정 후 `grok models`가 ocx 모델 목록을 표시하는지 + 각 항목 백엔드 폴백(chat_completions) 동작 기록. +- 스트리밍 长응답 1건 (사고 텍스트 포함 모델)로 reasoning 델타 처리 확인. + +## Verifier +- docs 빌드 체크 (docs-site 빌드 or 기존 lint 스크립트 — P에서 확인) +- 스모크 로그 receipt 커밋 (devlog 유닛에 요약, 로그 자체는 /tmp 유지) + +## Out of scope +- ocx `/v1/models` 응답 확장(zero-config), grok-build 소스 수정, push/release. From 9a4d7184d55517556d90fd03f827c07636abea44 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 19:59:43 +0900 Subject: [PATCH 03/22] fix(usage): always emit token-detail objects for strict Responses/chat clients grok-build's pinned async-openai fork deserializes input_tokens_details/ output_tokens_details as required (non-Option) fields, so routed models that report no cache/reasoning numbers made Grok Build exit 1 after successful text. Emit both detail objects with zero defaults from responsesUsage() and chatCompletionsUsage(); OpenAI's own ResponseUsage schema marks them required whenever usage is present, so this is spec-conformant normalization. --- src/bridge.ts | 37 ++++++++++++++-------- src/chat/outbound.ts | 14 ++++----- tests/bridge.test.ts | 41 +++++++++++++++++++++++++ tests/chat-completions-endpoint.test.ts | 32 +++++++++++++++++++ 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index c90e3ec831..cf687e02c9 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -14,8 +14,22 @@ function sseEvent(name: string, data: Record): string { } function responsesUsage(usage: OcxUsage | undefined): Record { - if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; - // Stateful providers may report an absolute active-context checkpoint separately from their + // input_tokens_details / output_tokens_details are ALWAYS emitted (zero defaults): + // strict Responses clients deserialize them as required fields — grok-build's pinned + // async-openai fork (rev 95b52ebd, response_usage.rs) has non-Option InputTokenDetails/ + // OutputTokenDetails, so omitting them turns a successful turn into a hard exit after + // response.completed ("missing field `input_tokens_details`", verified live 2026-07-23). + if (!usage) { + return { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }; + } + // inputTokens is already inclusive of cache read/write (types.ts convention). Stateful + // providers may report an absolute active-context checkpoint separately from their // per-attempt usage. Split that checkpoint into input + output without adding output twice. const inputTokens = usage.contextTotalTokens !== undefined ? Math.max(0, usage.contextTotalTokens - usage.outputTokens) @@ -27,11 +41,12 @@ function responsesUsage(usage: OcxUsage | undefined): Record { ? usage.contextTotalTokens : usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens, }; - const inputDetails: Record = {}; - if (usage.cachedInputTokens !== undefined) { - // cached_tokens carries cache READS only, matching OpenAI semantics. - inputDetails.cached_tokens = Math.min(usage.cachedInputTokens, inputTokens); - } + // cached_tokens carries cache READS only, matching OpenAI semantics, and is always present + // (zero default) for strict clients. Clamp to inputTokens so a provider's absolute + // checkpoint can never report more cache reads than input. + const inputDetails: Record = { + cached_tokens: Math.min(usage.cachedInputTokens ?? 0, inputTokens), + }; if (usage.cacheCreationInputTokens !== undefined) { const cacheRead = inputDetails.cached_tokens ?? 0; inputDetails.cache_write_tokens = Math.min( @@ -39,12 +54,8 @@ function responsesUsage(usage: OcxUsage | undefined): Record { Math.max(0, inputTokens - cacheRead), ); } - if (Object.keys(inputDetails).length > 0) { - out.input_tokens_details = inputDetails; - } - if (usage.reasoningOutputTokens !== undefined) { - out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens }; - } + out.input_tokens_details = inputDetails; + out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 }; return out; } diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index 286a34e2d3..f06b5469e7 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -27,19 +27,19 @@ export function chatCompletionsUsage(usage: unknown): Rec { const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {}; const prompt = typeof u.input_tokens === "number" ? u.input_tokens : 0; const completion = typeof u.output_tokens === "number" ? u.output_tokens : 0; - const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : undefined; + const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0; const out: Rec = { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion, }; - if (cached !== undefined) { - out.prompt_tokens_details = { cached_tokens: cached }; - } + // Detail objects are always emitted (zero defaults) so strict OpenAI-compatible + // clients that require them (see responsesUsage in src/bridge.ts) never fail on + // routed providers that report no cache/reasoning numbers. + out.prompt_tokens_details = { cached_tokens: cached }; const outDetails = isRec(u.output_tokens_details) ? u.output_tokens_details : {}; - if (typeof outDetails.reasoning_tokens === "number") { - out.completion_tokens_details = { reasoning_tokens: outDetails.reasoning_tokens }; - } + const reasoning = typeof outDetails.reasoning_tokens === "number" ? outDetails.reasoning_tokens : 0; + out.completion_tokens_details = { reasoning_tokens: reasoning }; return out; } diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 00b419f187..0fa6630337 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -147,6 +147,8 @@ describe("Responses bridge reasoning and usage parity", () => { input_tokens: 225_988, output_tokens: 12, total_tokens: 226_000, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, }); }); @@ -166,6 +168,45 @@ describe("Responses bridge reasoning and usage parity", () => { expect(totals).toEqual([10_000, 10_300]); }); + test("usage details are always present with zero defaults (grok-build strict Responses client)", async () => { + // grok-build's pinned async-openai deserializes input_tokens_details/output_tokens_details + // as required fields; omitting them fails the turn after successful text (2026-07-23 live). + const withoutDetails = await collectSse(bridgeToResponsesSSE(replay([ + { type: "done", usage: { inputTokens: 10, outputTokens: 5 } }, + ]), "routed/model")); + const completed = withoutDetails.find(f => f.event === "response.completed")?.data.response as Record; + expect(completed.usage).toMatchObject({ + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, + }); + + const noUsage = await collectSse(bridgeToResponsesSSE(replay([ + { type: "done" }, + ]), "routed/model")); + const bare = noUsage.find(f => f.event === "response.completed")?.data.response as Record; + expect(bare.usage).toMatchObject({ + input_tokens: 0, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }); + + const json = buildResponseJSON([ + { type: "done", usage: { inputTokens: 7, outputTokens: 3 } }, + ], "routed/model"); + expect(json.usage).toMatchObject({ + input_tokens: 7, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 10, + }); + }); + test("Anthropic cache read and write tokens pass through Responses usage without re-adding", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 07fac4478a..2877cf8ff2 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -7,6 +7,7 @@ import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../src/chat/inbound"; +import { chatCompletionsUsage } from "../src/chat/outbound"; let testDir = ""; let previousHome: string | undefined; @@ -188,6 +189,37 @@ test("chatCompletionsToResponsesBody rejects missing model", () => { .toThrow(ChatCompletionsRequestError); }); +test("chatCompletionsUsage always emits detail objects with zero defaults", () => { + // Strict OpenAI-compatible clients (grok-build) require token-detail objects; + // routed providers that report no cache/reasoning numbers must still produce them. + expect(chatCompletionsUsage({ input_tokens: 9, output_tokens: 4 })).toEqual({ + prompt_tokens: 9, + completion_tokens: 4, + total_tokens: 13, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }); + expect(chatCompletionsUsage(undefined)).toEqual({ + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }); + expect(chatCompletionsUsage({ + input_tokens: 20, + output_tokens: 10, + input_tokens_details: { cached_tokens: 5 }, + output_tokens_details: { reasoning_tokens: 3 }, + })).toEqual({ + prompt_tokens: 20, + completion_tokens: 10, + total_tokens: 30, + prompt_tokens_details: { cached_tokens: 5 }, + completion_tokens_details: { reasoning_tokens: 3 }, + }); +}); + test("POST /v1/chat/completions streams OpenAI-shaped chunks end to end", async () => { const upstream = mockChatUpstream(); saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); From ed75438f43a57cb6c16d600e6b6bdf98607e3fe0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 20:02:36 +0900 Subject: [PATCH 04/22] =?UTF-8?q?docs(devlog):=20wp1=20receipt=20=E2=80=94?= =?UTF-8?q?=20usage=20details=20always-emit=20verified=20(3-way=20grok=20e?= =?UTF-8?q?xit=200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260723_grok_build_bridge/011_receipt.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 devlog/_plan/260723_grok_build_bridge/011_receipt.md diff --git a/devlog/_plan/260723_grok_build_bridge/011_receipt.md b/devlog/_plan/260723_grok_build_bridge/011_receipt.md new file mode 100644 index 0000000000..beefbeebc4 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/011_receipt.md @@ -0,0 +1,32 @@ +# 011 — wp1 receipt: usage details 상시 방출 검증 + +Date: 2026-07-23 (KST), branch `codex/260723-grok-build-bridge`, commit `7d83deaf` + +## External source proof (Sol/priority cxc-search subagent, Tier-2 opened sources, 2026-07-23) + +- Claim 1 **PROVEN**: grok-build `a5727c5` Cargo.toml pins `async-openai = { git = "https://github.com/our-forks/async-openai.git", rev = "95b52ebd..." }` (fork org는 `our-forks`). 해당 rev의 `response_usage.rs`에서 `input_tokens_details: InputTokenDetails` / `output_tokens_details: OutputTokenDetails` — non-Option, serde default 없음, 중첩 `cached_tokens`/`reasoning_tokens`도 required. 원문 blob 열람 증명. +- Claim 2 **부분 반박(정밀화)**: OpenAI 공식 openapi (`openai/openai-openapi` @ `f9400172`)는 `Response.usage` 자체를 required로 두지 않지만, usage가 존재하면 `ResponseUsage` 스키마상 두 details는 required. → 상시 방출(zero defaults)은 **스펙 적합 정규화**이며 unknown-field 확장이 아님. 단 "항상 존재 보장"은 공식 계약보다 강한 우리 쪽 보증임을 문서에 명시. + +## Code change (7d83deaf) + +- `src/bridge.ts` `responsesUsage()`: no-usage 분기 포함 두 details 상시 방출 (cached_tokens/reasoning_tokens 기본 0, cache_write_tokens는 기존처럼 조건부). +- `src/chat/outbound.ts` `chatCompletionsUsage()`: `prompt_tokens_details`/`completion_tokens_details` 상시 방출. +- 테스트: `tests/bridge.test.ts` 신규 케이스(usage 부재/미보고/보고 3종 × SSE/JSON), `tests/chat-completions-endpoint.test.ts` `chatCompletionsUsage` 유닛 3종. + +## Verifier 결과 + +1. `bun run typecheck` — pass (worktree에 `bun install` 선행 필요했음) +2. `bun run test` — **3702 pass / 1 fail**. 실패 1건은 `tests/anthropic-thinking-signature.test.ts` "sanitize strips ocxr1..." — **stash 후 미수정 트리에서도 동일 실패** (full-run 한정, 단독 실행 시 pass — 테스트 격리/캐시 간섭으로 보이는 기존 이슈, 본 변경과 무관 확증). 변경 파일 3종 단독 실행 70 pass 0 fail. +3. 런타임 프로비넌스: 임시 스택 `OPENCODEX_HOME=/tmp/ocx-wp1-home` + `--port 10190`으로 **수정 체크아웃에서 직접 기동** (healthz pid 40465, version 2.7.34-dev). :10100 프로덕션 프록시(이 세션 서빙 중)는 건드리지 않음 — 리뷰어 blocker의 취지(수정 코드 서빙 증명)를 재시작 대신 격리 기동으로 충족. +4. endpoint 프로비넌스 (curl → :10190): + - `POST /v1/responses` (cursor/grok-4.5): usage에 `input_tokens_details:{cached_tokens:0}` / `output_tokens_details:{reasoning_tokens:0}` 포함 확인 + - `POST /v1/chat/completions` non-stream + stream 마지막 usage frame: `prompt_tokens_details`/`completion_tokens_details` 포함 확인 → `chatCompletionsUsage()` 실행 경로 증명 +5. live 3-way grok 매트릭스 (grok 0.2.101, `GROK_HOME=/tmp/grok-ocx-smoke-wp1` → :10190, 2026-07-23 19:5x KST): + +| model | configured backend | ocx model | exit | stdout | +|---|---|---|---|---| +| ocx-chat | chat_completions | cursor/grok-4.5 | **0** | OCX_WP1_OK | +| ocx-native-chat | chat_completions | gpt-5.4-mini | **0** | OCX_WP1_OK | +| ocx-resp | responses | cursor/grok-4.5 | **0** | OCX_WP1_OK | + +수정 전 baseline(000)은 chat=1/native=0/resp=1이었음 → 블로커 ③ 해소 확인. 로그: `/tmp/grok-wp1-ocx-{chat,native-chat,resp}.{out,err}`. From 0422ce193c297fb0c01c0692409fb3fb4062c683 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 20:45:01 +0900 Subject: [PATCH 05/22] fix(usage): preserve raw adapter usage provenance via bridge onUsage callback The always-emitted zero-default detail objects made request-log wire re-parsing read synthetic zeros as measured cache/reasoning numbers, suppressing cache_detail_missing in cost estimates. The bridge now reports raw adapter usage (pre-normalization) through an onUsage callback at every terminal path (done, max_tokens, incomplete, error-with-usage, adapter EOF), and applyResponseLogMetadata skips wire re-parsing when the bridge already reported (usageFromBridge). Native passthrough is unchanged. --- .../260723_grok_build_bridge/011_receipt.md | 23 +++++++ src/bridge.ts | 17 +++++ src/server/request-log.ts | 7 +- src/server/responses/core.ts | 38 +++++++++++ src/web-search/loop.ts | 5 +- tests/bridge.test.ts | 65 +++++++++++++++++++ tests/usage-shape-extraction.test.ts | 29 +++++++++ 7 files changed, 182 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260723_grok_build_bridge/011_receipt.md b/devlog/_plan/260723_grok_build_bridge/011_receipt.md index beefbeebc4..630627d0be 100644 --- a/devlog/_plan/260723_grok_build_bridge/011_receipt.md +++ b/devlog/_plan/260723_grok_build_bridge/011_receipt.md @@ -30,3 +30,26 @@ Date: 2026-07-23 (KST), branch `codex/260723-grok-build-bridge`, commit `7d83dea | ocx-resp | responses | cursor/grok-4.5 | **0** | OCX_WP1_OK | 수정 전 baseline(000)은 chat=1/native=0/resp=1이었음 → 블로커 ③ 해소 확인. 로그: `/tmp/grok-wp1-ocx-{chat,native-chat,resp}.{out,err}`. + +## Round 2 — 리뷰어 blocker 반영 (provenance 보존) + +Sol(priority) 리뷰어 Dalton 1라운드 FAIL: wire의 synthetic zero details를 `request-log`가 실측값으로 재파싱 → GUI가 cache read=0/reasoning=0으로 표시하고 `cache_detail_missing` 억제 (provenance 손실). + +반영 (커밋 2건째): +- `src/bridge.ts`: `bridgeToResponsesSSE`/`buildResponseJSON`에 `onUsage` 콜백 추가 — 터미널 이벤트(done/max_tokens/incomplete/error-with-usage)에서 **정규화 전 raw adapter usage**를 보고. +- `src/server/responses.ts`: 4개 호출부(스트림 2, JSON 2) + web-search 루프에 `onUsage` 배선, `logCtx.usageFromBridge` 마킹. +- `src/server/request-log.ts`: `applyResponseLogMetadata`가 `usageFromBridge`일 때 wire 재파싱으로 usage를 덮어쓰지 않음 (native passthrough는 기존 동작 유지). +- `src/web-search/loop.ts`: `WebSearchLoopDeps.onUsage` 추가. +- 테스트: bridge `onUsage` raw-vs-wire 검증, incomplete/failed 터미널 details 검증(제안 3), `applyResponseLogMetadata` provenance guard(제안 2). 대상 5파일 129 pass. + +재검증 (2026-07-23 20:3x KST, 새 :10190 프로세스 pid 44898): + +| model | exit | stdout | +|---|---|---| +| ocx-chat | 0 | OCX_WP1B_OK | +| ocx-native-chat | 0 | OCX_WP1B_OK | +| ocx-resp | 1회차 1(히트비트, 하단), 재시도 3/3 **exit 0** | OCX_WP1B_OK | + +## 신규 잔여 발견: `response.heartbeat` vs grok strict 디코더 + +ocx의 keep-alive `response.heartbeat` 프레임(코덱스는 unknown event 무시)은 grok 0.2.101 Responses 디코더의 **closed enum**에서 즉사(`unknown variant response.heartbeat`). upstream이 2초(heartbeatMs) 이상 침묵한 턴에서만 발생 — 이번 1회 재현, 재시도 3회는 히트비트 미발화로 전부 통과. wp2 문서화에 known-limitation으로 기재하고, 필요시 별도 결정(예: chat 백엔드 권장, 또는 Responses 클라이언트 UA 감지 시 히트비트 억제)은 후속 작업. diff --git a/src/bridge.ts b/src/bridge.ts index cf687e02c9..761ffcf722 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -118,6 +118,15 @@ export function bridgeToResponsesSSE( onFirstOutput?: () => void; onTerminal?: (status: ResponsesTerminalStatus) => void; onCompletedResponse?: (response: Record, providerState?: OcxProviderContinuationState) => void; + /** + * Raw adapter-reported usage at the terminal event, BEFORE wire normalization. + * responsesUsage() always emits token-detail objects with zero defaults for strict + * clients (grok-build), which makes the wire unusable as a provenance source: the + * request log must not read synthetic zeros as measured cache/reasoning numbers + * (cache_detail_missing would be silently suppressed). Callers set logCtx.usage + * from this callback instead of re-parsing the bridged SSE. + */ + onUsage?: (usage: OcxUsage | undefined) => void; }, ): ReadableStream { // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a @@ -692,11 +701,13 @@ export function bridgeToResponsesSSE( // Cache max-output partials so previous_response_id replay can continue them; // rememberResponseState rejects content-filtered incomplete responses. options?.onCompletedResponse?.(response, event.providerState); + options?.onUsage?.(event.usage); emit("response.incomplete", { response }); reportTerminal("incomplete"); } else { const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) }; options?.onCompletedResponse?.(response, event.providerState); + options?.onUsage?.(event.usage); emit("response.completed", { response, }); @@ -713,6 +724,7 @@ export function bridgeToResponsesSSE( if (currentToolCall) closeCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); flushHiddenReasoningEnvelope(); + options?.onUsage?.(event.usage); emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems, event.endTurn), @@ -736,6 +748,7 @@ export function bridgeToResponsesSSE( if (currentToolCall) closeCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); const failure = adapterFailureFromEvent(event); + if (event.usage) options?.onUsage?.(event.usage); emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), @@ -793,6 +806,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) closeCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + options?.onUsage?.(undefined); emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems), @@ -887,6 +901,8 @@ export function buildResponseJSON( /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */ compaction?: boolean; onProviderState?: (state: OcxProviderContinuationState) => void; + /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */ + onUsage?: (usage: OcxUsage | undefined) => void; }, ): Record { const responseId = `resp_${uuid()}`; @@ -1111,6 +1127,7 @@ export function buildResponseJSON( : incompleteEvent || stopReason === "max_tokens" ? "incomplete" : "completed"; + options?.onUsage?.(incompleteEvent?.usage ?? usage); return { id: responseId, object: "response", created_at: Math.floor(Date.now() / 1000), diff --git a/src/server/request-log.ts b/src/server/request-log.ts index df4f19d444..2ff8598cea 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -58,6 +58,11 @@ export interface RequestLogContext { /** Route adapter type ("cursor"/"kiro"/"anthropic"/…): drives estimated-usage detection * independent of the user-chosen provider NAME (devlog 130 B2). */ providerAdapter?: string; + /** Set when the bridge reported raw adapter usage via onUsage: the bridged wire now always + * carries synthetic zero-default token-detail objects (strict-client normalization, see + * responsesUsage in src/bridge.ts), so SSE/JSON re-parsing must not overwrite the raw + * provenance — a synthetic cached_tokens:0 is NOT a measured cache read. */ + usageFromBridge?: boolean; /** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…" * message) extracted from a `response.failed` SSE payload or non-streaming error body, so the * request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */ @@ -338,7 +343,7 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk const serviceTier = (source as { service_tier?: unknown }).service_tier; if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier; const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage); - if (usage) { + if (usage && !logCtx.usageFromBridge) { logCtx.usage = usage; if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e7d81f1104..46f4f5c55d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1374,6 +1374,15 @@ export async function handleResponses( hideThinkingSummary: parsed.options.hideThinkingSummary, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), + onUsage: usage => { + // Raw adapter usage, pre wire-normalization: the bridged SSE now always carries + // zero-default detail objects, so provenance must come from here (cache_detail_missing). + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, ...(routedCompaction ? {} : { onCompletedResponse: (response: Record, providerState?: OcxProviderContinuationState) => rememberResponseState( @@ -1411,6 +1420,13 @@ export async function handleResponses( toolSearchToolNames, ...(routedCompaction ? { compaction: true } : {}), onProviderState: state => { providerState = state; }, + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, }); if (!routedCompaction) { rememberResponseState( @@ -1442,6 +1458,13 @@ export async function handleResponses( forceEmptyResponseId: true, abortSignal: options.abortSignal, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome, connectTimeoutMs: config.connectTimeoutMs ?? 200_000, routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs, @@ -1788,6 +1811,14 @@ export async function handleResponses( hideThinkingSummary: parsed.options.hideThinkingSummary, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), + onUsage: usage => { + // Raw adapter usage, pre wire-normalization (see the runTurn branch above). + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full // PRE-compaction history, and a later previous_response_id expansion would rehydrate the // giant stale chain Codex just replaced. @@ -1837,6 +1868,13 @@ export async function handleResponses( toolSearchToolNames, ...(routedCompaction ? { compaction: true } : {}), onProviderState: state => { providerState = state; }, + onUsage: usage => { + logCtx.usageFromBridge = true; + if (usage) { + logCtx.usage = usage; + if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage; + } + }, }); // See the streaming branch: compaction turns skip the continuation cache. if (!routedCompaction) { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 17bcfdd587..ae8120e19e 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,5 +1,5 @@ import type { ProviderAdapter } from "../adapters/base"; -import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent } from "../types"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage } from "../types"; import { namespacedToolName } from "../types"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; @@ -189,6 +189,8 @@ export interface WebSearchLoopDeps { stallTimeoutSec?: number; /** One-shot TTFT callback: first non-empty model output observed (WP4). */ onFirstOutput?: () => void; + /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ + onUsage?: (usage: OcxUsage | undefined) => void; /** * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter, * or null when the pool is exhausted (same semantics as the normal routed path). @@ -575,6 +577,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { }); }); + test("onUsage reports raw adapter usage while the wire carries synthetic zero details", async () => { + // Provenance guard: request-log consumers must see the adapter-reported usage (no + // cache/reasoning numbers => cache_detail_missing), not the normalized wire zeros. + let rawUsage: unknown = "unset"; + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "done", usage: { inputTokens: 10, outputTokens: 5 } }, + ]), "routed/model", undefined, undefined, undefined, undefined, 2_000, { + onUsage: usage => { rawUsage = usage; }, + })); + expect(rawUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; + expect((completed.usage as Record).input_tokens_details).toEqual({ cached_tokens: 0 }); + + let jsonRawUsage: unknown = "unset"; + buildResponseJSON([ + { type: "done", usage: { inputTokens: 4, outputTokens: 2 } }, + ], "routed/model", { onUsage: usage => { jsonRawUsage = usage; } }); + expect(jsonRawUsage).toEqual({ inputTokens: 4, outputTokens: 2 }); + + // Adapter EOF (no terminal event): onUsage must still fire with undefined so the + // request log keeps provenance (usageFromBridge) instead of re-parsing wire zeros. + let eofUsage: unknown = "unset"; + const eofFrames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "text_delta", text: "partial" }, + ]), "routed/model", undefined, undefined, undefined, undefined, 2_000, { + onUsage: usage => { eofUsage = usage; }, + })); + expect(eofUsage).toBeUndefined(); + const eofResponse = eofFrames.find(f => f.event === "response.incomplete")?.data.response as Record; + expect(eofResponse.incomplete_details).toMatchObject({ reason: "adapter_eof" }); + expect(eofResponse.usage).toMatchObject({ + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + }); + + test("incomplete and failed terminal events also carry zero-default usage details", async () => { + const incomplete = await collectSse(bridgeToResponsesSSE(replay([ + { + type: "incomplete", + reason: "upstream_truncated", + retryable: true, + endTurn: false, + usage: { inputTokens: 8, outputTokens: 1 }, + }, + ]), "routed/model")); + const incompleteResponse = incomplete.find(f => f.event === "response.incomplete")?.data.response as Record; + expect(incompleteResponse.usage).toMatchObject({ + input_tokens: 8, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + + const failed = await collectSse(bridgeToResponsesSSE(replay([ + { type: "text_delta", text: "partial" }, + { type: "error", message: "boom", status: 502, usage: { inputTokens: 3, outputTokens: 1 } }, + ]), "routed/model")); + const failedResponse = failed.find(f => f.event === "response.failed")?.data.response as Record; + expect(failedResponse.usage).toMatchObject({ + input_tokens: 3, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }); + }); + test("Anthropic cache read and write tokens pass through Responses usage without re-adding", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { diff --git a/tests/usage-shape-extraction.test.ts b/tests/usage-shape-extraction.test.ts index b51360b6d6..747a9368b1 100644 --- a/tests/usage-shape-extraction.test.ts +++ b/tests/usage-shape-extraction.test.ts @@ -1,7 +1,36 @@ import { describe, expect, test } from "bun:test"; import { usageFromResponsesPayload } from "../src/server"; +import { applyResponseLogMetadata, type RequestLogContext } from "../src/server/request-log"; describe("usageFromResponsesPayload", () => { + test("bridge-reported usage wins over re-parsed wire payload (provenance guard)", () => { + // The bridge always emits zero-default detail objects for strict clients; when + // onUsage already recorded the raw adapter usage, SSE/JSON re-parsing must not + // overwrite it with synthetic zeros (cache_detail_missing suppression). + const logCtx: RequestLogContext = { model: "m", provider: "p", usageFromBridge: true }; + logCtx.usage = { inputTokens: 10, outputTokens: 5 }; + applyResponseLogMetadata(logCtx, { + response: { + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }); + expect(logCtx.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); + expect(logCtx.usage.cachedInputTokens).toBeUndefined(); + + // Without the bridge flag (native passthrough), wire parsing still applies. + const passthroughCtx: RequestLogContext = { model: "m", provider: "p" }; + applyResponseLogMetadata(passthroughCtx, { + response: { usage: { input_tokens: 4, output_tokens: 2, input_tokens_details: { cached_tokens: 1 } } }, + }); + expect(passthroughCtx.usage).toMatchObject({ inputTokens: 4, cachedInputTokens: 1 }); + }); + test("returns undefined for null / wrong types / missing token pairs", () => { expect(usageFromResponsesPayload(undefined)).toBeUndefined(); expect(usageFromResponsesPayload(null)).toBeUndefined(); From dcef3b2c123484e8b243145d2cf07766867ea6f2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:02:49 +0900 Subject: [PATCH 06/22] feat(grok): fenced managed-block inject/strip module for ~/.grok/config.toml Additive [model_providers.opencodex] + thin [model.ocx-*] aliases inside fence markers; one-time backup, user-owned-provider conflict skip, CRLF preservation, orphan-marker fail-safe. Pure module (grokHome injectable) with isolated tmpdir tests. --- src/grok/inject.ts | 209 +++++++++++++++++++++++++++++++ tests/grok-config-inject.test.ts | 119 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 src/grok/inject.ts create mode 100644 tests/grok-config-inject.test.ts diff --git a/src/grok/inject.ts b/src/grok/inject.ts new file mode 100644 index 0000000000..a068b6df15 --- /dev/null +++ b/src/grok/inject.ts @@ -0,0 +1,209 @@ +import { constants, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { applyEol, dominantEol } from "../codex/inject"; + +export interface GrokInjectModel { + id: string; + name?: string; + contextWindow?: number; +} + +export interface GrokInjectResult { + ok: boolean; + changed: boolean; + message: string; + skippedReason?: "no-grok-home" | "user-owned-provider"; +} + +const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; +const END_MARKER = "# <<< opencodex managed block <<<"; +const PROVIDER_HEADER = "[model_providers.opencodex]"; + +interface ManagedRegion { + start: number; + end: number; + orphaned: boolean; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function resolveGrokHome(grokHome?: string): string { + return grokHome ?? (process.env.GROK_HOME || join(homedir(), ".grok")); +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function findManagedRegion(content: string): ManagedRegion | null { + const start = content.indexOf(BEGIN_MARKER); + if (start === -1) return null; + const endMarkerStart = content.indexOf(END_MARKER, start + BEGIN_MARKER.length); + if (endMarkerStart === -1) return { start, end: content.length, orphaned: true }; + return { start, end: endMarkerStart + END_MARKER.length, orphaned: false }; +} + +function hasUserOwnedProvider(content: string, region: ManagedRegion | null): boolean { + const outsideManagedRegion = region + ? content.slice(0, region.start) + content.slice(region.end) + : content; + return /^\s*\[model_providers\.opencodex\]\s*(?:#.*)?$/m.test(outsideManagedRegion); +} + +function copyBackupOnce(configPath: string, backupPath: string): void { + if (existsSync(backupPath)) return; + try { + copyFileSync(configPath, backupPath, constants.COPYFILE_EXCL); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; + } +} + +function errorResult(action: string, error: unknown): GrokInjectResult { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, changed: false, message: `Could not ${action} Grok config: ${detail}` }; +} + +export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname = "127.0.0.1"): string { + const lines = [ + BEGIN_MARKER, + PROVIDER_HEADER, + `base_url = ${tomlString(`http://${hostname}:${port}/v1`)}`, + 'api_backend = "chat_completions"', + 'api_key = "opencodex-loopback"', + ]; + const aliasCounts = new Map(); + + for (const model of models) { + const baseAlias = `ocx-${model.id.replace(/[^A-Za-z0-9_-]/g, "-")}`; + const count = (aliasCounts.get(baseAlias) ?? 0) + 1; + aliasCounts.set(baseAlias, count); + const alias = count === 1 ? baseAlias : `${baseAlias}-${count}`; + lines.push( + "", + `[model.${alias}]`, + `model = ${tomlString(model.id)}`, + 'model_provider = "opencodex"', + `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`, + ); + if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) { + lines.push(`context_window = ${model.contextWindow}`); + } + } + + lines.push(END_MARKER); + return lines.join("\n"); +} + +export function injectGrokConfig( + port: number, + models: GrokInjectModel[], + opts: { grokHome?: string; hostname?: string } = {}, +): GrokInjectResult { + const grokHome = resolveGrokHome(opts.grokHome); + if (!isDirectory(grokHome)) { + return { + ok: true, + changed: false, + message: `Grok home not found at ${grokHome}; config injection skipped.`, + skippedReason: "no-grok-home", + }; + } + + const configPath = join(grokHome, "config.toml"); + const backupPath = join(grokHome, "config.toml.bak-opencodex"); + try { + const configExisted = existsSync(configPath); + const rawContent = configExisted ? readFileSync(configPath, "utf8") : ""; + const eol = dominantEol(rawContent); + const content = applyEol(rawContent, "\n"); + const region = findManagedRegion(content); + + if (hasUserOwnedProvider(content, region)) { + return { + ok: true, + changed: false, + message: "Warning: Grok config injection skipped because a user-owned [model_providers.opencodex] table exists outside the managed block.", + skippedReason: "user-owned-provider", + }; + } + + const block = buildGrokManagedBlock(port, models, opts.hostname); + let nextContent: string; + if (region) { + nextContent = content.slice(0, region.start) + block + content.slice(region.end); + } else if (content.length === 0) { + nextContent = `${block}\n`; + } else { + const separator = content.endsWith("\n") ? "\n" : "\n\n"; + nextContent = `${content}${separator}${block}\n`; + } + + const output = applyEol(nextContent, eol); + if (output === rawContent) { + return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." }; + } + if (configExisted && !region) copyBackupOnce(configPath, backupPath); + writeFileSync(configPath, output, "utf8"); + return { + ok: true, + changed: true, + message: region + ? "Updated the opencodex managed block in Grok config." + : "Added the opencodex managed block to Grok config.", + }; + } catch (error) { + return errorResult("inject", error); + } +} + +export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectResult { + const grokHome = resolveGrokHome(opts.grokHome); + if (!isDirectory(grokHome)) { + return { + ok: true, + changed: false, + message: `Grok home not found at ${grokHome}; no managed config to remove.`, + skippedReason: "no-grok-home", + }; + } + + const configPath = join(grokHome, "config.toml"); + if (!existsSync(configPath)) { + return { ok: true, changed: false, message: "Grok config not found; no managed block to remove." }; + } + + try { + const rawContent = readFileSync(configPath, "utf8"); + const eol = dominantEol(rawContent); + const content = applyEol(rawContent, "\n"); + const region = findManagedRegion(content); + if (!region) { + return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." }; + } + + let removalEnd = region.end; + if (!region.orphaned && content.startsWith("\n", removalEnd)) removalEnd += 1; + let prefix = content.slice(0, region.start); + if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); + const stripped = prefix + content.slice(removalEnd); + writeFileSync(configPath, applyEol(stripped, eol), "utf8"); + + return { + ok: true, + changed: true, + message: region.orphaned + ? "Removed an orphaned opencodex begin marker and all following Grok config content." + : "Removed the opencodex managed block from Grok config.", + }; + } catch (error) { + return errorResult("strip", error); + } +} diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts new file mode 100644 index 0000000000..b30bb04739 --- /dev/null +++ b/tests/grok-config-inject.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildGrokManagedBlock, injectGrokConfig, stripGrokConfig } from "../src/grok/inject"; + +const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; +const END_MARKER = "# <<< opencodex managed block <<<"; + +describe("Grok config injection", () => { + let root: string; + let grokHome: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-grok-inject-")); + grokHome = join(root, ".grok"); + mkdirSync(grokHome); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("creates and strips a fresh config", () => { + const configPath = join(grokHome, "config.toml"); + + const injected = injectGrokConfig(10100, [{ id: "gpt-5.6-sol", contextWindow: 1_050_000 }], { grokHome }); + expect(injected).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toContain(BEGIN_MARKER); + expect(readFileSync(configPath, "utf8")).toContain(END_MARKER); + + const stripped = stripGrokConfig({ grokHome }); + expect(stripped).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath, "utf8")).toBe(""); + }); + + test("backs up once, appends to user config, and restores user bytes", () => { + const configPath = join(grokHome, "config.toml"); + const backupPath = join(grokHome, "config.toml.bak-opencodex"); + const userContent = "theme = \"dark\"\n"; + writeFileSync(configPath, userContent, "utf8"); + + injectGrokConfig(10100, [{ id: "first" }], { grokHome }); + expect(readFileSync(backupPath, "utf8")).toBe(userContent); + writeFileSync(backupPath, "backup-must-survive\n", "utf8"); + + injectGrokConfig(10101, [{ id: "second" }], { grokHome }); + expect(readFileSync(backupPath, "utf8")).toBe("backup-must-survive\n"); + + stripGrokConfig({ grokHome }); + expect(readFileSync(configPath, "utf8")).toBe(userContent); + }); + + test("replaces the managed region idempotently", () => { + const configPath = join(grokHome, "config.toml"); + injectGrokConfig(10100, [{ id: "old-model" }], { grokHome }); + injectGrokConfig(10100, [{ id: "new-model" }, { id: "newer-model" }], { grokHome }); + + const content = readFileSync(configPath, "utf8"); + expect(content.match(new RegExp(BEGIN_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).toHaveLength(1); + expect(content).not.toContain("old-model"); + expect(content).toContain("[model.ocx-new-model]"); + expect(content).toContain("[model.ocx-newer-model]"); + }); + + test("skips a user-owned opencodex provider table", () => { + const configPath = join(grokHome, "config.toml"); + const userContent = "[model_providers.opencodex]\nbase_url = \"https://example.test/v1\"\n"; + writeFileSync(configPath, userContent, "utf8"); + + const result = injectGrokConfig(10100, [{ id: "ignored" }], { grokHome }); + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "user-owned-provider" }); + expect(result.message.toLowerCase()).toContain("user-owned"); + expect(readFileSync(configPath, "utf8")).toBe(userContent); + expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(false); + }); + + test("sanitizes aliases, suffixes collisions, and escapes TOML strings", () => { + const block = buildGrokManagedBlock(10100, [ + { id: "anthropic/claude-opus-4.8" }, + { id: "same/path", name: "Quoted \"name\"" }, + { id: "same.path", contextWindow: 200_000 }, + ]); + + expect(block).toContain("[model.ocx-anthropic-claude-opus-4-8]"); + expect(block).toContain("[model.ocx-same-path]"); + expect(block).toContain("[model.ocx-same-path-2]"); + expect(block).toContain('name = "Quoted \\"name\\""'); + expect(block).toContain("context_window = 200000"); + }); + + test("preserves CRLF through inject and strip", () => { + const configPath = join(grokHome, "config.toml"); + const userContent = "theme = \"dark\"\r\nnotify = true\r\n"; + writeFileSync(configPath, userContent, "utf8"); + + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + expect(readFileSync(configPath, "utf8")).not.toMatch(/(? { + const missingHome = join(root, "missing"); + const result = injectGrokConfig(10100, [], { grokHome: missingHome }); + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "no-grok-home" }); + }); + + test("strips an orphaned begin marker through EOF", () => { + const configPath = join(grokHome, "config.toml"); + writeFileSync(configPath, `theme = "dark"\n\n${BEGIN_MARKER}\npartial = true\n`, "utf8"); + + const result = stripGrokConfig({ grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + expect(result.message.toLowerCase()).toContain("orphaned"); + expect(readFileSync(configPath, "utf8")).toBe('theme = "dark"\n'); + }); +}); From b396508b43ae3affe52888237957087bfaec7344 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:10:19 +0900 Subject: [PATCH 07/22] feat(grok): auto-register/unregister opencodex models in Grok Build config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ocx start injects a fenced block of [model.ocx-*] entries (direct base_url/api_backend/api_key per model) into ~/.grok/config.toml when ~/.grok exists; stop/eject/uninstall/graceful-shutdown strip only the fenced block. Per-model direct fields are used because grok 0.2.101's [model_providers] inheritance parses but does not apply the inherited base_url to inference routing (falls through to cli-chat-proxy, 401 — verified live). --- src/cli/index.ts | 33 +++++++++++++++++++++++++++++++++ src/codex/inject.ts | 2 +- src/grok/inject.ts | 22 ++++++++++++++-------- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index e1c89e2a2b..3a998043a5 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { rmSync } from "node:fs"; import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject"; +import { stripGrokConfig } from "../grok/inject"; import { restoreLegacyOpenaiHistory } from "../codex/history-provider"; import { writeJournal, reconcileJournal } from "../codex/journal"; import { @@ -208,6 +209,7 @@ async function handleStart(options: { block?: boolean } = {}) { if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } } + if (!process.env.OCX_SERVICE) { try { stripGrokConfig(); } catch { /* best-effort restore */ } } }; let shuttingDown = false; @@ -265,6 +267,22 @@ async function handleStart(options: { block?: boolean } = {}) { [...visibleNativeSlugs(config)], models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), ); + // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an + // installed grok CLI can pick opencodex-routed models without manual config. No-op + // when ~/.grok is absent; removed again by stop/eject/uninstall/shutdown. + try { + const { injectGrokConfig } = await import("../grok/inject"); + const grokModels = [ + ...visibleNativeSlugs(config).map(id => ({ id })), + ...models.map(m => ({ + id: m.alias ?? `${m.provider}/${m.id}`, + ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), + })), + ]; + const r = injectGrokConfig(port, grokModels, config.hostname ? { hostname: config.hostname } : {}); + if (r.changed) console.log(` + Grok Build config updated (${grokModels.length} models via ~/.grok/config.toml)`); + else if (r.skippedReason === "user-owned-provider") console.error(`⚠️ ${r.message}`); + } catch { /* best-effort — grok integration must never block startup */ } } catch { /* best-effort — registry rebuilds on first /v1/models call */ } if (options.block ?? true) { setInterval(() => {}, 60_000); @@ -403,6 +421,11 @@ async function handleStop() { // Safety net: revert system env vars even if the daemon's syncCleanup didn't run // (e.g. SIGKILL). revertSystemEnv is ownership-checked and idempotent. try { revertSystemEnv(); } catch { /* best-effort */ } + // Same safety net for the Grok Build managed block (marker-owned, idempotent). + try { + const g = stripGrokConfig(); + if (g.changed) console.log(`↩️ ${g.message}`); + } catch { /* best-effort */ } if (stopFailed) process.exit(1); } @@ -447,6 +470,12 @@ async function handleUninstall() { if (!r.success) throw new Error(r.message); }); + await runStep("Grok Build config restored", () => { + const r = stripGrokConfig(); + if (!r.ok) throw new Error(r.message); + return r.changed; + }); + await runStep("system env vars reverted", () => { const r = revertSystemEnv(); if (!r.reverted && r.reason !== "no tracking file" && r.reason !== "not macOS") throw new Error(r.reason ?? "revert failed"); @@ -580,6 +609,10 @@ switch (command) { } const r = restoreNativeCodex(); console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`); + try { + const g = stripGrokConfig(); + if (g.changed) console.log(`✅ ${g.message}`); + } catch { /* best-effort */ } console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); break; } diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 6acc9c6a7c..d168fab820 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -69,7 +69,7 @@ function isLoopbackHostname(hostname: string | undefined): boolean { return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; } -function providerBaseHost(hostname: string | undefined): string { +export function providerBaseHost(hostname: string | undefined): string { const trimmed = (hostname ?? "127.0.0.1").trim(); const lower = trimmed.toLowerCase(); // Match what the server actually binds. Writing "localhost" while binding IPv4-only diff --git a/src/grok/inject.ts b/src/grok/inject.ts index a068b6df15..19beefa21d 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -1,7 +1,7 @@ import { constants, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { applyEol, dominantEol } from "../codex/inject"; +import { applyEol, dominantEol, providerBaseHost } from "../codex/inject"; export interface GrokInjectModel { id: string; @@ -19,6 +19,11 @@ export interface GrokInjectResult { const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; const END_MARKER = "# <<< opencodex managed block <<<"; const PROVIDER_HEADER = "[model_providers.opencodex]"; +// grok 0.2.101 verified live (2026-07-23): [model_providers.] inheritance parses but the +// inherited base_url is NOT applied to inference routing — the turn falls through to the default +// cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its +// own base_url/api_backend/api_key; the provider table remains only as a user-visible grouping +// header (harmless) and the user-owned conflict guard keeps checking for it. interface ManagedRegion { start: number; @@ -71,13 +76,11 @@ function errorResult(action: string, error: unknown): GrokInjectResult { return { ok: false, changed: false, message: `Could not ${action} Grok config: ${detail}` }; } -export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname = "127.0.0.1"): string { +export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname?: string): string { + const host = providerBaseHost(hostname); + const baseUrl = `http://${host}:${port}/v1`; const lines = [ BEGIN_MARKER, - PROVIDER_HEADER, - `base_url = ${tomlString(`http://${hostname}:${port}/v1`)}`, - 'api_backend = "chat_completions"', - 'api_key = "opencodex-loopback"', ]; const aliasCounts = new Map(); @@ -86,11 +89,14 @@ export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], h const count = (aliasCounts.get(baseAlias) ?? 0) + 1; aliasCounts.set(baseAlias, count); const alias = count === 1 ? baseAlias : `${baseAlias}-${count}`; + const isFirst = lines.length === 1; lines.push( - "", + ...(isFirst ? [] : [""]), `[model.${alias}]`, `model = ${tomlString(model.id)}`, - 'model_provider = "opencodex"', + `base_url = ${tomlString(baseUrl)}`, + 'api_backend = "chat_completions"', + 'api_key = "opencodex-loopback"', `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`, ); if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) { From 38a3c89fe38c3e1d3554bc9247933f56047acc1e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:18:37 +0900 Subject: [PATCH 08/22] fix(grok): data-safety hardening for config inject/strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orphaned begin marker now refuses mutation (inject AND strip) instead of treating everything to EOF as managed — user tables after a damaged fence can no longer be erased. - Atomic temp-write/rename (atomicWriteFile) instead of in-place truncation. - User-owned [model.*] aliases outside the fence are reserved so injection never emits a duplicate table header (whole-file TOML invalidation). - Dropped the obsolete model_providers guard/constant (no provider table is emitted since the per-model direct-fields deviation). - handleStop survives a throwing stopServiceIfInstalled (home-mismatch guard) so proxy stop, Codex restore, env revert, and grok strip still run. - ok:false results now surface as warnings on start/stop/eject paths. - Tests updated: per-model direct-field contract, user-alias collision, orphan refusal both ways. --- src/cli/index.ts | 19 +++++++-- src/grok/inject.ts | 66 +++++++++++++++++++------------- tests/grok-config-inject.test.ts | 45 +++++++++++++++------- 3 files changed, 87 insertions(+), 43 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 3a998043a5..dbe383bba4 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -281,7 +281,7 @@ async function handleStart(options: { block?: boolean } = {}) { ]; const r = injectGrokConfig(port, grokModels, config.hostname ? { hostname: config.hostname } : {}); if (r.changed) console.log(` + Grok Build config updated (${grokModels.length} models via ~/.grok/config.toml)`); - else if (r.skippedReason === "user-owned-provider") console.error(`⚠️ ${r.message}`); + else if (!r.ok) console.error(`⚠️ ${r.message}`); } catch { /* best-effort — grok integration must never block startup */ } } catch { /* best-effort — registry rebuilds on first /v1/models call */ } if (options.block ?? true) { @@ -372,11 +372,20 @@ async function handleTrayProxyRestart(): Promise { } async function handleStop() { - const stoppedService = stopServiceIfInstalled(); - if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); + // Service-manager stop can throw (e.g. OPENCODEX_HOME mismatch guard). That must not + // abort the rest of the teardown — proxy stop, Codex restore, env revert, and the + // Grok config strip below all still need to run. + let stopFailed = false; + let stoppedService = false; + try { + stoppedService = stopServiceIfInstalled(); + if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); + } catch (err) { + stopFailed = true; + console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); + } const pid = readPid(); - let stopFailed = false; if (pid) { try { // Graceful-first (management-API drain) — on Windows this is the only path where @@ -425,6 +434,7 @@ async function handleStop() { try { const g = stripGrokConfig(); if (g.changed) console.log(`↩️ ${g.message}`); + else if (!g.ok) console.error(`⚠️ ${g.message}`); } catch { /* best-effort */ } if (stopFailed) process.exit(1); } @@ -612,6 +622,7 @@ switch (command) { try { const g = stripGrokConfig(); if (g.changed) console.log(`✅ ${g.message}`); + else if (!g.ok) console.error(`⚠️ ${g.message}`); } catch { /* best-effort */ } console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back"); break; diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 19beefa21d..4d4f5814db 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -1,6 +1,7 @@ -import { constants, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { constants, copyFileSync, existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { atomicWriteFile } from "../config"; import { applyEol, dominantEol, providerBaseHost } from "../codex/inject"; export interface GrokInjectModel { @@ -13,17 +14,15 @@ export interface GrokInjectResult { ok: boolean; changed: boolean; message: string; - skippedReason?: "no-grok-home" | "user-owned-provider"; + skippedReason?: "no-grok-home" | "orphaned-marker"; } const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; const END_MARKER = "# <<< opencodex managed block <<<"; -const PROVIDER_HEADER = "[model_providers.opencodex]"; // grok 0.2.101 verified live (2026-07-23): [model_providers.] inheritance parses but the // inherited base_url is NOT applied to inference routing — the turn falls through to the default // cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its -// own base_url/api_backend/api_key; the provider table remains only as a user-visible grouping -// header (harmless) and the user-owned conflict guard keeps checking for it. +// own base_url/api_backend/api_key and no [model_providers] table is emitted at all. interface ManagedRegion { start: number; @@ -55,11 +54,27 @@ function findManagedRegion(content: string): ManagedRegion | null { return { start, end: endMarkerStart + END_MARKER.length, orphaned: false }; } -function hasUserOwnedProvider(content: string, region: ManagedRegion | null): boolean { +/** `[model.]` table headers the USER owns (outside our fence) — reserved for collisions. */ +function userModelAliases(content: string, region: ManagedRegion | null): Set { const outsideManagedRegion = region ? content.slice(0, region.start) + content.slice(region.end) : content; - return /^\s*\[model_providers\.opencodex\]\s*(?:#.*)?$/m.test(outsideManagedRegion); + const aliases = new Set(); + for (const match of outsideManagedRegion.matchAll(/^\s*\[model\.([A-Za-z0-9_-]+)\]\s*(?:#.*)?$/gm)) { + aliases.add(match[1]); + } + return aliases; +} + +function orphanedMarkerResult(action: string): GrokInjectResult { + return { + ok: false, + changed: false, + message: `Grok config ${action} refused: found the opencodex begin marker without its end marker. ` + + "The managed region boundary is ambiguous, so nothing was modified. " + + "Repair ~/.grok/config.toml manually (see config.toml.bak-opencodex) and re-run.", + skippedReason: "orphaned-marker", + }; } function copyBackupOnce(configPath: string, backupPath: string): void { @@ -76,19 +91,27 @@ function errorResult(action: string, error: unknown): GrokInjectResult { return { ok: false, changed: false, message: `Could not ${action} Grok config: ${detail}` }; } -export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname?: string): string { +export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname?: string, reservedAliases?: ReadonlySet): string { const host = providerBaseHost(hostname); const baseUrl = `http://${host}:${port}/v1`; const lines = [ BEGIN_MARKER, ]; const aliasCounts = new Map(); + const taken = new Set(reservedAliases ?? []); for (const model of models) { const baseAlias = `ocx-${model.id.replace(/[^A-Za-z0-9_-]/g, "-")}`; - const count = (aliasCounts.get(baseAlias) ?? 0) + 1; + let count = (aliasCounts.get(baseAlias) ?? 0) + 1; + let alias = count === 1 ? baseAlias : `${baseAlias}-${count}`; + // User-owned [model.] tables outside the fence are reserved: emitting a + // duplicate table header would make the whole TOML invalid for grok. + while (taken.has(alias)) { + count += 1; + alias = `${baseAlias}-${count}`; + } aliasCounts.set(baseAlias, count); - const alias = count === 1 ? baseAlias : `${baseAlias}-${count}`; + taken.add(alias); const isFirst = lines.length === 1; lines.push( ...(isFirst ? [] : [""]), @@ -131,17 +154,9 @@ export function injectGrokConfig( const eol = dominantEol(rawContent); const content = applyEol(rawContent, "\n"); const region = findManagedRegion(content); + if (region?.orphaned) return orphanedMarkerResult("injection"); - if (hasUserOwnedProvider(content, region)) { - return { - ok: true, - changed: false, - message: "Warning: Grok config injection skipped because a user-owned [model_providers.opencodex] table exists outside the managed block.", - skippedReason: "user-owned-provider", - }; - } - - const block = buildGrokManagedBlock(port, models, opts.hostname); + const block = buildGrokManagedBlock(port, models, opts.hostname, userModelAliases(content, region)); let nextContent: string; if (region) { nextContent = content.slice(0, region.start) + block + content.slice(region.end); @@ -157,7 +172,7 @@ export function injectGrokConfig( return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." }; } if (configExisted && !region) copyBackupOnce(configPath, backupPath); - writeFileSync(configPath, output, "utf8"); + atomicWriteFile(configPath, output); return { ok: true, changed: true, @@ -194,20 +209,19 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes if (!region) { return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." }; } + if (region.orphaned) return orphanedMarkerResult("cleanup"); let removalEnd = region.end; - if (!region.orphaned && content.startsWith("\n", removalEnd)) removalEnd += 1; + if (content.startsWith("\n", removalEnd)) removalEnd += 1; let prefix = content.slice(0, region.start); if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); const stripped = prefix + content.slice(removalEnd); - writeFileSync(configPath, applyEol(stripped, eol), "utf8"); + atomicWriteFile(configPath, applyEol(stripped, eol)); return { ok: true, changed: true, - message: region.orphaned - ? "Removed an orphaned opencodex begin marker and all following Grok config content." - : "Removed the opencodex managed block from Grok config.", + message: "Removed the opencodex managed block from Grok config.", }; } catch (error) { return errorResult("strip", error); diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index b30bb04739..d9e5ba47bb 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -63,16 +63,31 @@ describe("Grok config injection", () => { expect(content).toContain("[model.ocx-newer-model]"); }); - test("skips a user-owned opencodex provider table", () => { + test("emits per-model direct fields (grok 0.2.101 ignores model_providers inheritance)", () => { + const block = buildGrokManagedBlock(10190, [{ id: "cursor/grok-4.5", contextWindow: 500_000 }]); + expect(block).not.toContain("[model_providers"); + expect(block).not.toContain("model_provider ="); + const table = block.slice(block.indexOf("[model.ocx-cursor-grok-4-5]")); + expect(table).toContain('model = "cursor/grok-4.5"'); + expect(table).toContain('base_url = "http://127.0.0.1:10190/v1"'); + expect(table).toContain('api_backend = "chat_completions"'); + expect(table).toContain('api_key = "opencodex-loopback"'); + expect(table).toContain("context_window = 500000"); + }); + + test("reserves user-owned [model.*] aliases outside the fence", () => { const configPath = join(grokHome, "config.toml"); - const userContent = "[model_providers.opencodex]\nbase_url = \"https://example.test/v1\"\n"; + const userContent = '[model.ocx-mine]\nmodel = "user/model"\nbase_url = "https://example.test/v1"\n'; writeFileSync(configPath, userContent, "utf8"); - const result = injectGrokConfig(10100, [{ id: "ignored" }], { grokHome }); - expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "user-owned-provider" }); - expect(result.message.toLowerCase()).toContain("user-owned"); - expect(readFileSync(configPath, "utf8")).toBe(userContent); - expect(existsSync(join(grokHome, "config.toml.bak-opencodex"))).toBe(false); + const result = injectGrokConfig(10100, [{ id: "mine" }], { grokHome }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(configPath, "utf8"); + // The user's table survives untouched and our entry takes a suffixed alias — + // a duplicate [model.ocx-mine] header would invalidate the whole TOML. + expect(content.match(/\[model\.ocx-mine\]/g) ?? []).toHaveLength(1); + expect(content).toContain("[model.ocx-mine-2]"); + expect(content.startsWith(userContent)).toBe(true); }); test("sanitizes aliases, suffixes collisions, and escapes TOML strings", () => { @@ -107,13 +122,17 @@ describe("Grok config injection", () => { expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "no-grok-home" }); }); - test("strips an orphaned begin marker through EOF", () => { + test("refuses to mutate when the begin marker is orphaned (data-safety)", () => { const configPath = join(grokHome, "config.toml"); - writeFileSync(configPath, `theme = "dark"\n\n${BEGIN_MARKER}\npartial = true\n`, "utf8"); + const damaged = `theme = "dark"\n\n${BEGIN_MARKER}\npartial = true\n[model.user-added-later]\nmodel = "keep/me"\n`; + writeFileSync(configPath, damaged, "utf8"); - const result = stripGrokConfig({ grokHome }); - expect(result).toMatchObject({ ok: true, changed: true }); - expect(result.message.toLowerCase()).toContain("orphaned"); - expect(readFileSync(configPath, "utf8")).toBe('theme = "dark"\n'); + const stripResult = stripGrokConfig({ grokHome }); + expect(stripResult).toMatchObject({ ok: false, changed: false, skippedReason: "orphaned-marker" }); + expect(readFileSync(configPath, "utf8")).toBe(damaged); + + const injectResult = injectGrokConfig(10100, [{ id: "x" }], { grokHome }); + expect(injectResult).toMatchObject({ ok: false, changed: false, skippedReason: "orphaned-marker" }); + expect(readFileSync(configPath, "utf8")).toBe(damaged); }); }); From 138f9ded36adab3a3e45bb94b08680c3e0c5ba25 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:20:49 +0900 Subject: [PATCH 09/22] fix(grok): recognize quoted/whitespace-padded TOML model headers for alias reservation [model."ocx-mine"], [ model . ocx-mine ] and single-quoted forms all define the same table; canonicalize every equivalent spelling when reserving user-owned aliases so injection can never emit a duplicate table header. --- src/grok/inject.ts | 20 +++++++++++++++++--- tests/grok-config-inject.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 4d4f5814db..5648a6afb6 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -54,14 +54,28 @@ function findManagedRegion(content: string): ManagedRegion | null { return { start, end: endMarkerStart + END_MARKER.length, orphaned: false }; } -/** `[model.]` table headers the USER owns (outside our fence) — reserved for collisions. */ +/** + * `[model.]` table headers the USER owns (outside our fence) — reserved for collisions. + * TOML admits equivalent header spellings (`[model."ocx-mine"]`, `[ model . ocx-mine ]`, single + * quotes); all of them redefine the same table, so each form must be canonicalized. Headers whose + * second key segment cannot be recognized are skipped — our generated aliases always start with + * "ocx-" and unrecognizable user spellings of that prefix are already covered by the quoted forms. + */ function userModelAliases(content: string, region: ManagedRegion | null): Set { const outsideManagedRegion = region ? content.slice(0, region.start) + content.slice(region.end) : content; const aliases = new Set(); - for (const match of outsideManagedRegion.matchAll(/^\s*\[model\.([A-Za-z0-9_-]+)\]\s*(?:#.*)?$/gm)) { - aliases.add(match[1]); + const header = /^\s*\[\s*model\s*\.\s*(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*(?:#.*)?$/gm; + for (const match of outsideManagedRegion.matchAll(header)) { + const bare = match[1]; + const doubleQuoted = match[2]; + const singleQuoted = match[3]; + if (bare !== undefined) { aliases.add(bare); continue; } + if (singleQuoted !== undefined) { aliases.add(singleQuoted); continue; } + if (doubleQuoted !== undefined) { + try { aliases.add(JSON.parse(`"${doubleQuoted}"`) as string); } catch { aliases.add(doubleQuoted); } + } } return aliases; } diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index d9e5ba47bb..696cbc7f53 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -90,6 +90,31 @@ describe("Grok config injection", () => { expect(content.startsWith(userContent)).toBe(true); }); + test("recognizes quoted and whitespace-padded user model headers (TOML-equivalent forms)", () => { + const configPath = join(grokHome, "config.toml"); + const userContent = [ + '[model."ocx-quoted"]', + 'model = "user/a"', + "[ model . ocx-spaced ]", + 'model = "user/b"', + "[model.'ocx-single']", + 'model = "user/c"', + "", + ].join("\n"); + writeFileSync(configPath, userContent, "utf8"); + + injectGrokConfig(10100, [{ id: "quoted" }, { id: "spaced" }, { id: "single" }], { grokHome }); + const content = readFileSync(configPath, "utf8"); + // Each equivalent user spelling reserves its canonical alias; ours are suffixed. + expect(content).toContain("[model.ocx-quoted-2]"); + expect(content).toContain("[model.ocx-spaced-2]"); + expect(content).toContain("[model.ocx-single-2]"); + // Exactly one bare-form [model.ocx-quoted] must NOT exist (only the user's quoted header). + expect(content).not.toContain("[model.ocx-quoted]"); + // The whole file must stay valid TOML (no duplicate table definitions). + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + test("sanitizes aliases, suffixes collisions, and escapes TOML strings", () => { const block = buildGrokManagedBlock(10100, [ { id: "anthropic/claude-opus-4.8" }, From ee37fb56b1819273704992ad12b9958b10148bbf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:22:26 +0900 Subject: [PATCH 10/22] fix(grok): decode TOML \uXXXX/\UXXXXXXXX escapes when reserving user aliases --- src/grok/inject.ts | 27 ++++++++++++++++++++++++--- tests/grok-config-inject.test.ts | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 5648a6afb6..7641bd206e 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -73,9 +73,7 @@ function userModelAliases(content: string, region: ManagedRegion | null): Set { + if (esc[0] === "u") return String.fromCharCode(parseInt(esc.slice(1), 16)); + if (esc[0] === "U") { + const code = parseInt(esc.slice(1), 16); + return code <= 0x10ffff ? String.fromCodePoint(code) : whole; + } + switch (esc) { + case "b": return "\b"; + case "t": return "\t"; + case "n": return "\n"; + case "f": return "\f"; + case "r": return "\r"; + case '"': return '"'; + case "\\": return "\\"; + default: return whole; // invalid escape — keep raw, reservation stays conservative + } + }, + ); +} diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index 696cbc7f53..f47b6b805e 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -115,6 +115,21 @@ describe("Grok config injection", () => { expect(() => Bun.TOML.parse(content)).not.toThrow(); }); + test("decodes TOML unicode escapes in user model headers", () => { + const configPath = join(grokHome, "config.toml"); + // \U0000006F and \u006F are both "o" — these headers canonically define model.ocx-esc*. + const userContent = '[model."\\U0000006Fcx-esc"]\nmodel = "user/esc"\n[model."\\u006Fcx-esc4"]\nmodel = "user/esc4"\n'; + writeFileSync(configPath, userContent, "utf8"); + + injectGrokConfig(10100, [{ id: "esc" }, { id: "esc4" }], { grokHome }); + const content = readFileSync(configPath, "utf8"); + expect(content).not.toContain("[model.ocx-esc]"); + expect(content).not.toContain("[model.ocx-esc4]"); + expect(content).toContain("[model.ocx-esc-2]"); + expect(content).toContain("[model.ocx-esc4-2]"); + expect(() => Bun.TOML.parse(content)).not.toThrow(); + }); + test("sanitizes aliases, suffixes collisions, and escapes TOML strings", () => { const block = buildGrokManagedBlock(10100, [ { id: "anthropic/claude-opus-4.8" }, From c45e1c9c0d864b8f6ca294fd3b3588e920619146 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:25:43 +0900 Subject: [PATCH 11/22] docs(devlog): wp3 design + receipt (grok config auto-inject, 4-round review) --- .../030_grok_config_autoinject.md | 56 +++++++++++++++++++ .../260723_grok_build_bridge/031_receipt.md | 42 ++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 devlog/_plan/260723_grok_build_bridge/030_grok_config_autoinject.md create mode 100644 devlog/_plan/260723_grok_build_bridge/031_receipt.md diff --git a/devlog/_plan/260723_grok_build_bridge/030_grok_config_autoinject.md b/devlog/_plan/260723_grok_build_bridge/030_grok_config_autoinject.md new file mode 100644 index 0000000000..27dd4a3340 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/030_grok_config_autoinject.md @@ -0,0 +1,56 @@ +# 030 — wp3: Grok Build config 자동 등록·해제 (A안: 마커 블록 주입) + +Date: 2026-07-23. User-selected plan A: `ocx start`가 `~/.grok/config.toml`에 관리 블록을 주입, `ocx stop`/`eject`/`uninstall`이 제거. + +## Design + +### 주입 형태 (additive, 펜스 마커) + +```toml +# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> +[model_providers.opencodex] +base_url = "http://127.0.0.1:10100/v1" +api_backend = "chat_completions" +api_key = "opencodex-loopback" + +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +model_provider = "opencodex" +name = "OCX gpt-5.6-sol" +context_window = 1050000 +# <<< opencodex managed block <<< +``` + +- 근거: grok 0.2.101 신규 표면 `[model_providers.]` 상속 + `[model.] model_provider` (Sol 분석 001, config.rs:3921-3934, model_providers.rs:165-206). +- 백엔드는 `chat_completions` (히트비트 잔여 이슈 회피, 011 참조). +- `api_key`: grok 크레덴셜 체인이 non-empty를 요구 (loopback ocx는 admission 무시). +- alias 규칙: `ocx-` + model id에서 `[^A-Za-z0-9_-]`→`-` (TOML bare-key 안전: 점이 서브테이블을 만들므로 반드시 치환). 충돌 시 `-2` 서픽스. +- `context_window`: 카탈로그 값 있으면 기재, 없으면 생략(grok 기본 200k). + +### 라이프사이클 + +| 시점 | 동작 | +|---|---| +| `ocx start` (handleStart, syncModelsToCodex 이후) | `~/.grok` 존재 시에만 주입. 기존 펜스 블록은 통째로 교체(멱등). 첫 주입 전 1회 `config.toml.bak-opencodex` 백업 | +| `ocx stop` / daemon graceful shutdown (`!OCX_SERVICE`) | 펜스 블록만 제거, 사용자 블록 보존 | +| `ocx eject` / `ocx uninstall` | 동일 strip + runStep 로깅 | +| grok 미설치 (`~/.grok` 없음) | no-op, 로그 한 줄 | + +- `GROK_HOME` env 존중 (기본 `~/.grok`) — 테스트 격리에도 사용. +- 사용자가 펜스 밖에 자체 `[model_providers.opencodex]`를 정의한 경우: 주입 스킵 + 경고 (충돌 방지). +- 모델 소스: handleStart가 이미 쓰는 `fetchAllModels` + `filterCatalogVisibleModels` 집계 재사용 (Desktop3p 레지스트리와 동일 목록). + +## Work split (Sol 병렬) + +- **Sol 워커 (disjoint write set):** `src/grok/inject.ts` 순수 모듈 + `tests/grok-config-inject.test.ts`. 파일시스템은 `grokHome` 파라미터로 주입받아 tmpdir 테스트. +- **메인:** CLI 배선 (`src/cli/index.ts` start/stop/eject/uninstall + shutdown 훅), 통합 커밋, 전체 검증. + +## Verifier +1. `bun run typecheck`, 대상 테스트 + full `bun run test` +2. live: 격리 `GROK_HOME` + 수정 체크아웃 서버(:10190)로 start→config 주입 확인→`grok models`에 ocx-* 노출→1턴 스모크 exit 0→stop→블록 제거·사용자 블록 보존 확인 +3. `bun run privacy:scan` + +## Out of scope +- B안(models_base_url 카탈로그) — 네이티브 카탈로그 대체 부작용으로 보류 +- grok config 핫리로드 대응 (시작 순서만 문서화) +- docs-site 문서화 (wp2 잔여로 유지) diff --git a/devlog/_plan/260723_grok_build_bridge/031_receipt.md b/devlog/_plan/260723_grok_build_bridge/031_receipt.md new file mode 100644 index 0000000000..81bef08524 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/031_receipt.md @@ -0,0 +1,42 @@ +# 031 — wp3 receipt: Grok Build config 자동 등록·해제 + +Date: 2026-07-23 (KST), branch `codex/260723-grok-build-bridge`, commits `52dfb934` + `ead5e715` + +## 설계 이탈 (중요) + +030의 A안은 `[model_providers.opencodex]` 상속이었으나, **grok 0.2.101 라이브 검증에서 상속된 base_url이 실제 추론 라우팅에 적용되지 않음**을 확인: + +- `model_provider = "opencodex"` 상속 모델 → 요청이 기본 `cli-chat-proxy.grok.com`으로 나가 401 (`/tmp/grok-wp3.err`, dummy 키) +- 동일 필드를 모델 블록에 직접 기재 → 정상 라우팅 (`MP_DIRECT_OK`, `/tmp/grok-mp-debug`) +- grok 소스상 `with_provider_defaults`(model_providers.rs:166-206)는 병합을 수행하지만, 추론 클라이언트의 base_url 결정에는 반영 안 되는 것으로 보임 (0.2.101 시점) + +→ 구현은 **모델 블록마다 직접 base_url/api_backend/api_key 기재**로 전환. 코드 주석에 근거 명시. + +## Live verification (수정 체크아웃, :10190, 격리 GROK_HOME=/tmp/grok-home-wp3) + +1. 사용자 config(`[models] stream_tool_calls=false`) 존재 상태에서 `ocx start` → `+ Grok Build config updated (28 models)` 로그, 펜스 블록 추가, `config.toml.bak-opencodex` 1회 생성, 사용자 블록 보존 +2. `grok models` → `ocx-gpt-5-6-sol` 외 27개 노출 +3. 스모크: `ocx-cursor-grok-4-5` exit 0 `OCX_WP3_OK`, `ocx-gpt-5-4-mini` exit 0 `OCX_WP3_NATIVE_OK` +4. daemon SIGTERM(graceful shutdown) → syncCleanup의 stripGrokConfig 동작, 펜스 블록 0개, 사용자 config 원문 복원 +5. 재시작 → 재주입 멱등 확인 (블록 1개 유지) + +주의: `ocx stop` CLI는 이 테스트 환경에서 service-home mismatch로 stopServiceIfInstalled 단계에서 throw — 프로덕션 환경(동일 OPENCODEX_HOME)에서는 미해당이나, handleStop 내 strip 위치가 이 throw 뒤라 도달 못 하는 경로가 존재. 리뷰어 검토 항목. + +## Verifier +- typecheck pass, `tests/grok-config-inject.test.ts` 8 pass, privacy scan pass +- full suite 3713 pass / 1 fail (기존 anthropic-thinking-signature full-run 플레이크, wp1과 동일) + +## Round 2-4 — Sol 리뷰어(Socrates) FAIL 반복 수정 + +R1 FAIL (4 blockers): orphan 마커가 유저 콘텐츠 삭제 가능 / 비원자적 쓰기 / 유저 [model.*] alias 충돌 시 TOML 전체 무효화 / handleStop이 service-home mismatch throw로 strip 도달 불가. → `f75cc563`: orphan은 inject/strip 모두 거부(ok:false, 파일 불변), atomicWriteFile 전환, 유저 alias 예약+서픽스, handleStop try/catch로 teardown 연속성 확보, ok:false 경고 표면화, per-model direct-field 계약 테스트. +R2 FAIL (1 blocker): quoted/whitespace TOML 동등 표기([model."ocx-mine"], [ model . ocx-mine ]) 미인식. → `848cab37`: 3형태 canonicalize + Bun.TOML.parse 검증 테스트. +R3 FAIL (1 blocker): \UXXXXXXXX 이스케이프 미디코딩. → `7dfdc3f4`: TOML basic-string 디코더(\uXXXX/\UXXXXXXXX/기본 이스케이프) + 회귀. +R4 **PASS** (no remaining blockers). + +## 최종 live round-trip (하드닝 반영 후) + +start → 28모델 주입 → `ocx-cursor-grok-4-5` exit 0 `OCX_FINAL_OK` → stop (service-stop 경고에도 teardown 완주) → 펜스 제거·유저 config 원문 복원. + +최종: `tests/grok-config-inject.test.ts` 11 pass, typecheck clean, privacy scan pass, full suite 3716 pass / 1 기존 플레이크. 커밋 체인: 52dfb934 → ead5e715 → f75cc563 → 848cab37 → 7dfdc3f4. + +수용 잔여(리뷰어 동의): stale-read 락 없음(단일 라이터 가정), ensure live-proxy 분기의 grok 재주입 없음(기존 codex sync 동작과 일치), trailing-dash alias 미관. From 568a4967b2c0f3e06011c001cfbd4af8c031bea1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:39:44 +0900 Subject: [PATCH 12/22] feat(grok): deterministic fence on ensure/restart + heartbeat safety regression - syncGrokConfig helper (deps-injectable, mirrors codex/sync.ts) shared by start and both handleEnsure branches; the ensure parent injects after waitForProxy so the fence exists before ensure returns (spawned-child readiness race), and the live branch passes live.hostname from proxy-liveness instead of possibly-drifted config.hostname. - Catalog failure surfaces ok:false instead of silently skipping. - Regression pins that responsesSseToChatCompletionsSse never forwards a raw response.heartbeat frame (why the injected config pins chat_completions against grok's strict Responses decoder). --- src/cli/index.ts | 30 +++++--- src/grok/sync.ts | 56 +++++++++++++++ tests/chat-completions-endpoint.test.ts | 25 +++++++ tests/grok-sync.test.ts | 93 +++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 src/grok/sync.ts create mode 100644 tests/grok-sync.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index dbe383bba4..7a28d73049 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -271,16 +271,9 @@ async function handleStart(options: { block?: boolean } = {}) { // installed grok CLI can pick opencodex-routed models without manual config. No-op // when ~/.grok is absent; removed again by stop/eject/uninstall/shutdown. try { - const { injectGrokConfig } = await import("../grok/inject"); - const grokModels = [ - ...visibleNativeSlugs(config).map(id => ({ id })), - ...models.map(m => ({ - id: m.alias ?? `${m.provider}/${m.id}`, - ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), - })), - ]; - const r = injectGrokConfig(port, grokModels, config.hostname ? { hostname: config.hostname } : {}); - if (r.changed) console.log(` + Grok Build config updated (${grokModels.length} models via ~/.grok/config.toml)`); + const { syncGrokConfig } = await import("../grok/sync"); + const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); else if (!r.ok) console.error(`⚠️ ${r.message}`); } catch { /* best-effort — grok integration must never block startup */ } } catch { /* best-effort — registry rebuilds on first /v1/models call */ } @@ -304,6 +297,14 @@ async function handleEnsure() { }); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). await injectSystemEnv(live.port, config).catch(() => {}); + // Refresh the Grok Build fence too (same contract as start). live.hostname is the + // hostname the running proxy actually bound — config.hostname may have drifted. + try { + const { syncGrokConfig } = await import("../grok/sync"); + const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {}); + if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!g.ok) console.error(`⚠️ ${g.message}`); + } catch { /* best-effort */ } console.log(`✅ Proxy running on port ${live.port}`); return; } @@ -322,6 +323,15 @@ async function handleEnsure() { console.error("❌ Proxy did not become healthy after starting."); process.exit(1); } + // Deterministic fence guarantee: the spawned child injects late in its own startup, but + // this parent returns as soon as /healthz responds — inject here too (idempotent block + // replace) so `ocx ensure` never returns without the Grok fence in place. + try { + const { syncGrokConfig } = await import("../grok/sync"); + const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!g.ok) console.error(`⚠️ ${g.message}`); + } catch { /* best-effort */ } // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. await syncModelsToCodex(port).catch(e => { diff --git a/src/grok/sync.ts b/src/grok/sync.ts new file mode 100644 index 0000000000..864b1f3f86 --- /dev/null +++ b/src/grok/sync.ts @@ -0,0 +1,56 @@ +/** + * Shared Grok Build config sync: gather the visible model catalog and (re)inject the + * managed block into ~/.grok/config.toml. Used by `ocx start` (server process) and by + * `ocx ensure` / `ocx restart` (parent process, after live discovery or child readiness) + * so the fence exists deterministically once the proxy reports healthy. + * + * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy. + */ +import { visibleNativeSlugs, filterCatalogVisibleModels, type CatalogModel } from "../codex/catalog"; +import type { OcxConfig } from "../types"; +import { injectGrokConfig, type GrokInjectModel, type GrokInjectResult } from "./inject"; + +export interface GrokSyncDeps { + fetchAllModels: (config: OcxConfig) => Promise; + injectGrokConfig: typeof injectGrokConfig; +} + +async function defaultFetchAllModels(config: OcxConfig): Promise { + const { fetchAllModels } = await import("../server/management-api"); + return fetchAllModels(config); +} + +/** + * Build the model list and inject the fenced block. `hostname` should be the hostname the + * RUNNING proxy actually bound (live.hostname from proxy-liveness for ensure's live branch; + * config.hostname for a freshly spawned start) — a stale config.hostname could otherwise + * name a host the process never bound. + */ +export async function syncGrokConfig( + port: number, + config: OcxConfig, + opts: { hostname?: string; grokHome?: string } = {}, + deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig }, +): Promise { + let models: GrokInjectModel[]; + try { + const routed = filterCatalogVisibleModels(await deps.fetchAllModels(config), config); + models = [ + ...visibleNativeSlugs(config).map(id => ({ id })), + ...routed.map(m => ({ + id: m.alias ?? `${m.provider}/${m.id}`, + ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), + })), + ]; + } catch (err) { + return { + ok: false, + changed: false, + message: `Grok config sync skipped: model catalog unavailable (${err instanceof Error ? err.message : String(err)})`, + }; + } + return deps.injectGrokConfig(port, models, { + ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}), + ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}), + }); +} diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 2877cf8ff2..53d576f6e0 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -220,6 +220,31 @@ test("chatCompletionsUsage always emits detail objects with zero defaults", () = }); }); +test("responsesSseToChatCompletionsSse consumes response.heartbeat without forwarding a raw frame", async () => { + // grok-build's strict Responses decoder dies on unknown variants (response.heartbeat), + // which is why the injected Grok config pins api_backend = "chat_completions". This + // regression pins the safety property: heartbeats never surface as raw frames here — + // at most a valid role chunk is emitted. + const { responsesSseToChatCompletionsSse } = await import("../src/chat/outbound"); + const upstream = new Response([ + `event: response.heartbeat\ndata: ${JSON.stringify({ type: "response.heartbeat" })}\n\n`, + `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: "hi" })}\n\n`, + `event: response.heartbeat\ndata: ${JSON.stringify({ type: "response.heartbeat" })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } })}\n\n`, + ].join(""), { headers: { "Content-Type": "text/event-stream" } }); + const stream = responsesSseToChatCompletionsSse(upstream.body!, "routed/model"); + const text = await new Response(stream).text(); + expect(text).not.toContain("response.heartbeat"); + expect(text).toContain('"content":"hi"'); + expect(text).toContain("data: [DONE]"); + // Every data frame must be a chat.completion.chunk — no Responses-vocab leaks. + for (const line of text.split("\n")) { + if (!line.startsWith("data: ") || line === "data: [DONE]") continue; + const parsed = JSON.parse(line.slice(6)) as { object?: string }; + expect(parsed.object).toBe("chat.completion.chunk"); + } +}); + test("POST /v1/chat/completions streams OpenAI-shaped chunks end to end", async () => { const upstream = mockChatUpstream(); saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts new file mode 100644 index 0000000000..d741f2b45e --- /dev/null +++ b/tests/grok-sync.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { injectGrokConfig } from "../src/grok/inject"; +import { syncGrokConfig } from "../src/grok/sync"; +import type { CatalogModel } from "../src/codex/catalog"; +import type { OcxConfig } from "../src/types"; + +const baseConfig = { port: 10100, defaultProvider: "openai", providers: {} } as unknown as OcxConfig; + +function tempGrokHome(): { root: string; grokHome: string } { + const root = mkdtempSync(join(tmpdir(), "ocx-grok-sync-")); + const grokHome = join(root, ".grok"); + mkdirSync(grokHome); + return { root, grokHome }; +} + +describe("syncGrokConfig", () => { + test("injects natives plus routed models with catalog context windows", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const routed: CatalogModel[] = [ + { id: "grok-4.5", provider: "cursor", contextWindow: 500_000 } as CatalogModel, + ]; + const result = await syncGrokConfig(10190, baseConfig, { grokHome }, { + fetchAllModels: async () => routed, + injectGrokConfig, + }); + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + // Native slugs come from visibleNativeSlugs(config) — at least one gpt native present. + expect(content).toContain("[model.ocx-gpt-"); + expect(content).toContain("[model.ocx-cursor-grok-4-5]"); + expect(content).toContain("context_window = 500000"); + expect(content).toContain('base_url = "http://127.0.0.1:10190/v1"'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("hostname override reaches the generated base_url (ensure live branch)", async () => { + const { root, grokHome } = tempGrokHome(); + try { + await syncGrokConfig(10100, baseConfig, { grokHome, hostname: "0.0.0.0" }, { + fetchAllModels: async () => [], + injectGrokConfig, + }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + // providerBaseHost maps 0.0.0.0 to a loopback the client can dial. + expect(content).toContain('base_url = "http://127.0.0.1:10100/v1"'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("catalog failure surfaces ok:false without touching the config", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const result = await syncGrokConfig(10100, baseConfig, { grokHome }, { + fetchAllModels: async () => { throw new Error("proxy down"); }, + injectGrokConfig, + }); + expect(result.ok).toBe(false); + expect(result.changed).toBe(false); + expect(result.message).toContain("proxy down"); + expect(() => readFileSync(join(grokHome, "config.toml"), "utf8")).toThrow(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("re-sync is idempotent: one fence, latest catalog wins", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const deps = { fetchAllModels: async () => [], injectGrokConfig }; + await syncGrokConfig(10190, baseConfig, { grokHome }, { + ...deps, + fetchAllModels: async () => [{ id: "old", provider: "p" } as CatalogModel], + }); + await syncGrokConfig(10190, baseConfig, { grokHome }, { + ...deps, + fetchAllModels: async () => [{ id: "new", provider: "p" } as CatalogModel], + }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + expect(content.match(/>>> opencodex managed block/g) ?? []).toHaveLength(1); + expect(content).not.toContain("[model.ocx-p-old]"); + expect(content).toContain("[model.ocx-p-new]"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 4dd417a446ed964fb39ffdc49850b3e8e6d68496 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:41:52 +0900 Subject: [PATCH 13/22] fix(cli): restart proceeds past service-manager stop failure The home-mismatch guard throw previously set stopFailed and exited 1, which aborted ocx restart before its ensure step could bring the proxy back. The warning is preserved; local proxy/codex/grok teardown and the subsequent ensure are independent of the service manager. --- src/cli/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 7a28d73049..907d727b89 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -391,7 +391,9 @@ async function handleStop() { stoppedService = stopServiceIfInstalled(); if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); } catch (err) { - stopFailed = true; + // Service-manager failure (e.g. OPENCODEX_HOME mismatch guard) is warned but does not + // fail the whole stop: the local proxy/codex/grok teardown below is independent, and + // `ocx restart` must still be able to proceed to its ensure step. console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); } From 3e8a4ba33626a648fca76b4a7ca22e0c2032d69f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:43:54 +0900 Subject: [PATCH 14/22] docs(devlog): wp4 plan + receipt (ensure/restart hardening, heartbeat decision) --- .../040_production_hardening.md | 35 +++++++++++++++++++ .../260723_grok_build_bridge/041_receipt.md | 28 +++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 devlog/_plan/260723_grok_build_bridge/040_production_hardening.md create mode 100644 devlog/_plan/260723_grok_build_bridge/041_receipt.md diff --git a/devlog/_plan/260723_grok_build_bridge/040_production_hardening.md b/devlog/_plan/260723_grok_build_bridge/040_production_hardening.md new file mode 100644 index 0000000000..d30d82e081 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/040_production_hardening.md @@ -0,0 +1,35 @@ +# 040 — wp4: 라이프사이클 프로덕션 하드닝 + +Date: 2026-07-23. Goal-mode (HOTL) 사이클 1/3. QA 직전까지 프로덕션급. + +## Scope + +### 1. `ocx ensure` live-proxy 분기 grok 재주입 (c1) + +현황: `handleEnsure()` live 분기(src/cli/index.ts:290-299)는 `syncModelsToCodex`+`injectSystemEnv`만 수행 — grok config는 start 경로에서만 주입됨. 리뷰어(Socrates R1) 수용 잔여. + +계획(오딧 반영): `src/grok/sync.ts` 신설 — `codex/sync.ts:19`의 deps-주입 패턴을 미러링한 `syncGrokConfig(port, config, opts)`. handleStart와 handleEnsure **양쪽 분기**(live 분기 + spawned-child readiness 후 부모)에서 호출. + +- **오딧 블로커 1 (readiness race):** ensure의 spawned-child 경로는 `/healthz` 응답 직후 부모가 리턴하는데 자식의 grok 주입은 그보다 늦음 → ensure 부모가 `waitForProxy()` 성공 후 **직접** `syncGrokConfig(port, ...)`를 호출해 결정론적으로 주입 완료를 보장 (자식 주입과 멱등 충돌 없음 — 동일 블록 교체). +- **오딧 블로커 3 (live hostname):** live 분기에서는 `config.hostname`이 아니라 `live.hostname`(proxy-liveness 런타임 기록)을 hostname으로 전달. spawned 분기는 방금 띄운 config 기준이므로 config.hostname 사용. +- **오딧 제안 4 (관측성):** `syncGrokConfig`는 `GrokInjectResult`를 반환(테스트 가능); 명시적 `ocx ensure`에서 ok:false를 경고로 표면화. 테스트: `tests/grok-sync.test.ts` — deps 주입으로 fetch 목킹, ensure-경로 재주입/hostname 선택/실패 표면화 검증. + +### 2. `ocx restart` 왕복 검증 (c2) + +restart = handleStop → handleEnsure. stop이 strip, ensure가 자식 spawn(start) 또는 live 재주입. 격리 환경(:10190)에서 start→restart 후 펜스 1개·모델 반영 확인. + +- **오딧 블로커 2 (service-installed restart) — 유예의 정확한 범위:** 이 결함은 grok 신규 코드가 아니라 **기존 `ocx restart` 자체의 결함**이다: handleStop이 설치된 service manager를 중지한 뒤 handleEnsure가 unmanaged detached child로 대체하므로, service-installed 환경에서 restart는 **자동 재시작/로그인-시작 보장(service persistence)을 상실**한다. 또한 그 unmanaged child가 죽으면 grok fence는 dead proxy를 가리킨 채 **다음 start/ensure가 호출될 때까지 무기한** 남는다 (짧다는 보장 없음). 이 환경에서 라이브 재현 불가(실 launchd 서비스가 프로덕션 :10100 소유). 유예 조건(오딧 합의): ① 이번 wp4 verifier는 **non-service 경로 전제**임을 receipts에 명시, ② service-installed `ocx restart`의 persistence 손실 + 무기한 stale-fence 가능성을 wp5 문서 known-limitations에 **release-known-limitation으로 명기**, ③ 후속 acceptance criterion("service-installed restart는 service manager를 통해 재시작하고 fence를 재보장한다")을 receipts에 후속 과제로 고정. 기존-결함 수정 자체는 goal 파일 스코프(restart 재설계)가 아니므로 별도 이슈 대상. + +### 3. heartbeat 결정 (c3) + +증거: grok strict Responses 디코더는 `response.heartbeat` unknown variant에서 즉사(011 R2). 반면 **chat_completions 인바운드는 안전** — `src/chat/outbound.ts:211`이 `response.heartbeat`를 소비하며 **raw heartbeat 프레임은 전달하지 않고** 최대 유효한 Chat Completions role chunk(`ensureRole`)만 방출한다. 자동 주입 경로는 전 모델 `api_backend="chat_completions"`이므로 strict-디코더 crash에 도달 불가. + +결정(기록, 오딧 확인 6 반영): bridge의 `response.heartbeat`는 codex-rs keep-alive 계약(어떤 이벤트든 idle 타이머 re-arm, unknown 무시)에 최적이므로 **유지**. 대안(SSE comment는 eventsource 파서가 이벤트로 안 올려 codex idle 타이머 미갱신 위험, `response.in_progress`는 strict 스키마상 전체 response 스냅샷 페이로드 필요)은 기각. chat 인바운드는 **raw heartbeat를 전달하지 않음** — `ensureRole()`로 유효한 role chunk만 방출(스트림·논스트림 fold 모두, 오딧이 chat-completions.ts:182/197 확인). 조치: ① chat 경로 heartbeat 소비 회귀 테스트 추가, ② responses 백엔드 직결 사용자용 known-limitation을 wp5 문서에 명기. + +## Verifier +- 신규/기존 테스트 + typecheck + full test + privacy scan +- live: 격리 :10190에서 ensure(live 분기) 재주입 로그, restart 왕복 후 펜스/모델 상태 +- receipts: 041 + +## Out of scope +- bridge.ts 수정 (결정상 불필요), docs(wp5), 스모크(wp6) diff --git a/devlog/_plan/260723_grok_build_bridge/041_receipt.md b/devlog/_plan/260723_grok_build_bridge/041_receipt.md new file mode 100644 index 0000000000..4192d4bc65 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/041_receipt.md @@ -0,0 +1,28 @@ +# 041 — wp4 receipt: 라이프사이클 프로덕션 하드닝 + +Date: 2026-07-23 (KST), commits `8ddeab8f` + `7c521a6c` + +**전제(오딧 합의): 본 라이브 검증은 non-service 경로 한정.** service-installed 환경의 `ocx restart`는 service manager를 중지하고 unmanaged child로 대체하여 persistence(자동 재시작/로그인 시작)를 잃는 **기존 restart 결함**이 있으며, unmanaged child 사망 시 grok fence가 다음 start/ensure까지 **무기한** dead proxy를 가리킬 수 있다. → wp5 문서 known-limitations에 release-known-limitation으로 명기. **후속 acceptance criterion(고정):** "service-installed `ocx restart`는 service manager를 통해 재시작하고 재시작 후 fence를 재보장한다" — restart 재설계는 본 goal 스코프 밖, 별도 이슈 대상. + +## c1 — ensure 재주입 (라이브, :10190 격리) + +1. live 분기: fence 수동 strip → `ocx ensure` → `+ Grok Build config updated` + fence 1 복원. `live.hostname` 전달 (proxy-liveness 런타임 기록). +2. spawned 분기: `ocx restart`(stop→ensure) 후 부모가 waitForProxy 성공 직후 직접 주입 — readiness race 해소 (오딧 블로커 1). +3. `codexAutoStart=false`면 ensure가 조기 리턴하는 기존 게이트 확인 (grok과 무관, 기존 동작). + +## c2 — restart 왕복 (라이브) + +- 1회차 restart에서 **기존 결함 발견**: service-manager stop throw(home-mismatch)가 `stopFailed`→`process.exit(1)`로 이어져 ensure에 도달 못 하고 프록시가 죽은 채 종료. → `7c521a6c`: 경고는 유지하되 stopFailed로 승격하지 않음 (로컬 teardown/ensure는 service manager와 독립). +- 수정 후: `restart` → 구 pid stop + fence strip → 새 pid 기동 + fence 1 재주입. 연속 2회 왕복 모두 성공 (pid 56377→57004, uptime 리셋 확인). + +## c3 — heartbeat 결정 (코드+테스트 증거) + +- 결정: bridge keep-alive 유지 (codex-rs 계약 최적). 주입 경로는 전 모델 `chat_completions` — `responsesSseToChatCompletionsSse`가 raw `response.heartbeat` 프레임을 절대 전달하지 않음(최대 유효 role chunk). +- 회귀 고정: `tests/chat-completions-endpoint.test.ts` "consumes response.heartbeat without forwarding a raw frame" — 모든 data 프레임이 `chat.completion.chunk`임을 어서션. +- responses 백엔드 직결 사용자는 known-limitation (wp5 문서). + +## Verifiers + +- typecheck clean, privacy scan pass +- `tests/grok-sync.test.ts` 4 pass (catalog fold, hostname override, 실패 표면화, 멱등), `tests/grok-config-inject.test.ts` 11 pass, chat 엔드포인트 22 pass +- full suite **3721 pass / 1 fail** (기존 anthropic-thinking-signature full-run 플레이크 — wp1부터 동일, stash 검증 완료된 건) From 125e24f2e27cc241e56303eb589e2382edb559db Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:50:31 +0900 Subject: [PATCH 15/22] docs(site): Grok Build integration guide Auto-registration lifecycle, manual direct-fields recipe (model_providers inheritance bug on 0.2.101), loopback auth note, and known limitations (heartbeat vs strict Responses decoder, service-installed restart persistence loss, config hot-reload behavior). EN source only; sidebar entry with locale labels, Starlight EN fallback for locales. --- docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/grok-build.md | 94 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs-site/src/content/docs/guides/grok-build.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 10a7fc50b0..1ae6bdfb83 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -121,6 +121,7 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー" }, slug: "guides/codex-app-models" }, { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, + { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md new file mode 100644 index 0000000000..dd17db0417 --- /dev/null +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -0,0 +1,94 @@ +--- +title: Grok Build +description: Use any opencodex-routed model from xAI's Grok Build CLI — models are auto-registered into ~/.grok/config.toml while the proxy runs. +--- + +opencodex serves an OpenAI-compatible `POST /v1/chat/completions` (and `/v1/responses`) on its +local port, and Grok Build supports custom models against OpenAI-compatible servers. Starting +with this integration, opencodex registers its whole visible catalog into Grok Build +automatically — no manual config editing required. + +## Auto-registration + +When `~/.grok` exists, `ocx start` (and `ocx ensure` / `ocx restart`) writes a managed block +into `~/.grok/config.toml`: + +```toml +# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>> +[model.ocx-gpt-5-6-sol] +model = "gpt-5.6-sol" +base_url = "http://127.0.0.1:10100/v1" +api_backend = "chat_completions" +api_key = "opencodex-loopback" +name = "OCX gpt-5.6-sol" +# ... one [model.ocx-*] table per visible model ... +# <<< opencodex managed block <<< +``` + +- **Additive:** your own config outside the fence is never touched. Before the first + injection into a pre-existing file, a one-time backup is written to + `~/.grok/config.toml.bak-opencodex`. +- **Idempotent:** every `ocx start` (and `ocx ensure` while autostart is enabled) replaces + the fenced block with the current catalog. +- **Removed on teardown:** `ocx stop`, `ocx eject`, `ocx uninstall`, and graceful + non-service daemon shutdown strip the fenced block and restore your file + byte-for-byte. Under a service manager, teardown goes through `ocx stop`/`ocx + uninstall` (service-mode processes intentionally keep the block across respawns). +- **Conflict-safe:** aliases already defined by your own `[model.*]` tables are respected + (opencodex suffixes its own entries); a damaged fence (begin marker without end marker) + refuses any automatic change and asks for manual repair. + +Then pick a model inside Grok Build: + +```bash +grok models # lists ocx-* entries alongside native grok models +grok -m ocx-anthropic-claude-opus-4-8 -p "hello" +# or in the TUI: /model ocx-anthropic-claude-opus-4-8 +``` + +## Authentication note + +Grok Build requires a non-empty API key for custom models even on loopback. The injected +entries carry a placeholder (`opencodex-loopback`) — opencodex ignores admission keys for +loopback connections, so no real secret is involved. If you bind the proxy on a +non-loopback host, replace the per-model `api_key` with your opencodex admission token. + +The injected per-model `api_key` sits first in Grok's credential chain for these models, +so turns against opencodex need no additional Grok login. Keep your normal `grok login` / +`XAI_API_KEY` setup for native grok models and any harness features that contact xAI +directly. + +## Manual recipe (without auto-registration) + +If you manage `~/.grok/config.toml` yourself, add per-model tables with **direct fields**: + +```toml +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +base_url = "http://127.0.0.1:10100/v1" +api_backend = "chat_completions" +api_key = "opencodex-loopback" +``` + +Do not rely on `[model_providers.]` inheritance for the endpoint: as of Grok Build +0.2.101 the inherited `base_url` is not applied to inference routing (requests fall +through to the default xAI proxy and fail with 401). Direct per-model fields route +correctly. + +## Known limitations + +- **Responses backend and keep-alives:** opencodex emits a `response.heartbeat` keep-alive + on `/v1/responses` streams during upstream silence. Grok Build's Responses decoder + rejects unknown event types, so a manually configured `api_backend = "responses"` model + can fail mid-turn on slow upstreams. The auto-registered entries pin + `api_backend = "chat_completions"`, which never surfaces raw heartbeat frames. +- **Service-installed `ocx restart`:** when opencodex runs under a service manager, + `ocx restart` currently stops the service and replaces it with an unmanaged process — + service persistence (auto-restart, start-at-login) is lost until the next + `ocx service` setup, and if that unmanaged process dies the managed block can point at + a dead proxy until the next `ocx start`/`ocx ensure` refreshes it. +- **Config read timing:** start opencodex first, then launch `grok` for the most + predictable results. Recent Grok Build versions watch `config.toml` and hot-reload + `[model.*]` changes into an open session; older builds may need a restart. +- **Catalog updates:** the fenced block reflects the catalog at injection time. After + adding providers or models, run `ocx ensure` (or restart the proxy) to refresh it. From 644403e616c9c68dee23dca8d0972000e2c023b1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 23 Jul 2026 21:59:39 +0900 Subject: [PATCH 16/22] =?UTF-8?q?docs(devlog):=20wp6=20receipt=20=E2=80=94?= =?UTF-8?q?=20pre-QA=20smokes=20(tool-call=20round-trip=202-layer=20proof,?= =?UTF-8?q?=20catalog,=20reasoning=20bridge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260723_grok_build_bridge/042_receipt.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 devlog/_plan/260723_grok_build_bridge/042_receipt.md diff --git a/devlog/_plan/260723_grok_build_bridge/042_receipt.md b/devlog/_plan/260723_grok_build_bridge/042_receipt.md new file mode 100644 index 0000000000..cffae01e93 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/042_receipt.md @@ -0,0 +1,40 @@ +# 042 — wp6 receipt: pre-QA 스모크 (tool-call 왕복, 카탈로그, reasoning) + +Date: 2026-07-23 21:5x KST. Server: 수정 체크아웃 :10190 (pid 57004, OPENCODEX_HOME=/tmp/ocx-wp1-home 격리). grok 0.2.101, GROK_HOME=/tmp/grok-home-wp3. + +## 1. Tool-call 왕복 — 2-layer 증명 (오딧 합의 형식) + +### (a) Raw-wire 2-request 루프: **PASS** + +Req1 (`/tmp/wp6-tool-req1.raw`, 13 frames): tools[]와 함께 스트림 요청 → tool_call 프레임 수신. + +- naive append-style 재구성 (RAW 청크에서): name=`run_terminal_cmd` (정확히 1회), args=`{"command":"echo OCX_TOOL_OK"}` — **valid JSON, 중복 없음**. done-frame은 id 포함 replace-style 스냅샷으로 구분됨 (리뷰어 지적 duplication 리스크는 id 유무로 구분 가능하며, grok 실클라이언트가 (b)에서 정상 파싱함을 확인). +- finish_reason=`tool_calls`, usage details 포함 (`prompt_tokens_details`/`completion_tokens_details` — wp1 계약). + +Req2 (`/tmp/wp6-tool-req2.json/.resp`): 같은 `call_id`로 assistant.tool_calls + role:tool 결과 재전송 → finish=`stop`, 본문이 `OCX_TOOL_OK` 출력을 인용한 최종 답변. **call_id 등가성 확인** (req1 프레임에서 추출한 id를 그대로 사용, 200 응답). + +### (b) grok 실클라이언트 E2E: **PASS** (SKIP 아님) + +기본 샌드박스 ON(GROK_SANDBOX 미설정), cwd=/tmp/wp6-tool-cwd, `--tools run_terminal_cmd` 포지티브 allowlist, `--always-approve` 없음: + +``` +grok -p "Use the run_terminal_cmd tool to run exactly this command: echo OCX_TOOL_OK — then tell me its output." -m ocx-cursor-grok-4-5 --max-turns 4 --tools run_terminal_cmd +exit=0, stdout 말미: OCX_TOOL_OK (코드블록 인용) +``` + +grok가 스트림 파싱→툴 실행→function_call_output 재전송→최종 답변까지 전 루프를 자체 수행. 로그 `/tmp/wp6-grok-tool.{out,err}` (err의 Failed to fetch models 경고는 dummy XAI 키의 네이티브 카탈로그 fetch 실패 — 본 루프와 무관). + +## 2. 카탈로그 listing: **PASS** + +`grok models` → `ocx-*` 29줄 노출, `ocx-gpt-5-6-sol` 기본 선택 (`/tmp/wp6-models.out`). + +## 3. Reasoning delta: **PARTIAL — 브리지 PASS / 라이브 업스트림 미발화** + +- 브리지 단위 증명 (라이브 프로세스와 동일 코드): `response.reasoning_text.delta` → `delta.reasoning_content` 프레임 방출 확인 (bun 인라인 실행, 정확한 청크 인용): + `{"choices":[{"delta":{"reasoning_content":"thinking hard"},...}]}` +- 라이브 4개 모델(cursor/grok-4.5, opencode-go/glm-5.2, kimi/k3, anthropic/claude-opus-4-6 + gpt-5.5)에서 reasoning_content 프레임 미관측 — 이 계정/설정 조합에서 업스트림이 reasoning 텍스트를 스트림에 싣지 않음 (`claude-opus-4-6-thinking` 별도 alias 존재, hide-thinking 설정 영향 가능). **코드 경로는 위 단위 증명으로 커버**, 라이브 미발화는 upstream/설정 요인으로 기록 (QA 시나리오에 thinking-alias 턴 포함 권장). + +## 최종 게이트 + +- typecheck clean, privacy scan pass, full suite 3721 pass / 1 기존 플레이크 (wp4와 동일) +- 브랜치 push까지 완료 (c6는 D 마감 시 최종 캡처) From b2dc3c3c58d0ec8b3ea16d37cc5e574d6763467e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 02:18:48 +0900 Subject: [PATCH 17/22] =?UTF-8?q?docs(devlog):=20260726=20grok=20build=20p?= =?UTF-8?q?roduction=20roadmap=20=E2=80=94=20blocker=20inventory=20+=20ups?= =?UTF-8?q?tream=20source=20evidence=20+=205=20implementation=20cycles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../000_blocker_inventory.md | 112 +++++++++++++++ .../_plan/260726_grok_build_prod/000_plan.md | 41 ++++++ .../001_grok_source_evidence.md | 112 +++++++++++++++ .../010_config_safety_core.md | 119 ++++++++++++++++ .../020_nonloopback_auth.md | 86 +++++++++++ .../030_lifecycle_teardown.md | 134 ++++++++++++++++++ .../040_lifecycle_regressions.md | 79 +++++++++++ .../260726_grok_build_prod/050_docs_truth.md | 65 +++++++++ 8 files changed, 748 insertions(+) create mode 100644 devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md create mode 100644 devlog/_plan/260726_grok_build_prod/000_plan.md create mode 100644 devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md create mode 100644 devlog/_plan/260726_grok_build_prod/010_config_safety_core.md create mode 100644 devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md create mode 100644 devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md create mode 100644 devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md create mode 100644 devlog/_plan/260726_grok_build_prod/050_docs_truth.md diff --git a/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md b/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md new file mode 100644 index 0000000000..504e398696 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md @@ -0,0 +1,112 @@ +--- +created: 2026-07-26 +status: plan +tags: [grok-build, pr-403, review-blockers, production] +--- + +# 000 — PR #403 리뷰 블로커 인벤토리 (실측) + +대상: `codex/260726-grok-build-prod` (PR #403 재기반), 로컬 `dev` @`5a550867` 위 15커밋. +수집일: 2026-07-26. 출처: `gh api repos/lidge-jun/opencodex/pulls/403/comments`, +`gh api repos/lidge-jun/opencodex/issues/403/comments` (메인테이너 리뷰 2026-07-24T13:05:24Z). + +## 리뷰 소스별 원본 개수 + +| 소스 | 건수 | 성격 | +|------|------|------| +| 메인테이너 (lidge-jun) | 4 | blocking (High 2 / Medium 2) | +| chatgpt-codex-connector | 5 | 전부 P2 | +| coderabbitai | 6 인라인 | Major 4 / Minor 2 | + +중복을 접으면 고유 블로커는 8건(B1–B8)이다. + +## B1 — 비루프백 바인드에서 관리 블록이 실토큰을 덮어씀 (High) + +- 지적: 메인테이너 #2, codex P2 `src/grok/inject.ts:134`, CodeRabbit `docs/guides/grok-build.md:54` +- 현재 코드: `buildGrokManagedBlock()`가 모든 모델에 `api_key = "opencodex-loopback"` 고정 방출 + (`src/grok/inject.ts` 내 `lines.push(... 'api_key = "opencodex-loopback"')`). +- 실제 결과: `src/server/auth-cors.ts`는 비루프백 바인드에서 실제 `OPENCODEX_API_AUTH_TOKEN`을 + 요구하므로 자동 등록된 모델은 전부 401. 사용자가 손으로 키를 고쳐도 다음 + `start`/`ensure`/`restart`의 `syncGrokConfig()`가 블록을 통째로 재생성하며 되돌린다. + 메인테이너 재현 결과 `REAL_TOKEN_PRESERVED=false`. +- 판정: 유효. 자동 등록 자체를 루프백 바인드로 제한하고, 비루프백에서는 fence 밖 수동 설정을 + 안내해야 한다(사용자 소유 영역을 우리가 계속 덮어쓰지 않는 것이 핵심). + +## B2 — 서비스 소유권 가드 실패가 삼켜지고 공유 설정이 제거됨 (High) + +- 지적: 메인테이너 #1, codex P2 `src/cli/index.ts:397`, CodeRabbit `src/cli/index.ts:398` +- 현재 코드: `src/service.ts:869-891` `stopServiceIfInstalled()`는 첫 줄에서 + `assertServiceEnvironmentMatchesInstall()`를 호출해 다른 `CODEX_HOME`/`OPENCODEX_HOME`에 + 설치된 서비스면 **매니저를 건드리기 전에** throw한다. + `handleStop()`(`src/cli/index.ts` 내 try/catch)은 이 예외를 경고만 찍고 계속 진행해, + 아래에서 `stripGrokConfig()`로 공유 `~/.grok/config.toml` 블록을 제거한다. +- 실제 결과: 설치된 서비스는 살아 있는데 공유 라우팅 설정만 사라진다. 서비스가 프록시를 다시 + 띄우면 grok에는 모델이 없는 상태가 된다. `ocx restart`도 정지되지 않은 서비스 위에서 진행된다. +- 판정: 유효. 소유권 불일치(정지 시도조차 못 한 상태)와 단순 정지 실패를 구분해야 한다. + 전자는 공유 자원 teardown을 건너뛰고 실패로 전파한다. + +## B3 — 인용된 첫 키 세그먼트를 놓쳐 TOML 전체가 깨짐 (Medium) + +- 지적: 메인테이너 #3, codex P2 `src/grok/inject.ts:69`, CodeRabbit `src/grok/inject.ts:76` +- 현재 코드: `userModelAliases()`의 정규식이 + `/^\s*\[\s*model\s*\.\s*(?:([A-Za-z0-9_-]+)|"…"|'…')\s*\]/gm` — 두 번째 세그먼트의 인용은 + 처리하지만 **첫 세그먼트 `model`은 리터럴로만** 매칭한다. +- 실제 결과: 사용자가 `["model"."ocx-mine"]` 또는 `['model'.ocx-mine]`를 소유하면 예약에서 + 누락되고, 우리가 `[model.ocx-mine]`를 또 방출해 같은 테이블을 재정의한다. grok의 TOML 파서는 + `Cannot redefine key 'model'`로 설정 파일 전체를 거부한다 — 우리 블록뿐 아니라 사용자 설정까지 죽는다. +- 판정: 유효. 첫 세그먼트도 정규화하고 두 인용 형태 회귀 테스트를 추가한다. + +## B4 — CLI 라이프사이클 회귀 테스트 부재 (Medium) + +- 지적: 메인테이너 #4, CodeRabbit `src/cli/index.ts:278` +- 현재: `tests/grok-*.test.ts`는 inject/sync 헬퍼만 덮는다. `handleStart`의 grok 동기화 배선, + `handleEnsure`의 두 분기(라이브 프록시 발견 / 신규 spawn 후), stop 시 strip, 소유권 불일치 + 경로는 직접 테스트가 없다. B2가 이 구멍으로 통과했다. +- 판정: 유효. 변경된 라이프사이클 분기에 집중 회귀를 붙인다. + +## B5 — 개행 없는 설정 파일이 byte-for-byte 복원되지 않음 (P2) + +- 지적: codex P2 `src/grok/inject.ts:230` +- 현재 코드: inject는 `content.endsWith("\n") ? "\n" : "\n\n"`로 구분자를 넣고, + strip은 `prefix.endsWith("\n\n")`일 때 **한 개만** 되돌린다. +- 실제 결과: 원래 마지막 개행이 없던 사용자 파일이 `ocx stop` 후 개행 하나를 얻는다. + 사용자 소유 파일의 바이트 불일치. +- 판정: 유효. 주입한 구분자를 알 수 있어야 정확히 되돌릴 수 있다. + +## B6 — 의도적 서비스/API 종료가 fence를 남김 (P2) + +- 지적: codex P2 `src/cli/index.ts:212` +- 현재 코드: 데몬의 `syncCleanup()`은 `if (!process.env.OCX_SERVICE)`일 때만 + `stripGrokConfig()`를 부른다 — 서비스 매니저의 크래시/재spawn 때 fence를 지키려는 의도이며 + 이 배제 자체는 옳다. 그러나 `src/server/management-api.ts:136-148`의 `POST /api/stop`은 + `stopServiceIfInstalled()` + `restoreNativeCodex()`만 하고 grok strip을 전혀 호출하지 않는다. + 대시보드에서 정지하면 grok에는 죽은 엔드포인트를 가리키는 모델이 남는다. +- 판정: 유효. 명시적 종료 경로(서비스 정지, `POST /api/stop`)에서만 strip하고 + 크래시/재spawn 배제는 유지한다. + +## B7 — 문서가 비루프백 요건과 리로드 동작을 과장 (docs) + +- 지적: CodeRabbit `docs-site/.../grok-build.md:54`, `:92` +- 현재: 비루프백일 때 `api_key`만 교체하라고 안내한다(도달 불가한 `127.0.0.1` base_url 유지). + 또 "최근 grok이 config.toml을 감시해 열린 세션에 `[model.*]`를 핫리로드한다"고 단언한다 — + 버전 보증 없는 주장. +- 판정: 유효. `base_url`+`api_key` 동시 요구, 도달 가능한 예시 호스트, `grok inspect` 후 + 세션 재개라는 문서화된 절차로 교체. (B1에서 자동 등록을 루프백으로 제한하면 문서도 그에 맞게 + "비루프백은 수동 설정"으로 재작성된다.) + +## B8 — devlog 권장 백엔드가 자기 영수증과 모순 (docs) + +- 지적: CodeRabbit `devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md:14` +- 현재: 020 문서는 `responses`를 권장하지만 같은 유닛의 `011_receipt.md:53-55`는 grok이 + `response.heartbeat`에서 종료한다고 기록했고, 이후 계획은 그래서 `chat_completions`를 골랐다. +- 판정: 유효. `chat_completions` 권장으로 정정하고 Responses는 알려진 한계로 기록. + +## 작업 위상 매핑 + +| 블로커 | 실행 사이클 | +|--------|------------| +| B3, B5 | 010 — 설정 안전성 코어 | +| B1 | 020 — 비루프백 자동 등록 거부 | +| B2, B6 | 030 — 라이프사이클 teardown 정합성 | +| B4 | 040 — CLI 라이프사이클 회귀 | +| B7, B8 | 050 — 문서/devlog 진실 정렬 | diff --git a/devlog/_plan/260726_grok_build_prod/000_plan.md b/devlog/_plan/260726_grok_build_prod/000_plan.md new file mode 100644 index 0000000000..f926eedd2b --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/000_plan.md @@ -0,0 +1,41 @@ +# 000 — Grok Build 브리지 프로덕션화 로드맵 + +목표: PR #403(`codex/260726-grok-build-prod`, dev 리베이스 완료)에 남은 리뷰 블로커를 소스 근거로 해소하고, 공유 설정 파일(`~/.grok/config.toml`)과 서비스 라이프사이클을 안전하게 만든다. + +베이스: `dev` @ `faaaf98f` 위 리베이스. 이전 유닛은 `devlog/_plan/260723_grok_build_bridge/`. + +## 근거 조사 (2026-07-26) + +Grok Build 소스 `~/Developer/codex/180_grok-build` (SOURCE_REV `30192d2e`, `xai-grok-version` 0.2.110) 확인 결과: + +- `[model.]`는 `ConfigModelOverride`로 파싱된다 (`crates/codegen/xai-grok-shell/src/agent/config.rs:3914`). 지원 필드에 **`env_key`**(`:3928`)와 `auth_provider`(`:3930`), `extra_headers`가 있다. +- 자격 증명 우선순위는 `resolve_credentials`(`:4687`) 기준 `api_key` > `env_key` > auth_provider 캐시 토큰 > 세션 토큰 > `XAI_API_KEY`. +- `env_key`가 가리키는 환경변수가 비어 있으면 **세션 토큰으로 폴백하지 않는다** (`model_providers.rs:741` `model_own_unresolved_key_ignores_provider_inline_auth`). 즉 fail-closed다. +- 설치된 로컬 `~/.grok/bin/grok` 0.2.101 바이너리에도 `env_key` 문자열과 도움말(`Credential resolution: api_key > env_key > signed-in session token > XAI_API_KEY`)이 포함되어 있어 현재 사용자 환경에서도 쓸 수 있다. +- `api_backend`는 `chat_completions` / `responses` / `messages`이고 경로는 `base_url`에 상대 결합된다(`xai-grok-sampler/src/client.rs:703`). `/v1` 주입은 없다. +- config.toml 변경은 `ConfigFileWatcher` + `ConfigUpdate::ModelsChanged`로 실행 중 세션에 반영되지만(`config/reloader.rs:385`), docs.x.ai는 이를 보장하지 않고 `grok inspect` 후 재선택을 안내한다. 문서에서 hot-reload를 약속하면 안 된다. + +opencodex 쪽 경계: + +- `isApiAuthRequired(config) = !isLoopbackHostname(config.hostname)` (`src/server/auth-cors.ts:121`). 비루프백 바인드에서는 모든 데이터플레인 요청이 admission 토큰을 요구하므로, 현재 주입되는 `api_key = "opencodex-loopback"`은 **반드시 401**이 된다. 리뷰 지적이 실재한다. +- `~/.grok/config.toml`은 공유 파일이므로 실제 토큰을 직렬화하면 안 된다. `env_key = "OPENCODEX_API_AUTH_TOKEN"`이 두 요구를 동시에 만족한다. + +## 작업 순서 (의존성 순) + +| 단계 | 문서 | 내용 | +|---|---|---| +| wp1 | `010_config_safety.md` | 설정 파일 안전성: 비루프백 자격 증명, 인용 TOML 키 정규화, 개행 바이트 복원 | +| wp2 | `020_lifecycle_teardown.md` | 라이프사이클: 소유권 가드 실패 처리, 의도적 서비스/API 종료 시 fence 제거 | +| wp3 | `030_docs_truth.md` | 문서/데브로그 진실성 + 게이트 + PR #403 갱신 | + +wp1이 먼저인 이유: fence가 무엇을 쓰는지가 확정되어야 teardown 계약과 문서가 그 위에 얹힌다. wp2는 wp1이 만든 블록을 제거하는 경로를 다루고, wp3은 둘의 최종 동작을 문서화한다. + +## 스코프 경계 + +IN: `src/grok/*`, `src/cli/index.ts`, `src/service.ts`, `src/server/management-api.ts`, `docs-site/.../grok-build.md`, `devlog/_plan/*`, `tests/grok-*.test.ts`, `tests/service.test.ts` 계열 회귀. + +OUT: #403 머지, main 승격, 릴리스/dist-tag, 라우팅·어댑터 리팩터, 다른 워크트리 수정. + +## 게이트 + +`bun run typecheck`, `bun run test`, `bun run privacy:scan` 전부 green. 새로 추가되는 모든 조건 분기는 그 분기를 실제로 발동시키는 테스트를 동반한다 (C-ACTIVATION-GROUNDING-01). diff --git a/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md b/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md new file mode 100644 index 0000000000..69ed97b11f --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md @@ -0,0 +1,112 @@ +--- +created: 2026-07-26 +status: research +tags: [grok-build, upstream-source, evidence] +--- + +# 001 — grok-build 원본 근거 (설계 결정의 기준) + +참조 트리: `/Users/jun/Developer/codex/180_grok-build`. +**실측 HEAD `a5727c5960452e7527a154b25cb5bf00cda0545e`** ("Synced from monorepo", 2026-07-22), +`SOURCE_REV` = `30192d2eef5d91a8fff0e53957de5bd05b43398c`. +(이전 devlog가 인용한 `b189869`는 이 클론 히스토리에 없다 — 인용 갱신.) +모든 인용은 Rust 크레이트 원본이며 분석 마크다운이 아니다. + +## E1 — 설정 로딩과 중복 테이블 실패 범위 + +`crates/codegen/xai-grok-config/src/loader.rs:13` → `toml` 0.9 (`Cargo.lock` `toml 0.9.12+spec-1.1.0`). +레이어 병합 순서(낮음→높음): 시스템 managed → 사용자 managed → **`~/.grok/config.toml`** → +사용자 requirements → 시스템 requirements → macOS MDM. + +중복 `[model.x]`는 **해당 레이어 전체를 거부**한다(`std::io::Error::other(detail)`). +에러 문자열은 `loader.rs:44-52`의 `toml_error_detail`이 span에서 만들며 실측 재현 결과: + +``` +TOML parse error at line 4, column 8: duplicate key +``` + +`Display`를 쓰지 않는 이유가 원본 주석에 있다 — 문제 줄을 그대로 출력하면 그 줄에 담긴 비밀이 +로그로 샌다. 우리도 같은 이유로 사용자 설정 원문을 에러에 싣지 않는다. + +**결론(B3):** `[model.x]`와 `[model."x"]`는 같은 테이블이다. 하나만 중복돼도 사용자의 다른 모든 +설정까지 함께 죽는다. 첫 세그먼트 인용 형태를 반드시 정규화해야 한다. 또 점이 포함된 alias는 +반드시 인용해야 한다(`[model.grok-4.5]`는 키 경로이지 id가 아니다 — grok 자체 테스트가 이 함정을 문서화). + +## E2 — `[model.]` 스키마 + +`crates/codegen/xai-grok-shell/src/agent/config.rs:3915` `ConfigModelOverride`, +`#[derive(Deserialize, Default)] #[serde(default)]`. **필수 필드 없음**, `deny_unknown_fields` 없음. +수용 필드 34개. 우리 블록과 관련된 것: +`model`, `base_url`, `name`, `api_key`, `env_key`, `auth_provider`, `api_backend`, +`extra_headers`, `context_window`(`NonZeroU64` — 0은 무시됨). + +`auth_scheme`는 **모델 블록에서 설정 불가**하며 기본값 `Bearer`다. +알 수 없는 필드는 치명적이지 않다 — `serde_ignored`로 경고만 남기고 항목을 살린다 +(`config_model_override_parse.rs:266-302`, "관리 설정이 카탈로그 항목을 잃으면 안 된다"). + +## E3 — 비밀 없이 인증하기 (B1 설계의 핵심 근거) + +와이어 포맷: `Authorization: Bearer ` (`xai-grok-sampler/src/client.rs:404-433`). + +자격 증명 우선순위 (`config.rs:4678-4688`): +`api_key` → `env_key` → auth_provider 캐시 토큰 → 세션 토큰 → `XAI_API_KEY`. +**공백뿐인 `api_key`는 무시되고 `env_key`로 폴백한다** (`first_own_credential`이 `!k.trim().is_empty()` 필터). + +| 방식 | 동작 | 실패 시 | +|------|------|---------| +| `env_key = "VAR"` 또는 `["V1","V2"]` | 요청 시점에 lazy 해석, 처음 채워진 값 사용 | 경고 후 **키를 보내지 않음** (안전) | +| `api_key = "${VAR}"` | 로드 시 `shellexpand` 확장 | 미설정이면 **리터럴 `${VAR}`을 토큰으로 전송** (위험) | +| `auth_provider` | 헬퍼 명령 stdout에서 토큰 획득 | 파일/회전 토큰용 | + +**결론(B1):** 비루프백에서도 자동 등록을 포기할 필요가 없다. 리터럴 토큰 대신 +`env_key = "OPENCODEX_API_AUTH_TOKEN"`를 방출하면 우리는 사용자 파일에 비밀을 쓰지 않고, +grok은 실행 환경에서 토큰을 읽는다. 토큰이 없으면 grok은 키 없이 요청해 401이 나며 — 이는 +"조용히 잘못된 키를 보내는" 현재 동작보다 진단 가능하다. +`${VAR}` 확장은 실패 시 쓰레기 토큰을 전송하므로 채택하지 않는다. + +## E4 — 핫리로드는 실재한다 (B7 부분 반박) + +`xai-grok-shell/src/config/watcher.rs`가 `~/.grok/`의 `config.toml`을 감시한다 +(notify + debouncer, **1000 ms 디바운스**). 리로더는 `[model]` 테이블을 `toml::Value`로 비교하고 +달라졌을 때만 `ConfigUpdate::ModelsChanged`를 보낸다(`reloader.rs:385-393`): + +```rust +if old_model_table != new_model_table || old_models_table != new_models_table { + info!("model config change detected"); + let _ = self.config_update_tx.send(ConfigUpdate::ModelsChanged); +} +``` + +자기 쓰기 억제는 **의도적으로 없다** — 외부 프로세스(우리)의 쓰기가 삼켜지지 않도록 콘텐츠 기반 +중복 제거를 대신 쓴다(`watcher.rs:106-117`). + +**결론(B7):** "핫리로드가 없으니 `grok inspect` 후 세션을 다시 열라"는 지적은 원본과 맞지 않다. +열린 세션에 `[model.*]` 변경이 반영되는 것은 사실이다. 다만 문서에는 버전 보증 대신 +"감시 기반 리로드가 있고, 확인은 `grok inspect`"로 쓰고, 원자적 쓰기 필요성을 함께 적는다. +잘못된 config는 레이어 전체를 죽이므로 반쯤 쓰인 파일이 관측되면 안 된다 — 우리는 이미 +`atomicWriteFile`을 쓰고 있으며 이 계약을 문서화한다. + +## E5 — `api_backend`와 `response.heartbeat` (B8 확증) + +`xai-grok-sampling-types/src/types.rs:1010` `ApiBackend`는 정확히 세 값: +`chat_completions`(기본), `responses`, `messages`. +(원본 config 주석은 `messages`를 빠뜨린 stale 주석이다.) + +`responses` 백엔드에서 알 수 없는 최상위 `type` 태그는 **치명적**이다: +- 이벤트 열거형(async-openai 포크 `95b52ebd`)에 `#[serde(other)]`도 `Unknown` 변형도 없다. +- `client.rs:99-129`의 재시도는 `/response/tools`만 정리하므로 태그 오류를 못 살린다. +- 삼켜지는 비표준 이벤트는 doom-loop 체크 이벤트 하나뿐(`doom_loop.rs:252-260`). +- `SamplingError::Serialization`은 **재시도 불가**로 분류된다(`error.rs:245`). + +**결론(B8):** `011_receipt.md`의 관측(그록이 `response.heartbeat`에서 종료)은 원본 코드로 설명된다. +`chat_completions` 권장은 정당하며 `responses`는 알려진 한계로 기록한다. + +## E6 — `grok inspect`가 실제로 보여주는 것 + +`xai-grok-shell/src/inspect/mod.rs:281`. `Config Sources` 섹션은 각 레이어를 +`User: /path (empty|parse error)` 형태로 출력하며, note는 **실제 로더를 돌려서** 계산한다. +모델 카탈로그 섹션은 없다 — 대신 `Config Warnings`가 +`[model.""] ` 형태로 거부된 필드를 보고한다. + +**결론:** 문서에서 `grok inspect`는 "설정이 파싱됐는지와 어떤 필드가 거부됐는지 확인하는 명령"으로 +정확히 소개한다. "모델 목록을 보여준다"고 쓰면 틀린다. diff --git a/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md new file mode 100644 index 0000000000..c0c23895be --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md @@ -0,0 +1,119 @@ +--- +created: 2026-07-26 +status: plan +phase: wp1 +blockers: [B3, B5] +tags: [grok-build, toml, data-safety] +--- + +# 010 — 설정 안전성 코어 (B3 인용 키, B5 개행 복원) + +대상 파일: `src/grok/inject.ts`, `tests/grok-config-inject.test.ts`. +근거: `000_blocker_inventory.md` B3/B5, `001_grok_source_evidence.md` E1. + +## B3 — 첫 키 세그먼트 정규화 + +### 현재 + +```ts +const header = /^\s*\[\s*model\s*\.\s*(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*(?:#.*)?$/gm; +``` + +첫 세그먼트 `model`이 리터럴이라 `["model"."x"]`, `['model'.x]`를 놓친다. + +### 변경 + +두 세그먼트를 대칭적으로 다루는 형태로 재작성한다. 세그먼트 문법을 한 번만 정의하고 재사용: + +```ts +// bare | basic-string | literal-string — TOML이 허용하는 키 세그먼트 세 형태. +const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; +const MODEL_HEADER = new RegExp( + String.raw`^\s*\[\s*(${KEY_SEGMENT})\s*\.\s*(${KEY_SEGMENT})\s*\]\s*(?:#.*)?$`, + "gm", +); +``` + +그리고 세그먼트 → 실제 키 문자열로 되돌리는 단일 정규화 함수를 도입한다: + +```ts +/** TOML 키 세그먼트(bare/basic/literal)를 그것이 가리키는 실제 키로 되돌린다. */ +function canonicalKeySegment(raw: string): string | null { + if (raw.startsWith('"')) return decodeTomlBasicString(raw.slice(1, -1)); + if (raw.startsWith("'")) return raw.slice(1, -1); // literal string: 이스케이프 없음 + return raw; +} +``` + +`userModelAliases()`는 이제 첫 세그먼트가 정규화 후 `model`인 헤더만 채택한다: + +```ts +for (const match of outsideManagedRegion.matchAll(MODEL_HEADER)) { + if (canonicalKeySegment(match[1]!) !== "model") continue; + const alias = canonicalKeySegment(match[2]!); + if (alias !== null) aliases.add(alias); +} +``` + +기존 `decodeTomlBasicString`은 그대로 재사용한다(이미 `\uXXXX`/`\UXXXXXXXX` 처리 검증됨). +리터럴 문자열(`'...'`)은 TOML상 이스케이프가 없으므로 디코드하지 않는 현재 동작이 옳다. + +### 방출 측 대칭 확인 + +`buildGrokManagedBlock()`은 `ocx-` 접두 + `[^A-Za-z0-9_-]` 치환으로 alias를 만들므로 점이 남지 +않는다(E1의 점-alias 함정에 해당하지 않음). 이 성질이 회귀하지 않도록 테스트로 고정한다. + +### 회귀 테스트 (`tests/grok-config-inject.test.ts` 추가) + +1. `["model"."ocx-gpt-5"]`를 소유한 설정 → 생성 블록이 `[model.ocx-gpt-5]`를 재사용하지 않고 + `[model.ocx-gpt-5-2]`로 회피. +2. `['model'.ocx-gpt-5]` 동일. +3. `[ "model" . 'ocx-gpt-5' ]` 공백/혼합 인용 동일. +4. 방출된 모든 alias에 `.`이 없음 (점-alias 함정 고정). + +## B5 — 주입 구분자의 정확한 복원 + +### 현재 + +inject: `const separator = content.endsWith("\n") ? "\n" : "\n\n";` +strip: `if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);` — 항상 하나만 제거. + +원래 개행이 없던 파일은 `ocx stop` 후 개행 하나를 얻는다. + +### 변경 방침 + +구분자를 마커 자체에 기록하지 않는다(마커 문자열은 이미 배포된 사용자 파일에 존재하므로 형식을 +바꾸면 기존 블록과 호환이 깨진다). 대신 **복원 규칙을 대칭으로 만든다**: + +- inject는 그대로 두되, 개행 없는 파일에 붙일 때 우리가 넣은 것이 정확히 `"\n\n"`임을 명시한다. +- strip은 제거 후의 접미 상태로 판단하지 말고, **블록 앞에 우리가 넣은 분리 개행을 모두** 되돌린다: + +```ts +let prefix = content.slice(0, region.start); +const restOfFile = content.slice(removalEnd); +if (restOfFile.length === 0) { + // 블록이 파일 끝에 있었다 = 주입 시 우리가 앞에 분리 개행을 붙인 경우. + // 사용자 원문이 개행으로 끝나지 않았다면 "\n\n"을, 끝났다면 "\n"을 넣었으므로 + // 남은 접미 개행을 하나만 남기고(원문이 개행으로 끝났던 경우) 혹은 전부 제거한다. + prefix = prefix.endsWith("\n\n") ? prefix.slice(0, -2) + "\n" : prefix.replace(/\n$/, ""); +} +``` + +판정 근거: 블록은 항상 파일 끝에 추가되거나(신규) 기존 영역을 제자리 치환한다(갱신). +제자리 치환 경로에서는 구분자를 추가하지 않으므로 복원 대상이 없다. 따라서 되돌릴 구분자가 +존재하는 경우는 "블록 뒤에 아무것도 없는" 경우로 한정된다. + +**주의:** 사용자가 블록 뒤에 자기 내용을 덧붙였다면 `restOfFile`이 비지 않으므로 접두를 건드리지 +않는다 — 사용자 바이트를 임의로 줄이지 않는 보수적 동작이다. + +### 회귀 테스트 + +1. 개행 없이 끝나는 원문 → inject → strip → **원문과 바이트 동일**. +2. 개행 하나로 끝나는 원문 → 왕복 후 바이트 동일 (기존 테스트 강화). +3. 개행 두 개로 끝나는 원문 → 왕복 후 바이트 동일 (사용자 개행을 삼키지 않음). +4. 블록 뒤에 사용자 섹션이 있는 경우 → 왕복 후 바이트 동일. +5. CRLF 원문에서 1–4 반복(기존 `dominantEol`/`applyEol` 경로 유지 확인). + +## 게이트 + +`bun test tests/grok-config-inject.test.ts` → `bun run typecheck`. diff --git a/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md b/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md new file mode 100644 index 0000000000..b5e48e0b21 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md @@ -0,0 +1,86 @@ +--- +created: 2026-07-26 +status: plan +phase: wp2 +blockers: [B1] +tags: [grok-build, auth, non-loopback, security] +--- + +# 020 — 비루프백 인증 (B1) + +대상 파일: `src/grok/inject.ts`, `src/grok/sync.ts`, `tests/grok-config-inject.test.ts`, +`tests/grok-sync.test.ts`. +근거: `000_blocker_inventory.md` B1, `001_grok_source_evidence.md` E3. + +## 문제 재정의 + +리뷰어들은 "비루프백에서는 자동 등록을 거부하라"고 제안했다. 그 제안의 전제는 +**설정 파일에 실토큰을 쓰는 것 외에 방법이 없다**는 것이었다. 원본 확인 결과 그 전제가 틀렸다: +grok은 `env_key`로 환경변수 이름만 적어두면 요청 시점에 토큰을 읽는다(E3). + +따라서 본 계획은 지적의 **의도**(사용자 실토큰을 우리가 덮어쓰지 않는다, 작동하지 않는 설정을 +말없이 남기지 않는다)를 그대로 만족시키되, 기능을 잘라내지 않는 방식을 택한다. + +## 설계 + +### 1. 루프백 판정을 공유 가능한 곳으로 + +`src/codex/inject.ts`의 `isLoopbackHostname`은 모듈 private이고, 같은 이름이 `auth-cors.ts`에 +또 export되어 있으며 `service.ts`에 사본이 하나 더 있다. 새 사본을 만들지 않는다. +`src/codex/inject.ts`에서 `isLoopbackHostname`을 **export**하고 grok 모듈이 그것을 쓴다. +(`providerBaseHost`는 `0.0.0.0`을 `127.0.0.1`로 접는 다이얼 주소 매퍼라 판정에 쓸 수 없다.) + +### 2. 블록 생성의 인증 필드 분기 + +`buildGrokManagedBlock(port, models, hostname, reservedAliases)`에서: + +```ts +const nonLoopback = !isLoopbackHostname(hostname); +// 루프백: 데이터 플레인이 열려 있으므로 자리표시자 키로 충분하다. +// 비루프백: 실토큰이 필요하지만 사용자 파일에 비밀을 쓰지 않는다 — grok이 실행 환경에서 +// 읽도록 env_key만 남긴다(미설정 시 grok은 키 없이 호출해 401로 드러난다). +...(nonLoopback + ? [`env_key = ${tomlString(API_AUTH_TOKEN_ENV)}`] + : ['api_key = "opencodex-loopback"']), +``` + +`API_AUTH_TOKEN_ENV`는 `"OPENCODEX_API_AUTH_TOKEN"` — `auth-cors.ts`가 요구하는 바로 그 변수명이며 +이미 상수화되어 있으면 재사용하고, 아니면 한 곳에서 export한다. + +`api_key`를 아예 방출하지 않는 것이 중요하다. E3의 우선순위상 비어 있지 않은 `api_key`가 +`env_key`를 가리기 때문이다. + +### 3. 사용자가 손으로 넣은 키를 우리가 지우지 않는다는 보장 + +관리 블록은 마커 사이만 재생성하므로 fence 밖 사용자 `[model.*]`는 이미 보존된다(B3 예약 로직). +비루프백에서 우리 블록이 더 이상 리터럴 키를 쓰지 않으므로, 메인테이너가 재현한 +`REAL_TOKEN_PRESERVED=false` 시나리오 자체가 성립하지 않는다. + +### 4. 사용자에게 상태를 알린다 + +`GrokInjectResult.message`에 비루프백 모드임을 명시한다: + +``` +Added the opencodex managed block to Grok config (non-loopback bind: models read +OPENCODEX_API_AUTH_TOKEN from the environment — export it where you run `grok`). +``` + +CLI는 이미 `r.message`를 출력하므로 별도 배선이 필요 없다. + +## 회귀 테스트 + +`tests/grok-config-inject.test.ts`: +1. `hostname: "0.0.0.0"` → 블록에 `env_key = "OPENCODEX_API_AUTH_TOKEN"`이 있고 + `api_key`가 **없다**. +2. `hostname: "192.168.1.10"` → 동일 + `base_url`이 `http://192.168.1.10:/v1`. +3. `hostname` 미지정/`127.0.0.1`/`localhost`/`::1` → 기존대로 `api_key = "opencodex-loopback"`, + `env_key` 없음. +4. 비루프백 결과 메시지에 환경변수 이름이 포함된다. + +`tests/grok-sync.test.ts`: +5. 비루프백 hostname으로 `syncGrokConfig`를 **두 번** 실행해도 사용자가 fence 밖에 둔 + `[model.mine] api_key = "real-token"`이 바이트 그대로 남는다 (B1 재현의 반증). + +## 게이트 + +`bun test tests/grok-config-inject.test.ts tests/grok-sync.test.ts` → `bun run typecheck`. diff --git a/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md b/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md new file mode 100644 index 0000000000..8a6d845f97 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md @@ -0,0 +1,134 @@ +--- +created: 2026-07-26 +status: plan +phase: wp3 +blockers: [B2, B6] +tags: [grok-build, lifecycle, service, teardown] +--- + +# 030 — 라이프사이클 teardown 정합성 (B2, B6) + +대상 파일: `src/service.ts`, `src/cli/index.ts`, `src/server/management-api.ts`. +근거: `000_blocker_inventory.md` B2/B6 + 라이프사이클 인벤토리(서브에이전트 실측). + +## 실측된 현재 상태 + +| 경로 | 서비스 매니저 | Grok fence | 문제 | +|------|--------------|-----------|------| +| `ocx stop` (정상) | 정지 | strip | 정상 | +| `ocx stop` (소유권 불일치) | **살아있음** | strip | B2 — 공유 설정만 제거 | +| `ocx service stop` | 정지 | **남음** | B6 확장 — Codex는 복원하면서 grok은 방치 | +| `ocx service uninstall` | 제거 | **남음** | B6 확장 — 영구 방치 | +| `POST /api/stop` | 정지 시도 | **남음** | B6 + 가드 없는 throw로 500 | +| 서비스 프록시 크래시/재spawn | — | 남음 | 의도된 배제, 유지 | + +`stopServiceIfInstalled()`는 세 결과를 두 채널로 뭉갠다: `false`는 "미설치 또는 정지 실패", +throw는 "소유권 불일치"뿐이다. 호출자가 올바르게 분기할 수 없다. + +## B2 — 소유권 실패를 구분 가능한 타입으로 + +### 1. 오류 타입 도입 (`src/service.ts`) + +```ts +/** 서비스가 다른 CODEX_HOME/OPENCODEX_HOME에 설치되어 이 프로세스가 건드릴 수 없음. */ +export class ServiceOwnershipError extends Error { + readonly code = "service-ownership-mismatch" as const; +} +``` + +`assertServiceEnvironmentMatchesInstall()`의 두 throw를 이 타입으로 바꾼다. 메시지 문구는 +그대로 유지한다 — 기존 테스트(`service.test.ts:199`)와 사용자에게 익숙한 안내를 깨지 않는다. + +타입 판별 헬퍼도 함께 export한다: + +```ts +export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError { + return err instanceof ServiceOwnershipError; +} +``` + +### 2. `handleStop`이 공유 자원 teardown을 게이트 (`src/cli/index.ts`) + +```ts +let ownershipBlocked = false; +try { + stoppedService = stopServiceIfInstalled(); + ... +} catch (err) { + if (isServiceOwnershipError(err)) { + ownershipBlocked = true; + stopFailed = true; + console.error(`❌ ${err.message}`); + console.error(" Skipping shared teardown: the installed service may still be running and would respawn the proxy."); + } else { + console.error(`⚠️ Service manager stop failed: ${...}`); + } +} +``` + +그리고 공유 자원 정리(`restoreNativeCodex`, `revertSystemEnv`, `stripGrokConfig`)를 +`if (!ownershipBlocked)`로 감싼다. 로컬 프록시 정지 자체는 그대로 시도한다 — 그것은 +이 홈이 소유한 자원이다. + +**restart 영향:** `handleStop`이 `stopFailed`로 `process.exit(1)`하면 `handleEnsure`가 실행되지 +않는다. 소유권 불일치 상태에서 재주입까지 진행하는 것은 오히려 위험하므로 이 동작이 옳다. +사용자는 올바른 홈에서 다시 실행하라는 안내를 받는다. + +## B6 — 명시적 종료 경로에서 fence 제거 + +### 3. `serviceCommand`의 `stop` / `uninstall` (`src/service.ts`) + +두 경로 모두 `restoreNativeCodex()` 직후에 grok strip을 추가한다. 순환 의존을 피하려고 +`src/cli/index.ts`가 쓰는 것과 같은 정적 import를 쓴다(`src/grok/inject.ts`는 `src/config`와 +`src/codex/inject`만 의존하므로 안전). + +```ts +const g = stripGrokConfig(); +if (g.changed) console.log(`↩️ ${g.message}`); +else if (!g.ok) console.error(`⚠️ ${g.message}`); +``` + +이 두 경로는 이미 맨 앞에서 `assertServiceEnvironmentMatchesInstall()`을 부르고 예외를 전파하므로 +소유권 게이트는 이미 만족한다. + +### 4. `POST /api/stop` (`src/server/management-api.ts`) + +현재 6줄에 문제가 셋이다. 함께 고친다: + +```ts +if (url.pathname === "/api/stop" && req.method === "POST") { + const { restoreNativeCodex } = await import("../codex/inject"); + const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service"); + try { + stopServiceIfInstalled(); + } catch (err) { + if (isServiceOwnershipError(err)) { + // 설치된 서비스를 정지시킬 수 없다 = 공유 설정을 건드리면 안 되고, 종료해도 즉시 되살아난다. + return jsonResponse({ success: false, message: err.message }, 409); + } + throw err; + } + const restore = restoreNativeCodex(); + const { stripGrokConfig } = await import("../grok/inject"); + const grok = stripGrokConfig(); + setTimeout(...); + ... +} +``` + +`jsonResponse`가 상태 코드 인자를 받지 않으면 기존 시그니처를 확인해 맞춘다. +응답 메시지에 grok strip 결과를 합쳐 대시보드가 상태를 볼 수 있게 한다. + +`OCX_SERVICE=1` 크래시/재spawn 배제(`syncCleanup`의 게이트)는 **그대로 둔다** — 이 변경은 +명시적 종료 경로에만 strip을 추가한다. + +## 회귀 테스트 (뼈대만; 상세는 040에서) + +- 소유권 불일치 시 `handleStop`이 strip을 건너뛰고 실패 종료 코드를 남긴다. +- `service stop`/`uninstall`이 strip을 호출한다. +- `/api/stop`이 strip을 호출하고, 소유권 불일치에는 409로 응답하며 프록시를 종료하지 않는다. +- `syncCleanup`의 `OCX_SERVICE` 배제가 그대로 남아 있다. + +## 게이트 + +`bun test tests/service.test.ts tests/grok-*.test.ts` → `bun run typecheck` → 전체 `bun run test`. diff --git a/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md b/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md new file mode 100644 index 0000000000..649ae150de --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md @@ -0,0 +1,79 @@ +--- +created: 2026-07-26 +status: plan +phase: wp4 +blockers: [B4] +tags: [grok-build, tests, lifecycle] +--- + +# 040 — CLI 라이프사이클 회귀 (B4) + +대상 파일: `tests/helpers/isolated-grok-home.ts`(신규), `tests/grok-lifecycle.test.ts`(신규), +`tests/service.test.ts`, `tests/grok-config-inject.test.ts`. +근거: `000_blocker_inventory.md` B4 + 라이프사이클 커버리지 실측. + +## 제약: `src/cli/index.ts`는 import할 수 없다 + +최상위에서 `switch (command)`를 실행하는 스크립트라 핸들러를 직접 호출할 수 없다. +이 저장소에는 이미 확립된 세 가지 방식이 있고, 각각 알맞은 자리에 쓴다. + +| 스타일 | 용도 | 선례 | +|--------|------|------| +| A. 소스 슬라이스 순서 검증 | CLI 핸들러의 배선/순서 | `tests/stale-state-purge.test.ts:53`, `tests/uninstall.test.ts:41` | +| B. 주입 deps + 임시 디렉터리 | deps를 받는 유닛 | `tests/grok-sync.test.ts:12` | +| C. `mock.module` | 동적 import 가로채기 | `tests/vision-cache.test.ts:4` | + +소스 슬라이스만으로는 "동작"을 증명하지 못한다는 한계가 있으므로, 실제 동작 검증이 가능한 +계층(`serviceCommand`, `handleManagementAPI`, `stripGrokConfig`)은 B/C로 진짜 실행하고, +CLI 핸들러 배선은 A로 고정한다. 두 층을 합쳐야 B2가 통과했던 구멍이 막힌다. + +## 신규 헬퍼: `tests/helpers/isolated-grok-home.ts` + +`installIsolatedCodexHome`을 그대로 미러링한다. CLI 경로는 `stripGrokConfig()`를 인자 없이 +부르므로 `grokHome` 옵션이 아니라 `GROK_HOME` 환경변수 격리가 필요하다. + +```ts +export function installIsolatedGrokHome(prefix: string): { path: string; restore(): void } { + const root = mkdtempSync(join(tmpdir(), prefix)); + const grokHome = join(root, ".grok"); + mkdirSync(grokHome, { recursive: true }); + const prev = process.env.GROK_HOME; + process.env.GROK_HOME = grokHome; + return { path: grokHome, restore() { ... prev 복원, root 삭제 ... } }; +} +``` + +두 grok 테스트 파일에 중복된 `tempGrokHome()`도 이 헬퍼로 통일한다. + +## 테스트 목록 + +### 그룹 1 — 배선 (스타일 A, `tests/grok-lifecycle.test.ts`) + +1. `handleStart`가 Desktop3P 등록 실패와 **무관하게** grok 동기화를 시도한다. + 현재는 grok inject가 Desktop3P try 블록 **안쪽**에 중첩돼, 카탈로그 조회가 던지면 fence가 + 조용히 건너뛰어진다. 030에서 이 중첩을 형제 블록으로 분리하고, 이 테스트가 그 구조를 고정한다. + 검증: `handleStart` 슬라이스에서 `syncGrokConfig` 호출이 `buildDesktop3pRegistry`의 catch + **뒤**에 독립 try로 존재. +2. `handleEnsure` 라이브 분기가 `live.hostname`을, spawn 분기가 `config.hostname`을 넘긴다 + (분기별로 정확한 호스트 출처를 고정 — 뒤바뀌면 잘못된 base_url을 쓴다). +3. `handleStop`에서 `stripGrokConfig()` 호출이 소유권 게이트 **안쪽**에 있다 + (`ownershipBlocked` 검사가 strip보다 앞선다). +4. `syncCleanup`의 `OCX_SERVICE` 배제가 유지된다. + +### 그룹 2 — 실제 동작 (스타일 B/C) + +5. `serviceCommand("stop")`이 fence를 실제로 제거한다. + `installIsolatedGrokHome` + `installIsolatedCodexHome`로 격리하고, 플랫폼 ops는 + `mock.module`로 대체해 실제 launchd/systemd를 건드리지 않는다. +6. `serviceCommand("uninstall")` 동일. +7. 소유권 불일치 상태에서 `serviceCommand("stop")`이 **fence를 남긴 채** throw한다 + (`service-state.json` 세팅은 `tests/service.test.ts:188` 패턴 그대로). +8. `POST /api/stop`이 fence를 제거하고 성공 응답을 준다 — + `handleManagementAPI`를 직접 import + `new Request(...)`. +9. `POST /api/stop`이 소유권 불일치에 409로 응답하고 fence를 **남긴다**. +10. `ServiceOwnershipError`가 `isServiceOwnershipError`로 판별되고, 일반 정지 실패는 판별되지 + 않는다(오분류로 teardown을 통째로 막지 않도록). + +## 게이트 + +`bun test tests/grok-lifecycle.test.ts tests/service.test.ts` → 전체 `bun run test`. diff --git a/devlog/_plan/260726_grok_build_prod/050_docs_truth.md b/devlog/_plan/260726_grok_build_prod/050_docs_truth.md new file mode 100644 index 0000000000..30c2055068 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/050_docs_truth.md @@ -0,0 +1,65 @@ +--- +created: 2026-07-26 +status: plan +phase: wp5 +blockers: [B7, B8] +tags: [grok-build, docs, devlog] +--- + +# 050 — 문서/devlog 진실 정렬 (B7, B8) + +대상 파일: `docs-site/src/content/docs/guides/grok-build.md`, +`devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md`. +근거: `001_grok_source_evidence.md` E3/E4/E5/E6. + +## B7 — grok-build 가이드 + +### 1. 비루프백 절 재작성 (수용 + 확장) + +CodeRabbit 지적("api_key만 바꾸라고 하면 안 된다, base_url도 도달 가능해야 한다")은 옳다. +다만 020에서 비루프백 블록이 `env_key`를 방출하도록 바뀌므로 안내 내용 자체가 달라진다: + +- 자동 등록된 블록은 비루프백 바인드에서 `env_key = "OPENCODEX_API_AUTH_TOKEN"`을 쓴다. + 사용자는 **grok을 실행하는 환경에 그 변수를 export**하면 된다. 설정 파일을 손댈 필요가 없다. +- `base_url`은 우리가 실제 바인드 호스트로 생성하므로 `127.0.0.1` 고정 문제가 사라진다. + 수동 예시도 도달 가능한 호스트(`http://192.168.1.10:10100/v1`)로 바꾼다. +- 변수를 export하지 않으면 grok은 키 없이 호출해 401을 받는다는 사실을 명시한다 — + 조용한 실패가 아니라 진단 가능한 실패임을 알린다. + +### 2. 리로드 절 정정 (부분 반박) + +CodeRabbit은 "핫리로드를 약속하지 말라"고 했지만 원본에는 감시자가 실재한다(E4). +따라서 문장을 삭제하지 않고 **정확하게** 다시 쓴다: + +- grok은 `~/.grok/config.toml`을 감시하며 `[model]` 테이블이 실제로 달라졌을 때만 리로드한다 + (약 1초 디바운스, 내용 기반 비교). +- 확인 명령은 `grok inspect`이며, 이것이 보여주는 것은 **설정 소스 목록과 거부된 필드 경고**다. + 모델 카탈로그를 나열하지 않는다(E6). +- 잘못된 TOML은 사용자 레이어 전체를 무효화하므로 우리는 원자적 쓰기를 쓴다는 점을 적는다. + +버전 번호로 보증하지 않고 관측된 동작과 확인 방법을 제시하는 형태이므로, 지적의 의도 +(검증 불가한 약속 금지)는 충족된다. + +### 3. 백엔드 절 + +`api_backend`의 실제 수용값은 `chat_completions`(기본), `responses`, `messages` 세 가지임을 적고, +우리가 `chat_completions`를 방출하는 이유를 한 줄로 설명한다(다음 절). + +## B8 — devlog 020 정정 + +`260723_grok_build_bridge/020_docs_and_residual_smoke.md` 11행의 `responses` 권장을 +`chat_completions`로 바꾸고, 같은 유닛 `011_receipt.md:53-55`의 관측을 인용해 이유를 남긴다. +이번에는 추측이 아니라 원본 근거를 붙인다: + +> `responses` 백엔드에서 grok의 이벤트 열거형에는 catch-all 변형이 없고 +> (async-openai 포크 `95b52ebd`), 알 수 없는 최상위 `type` 태그는 +> `SamplingError::Serialization`으로 **재시도 없이** 턴을 종료시킨다. +> 삼켜지는 비표준 이벤트는 doom-loop 체크 하나뿐이다. +> 따라서 `response.heartbeat`를 내보내는 경로에서는 `chat_completions`를 쓴다. + +`020` 문서를 고치면서 원 문서의 다른 주장은 건드리지 않는다 — 이 유닛은 이미 마감된 기록이며 +정정 사유를 표시해 이력을 남긴다. + +## 게이트 + +`bun run docs:check`(존재 시) → `bun run typecheck` → 링크/빌드 확인. From 9501018f11d5a2edda8aea3acb3be0bbd814e799 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 02:40:38 +0900 Subject: [PATCH 18/22] =?UTF-8?q?docs(devlog):=20fold=20A-gate=20audit=20i?= =?UTF-8?q?nto=20the=20roadmap=20=E2=80=94=20B1=20reverts=20to=20refusing?= =?UTF-8?q?=20non-loopback=20auto-registration=20(env=5Fkey=20leaks=20the?= =?UTF-8?q?=20xAI=20session=20token),=20B5=20makes=20inject=20injective,?= =?UTF-8?q?=20030/040=20close=20the=20teardown=20hole=20and=20the=20unsafe?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../000_blocker_inventory.md | 25 ++++ .../001_grok_source_evidence.md | 42 ++++-- .../010_config_safety.md | 120 +++++++++++++++ .../010_config_safety_core.md | 88 ++++++++--- .../020_lifecycle_teardown.md | 139 ++++++++++++++++++ .../020_nonloopback_auth.md | 100 ++++++++----- .../260726_grok_build_prod/030_docs_truth.md | 76 ++++++++++ .../030_lifecycle_teardown.md | 84 ++++++++++- .../040_lifecycle_regressions.md | 36 ++++- 9 files changed, 634 insertions(+), 76 deletions(-) create mode 100644 devlog/_plan/260726_grok_build_prod/010_config_safety.md create mode 100644 devlog/_plan/260726_grok_build_prod/020_lifecycle_teardown.md create mode 100644 devlog/_plan/260726_grok_build_prod/030_docs_truth.md diff --git a/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md b/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md index 504e398696..b6c9baebb0 100644 --- a/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md +++ b/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md @@ -110,3 +110,28 @@ tags: [grok-build, pr-403, review-blockers, production] | B2, B6 | 030 — 라이프사이클 teardown 정합성 | | B4 | 040 — CLI 라이프사이클 회귀 | | B7, B8 | 050 — 문서/devlog 진실 정렬 | + +## A-게이트 감사에서 추가로 발견된 결함 (2026-07-26) + +독립 감사자가 리뷰 목록 밖에서 찾아낸 것들. 리뷰에는 없었지만 같은 모듈의 실제 결함이다. + +| # | 결함 | 귀속 | +|---|------|------| +| D1 | `handleStart`의 grok 동기화가 Desktop3P `try` 안에 중첩돼, 카탈로그 조회가 던지면 fence가 조용히 건너뛰어짐 (`src/cli/index.ts:263`) | 030 §5 | +| D2 | `ocx stop`이 `stripGrokConfig`의 `!ok`(orphaned-marker 거부 등)를 삼키고 0으로 종료 (`src/cli/index.ts:447`) | 030 §2d | +| D3 | `serviceCommand("stop")`이 설치 여부 가드 없이 `ops.stop()` 실행 (`src/service.ts:1151`) | 030 §3 | +| D4 | `POST /api/stop`의 `stopServiceIfInstalled()`가 무보호라 소유권 예외가 500으로 새고 프록시가 살아남음 | 030 §4 | +| D5 | `[[model.x]]`, `[model.x.sub]` 철자가 우리 블록과 duplicate-key 충돌하는데 예약되지 않음 | 010 (B3 확장) | +| D6 | 백업 `config.toml.bak-opencodex`가 최초 1회만 생성돼 임의로 낡을 수 있음 (`src/grok/inject.ts:186`) — orphaned-marker 안내가 이 파일을 가리킨다 | **미할당 잔여 위험** | +| D7 | 루트 dotted 키(`model.ocx-mine.x = 1`), `[model]` + dotted 키 형태도 충돌하나 예약 대상 아님 | **미할당 잔여 위험** | + +D6/D7은 이번 PR 범위(리뷰 블로커 해소)를 넘어서므로 여기 기록만 하고, 후속 유닛에서 다룬다. +D6은 사용자 데이터 복구 경로라 우선순위가 높다. + +## 정정 이력 + +- **2026-07-26, B1 설계 반전.** 초판은 `env_key` 방출로 자동 등록을 유지하려 했다. 감사에서 + `env_key` 미해석 시 grok이 xAI 세션 토큰을 우리 평문 LAN 주소로 전송함이 원본 코드와 상위 + 테스트로 확인됐다(`001` E3 정정). 메인테이너 원안(비루프백 자동 등록 거부)으로 되돌렸다. +- **2026-07-26, B5 알고리즘 교체.** 초판 strip 규칙은 정보 이론상 불가능한 복원을 시도했고 + 중간 삽입 경로에서 개행이 누적되는 퇴행을 유발했다. inject를 단사로 만드는 방식으로 교체. diff --git a/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md b/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md index 69ed97b11f..ce160cbe8d 100644 --- a/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md +++ b/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md @@ -52,17 +52,41 @@ TOML parse error at line 4, column 8: duplicate key `api_key` → `env_key` → auth_provider 캐시 토큰 → 세션 토큰 → `XAI_API_KEY`. **공백뿐인 `api_key`는 무시되고 `env_key`로 폴백한다** (`first_own_credential`이 `!k.trim().is_empty()` 필터). -| 방식 | 동작 | 실패 시 | -|------|------|---------| -| `env_key = "VAR"` 또는 `["V1","V2"]` | 요청 시점에 lazy 해석, 처음 채워진 값 사용 | 경고 후 **키를 보내지 않음** (안전) | -| `api_key = "${VAR}"` | 로드 시 `shellexpand` 확장 | 미설정이면 **리터럴 `${VAR}`을 토큰으로 전송** (위험) | +| 방식 | 동작 | 해석 실패 시 | +|------|------|-------------| +| `env_key = "VAR"` 또는 `["V1","V2"]` | 요청 시점에 lazy 해석, 처음 채워진 값 사용 | **다음 우선순위로 폴백** (아래 정정 참조) | +| `api_key = "${VAR}"` | 로드 시 `shellexpand` 확장 | 미설정이면 **리터럴 `${VAR}`을 토큰으로 전송** | | `auth_provider` | 헬퍼 명령 stdout에서 토큰 획득 | 파일/회전 토큰용 | -**결론(B1):** 비루프백에서도 자동 등록을 포기할 필요가 없다. 리터럴 토큰 대신 -`env_key = "OPENCODEX_API_AUTH_TOKEN"`를 방출하면 우리는 사용자 파일에 비밀을 쓰지 않고, -grok은 실행 환경에서 토큰을 읽는다. 토큰이 없으면 grok은 키 없이 요청해 401이 나며 — 이는 -"조용히 잘못된 키를 보내는" 현재 동작보다 진단 가능하다. -`${VAR}` 확장은 실패 시 쓰레기 토큰을 전송하므로 채택하지 않는다. +### 정정 (2026-07-26 A-게이트 감사) — `env_key`는 fail-safe가 아니다 + +초판은 "`env_key`가 해석되지 않으면 grok이 키 없이 호출한다"고 적었다. **틀렸다.** +`resolve_credentials`(`config.rs:4689-4715`)의 실제 폴백 사슬은: + +```rust +let (api_key, base_url, auth_type) = if let Some(key) = model.own_credential() { ... } +else if let Some(provider) = model.auth_provider.as_ref() { ... } +else if let Some(key) = session_key { // ← 로그인된 grok 세션 JWT + (Some(key.to_owned()), info.base_url.clone(), AuthType::SessionToken) +} else if let Ok(key) = read_xai_api_key_env() { // ← XAI_API_KEY +``` + +`base_url`은 **우리가 쓴 URL** 그대로다. 상위 테스트가 이 동작을 못박는다: +`resolve_credentials_empty_env_key_falls_through_to_session`(`config.rs:6550`)은 +`AuthType::SessionToken`과 `api_key == Some("session-jwt")`를 단언한다. + +fail-closed 경로는 존재하지만 `model_provider`가 설정된 경우에만 붙는다(`config.rs:3510-3513`). +우리 블록은 `model_provider`를 방출하지 않으므로(모델 공급자 상속이 라우팅되지 않는다는 이유로 +의도적으로 제외) 해당 보호가 걸리지 않는다. + +**보안 결론:** 비루프백 블록에서 `api_key`를 빼고 `env_key`만 쓰면, 사용자가 변수를 export하지 +않았을 때 grok이 **xAI 세션 토큰을 평문 HTTP LAN 주소로 전송**한다. 현재의 +`api_key = "opencodex-loopback"`은 비어 있지 않은 own-credential이라 사슬을 즉시 끊고 401로 끝난다. +즉 제안했던 설계는 무해한 401을 자격증명 유출로 바꾸는 **퇴행**이다. 채택하지 않는다. + +**결론(B1):** 메인테이너가 요구한 대로 비루프백에서는 **fence 자동 등록을 하지 않는다.** +`env_key`는 문서에 수동 레시피로만 남기고, 반드시 `model_provider` 또는 자리표시자 `api_key`와 +짝지어야 세션 토큰 폴백이 막힌다는 경고를 함께 적는다. ## E4 — 핫리로드는 실재한다 (B7 부분 반박) diff --git a/devlog/_plan/260726_grok_build_prod/010_config_safety.md b/devlog/_plan/260726_grok_build_prod/010_config_safety.md new file mode 100644 index 0000000000..acbf1988c1 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/010_config_safety.md @@ -0,0 +1,120 @@ +# 010 — wp1: 설정 파일 안전성 + +대상: `src/grok/inject.ts`, `tests/grok-config-inject.test.ts`, `tests/grok-sync.test.ts`. + +## 블로커 1 — 비루프백에서 플레이스홀더 자격 증명이 401을 만든다 + +현재 `buildGrokManagedBlock`은 모든 모델에 `api_key = "opencodex-loopback"`을 쓴다. 그런데 `isApiAuthRequired`는 `hostname`이 비루프백이면 true이고, 그때 `hasValidApiAuth`는 `isProxyAdmissionSecret`로 실제 토큰을 요구한다(`src/server/auth-cors.ts:121,163`). 따라서 LAN 바인드에서 자동 등록된 모든 모델이 401이다. 사용자가 손으로 고쳐도 다음 `ocx start`/`ensure`가 fence를 재생성하며 되돌린다. + +리뷰어 두 곳(Codex P2, CodeRabbit Major)은 "비루프백이면 자동 등록을 거부하라"고 제안했다. 하지만 Grok Build는 `env_key`를 지원하므로 더 나은 해법이 있다: **환경변수 이름만 쓰고 값은 쓰지 않는다.** + +### 결정 + +루프백: 기존대로 `api_key = "opencodex-loopback"` (프록시가 무시하는 더미). + +비루프백: `api_key` 대신 `env_key = "OPENCODEX_API_AUTH_TOKEN"`을 쓴다. 토큰 값은 config에 남지 않고, 변수가 없으면 grok이 세션 토큰으로 폴백하지 않고 fail-closed다 (`180_grok-build .../model_providers.rs:741`). 프록시 자신도 같은 환경변수에서 토큰을 읽으므로(`auth-cors.ts:111`) 이름이 자연히 일치한다. + +자동 등록 거부보다 이 쪽을 택하는 이유: 거부는 비루프백 사용자에게 기능을 완전히 없애고, 손수 쓴 설정도 fence 밖에 두어야 해 카탈로그 갱신 이점을 잃는다. `env_key`는 비밀 직렬화 금지라는 리뷰의 실제 요구를 지키면서 기능을 유지한다. + +### 변경 (`src/grok/inject.ts`) + +`buildGrokManagedBlock` 시그니처를 옵션 객체로 정리하되 기존 위치 인자 호환은 유지한다. + +```diff +-export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname?: string, reservedAliases?: ReadonlySet): string { +- const host = providerBaseHost(hostname); +- const baseUrl = `http://${host}:${port}/v1`; ++/** Env var the proxy reads its admission token from; also what grok is told to read. */ ++const ADMISSION_TOKEN_ENV = "OPENCODEX_API_AUTH_TOKEN"; ++ ++export function buildGrokManagedBlock(port: number, models: GrokInjectModel[], hostname?: string, reservedAliases?: ReadonlySet): string { ++ const host = providerBaseHost(hostname); ++ const baseUrl = `http://${host}:${port}/v1`; ++ // A non-loopback bind makes every data-plane request require the admission token ++ // (server/auth-cors.ts isApiAuthRequired). ~/.grok/config.toml is a shared user file, ++ // so we never serialize the token itself: grok resolves `env_key` from the environment ++ // and fails closed (no session-token fallback) when the variable is unset. ++ const requiresAdmission = !isLoopbackHostname(hostname); +``` + +모델 루프의 자격 증명 줄: + +```diff + 'api_backend = "chat_completions"', +- 'api_key = "opencodex-loopback"', ++ requiresAdmission ++ ? `env_key = ${tomlString(ADMISSION_TOKEN_ENV)}` ++ : 'api_key = "opencodex-loopback"', +``` + +`isLoopbackHostname`은 `src/server/auth-cors.ts`에서 가져온다. `src/codex/inject.ts`가 이미 같은 곳에서 import하므로 계층 위반이 아니다. + +## 블로커 2 — 인용된 첫 TOML 키 세그먼트를 놓친다 + +`userModelAliases`의 정규식은 `[model."x"]`는 잡지만 `["model"."x"]`, `['model'.x]`는 못 잡는다. TOML에서는 동일한 테이블이므로, 우리가 `[model.x]`를 또 내보내면 `Cannot redefine key` 파싱 실패로 **설정 전체가 무효**가 된다. + +```diff +- const header = /^\s*\[\s*model\s*\.\s*(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*(?:#.*)?$/gm; +- for (const match of outsideManagedRegion.matchAll(header)) { +- const bare = match[1]; +- const doubleQuoted = match[2]; +- const singleQuoted = match[3]; ++ const segment = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; ++ const header = new RegExp(String.raw`^\s*\[\s*(${segment})\s*\.\s*(${segment})\s*\]\s*(?:#.*)?$`, "gm"); ++ for (const match of outsideManagedRegion.matchAll(header)) { ++ if (canonicalTomlKey(match[1]) !== "model") continue; ++ const alias = canonicalTomlKey(match[2]); ++ if (alias !== undefined) aliases.add(alias); ++ } +``` + +새 헬퍼: + +```ts +/** Canonicalize one TOML key segment: bare, "basic", or 'literal' all denote the same key. */ +function canonicalTomlKey(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const value = raw.trim(); + if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { + return decodeTomlBasicString(value.slice(1, -1)); + } + if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { + return value.slice(1, -1); // literal strings take no escapes + } + return value; +} +``` + +## 블로커 3 — 후행 개행이 없던 파일이 바이트 단위로 복원되지 않는다 + +주입 시 후행 개행이 없으면 `separator = "\n\n"`를 넣는데, 제거 시에는 `prefix.endsWith("\n\n")`일 때 하나만 줄이고 블록 뒤 `\n` 하나만 제거한다. 결과적으로 원본에 없던 후행 개행이 남는다. + +정확한 대칭 복원: 주입이 붙인 것은 (앞 `\n\n`) + 블록 + (뒤 `\n`)이고 원본은 개행으로 끝나지 않았다. 그러므로 제거 시 접미사가 비어 있으면 앞의 `\n\n`을 **둘 다** 없애야 한다. + +```diff + let removalEnd = region.end; + if (content.startsWith("\n", removalEnd)) removalEnd += 1; + let prefix = content.slice(0, region.start); +- if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); +- const stripped = prefix + content.slice(removalEnd); ++ const suffix = content.slice(removalEnd); ++ if (prefix.endsWith("\n\n")) { ++ // Injection into a file WITHOUT a trailing newline added "\n\n" before the block and ++ // "\n" after it; injection into a file WITH one added a single "\n". Removing both ++ // separator newlines is only correct when nothing follows the block — otherwise the ++ // user's own blank line before our fence would be eaten. ++ prefix = suffix.length === 0 ? prefix.slice(0, -2) : prefix.slice(0, -1); ++ } ++ const stripped = prefix + suffix; +``` + +## 테스트 (`tests/grok-config-inject.test.ts`) + +기존 스타일(mkdtemp `grokHome` 주입)을 따라 4건 추가: + +1. `non-loopback bind uses env_key instead of a literal token` — `injectGrokConfig(port, models, { grokHome, hostname: "192.168.1.50" })` 후 파일에 `env_key = "OPENCODEX_API_AUTH_TOKEN"`이 있고 `api_key`와 실제 토큰 문자열이 **없음**을 단언. +2. `loopback keeps the placeholder api_key` — 대조군. +3. `reserves aliases declared with a quoted first key segment` — `["model"."ocx-probe"]`, `['model'.ocx-other]`를 미리 둔 뒤 생성 별칭이 충돌하지 않고 `-2` 접미사를 받는지 단언. +4. `restores a config that had no trailing newline byte-for-byte` — 원본 문자열을 저장 → inject → strip → `readFileSync`가 원본과 **정확히** 같은지 단언. + +`tests/grok-sync.test.ts`에는 비루프백 hostname을 넘겼을 때 `env_key`가 나오는 경로 1건을 추가한다. diff --git a/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md index c0c23895be..cd85daa499 100644 --- a/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md +++ b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md @@ -8,6 +8,10 @@ tags: [grok-build, toml, data-safety] # 010 — 설정 안전성 코어 (B3 인용 키, B5 개행 복원) +> **개정 2026-07-26 (A-게이트 감사 반영).** B5 초판 알고리즘은 실제로 버그를 고치지 못했고 +> 중간 삽입 경로에서 사이클마다 개행이 늘어나는 퇴행을 유발했다. 아래는 감사에서 실행 추적으로 +> 검증된 교정본이다. B3에는 미커버 충돌 철자 2종을 추가한다. + 대상 파일: `src/grok/inject.ts`, `tests/grok-config-inject.test.ts`. 근거: `000_blocker_inventory.md` B3/B5, `001_grok_source_evidence.md` E1. @@ -70,6 +74,36 @@ for (const match of outsideManagedRegion.matchAll(MODEL_HEADER)) { 2. `['model'.ocx-gpt-5]` 동일. 3. `[ "model" . 'ocx-gpt-5' ]` 공백/혼합 인용 동일. 4. 방출된 모든 alias에 `.`이 없음 (점-alias 함정 고정). +5. `[[model.ocx-gpt-5]]` (배열 테이블) 예약됨. +6. `[model.ocx-gpt-5.sub]` (하위 테이블) 예약됨. + +### 추가 철자 커버리지 (감사 지적) + +`tomllib` 실측 결과 아래 두 형태도 우리 블록과 **같은 duplicate-key 실패**를 낸다. 정규식이 +예약하지 못하면 B3가 막으려던 바로 그 사고가 그대로 난다. + +| 사용자 철자 | 우리 블록과의 충돌 | +|-------------|-------------------| +| `[[model.ocx-mine]]` | duplicate key | +| `[model.ocx-mine.sub]` | duplicate key (상위 테이블 충돌) | + +따라서 헤더 정규식을 다음으로 확장한다 — 여는 괄호를 1~2개 허용하고, 두 번째 세그먼트 뒤에 +**추가 세그먼트가 이어져도** 두 번째 세그먼트를 예약한다: + +```ts +const MODEL_HEADER = new RegExp( + String.raw`^\s*\[\[?\s*(${KEY_SEGMENT})\s*\.\s*(${KEY_SEGMENT})\s*(?:\.[^\]]*)?\]\]?\s*(?:#.*)?$`, + "gm", +); +``` + +세 세그먼트 헤더(`[model.x.y]`)는 이제 `x`를 예약한다. 초판은 이를 "예약하면 안 된다"고 적었으나 +실측상 `[model.x.y]`는 우리의 `[model.x]`와 충돌하므로 **예약이 정답**이다. +루트 dotted 키(`model.ocx-mine.x = 1`)와 `[model]` + dotted 키 형태는 우선순위를 낮춰 남기되, +`000`의 잔여 위험 목록에 기록한다. + +여는/닫는 괄호 개수 불일치(`[model.x]]`)는 어차피 유효하지 않은 TOML이며, 예약은 보수적 방향이라 +무해하다. ## B5 — 주입 구분자의 정확한 복원 @@ -80,39 +114,57 @@ strip: `if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);` — 항상 원래 개행이 없던 파일은 `ocx stop` 후 개행 하나를 얻는다. -### 변경 방침 +### 왜 strip만 고쳐서는 불가능한가 + +현재 inject는 `content.endsWith("\n") ? "\n" : "\n\n"`으로 구분자를 고른다. 결과 상태를 추적하면: + +| 원문 | inject 후 | +|------|-----------| +| `"X"` (개행 없음) | `"X\n\n" + B + "\n"` | +| `"X\n"` (개행 하나) | `"X\n\n" + B + "\n"` | + +**두 경우의 파일이 바이트 단위로 동일하다.** 어떤 strip 규칙도 둘을 구분할 수 없으므로, +inject를 그대로 둔 채 양쪽을 byte-for-byte 복원하는 것은 원리적으로 불가능하다. +따라서 **inject를 단사(injective)로 바꾸는 것**이 유일한 해법이다. -구분자를 마커 자체에 기록하지 않는다(마커 문자열은 이미 배포된 사용자 파일에 존재하므로 형식을 -바꾸면 기존 블록과 호환이 깨진다). 대신 **복원 규칙을 대칭으로 만든다**: +### 변경 (감사 검증 알고리즘) -- inject는 그대로 두되, 개행 없는 파일에 붙일 때 우리가 넣은 것이 정확히 `"\n\n"`임을 명시한다. -- strip은 제거 후의 접미 상태로 판단하지 말고, **블록 앞에 우리가 넣은 분리 개행을 모두** 되돌린다: +inject — 구분자를 항상 정확히 하나로: + +```ts +// 항상 개행 하나만 넣는다. 사용자 파일의 종결 상태가 그대로 보존되므로 +// strip이 우리가 넣은 개행을 모호함 없이 되돌릴 수 있다. +const separator = "\n"; +``` + +strip — 두 규칙으로 대칭 복원: ```ts let prefix = content.slice(0, region.start); const restOfFile = content.slice(removalEnd); -if (restOfFile.length === 0) { - // 블록이 파일 끝에 있었다 = 주입 시 우리가 앞에 분리 개행을 붙인 경우. - // 사용자 원문이 개행으로 끝나지 않았다면 "\n\n"을, 끝났다면 "\n"을 넣었으므로 - // 남은 접미 개행을 하나만 남기고(원문이 개행으로 끝났던 경우) 혹은 전부 제거한다. - prefix = prefix.endsWith("\n\n") ? prefix.slice(0, -2) + "\n" : prefix.replace(/\n$/, ""); -} +// 개행으로 끝나던 파일에 붙인 구분자. +if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); +// 개행 없이 끝나던 파일 끝에 붙인 구분자. +else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); ``` -판정 근거: 블록은 항상 파일 끝에 추가되거나(신규) 기존 영역을 제자리 치환한다(갱신). -제자리 치환 경로에서는 구분자를 추가하지 않으므로 복원 대상이 없다. 따라서 되돌릴 구분자가 -존재하는 경우는 "블록 뒤에 아무것도 없는" 경우로 한정된다. +감사에서 (a) 빈 파일, (b) 개행 없음, (c) 개행 하나, (d) 개행 둘, 개행 셋, 중간 삽입, 5회 +inject/strip 반복까지 실행 추적으로 검증했다 — 불일치 0, 드리프트 0. -**주의:** 사용자가 블록 뒤에 자기 내용을 덧붙였다면 `restOfFile`이 비지 않으므로 접두를 건드리지 -않는다 — 사용자 바이트를 임의로 줄이지 않는 보수적 동작이다. +**감수하는 변화 두 가지(문서에 명시한다):** +- 개행 없이 끝나던 파일은 이제 fence 앞에 빈 줄이 생기지 않는다(외형만 달라짐). +- 이미 배포된 코드가 쓴 기존 블록은 (c) 의미로 복원된다 — 이전 형식과 호환되며 손실이 없다. ### 회귀 테스트 1. 개행 없이 끝나는 원문 → inject → strip → **원문과 바이트 동일**. 2. 개행 하나로 끝나는 원문 → 왕복 후 바이트 동일 (기존 테스트 강화). 3. 개행 두 개로 끝나는 원문 → 왕복 후 바이트 동일 (사용자 개행을 삼키지 않음). -4. 블록 뒤에 사용자 섹션이 있는 경우 → 왕복 후 바이트 동일. -5. CRLF 원문에서 1–4 반복(기존 `dominantEol`/`applyEol` 경로 유지 확인). +4. 블록 뒤에 사용자 섹션이 있는 경우(중간 삽입) → 왕복 후 바이트 동일. +5. inject/strip을 **5회 반복**해도 파일이 자라지 않는다 (드리프트 가드). +6. **균일** CRLF 원문에서 1–4 반복. + 혼합 EOL은 `applyEol`이 정규화하므로 byte-for-byte 왕복이 설계상 불가능하다 — + 이 정규화는 의도된 동작이며 테스트는 균일 CRLF로 한정한다. ## 게이트 diff --git a/devlog/_plan/260726_grok_build_prod/020_lifecycle_teardown.md b/devlog/_plan/260726_grok_build_prod/020_lifecycle_teardown.md new file mode 100644 index 0000000000..8d3b795c66 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/020_lifecycle_teardown.md @@ -0,0 +1,139 @@ +# 020 — wp2: 라이프사이클 / teardown + +대상: `src/cli/index.ts`, `src/service.ts`, `src/server/management-api.ts`, `tests/service.test.ts`, 신규 `tests/grok-lifecycle.test.ts`. + +## 블로커 4 — 소유권 가드 실패 후에도 공유 fence를 제거한다 + +`handleStop`은 `stopServiceIfInstalled()`을 try/catch로 감싸 경고만 남기고 계속 진행한다(`src/cli/index.ts:390`). 이 함수는 `assertServiceEnvironmentMatchesInstall()`이 먼저 던지므로(`src/service.ts:870`), CODEX_HOME/OPENCODEX_HOME이 다른 홈에서 실행하면 **서비스 매니저를 건드리지도 못한 채** 예외가 난다. 그런데도 흐름은 447행의 `stripGrokConfig()`까지 내려가 다른 홈이 소유한 살아 있는 서비스의 라우팅 블록을 지운다. + +로컬 프록시/Codex 복원은 이 홈의 것이므로 계속해도 되지만, `~/.grok/config.toml`은 **전역 공유 파일**이라 다르다. + +### 결정 + +가드 실패를 전면 치명적으로 만들지 않는다(그러면 `ocx restart`가 막히고 로컬 정리도 못 한다). 대신 **공유 자원 정리만 건너뛴다.** + +```diff + let stopFailed = false; + let stoppedService = false; ++ // A guard failure means the service manager was never touched, so an installed service ++ // owned by ANOTHER home may still be live and still routing through the shared Grok fence. ++ let sharedTeardownSafe = true; + try { + stoppedService = stopServiceIfInstalled(); + if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); + } catch (err) { ++ sharedTeardownSafe = false; + console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); + } +``` + +그리고 strip 호출부: + +```diff +- try { +- const g = stripGrokConfig(); +- if (g.changed) console.log(`↩️ ${g.message}`); +- else if (!g.ok) console.error(`⚠️ ${g.message}`); +- } catch { /* best-effort */ } ++ if (sharedTeardownSafe) { ++ try { ++ const g = stripGrokConfig(); ++ if (g.changed) console.log(`↩️ ${g.message}`); ++ else if (!g.ok) console.error(`⚠️ ${g.message}`); ++ } catch { /* best-effort */ } ++ } else { ++ console.error( ++ "⚠️ Left the Grok Build managed block in place: the installed service could not be stopped " ++ + "from this home, so it may still be serving those models. Re-run `ocx stop` from the " ++ + "installing home, or remove the fenced block manually.", ++ ); ++ process.exitCode = 1; ++ } +``` + +`process.exitCode = 1`로 실패를 보이게 한다. 기존 `stopFailed` 경로의 `process.exit(1)`과 달리 exitCode만 세팅해 나머지 정리는 끝까지 돌게 한다. + +## 블로커 5 — 의도적 서비스 종료 경로가 fence를 남긴다 + +데몬은 `OCX_SERVICE=1`일 때 `syncCleanup`에서 strip을 일부러 건너뛴다(`cli/index.ts:212`) — 재시작 사이에 블록을 유지하려는 설계다. 문제는 **영구 종료** 경로도 아무도 지우지 않는다는 점이다: + +- `ocx service stop` (`src/service.ts:1149`) — `stripGrokConfig` 호출 없음. 파일 전체에 해당 심볼이 없다. +- `ocx service uninstall|remove` (`:1165`) — 동일. +- 대시보드 `POST /api/stop` (`src/server/management-api.ts:136`) — 동일. 게다가 `stopServiceIfInstalled()`이 **무방비**라, 소유권 불일치 시 핸들러가 통째로 throw되어 `restoreNativeCodex`도, 200 응답도, 드레인 타이머도 실행되지 않는다. CLI보다 나쁜 상태다. + +### 변경 A — `src/service.ts` + +`stop`과 `uninstall/remove` 케이스의 `restoreNativeCodex()` 직후에 fence 제거를 추가한다. 두 케이스 모두 앞서 `assertServiceEnvironmentMatchesInstall()`을 통과했으므로 이 홈이 소유자임이 이미 증명돼 있다. + +```diff + case "stop": + assertServiceEnvironmentMatchesInstall(); + ops.stop(); + await stopTrackedProxyForServiceCommand(); + { + const restore = restoreNativeCodex(); + if (restore.success) console.log("✅ service stopped + native Codex restored."); + else console.error(`⚠️ service stopped, but native Codex restore FAILED: ...`); + } ++ // Intentional, permanent teardown: the service-mode daemon deliberately keeps the ++ // Grok fence across respawns, so this path owns removing it. Otherwise the generated ++ // [model.ocx-*] entries survive pointing at a dead endpoint. ++ stripGrokConfigBestEffort(); + break; +``` + +`uninstall`/`remove` 케이스는 `removeServiceInstallState()` 직전에 같은 호출을 넣는다. + +헬퍼는 동적 import로 두어 모듈 사이클을 피한다: + +```ts +/** Remove the opencodex-managed ~/.grok block; never fails a teardown command. */ +function stripGrokConfigBestEffort(): void { + try { + const { stripGrokConfig } = require("./grok/inject") as typeof import("./grok/inject"); + const r = stripGrokConfig(); + if (r.changed) console.log("↩️ Removed the opencodex managed block from Grok config."); + } catch { /* best-effort */ } +} +``` + +`src/service.ts`는 이미 `./codex/inject`를 정적 import하므로 정적 import로 통일한다 (`import { restoreNativeCodex } from "./codex/inject";` 옆에 `import { stripGrokConfig } from "./grok/inject";`). `require` 대신 정적 import를 쓰는 쪽이 Bun 런타임 및 typecheck와 일관된다. + +### 변경 B — `src/server/management-api.ts` + +```diff + if (url.pathname === "/api/stop" && req.method === "POST") { + const { restoreNativeCodex } = await import("../codex/inject"); + const { stopServiceIfInstalled } = await import("../service"); +- stopServiceIfInstalled(); ++ const { stripGrokConfig } = await import("../grok/inject"); ++ // The ownership guard inside stopServiceIfInstalled throws when this process's ++ // CODEX_HOME/OPENCODEX_HOME differs from the installed service's. That must not abort ++ // the handler: without this catch the request never gets a response and the drain timer ++ // never runs. A guard failure also means another home may still be serving the Grok ++ // models, so the shared fence stays. ++ let sharedTeardownSafe = true; ++ try { ++ stopServiceIfInstalled(); ++ } catch (err) { ++ sharedTeardownSafe = false; ++ console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); ++ } + const restore = restoreNativeCodex(); ++ if (sharedTeardownSafe) { try { stripGrokConfig(); } catch { /* best-effort */ } } + setTimeout(async () => { +``` + +## 테스트 + +저장소 관례상 라이프사이클은 소스 텍스트 슬라이스 + 순서 단언이 지배적이다(`tests/service.test.ts:523`). 그 관례를 따르되, 순수 함수로 검증 가능한 부분은 실제 호출로 확인한다. + +신규 `tests/grok-lifecycle.test.ts`: + +1. `handleStop skips the shared Grok strip when the ownership guard fails` — `src/cli/index.ts`에서 `async function handleStop`부터 다음 `async function`까지 슬라이스한 뒤, `sharedTeardownSafe = false`가 catch 안에 있고 `stripGrokConfig()` 호출이 `if (sharedTeardownSafe)` 블록 안에 있음을 단언. +2. `ocx service stop strips the Grok fence after restoring Codex` — `service.ts`의 `case "stop":` 슬라이스에서 `stripGrokConfig` 존재와 `restoreNativeCodex` 뒤 순서를 단언. +3. `ocx service uninstall strips the Grok fence` — 동일 패턴. +4. `POST /api/stop guards the service stop and strips the fence` — `management-api.ts` 슬라이스에서 `stopServiceIfInstalled()`가 try 안에 있고 `stripGrokConfig`가 존재함을 단언. +5. `service-mode daemon shutdown still keeps the fence` — `cli/index.ts`의 `syncCleanup` 슬라이스에서 `!process.env.OCX_SERVICE` 가드가 유지됨을 단언 (회귀 방지: 크래시/respawn 예외를 없애면 안 된다). + +추가로 실제 발동 증거(C-ACTIVATION-GROUNDING-01)를 위해 `tests/grok-config-inject.test.ts`에서 `stripGrokConfig`가 실제 파일에 대해 도는 경로는 이미 wp1이 커버한다. diff --git a/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md b/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md index b5e48e0b21..2454cdbedc 100644 --- a/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md +++ b/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md @@ -6,20 +6,28 @@ blockers: [B1] tags: [grok-build, auth, non-loopback, security] --- -# 020 — 비루프백 인증 (B1) +# 020 — 비루프백 자동 등록 거부 (B1) + +> **개정 2026-07-26 (A-게이트 감사 반영).** 초판은 `env_key` 방출로 자동 등록을 유지하려 했다. +> 감사에서 그 설계가 보안 퇴행임이 원본 코드와 상위 테스트로 확인됐다(`001` E3 정정 참조). +> 본 문서는 메인테이너가 요구한 원안 — 비루프백에서는 fence를 만들지 않는다 — 으로 되돌린다. 대상 파일: `src/grok/inject.ts`, `src/grok/sync.ts`, `tests/grok-config-inject.test.ts`, `tests/grok-sync.test.ts`. 근거: `000_blocker_inventory.md` B1, `001_grok_source_evidence.md` E3. -## 문제 재정의 +## 왜 자동 등록을 거부하는가 + +비루프백 바인드에서 우리가 취할 수 있는 선택지는 셋뿐이고, 셋 다 문제가 있거나 우리 권한 밖이다. -리뷰어들은 "비루프백에서는 자동 등록을 거부하라"고 제안했다. 그 제안의 전제는 -**설정 파일에 실토큰을 쓰는 것 외에 방법이 없다**는 것이었다. 원본 확인 결과 그 전제가 틀렸다: -grok은 `env_key`로 환경변수 이름만 적어두면 요청 시점에 토큰을 읽는다(E3). +| 선택지 | 결과 | +|--------|------| +| 리터럴 실토큰을 fence에 쓴다 | 사용자 소유 파일에 비밀을 기록하고, 매 sync마다 사용자가 고친 값을 덮어쓴다 (현재 버그) | +| `env_key`만 쓰고 `api_key`를 뺀다 | 변수 미설정 시 grok이 **xAI 세션 토큰을 평문 LAN 주소로 전송** (E3 정정) | +| 자동 등록을 하지 않는다 | 사용자 파일을 건드리지 않고, 유출 경로가 없으며, 수동 설정 안내로 기능을 잃지 않는다 | -따라서 본 계획은 지적의 **의도**(사용자 실토큰을 우리가 덮어쓰지 않는다, 작동하지 않는 설정을 -말없이 남기지 않는다)를 그대로 만족시키되, 기능을 잘라내지 않는 방식을 택한다. +세 번째가 메인테이너 요구와 일치하고 유일하게 안전하다. 자동화는 우리가 인증을 안전하게 책임질 수 +있는 범위(루프백)로 한정한다. ## 설계 @@ -30,57 +38,73 @@ grok은 `env_key`로 환경변수 이름만 적어두면 요청 시점에 토큰 `src/codex/inject.ts`에서 `isLoopbackHostname`을 **export**하고 grok 모듈이 그것을 쓴다. (`providerBaseHost`는 `0.0.0.0`을 `127.0.0.1`로 접는 다이얼 주소 매퍼라 판정에 쓸 수 없다.) -### 2. 블록 생성의 인증 필드 분기 +### 2. `injectGrokConfig`가 비루프백에서 거부한다 -`buildGrokManagedBlock(port, models, hostname, reservedAliases)`에서: +거부는 `buildGrokManagedBlock`이 아니라 **파일을 건드리기 전** `injectGrokConfig` 초입에서 한다. +새 `skippedReason`을 추가한다: ```ts -const nonLoopback = !isLoopbackHostname(hostname); -// 루프백: 데이터 플레인이 열려 있으므로 자리표시자 키로 충분하다. -// 비루프백: 실토큰이 필요하지만 사용자 파일에 비밀을 쓰지 않는다 — grok이 실행 환경에서 -// 읽도록 env_key만 남긴다(미설정 시 grok은 키 없이 호출해 401로 드러난다). -...(nonLoopback - ? [`env_key = ${tomlString(API_AUTH_TOKEN_ENV)}`] - : ['api_key = "opencodex-loopback"']), +skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback"; ``` -`API_AUTH_TOKEN_ENV`는 `"OPENCODEX_API_AUTH_TOKEN"` — `auth-cors.ts`가 요구하는 바로 그 변수명이며 -이미 상수화되어 있으면 재사용하고, 아니면 한 곳에서 export한다. +```ts +if (!isLoopbackHostname(opts.hostname)) { + return { + ok: true, // 실패가 아니라 의도된 비적용 — 기동을 막지 않는다. + changed: false, + skippedReason: "non-loopback", + message: + `Grok auto-registration skipped: opencodex is bound to a non-loopback host ` + + `(${opts.hostname}). Non-loopback requests need your admission token, and a managed ` + + `block would either store that secret in your config or overwrite it on the next start. ` + + `Add models manually OUTSIDE the opencodex fence — see the Grok Build guide.`, + }; +} +``` -`api_key`를 아예 방출하지 않는 것이 중요하다. E3의 우선순위상 비어 있지 않은 `api_key`가 -`env_key`를 가리기 때문이다. +`ok: true`인 이유: 이것은 오류가 아니라 정책이다. CLI의 `else if (!r.ok)` 경로가 경고를 찍지 +않도록 하고, 대신 `skippedReason`을 보고 안내 문구를 한 번 출력한다. -### 3. 사용자가 손으로 넣은 키를 우리가 지우지 않는다는 보장 +### 3. 이미 존재하는 fence는 어떻게 하는가 -관리 블록은 마커 사이만 재생성하므로 fence 밖 사용자 `[model.*]`는 이미 보존된다(B3 예약 로직). -비루프백에서 우리 블록이 더 이상 리터럴 키를 쓰지 않으므로, 메인테이너가 재현한 -`REAL_TOKEN_PRESERVED=false` 시나리오 자체가 성립하지 않는다. +사용자가 루프백으로 쓰다가 `hostname`을 비루프백으로 바꾸면, 이전에 만든 fence가 남아 죽은 +`127.0.0.1` 주소를 가리킨다. 거부하면서 **기존 관리 블록은 제거**한다 — 그것도 우리가 만든 것이므로 +정리 책임이 있다. -### 4. 사용자에게 상태를 알린다 +```ts +// 비루프백으로 전환된 경우: 우리가 남긴 (이제 잘못된) 블록을 걷어낸다. +const removed = stripGrokConfig({ ...(opts.grokHome ? { grokHome: opts.grokHome } : {}) }); +``` -`GrokInjectResult.message`에 비루프백 모드임을 명시한다: +제거 결과는 메시지에 합쳐 사용자에게 알린다. -``` -Added the opencodex managed block to Grok config (non-loopback bind: models read -OPENCODEX_API_AUTH_TOKEN from the environment — export it where you run `grok`). -``` +### 4. 사용자 실토큰 보존 -CLI는 이미 `r.message`를 출력하므로 별도 배선이 필요 없다. +비루프백에서 우리는 아무것도 쓰지 않으므로, 사용자가 fence 밖에 둔 `api_key`는 정의상 보존된다. +메인테이너가 재현한 `REAL_TOKEN_PRESERVED=false`가 `true`로 뒤집힌다. ## 회귀 테스트 `tests/grok-config-inject.test.ts`: -1. `hostname: "0.0.0.0"` → 블록에 `env_key = "OPENCODEX_API_AUTH_TOKEN"`이 있고 - `api_key`가 **없다**. -2. `hostname: "192.168.1.10"` → 동일 + `base_url`이 `http://192.168.1.10:/v1`. -3. `hostname` 미지정/`127.0.0.1`/`localhost`/`::1` → 기존대로 `api_key = "opencodex-loopback"`, - `env_key` 없음. -4. 비루프백 결과 메시지에 환경변수 이름이 포함된다. +1. `hostname: "0.0.0.0"` → 파일이 생성되지 않고 `skippedReason === "non-loopback"`, + `ok === true`, `changed === false`. +2. `hostname: "192.168.1.10"` → 동일. 메시지에 수동 설정 안내가 포함된다. +3. `hostname` 미지정/`127.0.0.1`/`localhost`/`::1`/`::` → 기존대로 fence를 만든다 + (`0.0.0.0`과 `::`는 와일드카드지만 비루프백 노출이므로 **거부** 쪽으로 분류한다는 점을 테스트로 고정). +4. 루프백 fence가 있는 상태에서 비루프백으로 재호출 → 기존 관리 블록이 제거되고 사용자 바이트는 보존. +5. 어떤 경우에도 `env_key`가 방출되지 않는다 (세션 토큰 폴백 회귀 방지 가드). `tests/grok-sync.test.ts`: -5. 비루프백 hostname으로 `syncGrokConfig`를 **두 번** 실행해도 사용자가 fence 밖에 둔 +6. 비루프백 hostname으로 `syncGrokConfig`를 **두 번** 실행해도 사용자가 fence 밖에 둔 `[model.mine] api_key = "real-token"`이 바이트 그대로 남는다 (B1 재현의 반증). +## 와일드카드 바인드 분류 + +`0.0.0.0` / `::`는 `isLoopbackHostname`이 false를 준다(그 함수는 루프백 리터럴만 참으로 본다). +이 값들은 모든 인터페이스에 노출하므로 admission token이 요구되며, 따라서 **거부 대상이 맞다**. +`providerBaseHost`가 이들을 `127.0.0.1`로 접는 것은 다이얼 주소 계산용이지 보안 판정이 아니다 — +판정에는 절대 쓰지 않는다. + ## 게이트 `bun test tests/grok-config-inject.test.ts tests/grok-sync.test.ts` → `bun run typecheck`. diff --git a/devlog/_plan/260726_grok_build_prod/030_docs_truth.md b/devlog/_plan/260726_grok_build_prod/030_docs_truth.md new file mode 100644 index 0000000000..c23ead4f87 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/030_docs_truth.md @@ -0,0 +1,76 @@ +# 030 — wp3: 문서 진실성 + 게이트 + PR 갱신 + +대상: `docs-site/src/content/docs/guides/grok-build.md`, `devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md`, 리시트, PR #403. + +## 문서 수정 1 — 비루프백 안내가 틀렸다 + +현재 문구는 "비루프백이면 per-model `api_key`를 admission 토큰으로 바꿔라"고 한다. 두 가지가 틀렸다: (a) 다음 `ocx start`/`ensure`가 fence를 재생성하며 되돌린다, (b) `base_url`이 `127.0.0.1`로 남으면 애초에 도달 불가다. + +wp1 이후로는 자동 생성 블록이 비루프백에서 `env_key`를 쓰므로, 안내는 "환경변수를 설정하라"로 바뀐다. + +```diff +-## Authentication note +- +-Grok Build requires a non-empty API key for custom models even on loopback. The injected +-entries carry a placeholder (`opencodex-loopback`) — opencodex ignores admission keys for +-loopback connections, so no real secret is involved. If you bind the proxy on a +-non-loopback host, replace the per-model `api_key` with your opencodex admission token. ++## Authentication note ++ ++Grok Build requires a credential for custom models even on loopback. On a loopback bind the ++injected entries carry a placeholder (`opencodex-loopback`) — opencodex ignores admission ++keys for loopback connections, so no real secret is involved. ++ ++When the proxy is bound to a non-loopback host, opencodex requires a real admission token on ++every request. The generated entries then carry ++`env_key = "OPENCODEX_API_AUTH_TOKEN"` instead of a literal key: Grok Build reads the token ++from that environment variable at request time, so no secret is written into your shared ++`~/.grok/config.toml`. Export the same value you gave the proxy: ++ ++```bash ++export OPENCODEX_API_AUTH_TOKEN="…" # same token the proxy was started with ++grok -m ocx-… -p "hello" ++``` ++ ++If the variable is unset, Grok Build fails closed for those models rather than falling back ++to your xAI session token. +``` + +예시 TOML 블록에도 비루프백 변형을 덧붙인다. + +## 문서 수정 2 — hot-reload를 약속하지 않는다 + +소스에는 `ConfigFileWatcher` → `ConfigUpdate::ModelsChanged` 경로가 실제로 있지만 (`180_grok-build .../config/reloader.rs:385`), docs.x.ai는 이를 보장하지 않고 `grok inspect` 후 세션 재선택을 안내한다. 버전 보장 없이 "watch하고 hot-reload한다"고 쓰면 안 된다. + +```diff +-- **Config read timing:** start opencodex first, then launch `grok` for the most +- predictable results. Recent Grok Build versions watch `config.toml` and hot-reload +- `[model.*]` changes into an open session; older builds may need a restart. ++- **Config read timing:** start opencodex first, then launch `grok` for the most ++ predictable results. If you refresh the catalog while `grok` is already open, run ++ `grok inspect` to confirm the config was picked up, then reopen the session or ++ re-select the model with `/model`. Some builds do pick up `[model.*]` edits without a ++ restart, but that is not a documented guarantee — don't rely on it. +``` + +## 문서 수정 3 — 매뉴얼 레시피의 도달 가능한 호스트 + +CodeRabbit 지적대로 매뉴얼 예시가 `127.0.0.1` 고정이다. `base_url`은 grok이 실제로 도달할 수 있는 주소여야 한다는 주석을 추가한다(`endpoint()`가 `base_url`에 상대 결합하며 `/v1` 주입은 없음 — `xai-grok-sampler/src/client.rs:703`). + +## 데브로그 수정 — 이전 유닛의 backend 권장이 자기모순 + +`260723_grok_build_bridge/020_docs_and_residual_smoke.md:11`은 `responses`를 권장하지만, 같은 유닛 `011_receipt.md:53-55`는 grok이 `response.heartbeat`에서 이탈함을 기록했고 이후 계획은 그래서 `chat_completions`로 고정했다. 계획 문서를 실제 결정에 맞춘다 (과거 문서를 지우지 않고 정정 사유를 남긴다). + +## 최종 게이트 + +``` +bun run typecheck +bun run test +bun run privacy:scan +``` + +세 개 모두 green이어야 하며, 각 출력의 실제 tail을 `031_receipt.md`에 남긴다. + +## PR 갱신 + +`dev`가 그동안 움직였는지 `git fetch origin dev` 후 확인하고, 필요하면 다시 리베이스한 뒤 게이트를 재실행한다. 그다음 `codex/260726-grok-build-prod`를 push하고 PR #403 본문에 이번 수정 요약(각 블로커 → 해소 방식 → 증거 테스트 이름)을 추가한다. 머지는 하지 않는다. diff --git a/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md b/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md index 8a6d845f97..87d9bcdebe 100644 --- a/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md +++ b/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md @@ -8,7 +8,12 @@ tags: [grok-build, lifecycle, service, teardown] # 030 — 라이프사이클 teardown 정합성 (B2, B6) -대상 파일: `src/service.ts`, `src/cli/index.ts`, `src/server/management-api.ts`. +> **개정 2026-07-26 (A-게이트 감사 반영).** 초판의 오너십 게이트는 fence를 실제로 지키지 못했고 +> (`killProxy` → SIGTERM → `syncCleanup` 우회), `ocx update`/트레이 재시작 퇴행과 대시보드 409 +> 정지 문제를 놓쳤다. 아래는 그 네 가지를 포함한 교정본이다. + +대상 파일: `src/service.ts`, `src/cli/index.ts`, `src/server/management-api.ts`, +`src/lib/process-control.ts`, `gui/src/App.tsx`. 근거: `000_blocker_inventory.md` B2/B6 + 라이프사이클 인벤토리(서브에이전트 실측). ## 실측된 현재 상태 @@ -70,6 +75,46 @@ try { `if (!ownershipBlocked)`로 감싼다. 로컬 프록시 정지 자체는 그대로 시도한다 — 그것은 이 홈이 소유한 자원이다. +**정정(감사):** `revertSystemEnv`는 게이트에서 **제외**한다. 이것은 macOS `launchctl` 사용자 환경 +상태이고 자체 소유권 검사를 가지며(`src/server/system-env.ts:330`이 `"ownership mismatch"`를 반환) +`CODEX_HOME`과 무관하다. 게이트 대상은 `restoreNativeCodex`와 `stripGrokConfig` 둘뿐이다 — +소유권 단언 메시지 자체가 네이티브 Codex 복원을 겨냥한다(`service.ts:170`). + +### 2b. 게이트가 실제로 fence를 지키게 한다 (감사 blocking 5) + +`handleStop`에서 strip을 건너뛰어도 **프록시 종료 경로가 대신 strip한다**: +`stopProxy` → `stopProxyGracefully` → `POST /api/stop` → (409) → `!res.ok` → `killProxy` SIGTERM +→ 데몬의 `syncCleanup` → `OCX_SERVICE` 미설정이면 `stripGrokConfig()`. +즉 수동 기동/고아 프록시에서는 공유 설정이 그대로 사라진다 — B2가 말한 바로 그 상황이다. + +두 곳을 함께 막는다: + +1. `stopProxyGracefully`가 **409를 에스컬레이션 금지 신호로** 다룬다. 409는 "정지 요청이 + 정책상 거부됨"이지 "엔드포인트가 죽음"이 아니므로 `killProxy`로 승격하면 안 된다. + 반환 타입을 `boolean`에서 `"stopped" | "refused" | "unreachable"`로 넓히고, + `refused`면 호출자가 강제 종료 없이 실패를 보고한다. +2. 데몬의 `syncCleanup`에도 같은 판단을 심는다: strip 직전에 소유권을 확인해, + 불일치면 공유 파일을 건드리지 않는다. 프로세스 종료 경로가 어디서 오든 한 곳에서 막힌다. + +### 2c. 명시적으로 감수하는 동작 변화 (감사 regression A/B) + +`stopFailed = true`는 종료 코드 1을 낳고, 이는 두 곳에 파급된다: + +- **`ocx update`** — `src/update/index.ts:223`이 `stop.status !== 0`이면 업데이트를 중단한다. + 소유권 불일치 사용자는 원래 `CODEX_HOME`을 찾을 때까지 업데이트가 거부된다. + 의도된 안전 동작이지만 막다른 길이 되므로, 중단 메시지에 소유권 원인과 해결 방법 + (원래 홈에서 실행)을 함께 출력한다. +- **트레이 재시작** — `handleStop`이 내부에서 `process.exit(1)`을 하면 + `runTrayProxyRestart`의 `stop()`이 값을 반환하지 못해 `start()`가 조용히 사라진다 + (`src/cli/tray-proxy.ts:50`). `handleStop`이 즉시 `exit`하는 대신 `process.exitCode`를 + 세팅하고 반환하도록 바꿔 호출자가 결과를 관찰할 수 있게 한다. CLI 최상위에서는 동일하게 1로 끝난다. + +### 2d. 거부된 strip을 삼키지 않는다 (감사 잔여 결함 3) + +`handleStop`은 `stripGrokConfig()`가 `!ok`(예: orphaned-marker 거부)를 반환해도 로그만 찍고 +성공으로 끝난다. fence가 죽은 프록시를 가리킨 채 남는데 종료 코드는 0이다. +`!g.ok`일 때 `stopFailed = true`로 승격한다. + **restart 영향:** `handleStop`이 `stopFailed`로 `process.exit(1)`하면 `handleEnsure`가 실행되지 않는다. 소유권 불일치 상태에서 재주입까지 진행하는 것은 오히려 위험하므로 이 동작이 옳다. 사용자는 올바른 홈에서 다시 실행하라는 안내를 받는다. @@ -78,10 +123,18 @@ try { ### 3. `serviceCommand`의 `stop` / `uninstall` (`src/service.ts`) +선행 조건(감사): `stop`은 현재 `ops.stop()`을 **설치 여부와 무관하게** 호출한다(`service.ts:1151`). +`stopServiceIfInstalled`과 달리 `existsSync(plistPath())` 가드가 없어 미설치 환경에서도 실제 +`launchctl unload`가 실행된다. grok strip을 붙이기 전에 이 가드를 먼저 넣는다 — 그래야 이 경로가 +테스트 가능해지고(040), 미설치 사용자에게 헛된 부작용도 사라진다. + 두 경로 모두 `restoreNativeCodex()` 직후에 grok strip을 추가한다. 순환 의존을 피하려고 `src/cli/index.ts`가 쓰는 것과 같은 정적 import를 쓴다(`src/grok/inject.ts`는 `src/config`와 `src/codex/inject`만 의존하므로 안전). +순환 없음 확인(감사): `src/grok/inject.ts`의 import는 `node:fs`/`node:os`/`node:path`/`../config`/ +`../codex/inject`뿐이고, 이들 중 어느 것도 `grok/`, `service.ts`, `management-api.ts`에 도달하지 않는다. + ```ts const g = stripGrokConfig(); if (g.changed) console.log(`↩️ ${g.message}`); @@ -104,7 +157,7 @@ if (url.pathname === "/api/stop" && req.method === "POST") { } catch (err) { if (isServiceOwnershipError(err)) { // 설치된 서비스를 정지시킬 수 없다 = 공유 설정을 건드리면 안 되고, 종료해도 즉시 되살아난다. - return jsonResponse({ success: false, message: err.message }, 409); + return jsonResponse({ success: false, message: err.message }, 409, req, config); } throw err; } @@ -116,15 +169,40 @@ if (url.pathname === "/api/stop" && req.method === "POST") { } ``` -`jsonResponse`가 상태 코드 인자를 받지 않으면 기존 시그니처를 확인해 맞춘다. +`jsonResponse` 시그니처 확인 완료: `src/server/auth-cors.ts:104` +`jsonResponse(data, status = 200, req?, config?)` — 상태 코드를 받는다. +`req`/`config`를 함께 넘겨 CORS 헤더가 대시보드 오리진과 맞게 한다. 응답 메시지에 grok strip 결과를 합쳐 대시보드가 상태를 볼 수 있게 한다. +종료 스케줄(`setTimeout` → `drainAndShutdown` → `process.exit(0)`)은 **주입 가능하게** 바꾼다. +현재 구조에서는 테스트가 `handleManagementAPI`를 호출하면 200 ms 뒤 테스트 러너 프로세스가 +그대로 종료된다(감사 blocking 3). `ManagementApiDeps`에 종료 훅을 추가해 테스트가 대체하게 한다. + +### 4b. 대시보드 409 처리 (감사 blocking 6) + +`gui/src/App.tsx:181`의 정지 핸들러는 `res.ok`를 보지 않고 `stopping` 상태를 되돌리지도 않는다. +409를 받으면 버튼이 "stopping…"에 멈춘 채 아무 설명도 나오지 않는다. +응답을 검사해 실패면 `stopping`을 해제하고 서버 메시지를 노출한다. 상태 코드 변경과 **같은 +커밋에** 넣는다 — 백엔드만 바뀌면 UI가 멈춘다. + +### 5. `handleStart` grok 동기화 중첩 해소 (감사 잔여 결함 1) + +`src/cli/index.ts:263`의 grok 동기화는 Desktop3P 레지스트리 `try` **안쪽**에 중첩돼 있다. +`fetchAllModels`가 던지면 바깥 catch가 삼켜 fence가 조용히 건너뛰어진다 — +`syncGrokConfig` 자체에 카탈로그 실패 폴백이 있는데도 도달하지 못한다. +두 블록을 형제 `try`로 분리한다. 040의 배선 테스트가 이 구조를 고정한다. +(초판에서 040이 이 변경을 전제했으나 어느 사이클도 소유하지 않았다 — 여기로 귀속.) + `OCX_SERVICE=1` 크래시/재spawn 배제(`syncCleanup`의 게이트)는 **그대로 둔다** — 이 변경은 명시적 종료 경로에만 strip을 추가한다. ## 회귀 테스트 (뼈대만; 상세는 040에서) - 소유권 불일치 시 `handleStop`이 strip을 건너뛰고 실패 종료 코드를 남긴다. +- 소유권 불일치 시 `revertSystemEnv`는 **여전히 실행된다**(과잉 게이트 방지). +- 409를 받은 `stopProxyGracefully`가 `killProxy`로 승격하지 **않는다**. +- `syncCleanup`이 소유권 불일치에서 strip하지 않는다. +- `!ok` strip 결과가 `ocx stop`을 실패로 만든다. - `service stop`/`uninstall`이 strip을 호출한다. - `/api/stop`이 strip을 호출하고, 소유권 불일치에는 409로 응답하며 프록시를 종료하지 않는다. - `syncCleanup`의 `OCX_SERVICE` 배제가 그대로 남아 있다. diff --git a/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md b/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md index 649ae150de..bfb6b1dcb6 100644 --- a/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md +++ b/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md @@ -8,8 +8,25 @@ tags: [grok-build, tests, lifecycle] # 040 — CLI 라이프사이클 회귀 (B4) +> **개정 2026-07-26 (A-게이트 감사 반영).** 초판의 테스트 5–8은 그대로 쓰면 **사용자의 실제 +> launchd 서비스를 정지시키거나 테스트 러너를 종료**시킨다. 아래는 그 위험을 제거한 교정본이다. + 대상 파일: `tests/helpers/isolated-grok-home.ts`(신규), `tests/grok-lifecycle.test.ts`(신규), `tests/service.test.ts`, `tests/grok-config-inject.test.ts`. + +## 선행 조건 (030이 먼저 처리한다) + +감사에서 확인된 두 가지 때문에, 아래 테스트들은 030의 소스 변경 없이는 **작성 자체가 불가능**하다. + +1. `serviceCommand("stop")`은 `ops.stop()`을 무조건 호출하고(`service.ts:1151`), + `stopLaunchd`(`:585`)가 `homedir()` 기준으로 실제 `launchctl unload`를 실행한다. + `bun run test`가 **개발자의 실제 서비스를 정지**시킨다. 030에서 설치 여부 가드를 넣고, + 테스트는 `node:child_process`의 `execSync`/`execFileSync`를 `mock.module`로 대체한다. + `platformOps`(`service.ts:815`)가 모듈 private 함수를 클로저로 잡고 있어 + `mock.module("../src/service")`로는 내부를 갈아끼울 수 없다 — 프로세스 실행 계층에서 막는다. +2. `/api/stop` 성공 경로는 200 ms 뒤 `process.exit(0)`을 예약한다(`management-api.ts:148`). + 테스트가 그대로 호출하면 단언 직후 러너가 종료된다. 030에서 종료 훅을 주입 가능하게 만든 뒤에만 + 성공 경로 테스트를 쓴다. (409 경로는 타이머 전에 반환하므로 안전하다.) 근거: `000_blocker_inventory.md` B4 + 라이프사이클 커버리지 실측. ## 제약: `src/cli/index.ts`는 import할 수 없다 @@ -50,10 +67,8 @@ export function installIsolatedGrokHome(prefix: string): { path: string; restore ### 그룹 1 — 배선 (스타일 A, `tests/grok-lifecycle.test.ts`) 1. `handleStart`가 Desktop3P 등록 실패와 **무관하게** grok 동기화를 시도한다. - 현재는 grok inject가 Desktop3P try 블록 **안쪽**에 중첩돼, 카탈로그 조회가 던지면 fence가 - 조용히 건너뛰어진다. 030에서 이 중첩을 형제 블록으로 분리하고, 이 테스트가 그 구조를 고정한다. - 검증: `handleStart` 슬라이스에서 `syncGrokConfig` 호출이 `buildDesktop3pRegistry`의 catch - **뒤**에 독립 try로 존재. + 구현 소유는 030 §5. 검증: `handleStart` 슬라이스에서 `syncGrokConfig` 호출이 + `buildDesktop3pRegistry`의 catch **뒤**에 독립 try로 존재. 2. `handleEnsure` 라이브 분기가 `live.hostname`을, spawn 분기가 `config.hostname`을 넘긴다 (분기별로 정확한 호스트 출처를 고정 — 뒤바뀌면 잘못된 base_url을 쓴다). 3. `handleStop`에서 `stripGrokConfig()` 호출이 소유권 게이트 **안쪽**에 있다 @@ -63,16 +78,21 @@ export function installIsolatedGrokHome(prefix: string): { path: string; restore ### 그룹 2 — 실제 동작 (스타일 B/C) 5. `serviceCommand("stop")`이 fence를 실제로 제거한다. - `installIsolatedGrokHome` + `installIsolatedCodexHome`로 격리하고, 플랫폼 ops는 - `mock.module`로 대체해 실제 launchd/systemd를 건드리지 않는다. + `installIsolatedGrokHome` + `installIsolatedCodexHome`로 격리하고, + **`node:child_process`를 `mock.module`로 대체**해 실제 launchd/systemd 명령이 나가지 않게 한다. + 030의 설치 여부 가드가 선행되어야 한다. 6. `serviceCommand("uninstall")` 동일. 7. 소유권 불일치 상태에서 `serviceCommand("stop")`이 **fence를 남긴 채** throw한다 (`service-state.json` 세팅은 `tests/service.test.ts:188` 패턴 그대로). 8. `POST /api/stop`이 fence를 제거하고 성공 응답을 준다 — - `handleManagementAPI`를 직접 import + `new Request(...)`. -9. `POST /api/stop`이 소유권 불일치에 409로 응답하고 fence를 **남긴다**. + `handleManagementAPI`를 직접 import + `new Request(...)`, **주입된 종료 훅**으로 호출을 기록만 한다. + (라우팅 도달성 확인됨: `Host`/`Origin`이 `null`이면 `isLoopbackRequestHost(null)`가 참이라 + `isAllowedRequestOrigin`을 통과하고, `requireApiAuth`는 `server/index.ts:333`에 있어 이 함수 밖이다.) +9. `POST /api/stop`이 소유권 불일치에 409로 응답하고 fence를 **남긴다**. 프록시도 종료하지 않는다. 10. `ServiceOwnershipError`가 `isServiceOwnershipError`로 판별되고, 일반 정지 실패는 판별되지 않는다(오분류로 teardown을 통째로 막지 않도록). +11. `stopProxyGracefully`가 409를 받으면 `refused`를 반환하고 `killProxy`를 호출하지 않는다. +12. 대시보드 정지 핸들러가 409에서 `stopping`을 해제하고 메시지를 노출한다(GUI 테스트). ## 게이트 From fbfb3d13f843a6b17f45eed81eca579a38baa906 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 02:46:02 +0900 Subject: [PATCH 19/22] fix(grok): canonicalize both TOML key segments and restore user config byte-for-byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B3: the alias-collision scan only matched a literal first segment, so a user-owned ["model"."x"] or ['model'.x] table went unreserved and we emitted a duplicate [model.x]. grok rejects the ENTIRE config layer on a duplicate key, taking every unrelated user setting with it. Both segments are now canonicalized through one KEY_SEGMENT grammar, and array-of-table ([[model.x]]) plus sub-table ([model.x.sub]) spellings — which collide the same way — are reserved too. B5: injection added "\n\n" to a config lacking a trailing newline but strip removed only one, so `ocx stop` left an extra newline behind. The real problem was that "X" and "X\n" produced byte-identical files after inject, making restore impossible. Injection now always adds exactly one separator newline, and strip undoes exactly that — verified byte-for-byte across no/one/two/three trailing newlines, mid-file blocks, uniform CRLF, and five inject/strip cycles. --- src/grok/inject.ts | 57 +++++++++++---- tests/grok-config-inject.test.ts | 115 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 7641bd206e..22cb652bb6 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -54,26 +54,44 @@ function findManagedRegion(content: string): ManagedRegion | null { return { start, end: endMarkerStart + END_MARKER.length, orphaned: false }; } +/** + * A TOML key segment as it may be spelled in a table header: bare, basic string, or literal + * string. All three spellings of the same key address the SAME table, so both segments of a + * `[model.]` header must be canonicalized before comparison. + */ +const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; +/** + * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table + * (`[model.x.sub]`) spellings: both collide with a generated `[model.x]` and a single collision + * makes grok reject the ENTIRE config layer ("duplicate key"), taking every unrelated user + * setting with it. Reserving conservatively is always cheaper than that failure. + */ +const MODEL_TABLE_HEADER = new RegExp( + String.raw`^\s*\[\[?\s*(${KEY_SEGMENT})\s*\.\s*(${KEY_SEGMENT})\s*(?:\.[^\]]*)?\]\]?\s*(?:#.*)?$`, + "gm", +); + +/** Resolve a header key segment (bare / basic / literal) to the key it actually addresses. */ +function canonicalKeySegment(raw: string): string { + if (raw.startsWith('"')) return decodeTomlBasicString(raw.slice(1, -1)); + if (raw.startsWith("'")) return raw.slice(1, -1); // literal strings have no escapes + return raw; +} + /** * `[model.]` table headers the USER owns (outside our fence) — reserved for collisions. - * TOML admits equivalent header spellings (`[model."ocx-mine"]`, `[ model . ocx-mine ]`, single - * quotes); all of them redefine the same table, so each form must be canonicalized. Headers whose - * second key segment cannot be recognized are skipped — our generated aliases always start with - * "ocx-" and unrecognizable user spellings of that prefix are already covered by the quoted forms. + * TOML admits equivalent header spellings for BOTH segments (`["model"."ocx-mine"]`, + * `['model'.ocx-mine]`, `[ model . ocx-mine ]`); all of them redefine the same table, so each + * form is canonicalized before it is reserved. */ function userModelAliases(content: string, region: ManagedRegion | null): Set { const outsideManagedRegion = region ? content.slice(0, region.start) + content.slice(region.end) : content; const aliases = new Set(); - const header = /^\s*\[\s*model\s*\.\s*(?:([A-Za-z0-9_-]+)|"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]\s*(?:#.*)?$/gm; - for (const match of outsideManagedRegion.matchAll(header)) { - const bare = match[1]; - const doubleQuoted = match[2]; - const singleQuoted = match[3]; - if (bare !== undefined) { aliases.add(bare); continue; } - if (singleQuoted !== undefined) { aliases.add(singleQuoted); continue; } - if (doubleQuoted !== undefined) aliases.add(decodeTomlBasicString(doubleQuoted)); + for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) { + if (canonicalKeySegment(match[1]!) !== "model") continue; + aliases.add(canonicalKeySegment(match[2]!)); } return aliases; } @@ -175,8 +193,11 @@ export function injectGrokConfig( } else if (content.length === 0) { nextContent = `${block}\n`; } else { - const separator = content.endsWith("\n") ? "\n" : "\n\n"; - nextContent = `${content}${separator}${block}\n`; + // Exactly ONE separator newline, always. The old rule ("\n\n" when the file lacked a + // trailing newline) made two different originals — "X" and "X\n" — produce byte-identical + // files, so strip could not restore both. One newline keeps injection injective: the + // user's own terminator is preserved verbatim and strip can undo exactly what we added. + nextContent = `${content}\n${block}\n`; } const output = applyEol(nextContent, eol); @@ -226,8 +247,14 @@ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectRes let removalEnd = region.end; if (content.startsWith("\n", removalEnd)) removalEnd += 1; let prefix = content.slice(0, region.start); + const restOfFile = content.slice(removalEnd); + // Undo the single separator newline injection added. Two cases, mirroring inject: + // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one. + // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it. + // A block the user has appended content after is left alone: we never shrink their bytes. if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1); - const stripped = prefix + content.slice(removalEnd); + else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); + const stripped = prefix + restOfFile; atomicWriteFile(configPath, applyEol(stripped, eol)); return { diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index f47b6b805e..af5c2c0821 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -175,4 +175,119 @@ describe("Grok config injection", () => { expect(injectResult).toMatchObject({ ok: false, changed: false, skippedReason: "orphaned-marker" }); expect(readFileSync(configPath, "utf8")).toBe(damaged); }); + + describe("user table reservation across TOML header spellings", () => { + const configPath = () => join(grokHome, "config.toml"); + + // Every spelling below addresses the SAME table as a generated [model.ocx-mine]. grok's TOML + // parser rejects the entire config layer on a duplicate key, so an unreserved spelling would + // destroy every unrelated setting the user owns — not just our block. + const collidingSpellings: Array<[label: string, header: string]> = [ + ["quoted first segment", '["model"."ocx-mine"]'], + ["single-quoted first segment", "['model'.ocx-mine]"], + ["mixed quoting with whitespace", `[ "model" . 'ocx-mine' ]`], + ["bare (baseline)", "[model.ocx-mine]"], + ["array of tables", "[[model.ocx-mine]]"], + ["sub-table", "[model.ocx-mine.extra]"], + ["trailing comment", '[model."ocx-mine"] # mine'], + ]; + + for (const [label, header] of collidingSpellings) { + test(`reserves a user alias written as ${label}`, () => { + writeFileSync(configPath(), `${header}\nmodel = "user/keeps-this"\n`, "utf8"); + + injectGrokConfig(10100, [{ id: "mine" }], { grokHome }); + + const written = readFileSync(configPath(), "utf8"); + const generated = written.slice(written.indexOf(BEGIN_MARKER)); + expect(generated).toContain("[model.ocx-mine-2]"); + expect(generated).not.toContain("[model.ocx-mine]\n"); + }); + } + + test("does not reserve aliases from unrelated tables", () => { + // [models.*] and [model_providers.*] are different tables entirely — reserving from them + // would needlessly suffix our aliases. + writeFileSync( + configPath(), + '[models.ocx-mine]\nx = 1\n\n[model_providers.ocx-mine]\ny = 2\n\n[auth_provider.ocx-mine]\nz = 3\n', + "utf8", + ); + + injectGrokConfig(10100, [{ id: "mine" }], { grokHome }); + + const written = readFileSync(configPath(), "utf8"); + expect(written.slice(written.indexOf(BEGIN_MARKER))).toContain("[model.ocx-mine]"); + }); + + test("generated aliases never contain a dot", () => { + // A bare [model.grok-4.5] header is a THREE-segment key path, not the id "grok-4.5". + injectGrokConfig(10100, [{ id: "xai/grok-4.5" }, { id: "a.b.c" }], { grokHome }); + + const written = readFileSync(configPath(), "utf8"); + const headers = [...written.matchAll(/^\[model\.(.+)\]$/gm)].map(m => m[1]!); + expect(headers.length).toBeGreaterThan(0); + for (const alias of headers) expect(alias).not.toContain("."); + }); + }); + + describe("byte-for-byte restore of user config", () => { + const configPath = () => join(grokHome, "config.toml"); + + const originals: Array<[label: string, content: string]> = [ + ["no trailing newline", 'theme = "dark"'], + ["one trailing newline", 'theme = "dark"\n'], + ["two trailing newlines", 'theme = "dark"\n\n'], + ["three trailing newlines", 'theme = "dark"\n\n\n'], + ["multi-section, no trailing newline", '[a]\nx = 1\n\n[b]\ny = 2'], + ]; + + for (const [label, original] of originals) { + test(`inject + strip restores a config with ${label}`, () => { + writeFileSync(configPath(), original, "utf8"); + + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + stripGrokConfig({ grokHome }); + + expect(readFileSync(configPath(), "utf8")).toBe(original); + }); + } + + test("repeated inject/strip cycles never grow the file", () => { + const original = 'theme = "dark"\n'; + writeFileSync(configPath(), original, "utf8"); + + for (let i = 0; i < 5; i += 1) { + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + stripGrokConfig({ grokHome }); + expect(readFileSync(configPath(), "utf8")).toBe(original); + } + }); + + test("content the user appended after the block is preserved", () => { + const original = 'theme = "dark"\n'; + writeFileSync(configPath(), original, "utf8"); + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + + const withUserTail = `${readFileSync(configPath(), "utf8")}\n[mine]\nkeep = true\n`; + writeFileSync(configPath(), withUserTail, "utf8"); + + stripGrokConfig({ grokHome }); + + expect(readFileSync(configPath(), "utf8")).toBe(`${original}\n[mine]\nkeep = true\n`); + }); + + test("uniform CRLF round-trips byte-for-byte", () => { + // Mixed EOL cannot round-trip by design: applyEol normalizes the whole file to the + // dominant terminator. Uniform CRLF is the contract we hold. + const original = 'theme = "dark"\r\n[a]\r\nx = 1\r\n'; + writeFileSync(configPath(), original, "utf8"); + + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + expect(readFileSync(configPath(), "utf8")).not.toContain("\n\n\r\n"); + stripGrokConfig({ grokHome }); + + expect(readFileSync(configPath(), "utf8")).toBe(original); + }); + }); }); From e610760e03947639cc9fdb66c57f34da94eb5846 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 02:51:18 +0900 Subject: [PATCH 20/22] fix(grok): refuse auto-registration on non-loopback binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-loopback bind needs the real admission token, but a REGENERATED block cannot carry one safely. Writing the literal token puts the user's secret in their own config and overwrites whatever they set on the next start/ensure/restart — the REAL_TOKEN_PRESERVED=false reproduction. Dropping api_key in favour of env_key is worse: with no model_provider to fail closed, an unresolved env_key makes grok fall through to its xAI session bearer and send it to our plaintext LAN base_url (upstream resolve_credentials, config.rs). So we stop auto-registering there. Injection returns skippedReason "non-loopback" with guidance to configure models outside the fence, removes any stale block left from a previous loopback bind, and never touches the file again — so a user-managed api_key now survives repeated syncs. Wildcard binds (0.0.0.0, ::) count as non-loopback: they expose every interface. isLoopbackHostname is exported for this decision because providerBaseHost answers a different question (what to dial). --- src/codex/inject.ts | 8 ++++- src/grok/inject.ts | 28 +++++++++++++-- tests/grok-config-inject.test.ts | 60 ++++++++++++++++++++++++++++++++ tests/grok-sync.test.ts | 22 ++++++++---- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/codex/inject.ts b/src/codex/inject.ts index d168fab820..32c1272aa7 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -64,7 +64,13 @@ export interface InjectCodexOptions { * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider. */ -function isLoopbackHostname(hostname: string | undefined): boolean { +/** + * True only for hostnames that bind loopback ONLY. Wildcard binds ("0.0.0.0", "::") are NOT + * loopback: they expose the proxy on every interface and therefore require the admission token. + * Do not use `providerBaseHost` for this decision — it folds wildcards to 127.0.0.1 because it + * answers "what address do I dial", which is a different question from "is this exposed". + */ +export function isLoopbackHostname(hostname: string | undefined): boolean { const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]"; } diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 22cb652bb6..eef06507fc 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -2,7 +2,7 @@ import { constants, copyFileSync, existsSync, readFileSync, statSync } from "nod import { homedir } from "node:os"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; -import { applyEol, dominantEol, providerBaseHost } from "../codex/inject"; +import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject"; export interface GrokInjectModel { id: string; @@ -14,7 +14,7 @@ export interface GrokInjectResult { ok: boolean; changed: boolean; message: string; - skippedReason?: "no-grok-home" | "orphaned-marker"; + skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback"; } const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; @@ -176,6 +176,30 @@ export function injectGrokConfig( }; } + // Non-loopback binds require the real admission token (src/server/auth-cors.ts), and there is + // no safe way for a REGENERATED block to carry it: a literal token would write the user's + // secret into their own file and overwrite it on every start/ensure/restart, while omitting + // api_key in favour of env_key opens grok's credential fallthrough — with no `model_provider` + // to fail closed, an unresolved env_key makes grok send its xAI session bearer to our + // plaintext LAN endpoint (upstream config.rs resolve_credentials). So we do not auto-register + // at all here; the user configures models manually, outside our fence, where nothing we do + // can clobber their credential. + if (!isLoopbackHostname(opts.hostname)) { + const removed = stripGrokConfig({ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}) }); + const cleanup = removed.changed + ? " Removed the previously generated block, which pointed at a loopback address." + : ""; + return { + ok: true, // a deliberate policy skip, not a failure — it must never block startup + changed: removed.changed, + skippedReason: "non-loopback", + message: `Grok auto-registration skipped: opencodex is bound to the non-loopback host ` + + `"${opts.hostname}", where requests need your admission token. A managed block would ` + + `either store that secret in ~/.grok/config.toml or overwrite it on the next start, so ` + + `add the models yourself OUTSIDE the opencodex markers (see the Grok Build guide).${cleanup}`, + }; + } + const configPath = join(grokHome, "config.toml"); const backupPath = join(grokHome, "config.toml.bak-opencodex"); try { diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index af5c2c0821..d6dc26845d 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -290,4 +290,64 @@ describe("Grok config injection", () => { expect(readFileSync(configPath(), "utf8")).toBe(original); }); }); + + describe("non-loopback binds refuse auto-registration", () => { + const configPath = () => join(grokHome, "config.toml"); + + // A wildcard bind is NOT loopback: it exposes the proxy on every interface, so the data + // plane demands the admission token that a regenerated block cannot safely carry. + for (const hostname of ["0.0.0.0", "::", "[::]", "192.168.1.10", "proxy.lan"]) { + test(`skips injection when bound to ${hostname}`, () => { + const result = injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome, hostname }); + + expect(result).toMatchObject({ ok: true, changed: false, skippedReason: "non-loopback" }); + expect(existsSync(configPath())).toBe(false); + expect(result.message).toContain("admission token"); + }); + } + + for (const hostname of [undefined, "", "127.0.0.1", "localhost", "::1", "[::1]"]) { + test(`still registers when bound to ${hostname === undefined ? "(unset)" : hostname || "(empty)"}`, () => { + const result = injectGrokConfig( + 10100, + [{ id: "gpt-5.6-sol" }], + hostname === undefined ? { grokHome } : { grokHome, hostname }, + ); + + expect(result).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(configPath(), "utf8")).toContain("[model.ocx-gpt-5-6-sol]"); + }); + } + + test("never emits env_key, which would open grok's session-token fallthrough", () => { + // With no `model_provider` to fail closed, an unresolved env_key makes grok fall through + // to its xAI session bearer and send it to whatever base_url we wrote. + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome, hostname: "127.0.0.1" }); + expect(readFileSync(configPath(), "utf8")).not.toContain("env_key"); + }); + + test("removes a stale loopback block when the bind moves to non-loopback", () => { + const userContent = 'theme = "dark"\n'; + writeFileSync(configPath(), userContent, "utf8"); + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome, hostname: "127.0.0.1" }); + expect(readFileSync(configPath(), "utf8")).toContain(BEGIN_MARKER); + + const result = injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome, hostname: "0.0.0.0" }); + + expect(result).toMatchObject({ ok: true, changed: true, skippedReason: "non-loopback" }); + expect(readFileSync(configPath(), "utf8")).toBe(userContent); + }); + + test("leaves a user-managed api_key untouched across repeated syncs", () => { + // The exact scenario the maintainer reproduced as REAL_TOKEN_PRESERVED=false. + const userContent = '[model.mine]\nmodel = "gpt-5.6-sol"\nbase_url = "http://192.168.1.10:10100/v1"\napi_key = "real-admission-token"\n'; + writeFileSync(configPath(), userContent, "utf8"); + + for (let i = 0; i < 3; i += 1) { + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome, hostname: "192.168.1.10" }); + } + + expect(readFileSync(configPath(), "utf8")).toBe(userContent); + }); + }); }); diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index d741f2b45e..a3da662563 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectGrokConfig } from "../src/grok/inject"; @@ -39,16 +39,26 @@ describe("syncGrokConfig", () => { } }); - test("hostname override reaches the generated base_url (ensure live branch)", async () => { + test("the observed bind hostname reaches injection (ensure live branch)", async () => { const { root, grokHome } = tempGrokHome(); try { - await syncGrokConfig(10100, baseConfig, { grokHome, hostname: "0.0.0.0" }, { + // ensure's live branch passes live.hostname — the host the proxy ACTUALLY bound, which + // can differ from a drifted config.hostname. A wildcard bind exposes every interface, so + // injection must refuse rather than write a block that cannot authenticate. + const wildcard = await syncGrokConfig(10100, baseConfig, { grokHome, hostname: "0.0.0.0" }, { fetchAllModels: async () => [], injectGrokConfig, }); - const content = readFileSync(join(grokHome, "config.toml"), "utf8"); - // providerBaseHost maps 0.0.0.0 to a loopback the client can dial. - expect(content).toContain('base_url = "http://127.0.0.1:10100/v1"'); + expect(wildcard).toMatchObject({ ok: true, changed: false, skippedReason: "non-loopback" }); + expect(existsSync(join(grokHome, "config.toml"))).toBe(false); + + const loopback = await syncGrokConfig(10100, baseConfig, { grokHome, hostname: "::1" }, { + fetchAllModels: async () => [], + injectGrokConfig, + }); + expect(loopback).toMatchObject({ ok: true, changed: true }); + expect(readFileSync(join(grokHome, "config.toml"), "utf8")) + .toContain('base_url = "http://[::1]:10100/v1"'); } finally { rmSync(root, { recursive: true, force: true }); } From d736876b075468635377ad2d6c1c35dada3ed0ea Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 03:06:10 +0900 Subject: [PATCH 21/22] fix(lifecycle): keep the Grok fence and the service in agreement on teardown Ownership failures were indistinguishable from stop failures, so `ocx stop` warned and carried on: it stripped the shared ~/.grok fence and restored native Codex while the service installed under another home kept running and respawned the proxy. ServiceOwnershipError now marks that case, handleStop skips shared teardown and reports failure, and the daemon's own exit cleanup applies the same check -- without it, SIGTERM would strip the fence through the path handleStop just declined. Explicit teardown now includes the fence everywhere it should: `ocx service stop`, `ocx service uninstall`, and POST /api/stop all strip it, while the OCX_SERVICE crash/respawn exclusion stays. /api/stop also stops leaking the ownership error as a 500 that left the proxy running; it answers 409, stopProxy refuses to escalate that to SIGTERM, and the dashboard clears its stopping state instead of hanging. Also: guard the service stop on an actual installation (it ran launchctl unload regardless), de-nest the start-time Grok sync from the Desktop-3P try that swallowed catalog failures, fail `ocx stop` when a strip is refused, and return from handleStop rather than exiting so restart and the tray can react. Review fix on the previous commit: the sub-table tail [^\]]* matched newlines, so an unclosed header inside a multiline string swallowed the following lines and hid a real [model.] header -- reintroducing the duplicate-key config B3 exists to prevent. Every class is newline-free now, with a regression for it. --- .../010_config_safety_core.md | 49 ++++-- gui/src/App.tsx | 12 +- src/cli/index.ts | 88 +++++++---- src/grok/inject.ts | 14 +- src/lib/process-control.ts | 27 +++- src/server/management-api.ts | 24 ++- src/service.ts | 46 +++++- tests/grok-config-inject.test.ts | 35 ++++- tests/grok-lifecycle.test.ts | 146 ++++++++++++++++++ 9 files changed, 375 insertions(+), 66 deletions(-) create mode 100644 tests/grok-lifecycle.test.ts diff --git a/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md index cd85daa499..08c3b2fe18 100644 --- a/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md +++ b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md @@ -79,28 +79,51 @@ for (const match of outsideManagedRegion.matchAll(MODEL_HEADER)) { ### 추가 철자 커버리지 (감사 지적) -`tomllib` 실측 결과 아래 두 형태도 우리 블록과 **같은 duplicate-key 실패**를 낸다. 정규식이 -예약하지 못하면 B3가 막으려던 바로 그 사고가 그대로 난다. - -| 사용자 철자 | 우리 블록과의 충돌 | -|-------------|-------------------| -| `[[model.ocx-mine]]` | duplicate key | -| `[model.ocx-mine.sub]` | duplicate key (상위 테이블 충돌) | +`tomllib` 실측 결과는 아래와 같다. **정정(구현 리뷰):** 초판은 `[model.x.sub]`도 충돌한다고 +적었으나 실제로는 충돌하지 않는다. 하위 테이블 선언과 나중의 상위 테이블 헤더는 순서에 무관하게 +유효하다. 예약 자체는 유지하되(비용은 alias 접미사 하나뿐이고 사용자 네임스페이스를 침범하지 +않는 편이 낫다) 근거를 정확히 적는다. + +| 사용자 철자 | 우리 블록과의 충돌 | 예약 | +|-------------|-------------------|------| +| `[[model.ocx-mine]]` | **duplicate key** | 필수 | +| `["model"."ocx-mine"]` | **duplicate key** | 필수 | +| `[model.ocx-mine.sub]` | 충돌 없음 (유효) | 보수적 선택 | +| `[model]` + `ocx-mine.k = 1` | **duplicate key** | 미커버(D7) | +| 루트 `model.ocx-mine.k = 1` | **duplicate key** | 미커버(D7) | 따라서 헤더 정규식을 다음으로 확장한다 — 여는 괄호를 1~2개 허용하고, 두 번째 세그먼트 뒤에 **추가 세그먼트가 이어져도** 두 번째 세그먼트를 예약한다: ```ts const MODEL_HEADER = new RegExp( - String.raw`^\s*\[\[?\s*(${KEY_SEGMENT})\s*\.\s*(${KEY_SEGMENT})\s*(?:\.[^\]]*)?\]\]?\s*(?:#.*)?$`, + String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`, "gm", ); ``` +**모든 문자 클래스는 개행을 배제해야 한다(구현 리뷰에서 발견된 실제 결함).** +초판의 `[^\]]*`는 개행을 포함하므로, 멀티라인 문자열 안의 닫히지 않은 `[model.…` 한 줄이 +뒤따르는 줄들을 통째로 삼켜 **진짜 `[model.]` 헤더를 예약에서 누락**시킨다. +그 결과 우리가 중복 테이블을 방출해 grok이 설정 전체를 거부한다 — B3가 막으려던 사고가 +B3 수정 자체에 의해 재발한다. 재현 입력(유효한 TOML): + +```toml +prompt = """ +[model.a.b +""" + +[model.ocx-mine] +``` + +회귀 테스트로 고정한다. + 세 세그먼트 헤더(`[model.x.y]`)는 이제 `x`를 예약한다. 초판은 이를 "예약하면 안 된다"고 적었으나 -실측상 `[model.x.y]`는 우리의 `[model.x]`와 충돌하므로 **예약이 정답**이다. -루트 dotted 키(`model.ocx-mine.x = 1`)와 `[model]` + dotted 키 형태는 우선순위를 낮춰 남기되, -`000`의 잔여 위험 목록에 기록한다. +충돌하지 않더라도 사용자 네임스페이스를 피하는 편이 안전하므로 **예약을 유지**한다. +루트 dotted 키(`model.ocx-mine.x = 1`)와 `[model]` + dotted 키 형태는 실제로 충돌하지만 +우선순위를 낮춰 `000`의 잔여 위험(D7)으로 남긴다. +멀티라인 문자열 안의 헤더가 헛되이 예약되는 경우도 같은 성격의 잔여 위험이며, 비용은 alias +접미사 하나뿐이다. 여는/닫는 괄호 개수 불일치(`[model.x]]`)는 어차피 유효하지 않은 TOML이며, 예약은 보수적 방향이라 무해하다. @@ -153,7 +176,9 @@ inject/strip 반복까지 실행 추적으로 검증했다 — 불일치 0, 드 **감수하는 변화 두 가지(문서에 명시한다):** - 개행 없이 끝나던 파일은 이제 fence 앞에 빈 줄이 생기지 않는다(외형만 달라짐). -- 이미 배포된 코드가 쓴 기존 블록은 (c) 의미로 복원된다 — 이전 형식과 호환되며 손실이 없다. +- 이미 배포된 코드가 쓴 기존 블록은 (c) 의미로 복원된다. 구현 리뷰 실측: 개행 하나/둘로 끝나던 + 원문은 정확히 복원되고, 개행 없이 끝나던 원문만 개행 하나가 남는다. 사용자 바이트를 **먹는** + 방향이 아니라 **더하는** 방향이므로 안전하다. ### 회귀 테스트 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index cb0ff4195a..6295d3eece 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -178,7 +178,17 @@ export default function App() { const handleStop = async () => { if (!confirm(t("dash.stopConfirm"))) return; setStopping(true); - try { await fetch(`${API_BASE}/api/stop`, { method: "POST" }); } catch { /* connection drops */ } + try { + const res = await fetch(`${API_BASE}/api/stop`, { method: "POST" }); + // A refusal (409: a service under another home owns this proxy) returns normally instead + // of dropping the connection, so the button would otherwise sit in "stopping…" forever + // with nothing explaining why. + if (!res.ok) { + setStopping(false); + const detail = await res.json().catch(() => null) as { message?: string } | null; + if (detail?.message) alert(detail.message); + } + } catch { /* connection drops — the proxy is going down as expected */ } }; const brand = ( diff --git a/src/cli/index.ts b/src/cli/index.ts index 907d727b89..72df38f113 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,7 +29,7 @@ import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSele import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import { stopProxy } from "../lib/process-control"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; -import { diagnoseService, serviceCommand, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; +import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service"; import { startupHealthSummary } from "../codex/autostart-health"; import { drainAndShutdown, startServer } from "../server"; import { injectSystemEnv, revertSystemEnv } from "../server/system-env"; @@ -209,7 +209,13 @@ async function handleStart(options: { block?: boolean } = {}) { if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } } - if (!process.env.OCX_SERVICE) { try { stripGrokConfig(); } catch { /* best-effort restore */ } } + // Same ownership rule as `ocx stop`: if the installed service belongs to another home, the + // Grok fence is shared state we must not remove — that service keeps running and would be + // left pointing nowhere. This guard also covers signal-driven exits, which is the path that + // would otherwise bypass handleStop's gate entirely. + if (!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) { + try { stripGrokConfig(); } catch { /* best-effort restore */ } + } }; let shuttingDown = false; @@ -267,16 +273,18 @@ async function handleStart(options: { block?: boolean } = {}) { [...visibleNativeSlugs(config)], models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), ); - // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an - // installed grok CLI can pick opencodex-routed models without manual config. No-op - // when ~/.grok is absent; removed again by stop/eject/uninstall/shutdown. - try { - const { syncGrokConfig } = await import("../grok/sync"); - const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); - if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); - else if (!r.ok) console.error(`⚠️ ${r.message}`); - } catch { /* best-effort — grok integration must never block startup */ } } catch { /* best-effort — registry rebuilds on first /v1/models call */ } + // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed + // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is + // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown. + // Deliberately a SIBLING of the Desktop-3P block above: nesting it there meant a catalog + // failure skipped the fence entirely, even though syncGrokConfig handles that case itself. + try { + const { syncGrokConfig } = await import("../grok/sync"); + const r = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); + if (r.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!r.ok) console.error(`⚠️ ${r.message}`); + } catch { /* best-effort — grok integration must never block startup */ } if (options.block ?? true) { setInterval(() => {}, 60_000); await new Promise(() => {}); @@ -382,19 +390,26 @@ async function handleTrayProxyRestart(): Promise { } async function handleStop() { - // Service-manager stop can throw (e.g. OPENCODEX_HOME mismatch guard). That must not - // abort the rest of the teardown — proxy stop, Codex restore, env revert, and the - // Grok config strip below all still need to run. let stopFailed = false; let stoppedService = false; + // An ownership mismatch means the service manager was never even contacted: the installed + // service is still live and will respawn the proxy. Tearing down SHARED state in that + // situation (native Codex config, the Grok fence) removes config out from under a running + // service — the exact failure this flag prevents. A plain stop failure is different: we + // tried, so local teardown still proceeds. + let ownershipBlocked = false; try { stoppedService = stopServiceIfInstalled(); if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); } catch (err) { - // Service-manager failure (e.g. OPENCODEX_HOME mismatch guard) is warned but does not - // fail the whole stop: the local proxy/codex/grok teardown below is independent, and - // `ocx restart` must still be able to proceed to its ensure step. - console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); + if (isServiceOwnershipError(err)) { + ownershipBlocked = true; + stopFailed = true; + console.error(`❌ ${err.message}`); + console.error(" Skipping shared teardown (native Codex restore, Grok config): the installed service is still running."); + } else { + console.error(`⚠️ Service manager stop failed: ${err instanceof Error ? err.message : String(err)}`); + } } const pid = readPid(); @@ -437,18 +452,27 @@ async function handleStop() { removeRuntimePortIfPidIs(staleRuntimePid); } } - const r = restoreNativeCodex(); - console.log(`↩️ ${r.message}`); - // Safety net: revert system env vars even if the daemon's syncCleanup didn't run - // (e.g. SIGKILL). revertSystemEnv is ownership-checked and idempotent. + if (!ownershipBlocked) { + const r = restoreNativeCodex(); + console.log(`↩️ ${r.message}`); + } + // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl + // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL). try { revertSystemEnv(); } catch { /* best-effort */ } - // Same safety net for the Grok Build managed block (marker-owned, idempotent). - try { - const g = stripGrokConfig(); - if (g.changed) console.log(`↩️ ${g.message}`); - else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch { /* best-effort */ } - if (stopFailed) process.exit(1); + if (!ownershipBlocked) { + // Same safety net for the Grok Build managed block (marker-owned, idempotent). + try { + const g = stripGrokConfig(); + if (g.changed) console.log(`↩️ ${g.message}`); + // A refused strip (e.g. orphaned marker) leaves the fence pointing at a dead proxy — + // reporting success there hides a broken end state. + else if (!g.ok) { stopFailed = true; console.error(`⚠️ ${g.message}`); } + } catch { /* best-effort */ } + } + // Set the code rather than exiting inline: `restart` and the tray coordinator call this + // function and need it to RETURN so they can decide what to do next. + if (stopFailed) process.exitCode = 1; + return !stopFailed; } async function handleUninstall() { @@ -793,8 +817,10 @@ switch (command) { break; } case "restart": { - await handleStop(); - await handleEnsure(); + // A failed stop must not be followed by a re-inject: with a foreign service still running + // (ownership mismatch) we would rewrite shared config we just declined to touch. + if (await handleStop()) await handleEnsure(); + else console.error("↩️ Restart aborted: the proxy was not stopped cleanly."); break; } case "health": { diff --git a/src/grok/inject.ts b/src/grok/inject.ts index eef06507fc..c8a0944f58 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -62,12 +62,18 @@ function findManagedRegion(content: string): ManagedRegion | null { const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`; /** * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table - * (`[model.x.sub]`) spellings: both collide with a generated `[model.x]` and a single collision - * makes grok reject the ENTIRE config layer ("duplicate key"), taking every unrelated user - * setting with it. Reserving conservatively is always cheaper than that failure. + * (`[model.x.sub]`) spellings. `[[model.x]]` genuinely collides with a generated `[model.x]`, + * and one collision makes grok reject the ENTIRE config layer ("duplicate key"), taking every + * unrelated user setting with it; `[model.x.sub]` does not strictly collide, but reserving it + * costs only a suffixed alias and keeps us clear of the user's namespace. + * + * Every character class here is newline-free ON PURPOSE. With `[^\]]*` the optional sub-table + * tail runs past the end of its own line, so an unclosed `[model.…` inside a multiline string + * swallows the following lines — including a real `[model.]` header, which then goes + * unreserved and produces the very duplicate-key config this scan exists to prevent. */ const MODEL_TABLE_HEADER = new RegExp( - String.raw`^\s*\[\[?\s*(${KEY_SEGMENT})\s*\.\s*(${KEY_SEGMENT})\s*(?:\.[^\]]*)?\]\]?\s*(?:#.*)?$`, + String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`, "gm", ); diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 329a1fdb40..2cb3e57881 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -45,15 +45,22 @@ export function gracefulStopHost(hostname: string | undefined): string { return trimmed.includes(":") ? `[${trimmed}]` : trimmed; } +/** + * Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered + * that it must NOT be stopped from here, so callers must not escalate to a forced kill. + */ +export type GracefulStopResult = boolean | "refused"; + /** * Ask a running proxy to stop itself via the management API (`POST /api/stop`), which * drains in-flight turns, restores native Codex, and cleans its pid/runtime files. * This is the only way to get a GRACEFUL stop on Windows, where the POSIX * SIGTERM-then-SIGKILL ladder does not exist and `taskkill /F` gives the proxy no * chance to run its shutdown handlers. Returns false when the proxy can't be reached - * or doesn't exit in time — callers fall back to {@link killProxy}. + * or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"` + * when the proxy declines the stop (HTTP 409), which callers must NOT force past. */ -export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise { +export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise { const readRuntime = io.readRuntime ?? readRuntimePort; const runtime = readRuntime(pid); if (!runtime?.port) return false; @@ -69,6 +76,11 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): headers, signal: AbortSignal.timeout(2000), }); + // 409 is the proxy REFUSING to stop (a service installed under another home owns it and + // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to + // SIGTERM here would run the daemon's cleanup and strip shared config out from under the + // still-running service. Report the refusal instead of forcing. + if (res.status === 409) return "refused"; if (!res.ok) return false; } catch { return false; @@ -91,7 +103,16 @@ function drainDeadlineMs(): number { /** Graceful-first stop: management-API drain, then the platform kill ladder. */ export async function stopProxy(pid: number): Promise { if (!isProcessAlive(pid)) return; - if (await stopProxyGracefully(pid)) return; + const graceful = await stopProxyGracefully(pid); + if (graceful === "refused") { + // The proxy refused on purpose (foreign service owns it). Forcing would strip shared + // config while that service keeps the proxy alive. + throw new Error( + "The running proxy refused to stop: a service installed under a different " + + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.", + ); + } + if (graceful) return; killProxy(pid); } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 44ca3365e2..9587f1f2c6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -135,16 +135,32 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon if (url.pathname === "/api/stop" && req.method === "POST") { const { restoreNativeCodex } = await import("../codex/inject"); - const { stopServiceIfInstalled } = await import("../service"); - stopServiceIfInstalled(); + const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service"); + try { + stopServiceIfInstalled(); + } catch (err) { + if (isServiceOwnershipError(err)) { + // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn + // this proxy immediately, and its shared config is not ours to tear down. Refuse the + // stop instead of half-performing it. 409, not 500 — the request is well-formed. + return jsonResponse({ success: false, message: err.message }, 409, req, config); + } + throw err; + } const restore = restoreNativeCodex(); + // Both managed configs come down together on an explicit teardown. The daemon's own + // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence), + // which is exactly why an intentional stop has to do it here. + const { stripGrokConfig } = await import("../grok/inject"); + const grok = stripGrokConfig(); setTimeout(async () => { await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000); process.exit(0); }, 200); + const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`; return jsonResponse(restore.success - ? { success: true, message: "Proxy stopping, native Codex restored." } - : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.` }); + ? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` } + : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` }); } if (url.pathname.startsWith("/api/codex-auth/")) { diff --git a/src/service.ts b/src/service.ts index ea65c1958a..c1888821bd 100644 --- a/src/service.ts +++ b/src/service.ts @@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path"; import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config"; import { loadConfig } from "./config"; import { restoreNativeCodex } from "./codex/inject"; +import { stripGrokConfig } from "./grok/inject"; import { isWslRuntime } from "./codex/home"; import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; @@ -160,13 +161,42 @@ export function serviceReinstallArgs(): string[] { return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"]; } +/** + * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not + * touch it. Distinct from "stop failed": the manager was never even contacted, which means the + * installed service is still live and shared state (native Codex config, the Grok fence) must be + * left alone — tearing it down would strip config out from under a running service. + */ +export class ServiceOwnershipError extends Error { + readonly code = "service-ownership-mismatch" as const; +} + +export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError { + return err instanceof ServiceOwnershipError; +} + +/** + * True when no installed service exists, or the installed one belongs to THIS + * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state + * (native Codex config, the Grok fence) that a foreign service would still be relying on. + */ +export function serviceEnvironmentOwnedHere(): boolean { + try { + assertServiceEnvironmentMatchesInstall(); + return true; + } catch (err) { + if (isServiceOwnershipError(err)) return false; + return true; // unrelated failure: fall back to the previous behavior rather than wedging + } +} + export function assertServiceEnvironmentMatchesInstall(): void { const state = readServiceInstallState(); if (!state) return; const expected = normalizePathForCompare(state.codexHome); const actual = normalizePathForCompare(currentCodexHome()); if (expected !== actual) { - throw new Error( + throw new ServiceOwnershipError( `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` + "Run the service command from the same Codex home so native Codex restore updates the correct config.", ); @@ -174,7 +204,7 @@ export function assertServiceEnvironmentMatchesInstall(): void { const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome); const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome()); if (expectedOpenCodexHome !== actualOpenCodexHome) { - throw new Error( + throw new ServiceOwnershipError( `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` + "Run the service command from the same OpenCodex home so service state and secrets match.", ); @@ -1148,12 +1178,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { expect(written.slice(written.indexOf(BEGIN_MARKER))).toContain("[model.ocx-mine]"); }); + test("an unclosed header inside a multiline string does not swallow the next real header", () => { + // The sub-table tail must not run past its own line. When it did, this valid TOML made the + // scan reserve "a" and miss ocx-mine entirely, so we emitted a duplicate [model.ocx-mine] + // and grok rejected the whole config layer. + const userContent = 'prompt = """\n[model.a.b\n"""\n\n[model.ocx-mine]\nmodel = "user/keeps-this"\n'; + writeFileSync(configPath(), userContent, "utf8"); + + injectGrokConfig(10100, [{ id: "mine" }], { grokHome }); + + const generated = readFileSync(configPath(), "utf8"); + expect(generated.slice(generated.indexOf(BEGIN_MARKER))).toContain("[model.ocx-mine-2]"); + expect(generated.match(/^\[model\.ocx-mine\]$/gm)).toHaveLength(1); + }); + test("generated aliases never contain a dot", () => { // A bare [model.grok-4.5] header is a THREE-segment key path, not the id "grok-4.5". injectGrokConfig(10100, [{ id: "xai/grok-4.5" }, { id: "a.b.c" }], { grokHome }); @@ -254,13 +268,16 @@ describe("Grok config injection", () => { } test("repeated inject/strip cycles never grow the file", () => { - const original = 'theme = "dark"\n'; - writeFileSync(configPath(), original, "utf8"); - - for (let i = 0; i < 5; i += 1) { - injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); - stripGrokConfig({ grokHome }); - expect(readFileSync(configPath(), "utf8")).toBe(original); + // Deliberately the shapes that the previous separator rule got wrong: a file with no + // terminator, and a multi-section file. A file already ending in one newline round-tripped + // even before the fix, so it cannot detect drift. + for (const original of ['theme = "dark"', '[a]\nx = 1\n\n[b]\ny = 2']) { + writeFileSync(configPath(), original, "utf8"); + for (let i = 0; i < 5; i += 1) { + injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); + stripGrokConfig({ grokHome }); + expect(readFileSync(configPath(), "utf8")).toBe(original); + } } }); @@ -284,7 +301,9 @@ describe("Grok config injection", () => { writeFileSync(configPath(), original, "utf8"); injectGrokConfig(10100, [{ id: "gpt-5.6-sol" }], { grokHome }); - expect(readFileSync(configPath(), "utf8")).not.toContain("\n\n\r\n"); + // After CRLF normalization every \n carries a \r, so asserting on a bare "\n\n" could + // never fail. The real question is whether a blank CRLF line crept in before the fence. + expect(readFileSync(configPath(), "utf8")).not.toContain("\r\n\r\n\r\n"); stripGrokConfig({ grokHome }); expect(readFileSync(configPath(), "utf8")).toBe(original); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts new file mode 100644 index 0000000000..400c865ad8 --- /dev/null +++ b/tests/grok-lifecycle.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isServiceOwnershipError, ServiceOwnershipError } from "../src/service"; + +const CLI_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); +const SERVICE_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); +const MANAGEMENT_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "server", "management-api.ts"), "utf8"); +const PROCESS_CONTROL_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "lib", "process-control.ts"), "utf8"); + +function sliceFn(source: string, start: string, end: string): string { + const from = source.indexOf(start); + expect(from).toBeGreaterThan(-1); + const to = source.indexOf(end, from); + expect(to).toBeGreaterThan(from); + return source.slice(from, to); +} + +// `src/cli/index.ts` runs its command switch on import, so the handlers cannot be called from a +// test. Wiring assertions therefore read the source — the house pattern established by +// tests/stale-state-purge.test.ts and tests/uninstall.test.ts. +describe("Grok fence lifecycle wiring", () => { + test("handleStart syncs the Grok fence outside the Desktop-3P try", () => { + const startFn = sliceFn(CLI_SOURCE, "async function handleStart(", "async function handleEnsure("); + const registryAt = startFn.indexOf("buildDesktop3pRegistry("); + const registryCatchAt = startFn.indexOf("/* best-effort — registry rebuilds on first /v1/models call */", registryAt); + const grokSyncAt = startFn.indexOf('await import("../grok/sync")'); + + expect(registryCatchAt).toBeGreaterThan(registryAt); + // Nested inside the registry try, a catalog throw skipped the fence entirely. + expect(grokSyncAt).toBeGreaterThan(registryCatchAt); + }); + + test("ensure passes the observed bind host on the live branch and the configured host after spawning", () => { + const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); + const liveBranch = ensureFn.slice(0, ensureFn.indexOf("const pinPort")); + const spawnBranch = ensureFn.slice(ensureFn.indexOf("const pinPort")); + + // live.hostname is what the proxy ACTUALLY bound; config.hostname may have drifted. + expect(liveBranch).toContain("live.hostname ? { hostname: live.hostname }"); + expect(spawnBranch).toContain("config.hostname ? { hostname: config.hostname }"); + }); + + test("handleStop gates shared teardown on ownership but still reverts system env", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + + expect(stopFn).toContain("isServiceOwnershipError(err)"); + expect(stopFn).toContain("ownershipBlocked = true"); + + const gateAt = stopFn.indexOf("if (!ownershipBlocked)"); + const stripAt = stopFn.indexOf("stripGrokConfig()"); + const restoreAt = stopFn.indexOf("restoreNativeCodex()"); + const revertAt = stopFn.indexOf("revertSystemEnv()"); + + expect(gateAt).toBeGreaterThan(-1); + expect(stripAt).toBeGreaterThan(gateAt); + expect(restoreAt).toBeGreaterThan(gateAt); + // revertSystemEnv carries its own ownership check and concerns launchctl env, not + // CODEX_HOME — gating it too would be over-broad. + expect(stopFn.slice(revertAt - 200, revertAt)).toContain("NOT gated"); + }); + + test("a refused Grok strip makes ocx stop fail instead of reporting success", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + expect(stopFn).toContain("else if (!g.ok) { stopFailed = true;"); + }); + + test("handleStop returns its outcome so restart and the tray can react", () => { + const stopFn = sliceFn(CLI_SOURCE, "async function handleStop(", "async function handleUninstall("); + // process.exit() inside handleStop would strand runTrayProxyRestart's start() half. + expect(stopFn).toContain("process.exitCode = 1"); + expect(stopFn).toContain("return !stopFailed"); + expect(stopFn).not.toContain("process.exit(1)"); + + const restartCase = sliceFn(CLI_SOURCE, 'case "restart"', 'case "health"'); + expect(restartCase).toContain("if (await handleStop()) await handleEnsure()"); + }); + + test("the daemon's exit cleanup keeps the OCX_SERVICE exclusion and adds the ownership check", () => { + const startFn = sliceFn(CLI_SOURCE, "const syncCleanup = () => {", "let shuttingDown = false;"); + // Crash/respawn under a service manager must still keep the fence. + expect(startFn).toContain("!process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()"); + }); +}); + +describe("service teardown owns both managed configs", () => { + test("service stop strips the Grok fence and guards the platform stop on installation", () => { + const stopCase = sliceFn(SERVICE_SOURCE, 'case "stop":', 'case "status":'); + expect(stopCase).toContain("assertServiceEnvironmentMatchesInstall()"); + // An unguarded ops.stop() ran a real launchctl unload even with nothing installed. + expect(stopCase).toContain("isServiceInstalled()"); + expect(stopCase).toContain("stripGrokConfig()"); + }); + + test("service uninstall strips the Grok fence too", () => { + const uninstallCase = sliceFn(SERVICE_SOURCE, 'case "uninstall":', " default:"); + expect(uninstallCase).toContain("stripGrokConfig()"); + expect(uninstallCase).toContain("removeServiceInstallState()"); + }); +}); + +describe("ownership errors are distinguishable", () => { + test("ownership mismatch is its own error type, plain failures are not", () => { + expect(isServiceOwnershipError(new ServiceOwnershipError("mismatch"))).toBe(true); + // Misclassifying an ordinary stop failure would block teardown that is safe to run. + expect(isServiceOwnershipError(new Error("launchctl exited 1"))).toBe(false); + expect(isServiceOwnershipError("not an error")).toBe(false); + }); + + test("the guard still throws the documented message", () => { + expect(new ServiceOwnershipError("Service was installed with CODEX_HOME=/a").message) + .toContain("Service was installed with CODEX_HOME"); + }); +}); + +describe("POST /api/stop teardown", () => { + test("refuses with 409 on ownership mismatch instead of throwing a 500", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + + expect(handler).toContain("isServiceOwnershipError(err)"); + expect(handler).toContain("}, 409, req, config)"); + // The refusal must return BEFORE the shutdown is scheduled: a refused stop keeps running. + const refusalAt = handler.indexOf("409"); + const shutdownAt = handler.indexOf("drainAndShutdown"); + expect(refusalAt).toBeLessThan(shutdownAt); + }); + + test("strips the Grok fence on an accepted stop", () => { + const handler = sliceFn(MANAGEMENT_SOURCE, '"/api/stop"', "/api/codex-auth/"); + expect(handler).toContain('await import("../grok/inject")'); + expect(handler).toContain("stripGrokConfig()"); + }); + + test("a 409 does not escalate to a forced kill", () => { + // Escalating would run the daemon's cleanup and strip shared config while the foreign + // service keeps the proxy alive — the exact hole the ownership gate exists to close. + expect(PROCESS_CONTROL_SOURCE).toContain('if (res.status === 409) return "refused"'); + + const stopProxyFn = sliceFn(PROCESS_CONTROL_SOURCE, "export async function stopProxy(", "export function killProxy("); + const refusedAt = stopProxyFn.indexOf('graceful === "refused"'); + const killAt = stopProxyFn.indexOf("killProxy(pid)"); + expect(refusedAt).toBeGreaterThan(-1); + expect(refusedAt).toBeLessThan(killAt); + expect(stopProxyFn).toContain("throw new Error("); + }); +}); From 092dd749102ae568b11c89b9c8e3a57bfb2b877d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 26 Jul 2026 03:09:36 +0900 Subject: [PATCH 22/22] docs(grok): state what the integration actually does on non-loopback and reload The guide told users to swap api_key on a non-loopback bind, which no longer matches the code (auto-registration is refused there) and never addressed the unreachable 127.0.0.1 base_url. It now explains why the fence is loopback-only, gives a manual recipe with a reachable host and the admission token, and warns against env_key -- an unresolved one makes Grok fall through to the xAI session token and send it to whatever base_url the entry names. The reload note claimed hot reload without a version guarantee. Grok does watch config.toml and reload on a real [model] change, so the note now describes the observed mechanism, points at grok inspect for verification (config sources and rejected fields, not a model list), and records that one TOML error invalidates the whole user layer -- which is why we write atomically. Also corrects devlog 020, which recommended the responses backend against its own receipt: Grok's Responses event enum has no catch-all, so an unknown top-level type ends the turn with a non-retryable Serialization error. --- .../020_docs_and_residual_smoke.md | 12 ++++- .../src/content/docs/guides/grok-build.md | 45 ++++++++++++++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md b/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md index b9a14f7335..db31b304f7 100644 --- a/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md +++ b/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md @@ -2,13 +2,23 @@ Precondition: wp1 (usage details 상시 방출) 완료, 3-way 매트릭스 exit 0. +> **정정 2026-07-26.** 아래 "백엔드 선택"의 `responses` 권장은 같은 유닛의 +> `011_receipt.md:53-55`(grok이 `response.heartbeat`에서 종료)와 모순됐고, 이후 구현은 그래서 +> `chat_completions`를 골랐다. 권장을 `chat_completions`로 정정하고 `responses`는 알려진 한계로 +> 기록한다. 근거는 추측이 아니라 원본이다: grok의 Responses 이벤트 열거형(async-openai 포크 +> `95b52ebd`)에는 catch-all 변형이 없고, 알 수 없는 최상위 `type` 태그는 +> `SamplingError::Serialization`으로 분류되어 **재시도 없이** 턴을 끝낸다. 삼켜지는 비표준 +> 이벤트는 doom-loop 체크 하나뿐이다. 상세: `devlog/_plan/260726_grok_build_prod/001` E5. + ## Deliverables ### 1. 사용자 문서 (docs-site) - 위치 후보: `docs-site` 내 클라이언트 연동 섹션 (기존 Claude Code / Copilot 인바운드 문서 형식 답습 — P에서 실제 경로 확인). - 내용: - 권장 config: `[model_providers.opencodex]` 블록 + 얇은 `[model.]` 목록 (Sol 분석의 신규 표면 활용, grok 0.2.101+). - - 백엔드 선택: `responses` 권장 (wp1 이후 완전 동작; harness가 내부적으로 Responses vocab을 쓰는 경로 존재), `chat_completions`도 동작. + - 백엔드 선택: **`chat_completions` 권장**. `responses`는 우리가 keep-alive로 보내는 + `response.heartbeat`를 grok이 역직렬화하지 못해 턴이 중단되므로 알려진 한계로 문서화한다. + (수용값은 `chat_completions`(기본), `responses`, `messages` 셋이다.) - 인증: loopback은 아무 non-empty `XAI_API_KEY`/api_key로 충분(ocx가 무시), non-loopback은 ocx admission token. - 카탈로그: `GROK_MODELS_BASE_URL=http://127.0.0.1:10100/v1`. - 한계: grok 하니스 시스템 프롬프트는 xAI 톤 전제, 모델별 tool 동작 차이. diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index dd17db0417..08ad9f9f0e 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -50,8 +50,23 @@ grok -m ocx-anthropic-claude-opus-4-8 -p "hello" Grok Build requires a non-empty API key for custom models even on loopback. The injected entries carry a placeholder (`opencodex-loopback`) — opencodex ignores admission keys for -loopback connections, so no real secret is involved. If you bind the proxy on a -non-loopback host, replace the per-model `api_key` with your opencodex admission token. +loopback connections, so no real secret is involved. + +**Auto-registration is loopback-only.** When opencodex binds a non-loopback host — including +the wildcards `0.0.0.0` and `::`, which expose every interface — requests need your real +admission token, and a managed block cannot carry one safely. Writing the literal token would +put your secret into `~/.grok/config.toml` and overwrite whatever you set there on the next +`ocx start`/`ensure`/`restart`. So opencodex writes nothing at all in that case (and removes +any block left over from an earlier loopback bind), and you configure the models yourself +outside the managed markers, where nothing opencodex does can clobber them. See +[Manual recipe](#manual-recipe-without-auto-registration) for the exact table, and set both +`base_url` (a host that is actually reachable from where you run `grok`) and `api_key` +(your `OPENCODEX_API_AUTH_TOKEN`). + +Do not replace `api_key` with `env_key` here. With no `model_provider` set, an `env_key` +that fails to resolve does not stop the request — Grok falls through to your xAI session +token and sends it to whatever `base_url` the entry names, which for a LAN deployment is a +plaintext HTTP endpoint that is not xAI. The injected per-model `api_key` sits first in Grok's credential chain for these models, so turns against opencodex need no additional Grok login. Keep your normal `grok login` / @@ -60,7 +75,8 @@ directly. ## Manual recipe (without auto-registration) -If you manage `~/.grok/config.toml` yourself, add per-model tables with **direct fields**: +If you manage `~/.grok/config.toml` yourself — or opencodex is on a non-loopback bind — add +per-model tables with **direct fields**, outside the `# >>> opencodex managed block` markers: ```toml [model.ocx-opus] @@ -70,11 +86,25 @@ api_backend = "chat_completions" api_key = "opencodex-loopback" ``` +For a proxy reachable over the network, point `base_url` at the address `grok` can actually +dial and use your admission token: + +```toml +[model.ocx-opus] +model = "anthropic/claude-opus-4-8" +base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 +api_backend = "chat_completions" +api_key = "your-OPENCODEX_API_AUTH_TOKEN" +``` + Do not rely on `[model_providers.]` inheritance for the endpoint: as of Grok Build 0.2.101 the inherited `base_url` is not applied to inference routing (requests fall through to the default xAI proxy and fail with 401). Direct per-model fields route correctly. +Quote any alias containing a dot: bare `[model.grok-4.5]` is a three-segment key path, not +the id `grok-4.5`. Generated aliases avoid dots entirely for this reason. + ## Known limitations - **Responses backend and keep-alives:** opencodex emits a `response.heartbeat` keep-alive @@ -88,7 +118,12 @@ correctly. `ocx service` setup, and if that unmanaged process dies the managed block can point at a dead proxy until the next `ocx start`/`ocx ensure` refreshes it. - **Config read timing:** start opencodex first, then launch `grok` for the most - predictable results. Recent Grok Build versions watch `config.toml` and hot-reload - `[model.*]` changes into an open session; older builds may need a restart. + predictable results. Grok Build watches `~/.grok/config.toml` and reloads when the + `[model]` table actually changes (roughly a one-second debounce, compared by content), so + a refreshed block reaches an open session without a restart. To confirm what Grok parsed, + run `grok inspect`: it lists the config sources it loaded and warns about any field it + rejected. It does not print the resolved model list. Note that a single TOML error + invalidates the *entire* user config layer, which is why opencodex writes the file + atomically — Grok never sees a half-written config. - **Catalog updates:** the fenced block reflects the catalog at injection time. After adding providers or models, run `ocx ensure` (or restart the proxy) to refresh it.