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"} 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/011_receipt.md b/devlog/_plan/260723_grok_build_bridge/011_receipt.md new file mode 100644 index 0000000000..630627d0be --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/011_receipt.md @@ -0,0 +1,55 @@ +# 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}`. + +## 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/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..db31b304f7 --- /dev/null +++ b/devlog/_plan/260723_grok_build_bridge/020_docs_and_residual_smoke.md @@ -0,0 +1,37 @@ +# 020 — wp2: Grok Build 연동 문서화 + 잔여 스모크 + +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+). + - 백엔드 선택: **`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 동작 차이. +- 번역 로케일 모순 없는지 확인 (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. 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 미관. 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 검증 완료된 건) 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 마감 시 최종 캡처) 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..b6c9baebb0 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/000_blocker_inventory.md @@ -0,0 +1,137 @@ +--- +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 진실 정렬 | + +## 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/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..ce160cbe8d --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/001_grok_source_evidence.md @@ -0,0 +1,136 @@ +--- +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에서 토큰 획득 | 파일/회전 토큰용 | + +### 정정 (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 부분 반박) + +`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.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 new file mode 100644 index 0000000000..08c3b2fe18 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/010_config_safety_core.md @@ -0,0 +1,196 @@ +--- +created: 2026-07-26 +status: plan +phase: wp1 +blockers: [B3, B5] +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. + +## 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 함정 고정). +5. `[[model.ocx-gpt-5]]` (배열 테이블) 예약됨. +6. `[model.ocx-gpt-5.sub]` (하위 테이블) 예약됨. + +### 추가 철자 커버리지 (감사 지적) + +`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`^[ \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`를 예약한다. 초판은 이를 "예약하면 안 된다"고 적었으나 +충돌하지 않더라도 사용자 네임스페이스를 피하는 편이 안전하므로 **예약을 유지**한다. +루트 dotted 키(`model.ocx-mine.x = 1`)와 `[model]` + dotted 키 형태는 실제로 충돌하지만 +우선순위를 낮춰 `000`의 잔여 위험(D7)으로 남긴다. +멀티라인 문자열 안의 헤더가 헛되이 예약되는 경우도 같은 성격의 잔여 위험이며, 비용은 alias +접미사 하나뿐이다. + +여는/닫는 괄호 개수 불일치(`[model.x]]`)는 어차피 유효하지 않은 TOML이며, 예약은 보수적 방향이라 +무해하다. + +## B5 — 주입 구분자의 정확한 복원 + +### 현재 + +inject: `const separator = content.endsWith("\n") ? "\n" : "\n\n";` +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 — 구분자를 항상 정확히 하나로: + +```ts +// 항상 개행 하나만 넣는다. 사용자 파일의 종결 상태가 그대로 보존되므로 +// strip이 우리가 넣은 개행을 모호함 없이 되돌릴 수 있다. +const separator = "\n"; +``` + +strip — 두 규칙으로 대칭 복원: + +```ts +let prefix = content.slice(0, region.start); +const restOfFile = content.slice(removalEnd); +// 개행으로 끝나던 파일에 붙인 구분자. +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. + +**감수하는 변화 두 가지(문서에 명시한다):** +- 개행 없이 끝나던 파일은 이제 fence 앞에 빈 줄이 생기지 않는다(외형만 달라짐). +- 이미 배포된 코드가 쓴 기존 블록은 (c) 의미로 복원된다. 구현 리뷰 실측: 개행 하나/둘로 끝나던 + 원문은 정확히 복원되고, 개행 없이 끝나던 원문만 개행 하나가 남는다. 사용자 바이트를 **먹는** + 방향이 아니라 **더하는** 방향이므로 안전하다. + +### 회귀 테스트 + +1. 개행 없이 끝나는 원문 → inject → strip → **원문과 바이트 동일**. +2. 개행 하나로 끝나는 원문 → 왕복 후 바이트 동일 (기존 테스트 강화). +3. 개행 두 개로 끝나는 원문 → 왕복 후 바이트 동일 (사용자 개행을 삼키지 않음). +4. 블록 뒤에 사용자 섹션이 있는 경우(중간 삽입) → 왕복 후 바이트 동일. +5. inject/strip을 **5회 반복**해도 파일이 자라지 않는다 (드리프트 가드). +6. **균일** CRLF 원문에서 1–4 반복. + 혼합 EOL은 `applyEol`이 정규화하므로 byte-for-byte 왕복이 설계상 불가능하다 — + 이 정규화는 의도된 동작이며 테스트는 균일 CRLF로 한정한다. + +## 게이트 + +`bun test tests/grok-config-inject.test.ts` → `bun run typecheck`. 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 new file mode 100644 index 0000000000..2454cdbedc --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/020_nonloopback_auth.md @@ -0,0 +1,110 @@ +--- +created: 2026-07-26 +status: plan +phase: wp2 +blockers: [B1] +tags: [grok-build, auth, non-loopback, security] +--- + +# 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. + +## 왜 자동 등록을 거부하는가 + +비루프백 바인드에서 우리가 취할 수 있는 선택지는 셋뿐이고, 셋 다 문제가 있거나 우리 권한 밖이다. + +| 선택지 | 결과 | +|--------|------| +| 리터럴 실토큰을 fence에 쓴다 | 사용자 소유 파일에 비밀을 기록하고, 매 sync마다 사용자가 고친 값을 덮어쓴다 (현재 버그) | +| `env_key`만 쓰고 `api_key`를 뺀다 | 변수 미설정 시 grok이 **xAI 세션 토큰을 평문 LAN 주소로 전송** (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. `injectGrokConfig`가 비루프백에서 거부한다 + +거부는 `buildGrokManagedBlock`이 아니라 **파일을 건드리기 전** `injectGrokConfig` 초입에서 한다. +새 `skippedReason`을 추가한다: + +```ts +skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback"; +``` + +```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.`, + }; +} +``` + +`ok: true`인 이유: 이것은 오류가 아니라 정책이다. CLI의 `else if (!r.ok)` 경로가 경고를 찍지 +않도록 하고, 대신 `skippedReason`을 보고 안내 문구를 한 번 출력한다. + +### 3. 이미 존재하는 fence는 어떻게 하는가 + +사용자가 루프백으로 쓰다가 `hostname`을 비루프백으로 바꾸면, 이전에 만든 fence가 남아 죽은 +`127.0.0.1` 주소를 가리킨다. 거부하면서 **기존 관리 블록은 제거**한다 — 그것도 우리가 만든 것이므로 +정리 책임이 있다. + +```ts +// 비루프백으로 전환된 경우: 우리가 남긴 (이제 잘못된) 블록을 걷어낸다. +const removed = stripGrokConfig({ ...(opts.grokHome ? { grokHome: opts.grokHome } : {}) }); +``` + +제거 결과는 메시지에 합쳐 사용자에게 알린다. + +### 4. 사용자 실토큰 보존 + +비루프백에서 우리는 아무것도 쓰지 않으므로, 사용자가 fence 밖에 둔 `api_key`는 정의상 보존된다. +메인테이너가 재현한 `REAL_TOKEN_PRESERVED=false`가 `true`로 뒤집힌다. + +## 회귀 테스트 + +`tests/grok-config-inject.test.ts`: +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`: +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 new file mode 100644 index 0000000000..87d9bcdebe --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/030_lifecycle_teardown.md @@ -0,0 +1,212 @@ +--- +created: 2026-07-26 +status: plan +phase: wp3 +blockers: [B2, B6] +tags: [grok-build, lifecycle, service, teardown] +--- + +# 030 — 라이프사이클 teardown 정합성 (B2, B6) + +> **개정 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 + 라이프사이클 인벤토리(서브에이전트 실측). + +## 실측된 현재 상태 + +| 경로 | 서비스 매니저 | 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)`로 감싼다. 로컬 프록시 정지 자체는 그대로 시도한다 — 그것은 +이 홈이 소유한 자원이다. + +**정정(감사):** `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`가 실행되지 +않는다. 소유권 불일치 상태에서 재주입까지 진행하는 것은 오히려 위험하므로 이 동작이 옳다. +사용자는 올바른 홈에서 다시 실행하라는 안내를 받는다. + +## B6 — 명시적 종료 경로에서 fence 제거 + +### 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}`); +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, req, config); + } + throw err; + } + const restore = restoreNativeCodex(); + const { stripGrokConfig } = await import("../grok/inject"); + const grok = stripGrokConfig(); + setTimeout(...); + ... +} +``` + +`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` 배제가 그대로 남아 있다. + +## 게이트 + +`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..bfb6b1dcb6 --- /dev/null +++ b/devlog/_plan/260726_grok_build_prod/040_lifecycle_regressions.md @@ -0,0 +1,99 @@ +--- +created: 2026-07-26 +status: plan +phase: wp4 +blockers: [B4] +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할 수 없다 + +최상위에서 `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 동기화를 시도한다. + 구현 소유는 030 §5. 검증: `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`로 격리하고, + **`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(...)`, **주입된 종료 훅**으로 호출을 기록만 한다. + (라우팅 도달성 확인됨: `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 테스트). + +## 게이트 + +`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` → 링크/빌드 확인. 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..08ad9f9f0e --- /dev/null +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -0,0 +1,129 @@ +--- +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. + +**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` / +`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 — 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] +model = "anthropic/claude-opus-4-8" +base_url = "http://127.0.0.1:10100/v1" +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 + 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. 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. 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/bridge.ts b/src/bridge.ts index c90e3ec831..761ffcf722 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; } @@ -107,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 @@ -681,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, }); @@ -702,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), @@ -725,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), @@ -782,6 +806,7 @@ export function bridgeToResponsesSSE( flushHiddenRawReasoning(); if (currentToolCall) closeCurrentToolCall(); if (currentWebSearch) closeCurrentWebSearch("failed", []); + options?.onUsage?.(undefined); emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems), @@ -876,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()}`; @@ -1100,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/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/src/cli/index.ts b/src/cli/index.ts index e1c89e2a2b..72df38f113 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 { @@ -28,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"; @@ -208,6 +209,13 @@ async function handleStart(options: { block?: boolean } = {}) { if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { try { restoreNativeCodex(); } 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; @@ -266,6 +274,17 @@ async function handleStart(options: { block?: boolean } = {}) { models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), ); } 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(() => {}); @@ -286,6 +305,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; } @@ -304,6 +331,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 => { @@ -354,11 +390,29 @@ async function handleTrayProxyRestart(): Promise { } async function handleStop() { - const stoppedService = stopServiceIfInstalled(); - if (stoppedService) console.log("🛑 Service manager stopped (won't respawn)."); + 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) { + 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(); - let stopFailed = false; if (pid) { try { // Graceful-first (management-API drain) — on Windows this is the only path where @@ -398,12 +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 */ } - 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() { @@ -447,6 +516,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 +655,11 @@ switch (command) { } const r = restoreNativeCodex(); console.log(r.success ? `✅ ${r.message}` : `⚠️ ${r.message}`); + 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; } @@ -737,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/codex/inject.ts b/src/codex/inject.ts index 6acc9c6a7c..32c1272aa7 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -64,12 +64,18 @@ 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]"; } -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 new file mode 100644 index 0000000000..c8a0944f58 --- /dev/null +++ b/src/grok/inject.ts @@ -0,0 +1,321 @@ +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, isLoopbackHostname, providerBaseHost } 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" | "orphaned-marker" | "non-loopback"; +} + +const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>"; +const END_MARKER = "# <<< opencodex managed block <<<"; +// 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 and no [model_providers] table is emitted at all. + +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 }; +} + +/** + * 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. `[[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`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`, + "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 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(); + for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) { + if (canonicalKeySegment(match[1]!) !== "model") continue; + aliases.add(canonicalKeySegment(match[2]!)); + } + 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 { + 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?: 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, "-")}`; + 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); + taken.add(alias); + const isFirst = lines.length === 1; + lines.push( + ...(isFirst ? [] : [""]), + `[model.${alias}]`, + `model = ${tomlString(model.id)}`, + `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) { + 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", + }; + } + + // 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 { + const configExisted = existsSync(configPath); + const rawContent = configExisted ? readFileSync(configPath, "utf8") : ""; + const eol = dominantEol(rawContent); + const content = applyEol(rawContent, "\n"); + const region = findManagedRegion(content); + if (region?.orphaned) return orphanedMarkerResult("injection"); + + 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); + } else if (content.length === 0) { + nextContent = `${block}\n`; + } else { + // 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); + if (output === rawContent) { + return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." }; + } + if (configExisted && !region) copyBackupOnce(configPath, backupPath); + atomicWriteFile(configPath, output); + 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." }; + } + if (region.orphaned) return orphanedMarkerResult("cleanup"); + + 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); + else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1); + const stripped = prefix + restOfFile; + atomicWriteFile(configPath, applyEol(stripped, eol)); + + return { + ok: true, + changed: true, + message: "Removed the opencodex managed block from Grok config.", + }; + } catch (error) { + return errorResult("strip", error); + } +} +/** Decode a TOML basic-string body: JSON-compatible escapes plus TOML's \uXXXX / \UXXXXXXXX. */ +function decodeTomlBasicString(body: string): string { + return body.replace( + /\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g, + (whole, esc: string) => { + 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/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/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/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/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 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 { 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,110 @@ 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("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/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 07fac4478a..53d576f6e0 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,62 @@ 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("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-config-inject.test.ts b/tests/grok-config-inject.test.ts new file mode 100644 index 0000000000..6e89f44c1d --- /dev/null +++ b/tests/grok-config-inject.test.ts @@ -0,0 +1,372 @@ +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("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.ocx-mine]\nmodel = "user/model"\nbase_url = "https://example.test/v1"\n'; + writeFileSync(configPath, userContent, "utf8"); + + 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("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("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" }, + { 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("refuses to mutate when the begin marker is orphaned (data-safety)", () => { + const configPath = join(grokHome, "config.toml"); + const damaged = `theme = "dark"\n\n${BEGIN_MARKER}\npartial = true\n[model.user-added-later]\nmodel = "keep/me"\n`; + writeFileSync(configPath, damaged, "utf8"); + + 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); + }); + + 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("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 }); + + 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", () => { + // 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); + } + } + }); + + 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 }); + // 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); + }); + }); + + 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-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("); + }); +}); diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts new file mode 100644 index 0000000000..a3da662563 --- /dev/null +++ b/tests/grok-sync.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +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"; +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("the observed bind hostname reaches injection (ensure live branch)", async () => { + const { root, grokHome } = tempGrokHome(); + try { + // 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, + }); + 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 }); + } + }); + + 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 }); + } + }); +}); 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();