From ea4589ea609cc13643ff9bdfe10e18422ab5ba81 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:27:30 +0900 Subject: [PATCH 01/17] docs(devlog): plan the priority-65 closeout unit Eight work-phases, each one PABCD cycle consuming one decade doc. Two adversarial audit rounds; the first found that the planned schema guard would have broken the passthrough fail-closed contract, and re-running all three candidate designs against 63 files proved it (schema guard: +1 regression; translation-path guard: none). --- .../000_research.md | 80 ++ .../010_wp2_responses_boundary.md | 642 ++++++++++ .../020_wp3_combo_metadata_carry.md | 830 +++++++++++++ .../030_wp4_combo_context_cap.md | 78 ++ .../040_wp5_reset_credit_identity.md | 822 +++++++++++++ .../050_wp6_gate_unblock.md | 81 ++ .../060_wp7_rollback_journal_crud.md | 1054 +++++++++++++++++ .../070_wp8_dispositions_and_regression.md | 97 ++ 8 files changed, 3684 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/000_research.md create mode 100644 devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md create mode 100644 devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md create mode 100644 devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md create mode 100644 devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md create mode 100644 devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md create mode 100644 devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md create mode 100644 devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md diff --git a/devlog/_plan/260904_priority65_closeout/000_research.md b/devlog/_plan/260904_priority65_closeout/000_research.md new file mode 100644 index 0000000000..17813e3fe0 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/000_research.md @@ -0,0 +1,80 @@ +# 260904 우선순위 65+ 종결 — 리서치 + +base `origin/dev` = `2421e44ce` (package 2.43.0), 작업 브랜치 `codex/priority65-closeout`, +워크트리 `/Users/jun/.codex/worktrees/f96c/opencodex`. + +## 선정 근거 + +메인테이너가 이슈/PR 코멘트에 남긴 `## 리뷰 · 우선순위 NN / 80` 점수를 GraphQL로 +전수 수집해 65점 이상만 추렸다. 이슈 8건, PR 13건이 나왔고, 5개 read-only 조사 +레인이 각 항목을 현재 dev 코드에 대조해 판정했다. + +수집 명령(재현 가능): + +``` +gh api graphql --paginate -f query='...issues(states: OPEN)...comments(first: 40)...' \ + --jq '... capture("우선순위 (?[0-9]+) / 80").s | tonumber ...' | sort -rn +``` + +## 판정 결과 + +| ID | 점수 | 판정 | 근거 | +|----|------|------|------| +| #3375-D | 72 | CLEAR_FIX | 원장 완비, 프로덕션 호출자 0개 | +| #3259 | 72 | 경계만 CLEAR | 스키마 loose 폴백이 타입 계약을 깸 | +| #3464 | 71 | CLEAR_BUT_LARGE | `buildPlist`에 launcher 분기 없음 | +| PR #3251 | 71 | 게이트만 | 코드 정확, 스크린샷 누락 | +| PR #3461 | 70 | CLEAR_FIX | 좁은 매처, 기존 단정 보존 | +| PR #3348 | 71 | BLOCKED | 410/413 hop blocker 잔존 | +| PR #3329 | 71 | NEEDS_DESIGN | 쿨다운 우선순위 무단 역전 | +| PR #3389 | 68 | NEEDS_DESIGN | zero-output 전제가 실험으로 반증 | +| PR #3327 | 68 | 게이트만 | 테스트 전용, 스크린샷 게이트 오탐 | +| PR #3332 | 67 | carry 필요 | OUTPUT을 INPUT 필드에 매핑 | +| PR #3061 | 72 | REJECT | #3351이 상위 구현을 이미 랜딩 | +| #3245 | 66 | BLOCKED | 실패 지점이 첫 POST 이전 | +| #3425 | 70 | NEEDS_DESIGN | 이전 REJECT 유효, 새 용의자도 반증됨 | + +## 이번 유닛이 다루는 것 + +판정이 CLEAR이거나, 게이트 하나만 남았거나, CRUD/UX 규칙으로 결정 가능한 것만 +가져간다. 설계 결정이 선행하는 항목(#3329, #3389, #3348, #3425, #3376, #3377, +#3447 Antigravity 절반)은 이 유닛의 범위 밖이고, 각각 근거를 이슈/PR에 남긴다. + +## work-phase 맵 + +골플랜 `.codexclaw/goalplans/65-stacked-pr-close-co-authored-by-exact-head-ci/goalplan.json`의 +work-phase와 이 유닛의 문서가 아래처럼 대응한다. + +| wp | 대상 | 문서 | +|----|------|------| +| wp1 | Phase-0 docs-only 로드맵 | 이 유닛 전체 (이 사이클의 산출물) | +| wp2 | #3259 responses 경계 | `010_wp2_responses_boundary.md` | +| wp3 | #3332 carry | `020_wp3_combo_metadata_carry.md` | +| wp4 | #3461 스쿼시 머지 | `030_wp4_combo_context_cap.md` | +| wp5 | #3375-D reset-credit identity | `040_wp5_reset_credit_identity.md` | +| wp6 | #3251 / #3327 게이트 해제 | `050_wp6_gate_unblock.md` | +| wp7 | #3379 롤백 저널 삭제 CRUD | `060_wp7_rollback_journal_crud.md` | +| wp8 | 처분 기록 + 최종 회귀 증명 | `070_wp8_dispositions_and_regression.md` | + +wp1은 자기 자신이 산출물이므로 decade 문서를 따로 갖지 않는다. 나머지 일곱 개는 +각각 하나의 decade 문서를 소비하는 한 번의 PABCD 사이클이다. + +## 구속 조건 + +- 로컬 전체 스위트 금지. `bun run test`와 인자 없는 `bun test` 모두 이 유닛에서 + 실행하지 않는다. focused `bun test tests/.test.ts`와 라이브 GitHub CI가 + 검증자다. +- push는 `--no-verify`. +- 타 기여자 작업을 carry/재구현/스쿼시할 때 `Co-authored-by` 트레일러가 스쿼시 + 뒤에도 남아야 한다. 산문 크레딧은 등가물이 아니다 (AGENTS.md + `missing_coauthor_credit`). +- `dev`는 이 세션 중에도 움직일 수 있다. 머지 직전마다 `git fetch origin dev`로 + head를 다시 읽고 ancestry를 재확인한다. + +## 검증자 사전 확인 (PLAN-VERIFIER-REAL-01) + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun run typecheck` | 0 | 예 — `tsconfig.json`이 `src/`, `tests/` 전체를 include | +| `bun test tests/.test.ts` | 파일별 | 예 — 대상 파일을 직접 인자로 받음 | +| `gh pr checks ` | 상태별 | 예 — PR head SHA의 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md new file mode 100644 index 0000000000..99ab1cf15d --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md @@ -0,0 +1,642 @@ +# wp2 — 이슈 #3259: responses tool result 경계 하드닝 + +대상 이슈: [#3259](https://github.com/lidge-jun/opencodex/issues/3259) +작업 브랜치: `codex/priority65-closeout` (base `origin/dev` = `2421e44ce`, package 2.43.0) +cwd: `/Users/jun/.codex/worktrees/f96c/opencodex` + +한 줄 요약: `inputItemSchema` 의 loose 폴백이 call_id 없는 tool 아이템을 삼켜 +`toolCallId: undefined` 가 어댑터까지 내려간다. 결함은 **번역 경로에만** 있으므로 +파싱 시점이 아니라 **번역 경로에서** 400으로 끊는다. + +> **개정 이력 (v2).** 초판은 스키마(파싱 시점) 거부를 골랐다. 감사에서 Critical blocker가 +> 나왔고, 재검증 결과 **초판 판정이 틀렸다**. 무엇이 틀렸고 어떻게 바로잡았는지는 §11에 +> 남긴다. 같은 실수를 반복하지 않으려면 §11을 먼저 읽어라. + +--- + +## 1. 결함 재검증 + +### 1.1 스키마가 call_id 없는 tool 아이템을 통과시킨다 + +`src/responses/schema.ts:78-81` 의 엄격한 스키마: + +```ts +const functionCallOutputItemSchema = z.object({ + type: z.literal("function_call_output"), + call_id: z.string().min(1), + output: toolOutputSchema.optional(), +}); +``` + +`src/responses/schema.ts:97-107` 의 union 마지막 대안이 이걸 무력화한다: + +```ts +export const inputItemSchema = z.union([ + userMessageItemSchema, + systemMessageItemSchema, + assistantMessageItemSchema, + reasoningItemSchema, + functionCallItemSchema, + functionCallOutputItemSchema, + customToolCallItemSchema, + customToolCallOutputItemSchema, + z.object({ type: z.string() }).loose(), // ← :106. 모든 실패를 흡수 +]); +``` + +zod union은 첫 성공 대안을 채택한다. `call_id` 가 없으면 엄격한 대안이 실패하고 +마지막 loose 대안이 성공하므로, 아이템은 원본 그대로 `data.input` 에 들어간다. +실측(`inputItemSchema.safeParse`): `function_call`, `function_call_output`, +`custom_tool_call`, `custom_tool_call_output` 네 타입 전부 PASS. `call_id` 가 숫자여도 PASS. + +### 1.2 파서가 무검사 대입한다 + +`src/responses/parser.ts:732-745`: + +```ts + if (effectiveType === "function_call_output") { + const output = item as { call_id: string; output?: string | unknown[] }; // :733 거짓말하는 캐스트 + attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning); + pendingReasoning.length = 0; + const toolInfo = findToolById(messages, output.call_id); + messages.push({ + role: "toolResult", toolCallId: output.call_id, // :738 무검사 대입 +``` + +`:747-752` 의 `custom_tool_call_output` 도 동일하다(`:752`). +`as { call_id: string }` 캐스트가 런타임 검증 없이 타입만 주장하므로 tsc는 침묵한다. + +### 1.3 타입이 non-optional인데 undefined가 들어간다 + +`src/types/request.ts:165-175` 는 `toolCallId: string` (:168, non-optional)이다. +실제 파서 출력(실측, 위임 히스토리 모양): + +``` +toolResult= {"role":"toolResult","toolName":"","content":"bootstrap result","isError":false,...} +toolCallId typeof: undefined +``` + +`toolCallId` 키가 아예 사라진다. `string` 선언 필드가 런타임에 `undefined` 다. + +### 1.4 세 어댑터의 실제 관측 결과 (전부 실행함) + +| 어댑터 | 위치 | 실행 결과 | +|---|---|---| +| ollama-native | `src/adapters/ollama-native.ts:334` | `throw` — `ollama-native orphan tool result ` | +| kiro | `src/adapters/kiro-wire.ts:32` (호출부 `kiro.ts:661`) | 실측 `TypeError: undefined is not an object (evaluating 'id.replace')` | +| anthropic | `src/adapters/anthropic.ts:775` → `orphanToolResultText` `:637-643` | 실측: 오염 문자열을 업스트림에 **전송** | + +anthropic이 가장 나쁘다. 던지지 않고 오염된 문자열을 보낸다. 실측: + +``` +contains 'undefined'? true +leak snippet: [tool_result without adjacent tool_use: undefined]\nbootstrap result +``` + +원인(`anthropic.ts:637-643`) — `toolName` 이 빈 문자열이라 label이 곧 `toolCallId` 가 된다: + +```ts +function orphanToolResultText(msg: OcxToolResultMessage): string { + const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId; +``` + +`src/adapters/google.ts:269` 의 `geminiOrphanToolResultParts` 도 같은 label 로직이라 동일하게 오염된다. + +### 1.5 **결정적 사실 — 결함은 번역 경로에만 있다** + +이것이 이 문서 전체의 설계 근거다. 직접 실행해서 확인했다. + +passthrough 어댑터(`src/adapters/openai-responses.ts:2287` `createResponsesPassthroughAdapter`)는 +`parsed._rawBody` 만 읽는다. `rg -n 'context.messages' src/adapters/openai-responses.ts` → +**히트 0건**. 파서는 원본 body를 `_rawBody` 로 그대로 실어 보낸다(`parser.ts:850` `_rawBody: body`). + +실측으로 독립성을 증명했다 — `context.messages` 를 **빈 배열로 비운 뒤** 같은 요청을 빌드: + +``` +guard fires on translated path? true +passthrough forwards item verbatim? {"type":"message","role":"user","content":[{"type":"input_text","text":"[tool output for unknown call]\nbootstrap result"}]} +passthrough identical with EMPTY context.messages? true ← 바이트 동일 +anthropic leaks undefined? true +``` + +두 가지가 동시에 확인된다: + +1. passthrough 출력은 `context.messages` 와 **완전히 무관**하다(비워도 바이트 동일). +2. passthrough는 짝 없는 tool output을 **스스로 복구**한다 — `"[tool output for unknown call]"` + 로 바꿔 정상 user 메시지로 내보낸다. `undefined` 오염이 **없다**. + +즉 passthrough는 이 결함의 피해자가 아니라 **이미 올바르게 동작하는 경로**다. +여기에 400을 씌우면 멀쩡한 트래픽을 죽인다. + +--- + +## 2. 스코프 경계 + +### IN + +- **번역 경로**에서 짝 없는(call_id 없는) tool result를 400으로 거부. +- 400 응답의 정확한 형태(status / type / code / message) 확정. +- passthrough / forward / key 모드가 **영향받지 않음**을 실측으로 증명. +- 회귀 피해 전수 조사(표본 아님)와 focused 실행 목록 확정. +- `tests/responses-parser.test.ts` 파서 단위 회귀 + 경계 동작 테스트 추가. +- anthropic `undefined` 누출이 이 가드로 해소되는지 확인 + 명시. + +### OUT + +- **짝 없는 부트스트랩 tool result를 드롭/합성/강등할 것인가** — 제품 판단. 별도 결정. + (passthrough는 이미 `[tool output for unknown call]` 로 강등한다. 번역 경로에 + 같은 정책을 이식할지는 이 work-phase가 정할 문제가 아니다.) +- `orphanToolResultText` / `geminiOrphanToolResultParts` 의 label 폴백 수정. +- `src/types/request.ts:168` 을 `toolCallId?: string` 으로 바꾸는 것. + 타입 선언이 맞고 런타임이 틀렸다. 타입을 느슨하게 하면 결함을 정당화한다. +- ollama-native / kiro 의 throw 자체. +- `parser.ts:721` 의 `toolCallId: out.call_id ?? ""` (`tool_search_output`) — §6.3. +- 스키마(`inputItemSchema`) 변경. §3에서 기각한다. + +--- + +## 3. 설계 결정 — 세 안 비교 + +세 안을 **전부 실제로 패치해서** 동일한 63개 파일 스위트로 돌렸다. 표본이 아니라 같은 조건이다. + +### 안 A — 스키마 거부 (`src/responses/schema.ts` `inputItemSchema`) + +loose 폴백이 call_id 없는 tool 아이템을 삼키지 못하게 해 `parseRequest` 가 던지게 한다. + +**기각한다. 결정적 근거는 실행 순서다.** + +`src/server/responses/core.ts` 에서 두 지점이 같은 `handleResponsesInner` 안에 있고 순서가 고정이다: + +```ts +// :2812 + parsed = parseRequest(body); +... +// :3719 — 훨씬 뒤 + if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { +``` + +`parseRequest` 가 passthrough 분기보다 **먼저** 실행된다. 따라서 스키마에서 400을 내면 +**번역을 전혀 하지 않는 forward/key passthrough까지 끊는다.** 그런데 §1.5에서 실측했듯 +passthrough는 이 결함의 영향을 받지 않고 오히려 스스로 복구한다. 안 A는 +**결함이 없는 경로를 죽이는 대가로 결함이 있는 경로를 고치는** 교환이다. + +실측 피해(63파일 스위트, 아래 §7 baseline 대비): + +``` +안 A: 1693 pass / 4 fail ← baseline 대비 +1 회귀 +(fail) routed compaction for key-mode openai-responses (#422) > raw input_image never reaches the upstream +``` + +이 실패는 픽스처 결함이 아니다. `routedCompaction` 경로(`core.ts:3691-3712`)는 +key-mode openai-responses 어댑터를 쓰는데, 그 어댑터는 `_rawBody` 에서 빌드한다 +(`core.ts:3703-3706` 주석이 명시: *the key-mode openai-responses adapter builds from _rawBody*). +즉 **번역하지 않는 경로가 스키마 400에 걸린 것**이고, 안 A의 구조적 결함을 그대로 보여준다. + +### 안 B — 번역 경로 거부 (`core.ts`, passthrough 분기 **이후**) + +passthrough 블록이 끝나는 `core.ts:5153` 이후, 즉 번역 경로만 지나는 지점에서 검사한다. + +실측: **baseline과 동일(1694 pass / 3 fail)**. 추가 회귀 0건. +passthrough 테스트 전부 green, routed compaction도 green. + +단, 위치를 잘못 잡으면 실패한다. 처음 `:5153` 직후에 무조건 검사하도록 넣었더니 +routed compaction이 깨졌다(실측 1 fail). `routedCompaction` 은 passthrough 분기를 +`!routedCompaction` 조건으로 **건너뛰고** 내려오지만 여전히 `_rawBody` 기반이기 때문이다. +그래서 위치가 아니라 **어댑터 성질**로 조건을 걸어야 한다: +`if (!("passthrough" in adapter && adapter.passthrough))`. 이렇게 바꾼 뒤 green을 확인했다. + +### 안 C — 어댑터 공통 래퍼 (`src/adapters/registry.ts:155` `createRegisteredAdapter`) + +모든 어댑터의 `buildRequest` 를 감싸는 **단 하나의 래퍼**가 이미 존재한다(`:154-172`). +여기 넣으면 `core.ts` 의 8개 `buildRequest` 호출 지점 +(`:1258, :3775, :4174, :4285, :4392, :5806, :5931, :6373`)을 **전부** 한 번에 덮는다. + +실측: 안 B와 동일하게 **baseline 동일(1694 pass / 3 fail)**, `tsc` exit 0. + +**그런데 안 C는 에러 형태를 통제할 수 없다.** 래퍼는 `buildRequest` 안이므로 throw만 가능하고, +그 throw를 받는 `core.ts:3774-3788` 의 catch는 **두 개의 지정된 에러 클래스만** 400으로 바꾼다: + +```ts + } catch (error) { + releaseCodexAuthContextProbeLease(authCtx); + if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) { + return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); + } + throw error; // ← 그 외는 전부 재throw = 500 + } +``` + +바로 위 주석이 이 함정을 직접 기록해 두었다: *Rethrowing it here escaped every catch up to the +Bun handler, so the same request produced an unstructured 500 — and no request log*. +안 C로 400을 내려면 **새 에러 클래스를 만들어 이 catch에 등록**해야 하고, 그러면 변경 표면이 +`registry.ts` + 새 에러 모듈 + `core.ts` catch 세 곳으로 늘어난다. 경계 하드닝 한 건에 비해 과하다. + +### 결정: **안 B** + +| 기준 | A 스키마 | B 번역 경로 | C 레지스트리 래퍼 | +|---|---|---|---| +| passthrough 계약 보존 | **깨짐** (+1 회귀) | 보존 | 보존 | +| routed compaction 보존 | **깨짐** | 보존 | 보존 | +| 400 형태 통제 | 가능 | **가능 (직접 return)** | 불가 (500이 기본) | +| 변경 파일 수 | 1 | **1** | 3 | +| 결함 없는 경로에 영향 | 있음 | **없음** | 없음 | + +안 B는 결함이 실재하는 경로에만 정확히 걸리고, 400을 직접 반환하며, 파일 하나만 바꾼다. + +--- + +## 4. 파일 변경 맵 + +### 4.1 MODIFY — `src/server/responses/core.ts` (유일한 프로덕션 변경) + +**위치**: passthrough 블록이 닫히는 `:5153` 직후, 사이드카 계획(`:5155` 주석) 직전. + +**before** (현재 `:5147-5158`): + +```ts + } finally { + if (hostAdmissionLease) { + releaseUpstreamHostAdmission(hostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } + } + + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. +``` + +**after**: + +```ts + } finally { + if (hostAdmissionLease) { + releaseUpstreamHostAdmission(hostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } + } + + // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId + // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all + // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A + // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 + // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends + // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259). + // + // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch + // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed + // compaction — paths that never read context.messages, build from _rawBody, and already + // degrade an unpaired output to "[tool output for unknown call]" on their own. + // + // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above + // yet still builds from _rawBody (see the :3703 comment). + if (!("passthrough" in adapter && adapter.passthrough)) { + const unpaired = parsed.context.messages.find( + message => message.role === "toolResult" + && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string" + || (message as { toolCallId: string }).toolCallId.length === 0), + ); + if (unpaired) { + // Never interpolate the tool output: this message reaches the client and the logs. + return formatErrorResponse( + 400, + "invalid_request_error", + "tool result requires a non-empty string call_id", + ); + } + } + + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. +``` + +`formatErrorResponse` 는 이 파일에서 이미 쓰인다(예: `:2875`, `:3785`). **새 import 불필요.** + +### 4.2 MODIFY — `tests/responses-parser.test.ts` (회귀 테스트) + +파일 끝(`:797`, `describe("codex-rs compat surface (260707)")` 가 `:568`~`:797`) 뒤에 추가한다. + +이 테스트가 검증하는 것은 **파서가 결함 상태를 실제로 만들어낸다**는 사실 자체다. +§4.1 가드는 이 상태를 전제로 동작하므로, 이 관측이 무너지면 가드도 무의미해진다. + +```ts +describe("unpaired tool result boundary (#3259)", () => { + // The real delegation-history shape that produced the defect: a subagent bootstrap turn + // whose FIRST tool result has no originating call in the same request. + const delegationHistory = (toolItem: Record) => ({ + model: "test-model", + input: [ + { type: "message", role: "developer", content: [{ type: "input_text", text: "You are a subagent." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + toolItem, + ], + }); + + const toolResultOf = (item: Record) => + parseRequest(delegationHistory(item)).context.messages.find(m => m.role === "toolResult") as + | { toolCallId?: unknown } + | undefined; + + test("a function_call_output with no call_id still parses, and yields an unusable toolCallId", () => { + // This is the state src/server/responses/core.ts guards on. `toolCallId` is declared + // `string` (src/types/request.ts:168) but is undefined here — the schema catch-all + // (schema.ts:106) accepted the item and parser.ts:738 assigned it unchecked. + const result = toolResultOf({ type: "function_call_output", output: "bootstrap result" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("an empty-string call_id is equally unusable", () => { + // findToolById (parser.ts:328) matches by identity, so "" can never pair. The guard + // must treat it exactly like undefined. + const result = toolResultOf({ type: "function_call_output", call_id: "", output: "x" }); + expect(result?.toolCallId).toBe(""); + }); + + test("a well-formed tool result on the same history pairs normally", () => { + const result = toolResultOf({ type: "function_call_output", call_id: "call_1", output: "ok" }); + expect(result).toMatchObject({ toolCallId: "call_1", content: "ok" }); + }); + + test("custom_tool_call_output has the identical hole (parser.ts:752)", () => { + const result = toolResultOf({ type: "custom_tool_call_output", output: "x" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("tolerances unrelated to call_id stay intact", () => { + // parser.ts:611-621 deliberately tolerates non-JSON arguments; nothing here may 400 it. + expect(() => parseRequest(delegationHistory({ + type: "function_call", call_id: "c1", name: "shell", arguments: "not json", + }))).not.toThrow(); + // Unknown future item types must keep flowing through the catch-all untouched. + expect(() => parseRequest(delegationHistory({ + type: "brand_new_item_2027", foo: 1, + }))).not.toThrow(); + }); +}); +``` + +`describe` / `expect` / `test` / `parseRequest` 는 `:1-4` 에서 이미 import되어 있다. + +### 4.3 서버 레벨 400 회귀 테스트 (권장, 위치 주의) + +`core.ts` 가드의 400을 직접 검증하려면 `handleResponses` 를 통과시켜야 하고, 그건 +완전한 `config` 가 필요하다(실측: config 없이 호출하면 `Object.hasOwn(config.providers, ...)` 에서 +404가 난다). 기존 하네스가 이미 있는 `tests/responses-compaction-routing.test.ts` 의 +`compactionRequest()` / `keyProviderConfig()` 패턴(`:89-115`)을 참고해 +**번역 어댑터(anthropic 등) 라우트로** 케이스를 추가하라. 검증할 것은 두 가지다: + +- 응답이 `400` 이고 message가 `tool result requires a non-empty string call_id` 다. +- `fetch` 스텁이 **한 번도 호출되지 않는다**(업스트림 전송 0회). + +passthrough 라우트로 같은 body를 보내면 **200이 나와야 한다**. 그 대비가 이 설계의 핵심 주장이다. + +### 4.4 NEW / DELETE + +없음. + +### 4.5 변경하지 **않는** 파일 (명시) + +- `src/responses/schema.ts` — 안 A 기각(§3). +- `src/responses/parser.ts` — 파서는 passthrough와 공유된다. 여기서 `call_id` 를 강제하면 + 안 A와 같은 부작용이 난다. +- `src/adapters/` 전부 — 가드가 앞서므로 도달하지 않는다. +- `tests/openai-responses-compaction-routing` 픽스처 — 안 B에서는 **수리가 필요 없다** + (초판이 요구했던 픽스처 수정은 안 A의 부작용이었다). + +--- + +## 5. 조건 분기 activation scenario + +§4.1이 추가하는 조건 분기는 **두 개**다. + +### 분기 1 — `if (!("passthrough" in adapter && adapter.passthrough))` + +- **C(클라이언트)가 어떻게 발화시키는가**: 라우팅 결과가 passthrough 어댑터 + (`openai-responses` key/forward, `azure-openai` — `adapters/azure.ts:5` 가 + `ProviderAdapter & { passthrough: true }` 를 반환)인 요청. 예: xAI grok을 key 모드로, + 또는 ChatGPT forward 모드로 부르는 Codex 턴. +- **무엇이 관측되는가**: 가드 전체를 건너뛴다. call_id 없는 tool output이 있어도 **200**이고, + 업스트림 body에는 passthrough 자신의 복구 결과 + `[tool output for unknown call]` 가 실린다(§1.5 실측). `undefined` 는 나타나지 않는다. +- **검증**: `bun test tests/openai-responses-passthrough.test.ts` 가 green을 유지 + (실측 125/125 상당, 전체 배치에서 baseline 동일). + +### 분기 2 — `if (unpaired)` — `toolCallId` 가 문자열이 아니거나 빈 문자열 + +- **C가 어떻게 발화시키는가**: 번역 어댑터(anthropic / google / kiro / ollama-native / cursor …)로 + 라우팅되는 요청에, 같은 요청 안에 짝이 되는 `function_call` 없이 + `{type: "function_call_output", output: ...}` 만 실려 오고 `call_id` 가 누락/빈문자열/비문자열인 경우. + 위임(subagent) 부트스트랩 히스토리가 이슈 #3259가 보고한 실제 트래픽 모양이다. +- **무엇이 관측되는가**: HTTP `400`, + `{"error":{"message":"tool result requires a non-empty string call_id","type":"invalid_request_error","code":"invalid_request_error"}}`. + **업스트림 provider 호출 0회** — 가드가 `buildRequest` 앞이므로 fetch 스텁이 한 번도 안 불린다. + 이전에는 anthropic이 200과 함께 오염 문자열을 보냈다. +- **검증**: §4.3 서버 레벨 테스트. + +### 분기 미도달 — 정상 번역 턴 + +- **C가 어떻게 발화시키는가**: 짝이 맞는 `function_call` + `function_call_output`. +- **무엇이 관측되는가**: `find` 가 `undefined` 를 반환해 가드를 통과. 동작 변화 0. + `messages` 순회 1회가 추가될 뿐이다(요청당 O(n), n = 메시지 수). + +--- + +## 6. anthropic `undefined` 누출 — 이 가드로 해소되는가 + +### 6.1 결론 + +**번역 경로에서 해소된다. 어댑터는 한 줄도 고치지 않는다.** + +### 6.2 근거 + +§1.5 실측에서 두 사실이 같이 나왔다: + +``` +guard fires on translated path? true ← 가드 조건이 정확히 이 요청에서 참 +anthropic leaks undefined? true ← 가드가 없으면 오염이 실제로 나감 +``` + +가드가 `buildRequest` 이전에 400을 반환하므로 `orphanToolResultText` 가 호출되지 않는다. +`kiro-wire.ts:32` TypeError, `ollama-native.ts:334` throw, `google.ts:269` 의 동일 label 오염도 +모두 같은 이유로 도달 불가가 된다. **네 어댑터 결함이 한 곳의 변경으로 사라진다.** + +### 6.3 남는 구멍 (OUT, 후속 단위) + +`parser.ts:721` 의 `toolCallId: out.call_id ?? ""` (`tool_search_output`) 는 +이 가드에 **잡힌다** — 빈 문자열도 분기 2의 조건이기 때문이다. 즉 지금까지 +`"tool_search ()"` 로 조용히 오염되던 요청이 이제 400이 된다. + +이건 **의도한 부작용이자 동시에 위험**이다. `tool_search_output` 에 `call_id` 가 없는 요청을 +실제로 보내는 클라이언트가 있다면 회귀가 된다. 63파일 전수 스위트에서 그런 픽스처는 +**나오지 않았다**(§7). 그래도 배포 후 400 로그에서 이 메시지가 뜨는지 지켜볼 것. +필요하면 `tool_search_output` 만 예외 처리하는 후속 단위를 연다. + +--- + +## 7. 회귀 전수 조사 + Verifier (PLAN-VERIFIER-REAL-01) + +### 7.1 후보 전수 수집 (표본 아님) + +```bash +rg -n '"function_call_output"|"custom_tool_call_output"|"custom_tool_call"|"function_call"' tests/ -l | sort +``` + +→ **65개 파일**. 이 중 `tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json` (JSON)과 +`tests/helpers/agent-task-recovery.ts` (헬퍼)는 테스트 파일이 아니므로 실행 대상에서 빠지고, +**63개 `.test.ts`** 가 실행 목록이 된다. 목록은 위 명령으로 재생성 가능하다. + +### 7.2 baseline 먼저 확정 (이게 없으면 판정 불가) + +```bash +git stash push -- src/server/responses/core.ts # 변경 제거 +bun test <63개 파일> +``` + +실측 baseline: `1694 pass / 3 fail`. + +**그 3건은 이 변경과 무관한 기존 실패다.** `tests/server-xai-responses-streaming.test.ts` 의 +`xAI OAuth Responses streaming opt-in` 3개 테스트로, **깨끗한 트리에서도 동일하게 실패한다** +(실측). 단독 실행 시에는 `3 pass / 0 fail` 이므로 63파일 병렬 배치에서만 나타나는 +기존 간섭이다. 이 변경의 책임이 아니고, 이 work-phase에서 고치지 않는다. + +### 7.3 세 안 실측 비교 (같은 63파일, 같은 조건) + +| 안 | 결과 | baseline 대비 | +|---|---|---| +| baseline (변경 없음) | 1694 pass / 3 fail | — | +| **안 B (채택)** | **1694 pass / 3 fail** | **+0 회귀** | +| 안 C (레지스트리) | 1694 pass / 3 fail | +0 회귀 (단 400 불가, §3) | +| 안 A (스키마) | 1693 pass / 4 fail | **+1 회귀** (routed compaction) | + +안 A만 추가 회귀를 낸다. 그 1건이 §3에서 설명한 구조적 결함의 직접 증거다. + +### 7.4 Verifier 표 (전부 실제 실행함) + +| # | 커맨드 | exit | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun x tsc --noEmit` | 0 | 읽는다 — `src/server/responses/core.ts` 는 tsconfig 대상이고, `message` 좁히기 캐스트가 여기서 판정된다 | +| V2 | `bun test tests/responses-parser.test.ts` | 0 | 읽는다 — §4.2 새 블록이 이 파일에 있고 `parseRequest` 를 직접 호출한다. **단 `core.ts` 가드는 보지 못한다** — 파서 단위 관측 전용이다 | +| V3 | `bun test tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — `handleResponses` 를 통과하므로 §4.1 가드 코드를 **실제로 실행**한다. routedCompaction이 가드에 걸리지 않아야 함을 여기서 판정한다. 안 B 초기 위치(무조건 검사)에서 실제 red를 봤다 | +| V4 | `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — **V4가 이 변경을 관측하는 커맨드다**. 초판이 지목한 `:2343` 단독 테스트는 `adapter.buildRequest({_rawBody})` 직접 호출이라(`:2368`) `core.ts` 를 우회해 **아무것도 관측하지 못한다**. compaction 파일이 `handleResponses` 경유로 가드를 실행하므로 둘을 **함께** 돌려야 passthrough 보존을 실제로 증명한다. 실측 176 pass / 0 fail | +| V5 | 63파일 전수 (§7.1 목록) | 3 fail = baseline | 읽는다 — 55개 파일이 `handleResponses` 를 호출하므로 가드 코드가 광범위하게 실행된다. baseline과 동일해야 통과 | + +**금지**: `bun run test`, 인자 없는 `bun test`. V5는 rg로 수집한 명시적 파일 목록 실행이지 +전체 스위트가 아니다(전체는 ~850파일, V5는 63파일). + +### 7.5 red 확인 의무 + +V3는 **red를 실제로 봤다**(가드를 위치 기반으로 넣었을 때 1 fail). V2의 파서 관측 테스트는 +현재 코드에서 green이다 — 결함 상태를 기술하는 테스트이기 때문이다. §4.3 서버 테스트를 +추가한다면 가드를 잠시 제거해 red를 확인하라. red를 못 본 테스트는 신뢰할 수 없다. + +--- + +## 8. Accept criteria (testable) + +1. `bun x tsc --noEmit` exit 0. (V1) +2. `bun test tests/responses-parser.test.ts` exit 0, §4.2 블록 5건 pass. (V2) +3. `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` + exit 0. (V4 — 실측 176 pass / 0 fail) +4. 63파일 전수 실행이 **baseline과 동일**: 1694 pass / 3 fail, 그 3건이 §7.2의 + xAI 기존 실패와 **정확히 일치**. 다른 실패가 하나라도 늘면 실패다. (V5) +5. **번역 어댑터** 라우트 + call_id 없는 `function_call_output` → HTTP `400`, + `type`/`code` = `invalid_request_error`, message = `tool result requires a non-empty string call_id`. +6. 같은 요청에서 업스트림 fetch **0회**. +7. **passthrough 라우트**에 동일 body → **200**, 업스트림 body에 + `[tool output for unknown call]` 포함, `undefined` **미포함**. +8. 400 body가 요청의 `output` 값을 포함하지 않는다(메시지가 상수 문자열이므로 구조적으로 보장). +9. 변경 파일이 정확히 `src/server/responses/core.ts` 와 `tests/responses-parser.test.ts` + (+§4.3 채택 시 서버 테스트 파일) 뿐이다. `src/responses/schema.ts`, `src/responses/parser.ts`, + `src/adapters/` 는 `git diff --name-only` 에 **나오지 않는다**. + +--- + +## 9. 필드 체인 (PLAN-FIELD-CHAIN-01) + +**이 변경은 타입이나 enum에 필드를 추가하지 않는다.** 새 creation → serialization → +deserialization → consumer 체인이 생기지 않는다. + +확인 사항: + +- `OcxToolResultMessage`(`src/types/request.ts:165-175`) 변경 없음. + `toolCallId: string` 유지 — 이 변경의 목적이 **그 선언을 참으로 만드는 것**이다. +- `OcxToolCall`(`:211-227`), `inputItemSchema`, `responsesRequestSchema` 전부 변경 없음. +- 새 에러 클래스 없음. 새 에러 `code` 없음. 따라서 `classifyError`(`src/lib/errors.ts:149`), + `/api/logs` 소비자, GUI 에러 표시 경로 모두 변경 없음. +- 가드는 **읽기 전용**이다. `parsed` 를 변형하지 않으므로 이후 파이프라인 + (사이드카 계획, 툴 브리지, 스트림 처리)의 입력이 달라지지 않는다. + +영향을 받는 유일한 관측 가능 표면은 **번역 경로의 HTTP 400 응답**이다. +`formatErrorResponse(400, "invalid_request_error", ...)` 는 이 파일이 이미 여러 번 쓰는 형태이고 +(`:2875`, `:3785`, `:5824`), `classifyError` 가 `code` 를 `invalid_request_error` 로 확정한다. +커스텀 `code` 를 넘겨도 `isCyberPolicyCode` 가 아니면 무시된다(`src/bridge.ts:2088-2093`). + +--- + +## 10. 구현 순서 + +1. 63파일 목록 재생성(§7.1) 후 **baseline 먼저 측정**. 이걸 건너뛰면 §7.2의 기존 xAI 실패 3건을 + 자기 회귀로 오인한다. +2. `src/server/responses/core.ts` 에 §4.1 적용. +3. `bun x tsc --noEmit` (V1). +4. `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` (V4). + **여기가 설계의 핵심 주장이 무너지는지 보는 지점이다.** +5. `tests/responses-parser.test.ts` 에 §4.2 적용 후 V2. +6. (선택) §4.3 서버 테스트 추가 — 가드 제거 상태에서 red 확인 후 복구. +7. V5 전수 실행, baseline과 대조. +8. `git diff --name-only` 로 accept criteria 9 확인. + +PR은 `dev` 를 target하고 설명에 `Closes #3259` 를 넣는다. `dev` 는 default branch가 아니므로 +머지 후 이슈를 **수동으로 닫아야 한다**(AGENTS.md). + +--- + +## 11. 초판(v1)이 틀린 지점 — 재발 방지 기록 + +초판은 **안 A(스키마 거부)** 를 골랐다. 감사에서 Critical blocker가 나왔고 재검증 결과 사실이었다. +세 가지가 틀렸다. + +### 11.1 픽스처를 조작한 결과를 근거로 썼다 (가장 심각) + +초판 §3.3은 좁은 스키마 가드가 `openai-responses-passthrough` 의 fail-closed 픽스처를 +관용한다고 적으며 `fco output unknown block PASS` 를 근거로 제시했다. +**그 PASS는 픽스처에 없는 `call_id: "c1"` 을 내가 임의로 덧붙였을 때만 나온다.** +원본 `tests/openai-responses-passthrough.test.ts:2350-2366` 의 6개 픽스처는 **전부 call_id가 없다**: + +```ts + const invalidOutputs = [ + { type: "custom_tool_call_output" }, + { type: "function_call_output", output: [{ type: "bogus", value: "not a tool-output part" }] }, + // … 나머지 4개도 전부 call_id 없음 + ]; +``` + +즉 초판은 **자기가 만든 변형을 원본이라고 착각하고** 안전하다고 결론지었다. +교훈: 픽스처를 인용할 때는 반드시 원본 라인을 다시 열어 대조한다. 프로브 스크립트에서 +값을 채워 넣었다면 그건 근거가 아니다. + +### 11.2 이 변경을 볼 수 없는 커맨드를 verifier로 지목했다 + +초판 V4는 `tests/openai-responses-passthrough.test.ts` 단독이었다. 그런데 그 파일의 해당 +테스트는 `adapter.buildRequest({ ...parsedBase, _rawBody })` 를 **직접** 호출한다(`:2367-2370`). +`handleResponses` 도 `parseRequest` 도 지나지 않으므로 스키마 변경도 core 변경도 관측 못 한다. +실제로 가드를 넣어도 그 파일은 초록이었다 — **초록의 의미가 없는 초록**이었다. +교훈: verifier를 적을 때 "이 커맨드가 변경 지점을 실행하는 호출 경로"를 한 줄로 쓸 수 없으면 +그 커맨드는 verifier가 아니다. v2 §7.4는 각 행에 그 근거를 강제로 적었다. + +### 11.3 결함의 위치를 잘못 짚었다 + +결함은 파싱이 아니라 **번역**에 있었다. passthrough는 `_rawBody` 만 읽고(`context.messages` 참조 +0건), 짝 없는 output을 스스로 강등해 이미 올바르게 동작한다. 초판은 이걸 확인하지 않고 +`parseRequest` 가 passthrough보다 먼저 실행된다는 사실(`core.ts:2812` vs `:3719`)도 놓쳐서, +**멀쩡한 경로까지 끊는 위치**에 가드를 놓았다. 그 대가가 실측 +1 회귀였다. +교훈: "어디서 터지는가"가 아니라 "어떤 코드가 그 필드를 실제로 읽는가"로 경계를 정한다. +`rg -n 'context.messages' src/adapters/.ts` 한 번이면 갈렸다. + +### 11.4 회귀 조사를 표본으로 했다 + +초판은 6개 파일을 골라 돌리고 "추가 피해 0건"이라고 적었다. v2는 rg로 65개 후보를 전수 수집해 +63개를 돌렸고, 그 과정에서 **baseline에 이미 3건의 기존 실패가 있다**는 사실을 발견했다(§7.2). +표본만 돌렸다면 이 3건을 자기 회귀로 오인하거나, 반대로 진짜 회귀를 놓쳤을 것이다. + diff --git a/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md new file mode 100644 index 0000000000..86d42d1768 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md @@ -0,0 +1,830 @@ +# wp3 — PR #3332 재구현 carry: Claude combo capability + provider output budget + +대상: PR [#3332](https://github.com/lidge-jun/opencodex/pull/3332) (@full999), +head `f05c23e06`, base `dev`, 상태 `OPEN` / `mergeable: CONFLICTING` / `mergeStateStatus: DIRTY`. + +체리픽이 불가능하므로 **재구현(carry)** 한다. 원 diff 5파일 중 4파일을 가져오고, +그중 한 hunk는 결함이 있어 고쳐서 가져온다. + +작업 브랜치: `codex/priority65-closeout` (base `origin/dev` = `2421e44ce`, package 2.43.0). + +--- + +## 1. 왜 재구현인가 + +`gh pr view 3332` 확인 결과: + +``` +{"mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"f05c23e06fa14534a983472b22e7477e98e135e7","baseRefName":"dev"} +``` + +PR이 작성된 뒤 dev가 같은 영역을 움직였다. 특히 `src/providers/registry.ts` 의 +Anthropic 엔트리에는 PR 작성 시점에 없던 `modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }` +가 이미 들어와 있다(현재 `registry.ts:1371`, `:1388`, PR #3454로 추가됨). +따라서 PR의 registry hunk는 컨텍스트가 어긋나 그대로 적용되지 않는다. + +--- + +## 2. 확인된 결함 — PR의 `vendorMetadataComboFallback` 이 OUTPUT 상한을 INPUT 상한에 매핑한다 + +### 2.1 결함 hunk + +원 PR `src/codex/catalog/provider-fetch.ts` 의 `vendorMetadataComboFallback` 안: + +```ts + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxInputTokens: metadata.maxTokens } // ← 결함 + : {}), +``` + +`ModelMetadata.maxTokens` 는 OUTPUT 상한이다. `src/generated/model-metadata.ts:4-13` 의 +타입 정의에서 `contextWindow` 와 `maxTokens` 는 별도 필드이고, 같은 파일 안의 기존 소비자 +`provider-fetch.ts:2516` 은 이미 올바른 방향으로 매핑하고 있다: + +```ts +...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), +``` + +즉 PR 혼자만 반대 방향으로 쓴다. + +### 2.2 실제 값으로 확인 + +`bun -e` 로 vendor 테이블을 직접 읽었다: + +``` +claude-opus-5 {"contextWindow":1000000,"maxTokens":128000,"input":["text","image"],"reasoning":true} +claude-sonnet-5 {"contextWindow":1000000,"maxTokens":128000,...} +claude-haiku-4-5{"contextWindow":200000, "maxTokens":64000, ...} +claude-fable-5-1 undefined ← 테이블에 없음. point-release 폴백이 실제로 필요하다는 증거 +``` + +### 2.3 결함이 어떻게 전파되는가 + +`aggregation.ts:161-164`: + +```ts + const maxInputTokens = Math.min( + contextWindow, + ...members.map(member => member.maxInputTokens ?? member.contextWindow!), + ); +``` + +`Math.min` 이므로 멤버 하나가 128k를 들고 오면 combo 전체 입력창이 128k로 내려앉는다. +1M 창을 가진 다른 타깃까지 같이 끌려 내려간다. + +**실측(스크래치 프로브, `.tmp/` 에서 실행 후 삭제):** + +| 시나리오 | member ctx | member maxIn | member maxOut | combo maxIn | combo autoCompact | +|---|---|---|---|---|---| +| PR 그대로 (`maxInputTokens`) | 1,000,000 | **128,000** | (없음) | **128,000** | **128,000** | +| 수정안 (`maxOutputTokens`) | 1,000,000 | 1,000,000 | 128,000 | **1,000,000** | **900,000** | + +autoCompact까지 900k → 128k로 무너진다. `clampAutoCompactTokenLimit` +(`src/providers/auto-compact-budget.ts:15-24`)이 후보에 `maxInputTokens` 를 넣고 +`Math.min` 하기 때문이다. 즉 PR을 그대로 머지하면 Claude combo 사용자는 +입력 컨텍스트의 87%를 잃는다. + +### 2.4 수정 + +`maxInputTokens` → `maxOutputTokens`. 슬롯은 이미 존재한다 +(`ComboCatalogMemberFallback.maxOutputTokens`, `provider-fetch.ts:858`), 소비 경로도 +이미 배선되어 있다(`:896-898` 의 `addMaxOutput`, `:918`, `:996-997`). +새 필드를 만들 필요가 없다. + +--- + +## 3. 스코프 + +### IN + +- `src/codex/catalog/provider-fetch.ts` — vendor metadata combo 폴백 신규 추가 (결함 수정본). +- `src/adapters/anthropic.ts` — 생략된 `max_tokens` 에 provider output budget 적용. +- `src/providers/registry.ts` — Anthropic 두 엔트리에 `defaultMaxOutputTokens` 추가. +- `tests/codex-catalog.test.ts` — 폴백 회귀 테스트 + **2.3 결함을 잡는 단정**. +- `tests/anthropic-reasoning.test.ts` — output budget 회귀 테스트. + +### OUT + +- `aggregation.ts:161` 의 `Math.min` 자체는 건드리지 않는다. 그 로직은 옳다 + (combo 입력창은 최소 멤버가 결정). 잘못된 것은 그 자리에 들어가는 값이지 연산이 아니다. +- Anthropic 외 프로바이더의 `defaultMaxOutputTokens` 추가. +- `ROUTED_COMBO_MEMBER_REASONING_EFFORTS` 를 Grok 등 다른 벤더로 확장하는 일반화. +- `devlog/` 외 문서 변경, docs-site 갱신. +- 릴리스, 태그, 푸시. + +--- + +## 4. 파일 변경 맵 + +### 4.1 MODIFY `src/codex/catalog/provider-fetch.ts` + +#### hunk A — import에 `ModelMetadata` 타입 추가 (line 35) + +before: + +```ts +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; +``` + +after: + +```ts +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +``` + +#### hunk B — `ComboCatalogMemberFallback` 선언(`:855-863`) 뒤, `resolveComboCatalogMember` JSDoc(`:865`) 앞에 삽입 + +before (현재 `:863-875`): + +```ts + readonly reasoningEfforts?: readonly string[]; +} + +/** + * Resolve a combo target to a catalog member for derivation. +``` + +after: + +```ts + readonly reasoningEfforts?: readonly string[]; +} + +/** + * Ladder advertised for a combo member whose vendor metadata says it reasons but + * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show + * the effort control; the routed adapters clamp to the real upstream top rung. + */ +const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Vendor-table lookup tolerant of point releases and date pins. Configured combo + * targets often name a variant the table does not carry (`claude-fable-5-1`, + * `claude-opus-4-5-20251101`); the base family row still describes its modality + * and reasoning capability, so fall back to it before giving up. + */ +function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { + const exact = getModelMetadataCaseInsensitive(provider, modelId); + if (exact) return exact; + let candidate = modelId.replace(/\[[^\]]*\]$/, ""); + while (true) { + const trimmed = candidate.replace(/-\d+$/, ""); + if (trimmed === candidate || !trimmed.includes("-")) return undefined; + const hit = getModelMetadataCaseInsensitive(provider, trimmed); + if (hit) return hit; + candidate = trimmed; + } +} + +/** + * Combo members are usually thin discovery rows (id + context window). Without a + * capability source the combo intersection collapses to text-only / no effort ladder, + * and the Codex app then refuses image attachments and hides the effort picker for + * every Claude combo. The generated vendor table knows both, so use it as the + * last-resort fallback when the caller supplied none. + * + * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`. + * Mapping it onto `maxInputTokens` would be read by the combo intersection + * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and + * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it. + */ +function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { + const metadataProvider = resolveMetadataProvider(target.provider); + const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined; + if (!metadata) return undefined; + return { + ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ? { contextWindow: metadata.contextWindow } + : {}), + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxOutputTokens: metadata.maxTokens } + : {}), + ...(Array.isArray(metadata.input) && metadata.input.length > 0 + ? { inputModalities: [...metadata.input] } + : {}), + ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), + }; +} + +/** + * Resolve a combo target to a catalog member for derivation. +``` + +**원 PR과의 유일한 차이는 `maxInputTokens:` → `maxOutputTokens:` 한 줄과 그 이유를 적은 JSDoc 문단이다.** + +#### hunk C — `resolveComboCatalogMember` 시그니처와 폴백 결정 (`:875-887`) + +before: + +```ts +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, + fallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; +``` + +after: + +```ts +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, + callerFallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + const fallback = callerFallback ?? vendorMetadataComboFallback(target); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; +``` + +파라미터 이름만 바뀌고 함수 본문 나머지는 `fallback` 이라는 지역 상수를 계속 쓰므로 +아래 코드는 손대지 않는다. 유일한 외부 호출부는 `provider-fetch.ts:2153-2160` 이고 +positional 인자를 쓰므로 시그니처 호환이 유지된다. + +`callerFallback ?? ...` 순서가 중요하다. native alias 폴백 +(`:2140-2151` 의 `nativeAliasFallback`)이 넘어오면 그것이 이기고, +vendor 테이블은 caller가 아무것도 주지 않았을 때만 쓰인다. + +### 4.2 MODIFY `src/adapters/anthropic.ts` + +`modelRecordValue` 는 이미 `:30` 에서 import되어 있다. 추가 import 없음. + +#### hunk D — `:898-903` + +before: + +```ts + const tools = toolsToAnthropicFormat(parsed, toolNames); + + const body: Record = { + model: parsed.modelId, + messages, + stream: parsed.stream, + max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, + }; +``` + +after: + +```ts + const tools = toolsToAnthropicFormat(parsed, toolNames); + + // Codex never sends `max_output_tokens`, so the omitted-limit default decides how + // long a Claude answer may run. Honor the provider's configured output budget + // (`modelMaxOutputTokens` / `defaultMaxOutputTokens`) before falling back to the + // conservative 8192, which truncates long answers with stop_reason=max_tokens. + const configuredMaxOut = modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + const omittedMaxTokens = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? configuredMaxOut + : DEFAULT_MAX_TOKENS; + const body: Record = { + model: parsed.modelId, + messages, + stream: parsed.stream, + max_tokens: parsed.options.maxOutputTokens ?? omittedMaxTokens, + }; +``` + +#### hunk E — adaptive thinking 분기 `:945` + +before: + +```ts + body.max_tokens = explicitMaxOut !== undefined + ? explicitMaxOut + : Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)); +``` + +after: + +```ts + body.max_tokens = explicitMaxOut !== undefined + ? explicitMaxOut + : Math.max(omittedMaxTokens, Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor))); +``` + +#### hunk F — budget thinking 분기 `:951` + +before: + +```ts + const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; +``` + +after: + +```ts + const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens; +``` + +세 hunk 모두 원 PR 그대로다. 수정 없음. + +### 4.3 MODIFY `src/providers/registry.ts` + +#### hunk G — 상수 추가, `:351` (`ANTHROPIC_MODEL_CONTEXT_WINDOWS`) 바로 뒤 + +before: + +```ts +const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, ... "claude-haiku-4-5": 200_000 }; + +/** + * The effort rungs opencodex exposes for native Anthropic models. +``` + +after: + +```ts +const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, ... "claude-haiku-4-5": 200_000 }; +// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x +// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a +// larger request never over-allocates; it only stops the 8192 truncation. +const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; + +/** + * The effort rungs opencodex exposes for native Anthropic models. +``` + +#### hunk H — `anthropic` 엔트리 (`:1369-1373`) + +**원 PR과 컨텍스트가 다르다.** dev가 `modelReasoningEfforts` 줄을 추가했으므로 +그 줄을 보존한 채 삽입한다. + +before: + +```ts + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultModel: "claude-sonnet-5", + }, + { + id: "anthropic-apikey", +``` + +after: + +```ts + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + // Codex omits max_output_tokens; without a provider budget the Anthropic adapter + // falls back to 8192, which truncates long answers with stop_reason=max_tokens. + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, + { + id: "anthropic-apikey", +``` + +#### hunk I — `anthropic-apikey` 엔트리 (`:1385-1390`) + +before: + +```ts + models: [...ANTHROPIC_MODELS], + liveModels: true, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultModel: "claude-sonnet-5", + }, +``` + +after: + +```ts + models: [...ANTHROPIC_MODELS], + liveModels: true, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, +``` + +주의: `registry.ts:1354` 에 이미 `defaultMaxOutputTokens: 64_000` 이 있지만 +그것은 `command-code` 엔트리다. Anthropic과 무관하므로 이 hunk는 여전히 필요하다. + +### 4.4 MODIFY `tests/codex-catalog.test.ts` — 5장 참조 + +### 4.5 MODIFY `tests/anthropic-reasoning.test.ts` — 5장 참조 + +### DELETE + +없음. + +--- + +## 5. 회귀 테스트 + +### 5.1 원 PR 테스트로는 결함이 안 잡힌다 + +원 PR이 `tests/codex-catalog.test.ts` 에 넣은 단정은 이렇다: + +```ts + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); +``` + +`toMatchObject` 는 **열거한 키만** 본다. `maxInputTokens` 를 적지 않았으므로 +그 값이 128k로 오염되어도 통과한다. `contextWindow` 는 1M 그대로 살아남기 때문에 +(`Math.min` 붕괴는 `maxInputTokens` 에서 일어난다) 이 단정은 결함을 볼 수 없다. + +실측으로 확인: PR 폴백을 넣은 member는 +`{ctx: 1000000, maxIn: 128000}` 이다. `contextWindow` 단정은 초록이다. + +### 5.2 ADD — 결함 저격 테스트 + +`tests/codex-catalog.test.ts` 의 `describe("combo catalog capability intersection")` +(`:170` 시작) 안, `:1552` 의 `resolveComboCatalogMember ... toBeUndefined()` 를 +닫는 `});` (`:1557`) 뒤에 추가한다. + +```ts + test("resolveComboCatalogMember restores vendor image and effort capabilities for thin Claude rows", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + // A discovery row that only carries id + window (the live Anthropic /models shape). + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // Point-release ids fall back to their family row in the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-fable-5-1" }, + new Map([["anthropic/claude-fable-5-1", { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // An explicit caller fallback still wins over the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + undefined, + { inputModalities: ["text"], reasoningEfforts: [] }, + )).toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] }); + // Unknown ids keep their unknown ladder rather than inventing one. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat" as const, baseUrl: "https://a.example/v1" }]]), + )).not.toHaveProperty("reasoningEfforts"); + }); + + // 2.3의 결함을 저격한다. 위 테스트는 toMatchObject라 maxInputTokens를 보지 않으므로 + // 이 테스트가 없으면 OUTPUT 상한을 INPUT 슬롯에 넣는 회귀가 초록으로 통과한다. + test("vendor metadata fills the OUTPUT ceiling and never shrinks the combo input window", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + const member = resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + ); + // claude-opus-5 vendor row is { contextWindow: 1e6, maxTokens: 128_000 }. + // maxTokens is the OUTPUT ceiling: it must land on maxOutputTokens, never maxInputTokens. + expect(member?.maxOutputTokens).toBe(128_000); + expect(member?.maxInputTokens ?? 1_000_000).toBe(1_000_000); + + // The intersection in deriveComboCatalogModel takes Math.min over member input + // ceilings, so a misplaced 128k would collapse a 1M combo window (and its + // autoCompact budget) for every other target in the group. + const peer: CatalogModel = { + provider: "xai", + id: "grok-4.6", + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }; + const derived = deriveComboCatalogModel("claude-grok", normalizedCombo({ + targets: [ + { provider: "anthropic", model: "claude-opus-5", weight: 1 }, + { provider: "xai", model: "grok-4.6", weight: 1 }, + ], + }), [member!, peer]); + expect(derived).toMatchObject({ + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + autoCompactTokenLimit: 900_000, + }); + }); +``` + +`deriveComboCatalogModel`, `resolveComboCatalogMember`, `CatalogModel` 은 이미 +`tests/codex-catalog.test.ts:10` 에서 import되어 있고 `normalizedCombo` 헬퍼는 +`:74-89` 에 있다. 추가 import 불필요. + +**이 테스트가 결함을 실제로 잡는지 확인한 근거:** `.tmp/` 프로브에서 PR 원본 매핑을 흉내내 +`maxInputTokens: 128000` 폴백을 주입하면 combo가 +`{ctx: 1000000, maxIn: 128000, acl: 128000}` 로 나온다. +위 단정은 `maxIn: 1000000`, `acl: 900000` 을 요구하므로 **두 줄이 동시에 빨개진다.** +수정본에서는 `{ctx: 1000000, maxIn: 1000000, maxOut: 128000, acl: 900000}` 로 초록이다. + +### 5.3 ADD — output budget 테스트 + +`tests/anthropic-reasoning.test.ts` `:283` (adaptive-thinking 관련 테스트 사이)에 +원 PR 테스트를 그대로 추가한다: + +```ts + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { + const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; + // No reasoning: the configured budget is the wire max_tokens. + expect((await bodyOf(parsed("none", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + expect((await bodyOf(parsed("none", {}, "claude-fable-5"), budgeted)).max_tokens).toBe(32_000); + // Adaptive thinking: the budget still wins over the headroom-derived ceiling. + expect((await bodyOf(parsed("max", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + // Budget thinking on an older family keeps max_tokens above the thinking budget. + const legacy = await bodyOf(parsed("high", {}, "claude-haiku-4-5"), budgeted); + expect(legacy.max_tokens as number).toBeGreaterThan((legacy.thinking as { budget_tokens: number }).budget_tokens); + // An explicit caller limit still wins over the configured budget. + expect((await bodyOf(parsed("none", { maxOutputTokens: 512 }, "claude-opus-5"), budgeted)).max_tokens).toBe(512); + }); +``` + +`bodyOf` 는 `:23` 에서 두 번째 인자로 provider를 받도록 이미 정의되어 있다 +(`configuredProvider = provider`). 헬퍼 수정 불필요. + +**현재 코드에서 이 테스트가 빨간지 확인함.** 어댑터를 고치기 전 실측값: + +| 케이스 | 현재 (budget 무시) | 테스트 기대 | +|---|---|---| +| `none` / opus-5 | 8192 | 64000 | +| `none` / fable-5 | 8192 | 32000 | +| `max` / opus-5 | 40192 | 64000 | +| `high` / haiku-4-5 | 24576 (budget 16384) | > budget — 현재도 통과 | +| explicit 512 | 512 | 512 — 현재도 통과 | + +앞의 세 줄이 실패하므로 테스트는 vacuous하지 않다. + +--- + +## 6. 조건 분기 activation scenario + +새로 생기는 분기는 셋이다. + +### 분기 1 — `callerFallback ?? vendorMetadataComboFallback(target)` + +- **발화 조건:** combo 타깃을 resolve할 때 caller가 fallback을 주지 않은 경우. + native alias combo가 아닌 모든 일반 combo가 여기 해당한다 + (`provider-fetch.ts:2144` 의 `nativeAliasFallback` 은 + `combo.nativeAlias && combo.alias && nativeContextWindow !== undefined` 일 때만 값이 있다). +- **C가 어떻게 발화시키는가:** `ocx` 설정에 Claude 타깃을 포함한 combo를 정의하고 + Anthropic 프로바이더를 켠 뒤 카탈로그를 재생성한다. 라이브 Anthropic `/models` 는 + id와 컨텍스트 창만 주므로 thin row가 된다. +- **관측 대상:** `GET /v1/models` 의 해당 combo 항목. + before: `inputModalities: ["text"]`, `reasoningEfforts: []`. + after: `["text","image"]`, `["low","medium","high","xhigh","max"]`, + 그리고 `maxInputTokens` 는 1,000,000 유지. +- **비발화 확인:** native alias combo는 caller fallback이 있으므로 vendor 테이블을 타지 않는다. + 5.2의 세 번째 단정이 이것을 고정한다. + +### 분기 2 — `comboMemberVendorMetadata` 의 point-release 트리밍 루프 + +- **발화 조건:** 정확 일치가 없고 모델 id가 `-<숫자>` 로 끝날 때. +- **C가 어떻게 발화시키는가:** combo 타깃으로 `anthropic/claude-fable-5-1` 을 쓴다. + 이 id는 vendor 테이블에 **없다** (`getModelMetadataCaseInsensitive("anthropic", "claude-fable-5-1")` + → `undefined`, 실측 확인). 한 번 트리밍하면 `claude-fable-5` 가 되고 그 행은 존재한다. +- **관측 대상:** 해당 멤버가 `inputModalities: ["text","image"]` 와 5단 ladder를 얻는다. +- **종료 보장:** `trimmed === candidate` (더 깎을 게 없음) 또는 + `!trimmed.includes("-")` 에서 `undefined` 반환. 매 반복마다 문자열이 짧아지므로 무한루프 없음. +- **비발화 확인:** `ghost` 같은 하이픈 없는 미지의 id는 첫 반복에서 + `trimmed === candidate` 로 즉시 빠진다. 5.2의 네 번째 단정이 고정한다. + +### 분기 3 — `omittedMaxTokens` (adapter) + +- **발화 조건:** `parsed.options.maxOutputTokens` 가 undefined이고 + 프로바이더에 `modelMaxOutputTokens[model]` 또는 `defaultMaxOutputTokens` 가 양수로 있을 때. +- **C가 어떻게 발화시키는가:** Codex는 `max_output_tokens` 를 보내지 않는다. + 따라서 Anthropic 프로바이더로 라우팅되는 **모든 Codex 요청**이 발화 조건이고, + hunk G~I가 registry에 64k를 넣는 순간 기본 발화한다. +- **관측 대상:** 업스트림 요청 body의 `max_tokens`. + 8192 → 64000. 사용자가 보는 증상으로는 긴 답변이 `stop_reason: "max_tokens"` 로 + 잘리던 것이 멈춘다. +- **비발화 확인:** 호출자가 명시적으로 `maxOutputTokens: 512` 를 주면 512가 유지된다 + (5.3의 마지막 단정). 프로바이더에 budget이 없으면 `DEFAULT_MAX_TOKENS` = 8192 그대로다 + (`tests/anthropic-reasoning.test.ts` 의 기존 40192/24576 단정이 이를 고정한다 — + 그 테스트들은 budget 없는 `provider` 를 쓴다). + +--- + +## 7. 필드 체인 (PLAN-FIELD-CHAIN-01) + +**새 필드는 추가하지 않는다.** `ComboCatalogMemberFallback.maxOutputTokens` 는 +`provider-fetch.ts:858` 에 이미 있고, `CatalogModel.maxOutputTokens` 는 +`parsing.ts:116` 에 이미 있다. registry의 `defaultMaxOutputTokens` 도 +`registry.ts:282` 에 이미 선언된 필드다. 이 작업은 **기존 필드에 값을 채우는 일**이다. + +그래도 값이 흐르는 경로 전체를 열거한다. + +### 7.1 `maxOutputTokens` 체인 (combo 폴백 경로) + +| 단계 | 위치 | 하는 일 | +|---|---|---| +| creation | `provider-fetch.ts` `vendorMetadataComboFallback` (신규) | vendor `metadata.maxTokens` → `maxOutputTokens` | +| 주입 | `resolveComboCatalogMember:885` (수정) | `callerFallback ?? vendor` | +| 소비 A (완전한 row) | `:896-898` `addMaxOutput`, `:918` | 멤버에 `maxOutputTokens` 가 없을 때만 채움 | +| 소비 B (합성 row) | `:996-997` | `positiveSafeInteger(hinted, base) ?? fallback` | +| 출력 | `:1025` | `CatalogModel` 에 실림 | +| combo 집계 | `aggregation.ts:165-169` | **모든** 멤버가 값을 가질 때만 `Math.min`, 아니면 `undefined` | +| combo 출력 | `aggregation.ts:189` | `...(maxOutputTokens !== undefined ? { maxOutputTokens } : {})` | +| serialization | `aggregation.ts:330` `normalizedOpenAiApiSignature` | `maxOutputTokens ?? null` — 시그니처에 포함 | +| serialization | `parsing.ts:116` `CatalogModel` | 카탈로그 JSON 필드 | + +**주의점 두 가지.** + +1. `aggregation.ts:168` 은 `knownMaxOutputTokens.length === members.length` 를 요구한다. + Claude 멤버 하나에만 값이 생기고 다른 멤버에 없으면 combo의 `maxOutputTokens` 는 + 여전히 `undefined` 다. 이는 **의도된 보수적 동작**이고 기존 로직이며 이번 변경 대상이 아니다. +2. `comboCatalogWarningSignature` (`aggregation.ts:225-247`)에는 + `maxOutputTokens` 가 **없다**. 반면 `maxInputTokens` 와 + `autoCompactTokenLimit` 은 있다(`:239-240`). + 따라서 PR 원본을 그대로 머지하면 이 경고 시그니처까지 값이 바뀌어 + `warnUncataloguedComboOnce` 의 dedupe 키가 달라진다. 수정본은 + `maxInputTokens` 를 건드리지 않으므로 시그니처가 변하지 않는다 — carry 수정의 부수 이득이다. + +### 7.2 `defaultMaxOutputTokens` 체인 (registry → adapter) + +| 단계 | 위치 | +|---|---| +| creation | `registry.ts:1373, 1389` (신규 값), 타입은 `:282` 에 기존 | +| seed 전파 | `providers/derive.ts:237, 297, 488` — 프로바이더 설정에 없으면 registry 값 주입 | +| 런타임 병합 | `router.ts:452-453` — `provider.defaultMaxOutputTokens === undefined` 일 때 registry 값 | +| login 경로 | `oauth/login-cli.ts:110`, `oauth/index.ts:1190` (허용 키 목록) | +| config 검증 | `config.ts:1388`, `server/auth-cors.ts:688, 808` (editor 권한) | +| rename 마이그레이션 | `providers/model-rename-migration.ts:86` (`modelMaxOutputTokens` 만 해당) | +| **소비 (이번에 신규)** | `adapters/anthropic.ts` hunk D/E/F | +| 기존 소비자 (변경 없음) | `openai-chat.ts:1403-1404`, `ollama-native.ts:1065-1066`, `command-code.ts:510` | +| 진단 표면 | `routing/compatibility/behavior.ts:185`, `provider-fetch.ts:586` (`maxOut` 진단 필드) | + +anthropic 어댑터가 쓰는 `modelRecordValue → defaultMaxOutputTokens` 우선순위는 +`openai-chat.ts:1403-1404` 와 동일한 패턴이다. 새 규약이 아니다. + +--- + +## 8. 건전성 재확인 (요청 항목) + +### 8.1 adapter output budget — 건전함 + +`modelRecordValue` (`src/reasoning-effort.ts:115-127`)는 정확 일치 → `:` 앞 family → +대소문자 무시 순으로 찾는다. `parsed.modelId` 는 어댑터 시점에서 프로바이더 접두사가 벗겨진 +순수 모델 id다(`body.model = parsed.modelId` 로 그대로 업스트림에 나감). 따라서 +registry의 `"claude-opus-5"` 키와 맞는다. + +세 hunk의 우선순위가 일관된다: **명시적 caller 값 > 설정된 budget > 8192**. +hunk E의 `Math.max(omittedMaxTokens, ...)` 는 budget이 adaptive 천장(40192)보다 +작을 때 천장을 유지하므로 thinking headroom을 깎지 않는다. budget 64k면 64k가 이긴다. + +hunk F의 `REASONING_MAX_TOKENS_CEILING = 32_000` 클램프는 그대로 살아있어 +legacy budget-thinking 경로에서 `max_tokens` 가 32k를 넘지 않는다. 실측에서 +`high/haiku-4-5` 가 24576, budget 16384로 부등식이 유지됨을 확인했다. + +### 8.2 registry `defaultMaxOutputTokens = 64_000` — 건전함 + +vendor 테이블 기준 현행 Claude 계열 OUTPUT 상한: opus-5 / sonnet-5 / fable-5 / +opus-4-6 / opus-4-8 / sonnet-4-6 = 128,000, haiku-4-5 = 64,000. +**64k는 전 계열의 최소값 이하**이므로 어떤 모델에서도 서버 상한을 넘지 않는다. +128k로 올리면 haiku-4-5에서 초과하므로 64k 선택이 맞다. + +`command-code` 가 같은 64k를 쓰고 있어(`:1354`) 선례도 있다. + +### 8.3 남는 비대칭 하나 (기록만, 이번 스코프 아님) + +`ANTHROPIC_MODEL_CONTEXT_WINDOWS` 는 `claude-fable-5-1` 을 포함하지만 +vendor 테이블에는 그 행이 없다. 그래서 registry 경유 경로는 창을 알고 +vendor 경유 폴백은 family 트리밍에 의존한다. 분기 2가 이를 흡수하므로 +동작 결함은 아니다. 테이블 갱신은 별도 작업. + +--- + +## 9. Verifier (PLAN-VERIFIER-REAL-01) + +**모두 실제로 실행했다.** 전체 스위트(`bun run test`, bare `bun test`)는 금지이므로 +focused 파일만 쓴다. + +| # | 커맨드 | exit | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun test tests/codex-catalog.test.ts` | **0** (266 pass / 0 fail, 5.83s) | 읽는다 — `:10` 에서 `resolveComboCatalogMember`, `deriveComboCatalogModel` 을 `src/codex/catalog` 에서 직접 import하므로 hunk B/C를 실행 경로로 통과한다. | +| V2 | `bun test tests/anthropic-reasoning.test.ts` | **0** (66 pass / 0 fail, 111ms) | 읽는다 — `:2` 에서 `createAnthropicAdapter` 를 `src/adapters/anthropic` 에서 import하고 `buildRequest` 로 실제 body를 만든다. hunk D/E/F가 그 안이다. | +| V3 | `bun run typecheck` | 아래 참조 | 읽는다 — `tsc --noEmit` 가 저장소 전체를 검사하므로 `ModelMetadata` 타입 import(hunk A)와 `ComboCatalogMemberFallback` 구조 적합성을 강제한다. | +| V4 | `bun -e` vendor 테이블 조회 | **0** | 읽는다 — `src/generated/model-metadata.ts` 를 직접 로드해 `maxTokens` 가 OUTPUT임을 확인한 근거. | +| V5 | `.tmp/` 스크래치 프로브 | **0** | 읽는다 — `src/codex/catalog` 를 로드해 2.3 표의 실측값을 산출. **검증용이며 커밋하지 않는다.** | + +V1/V2는 **변경 전 baseline**으로 실행했다. 둘 다 초록이므로 이후 실패는 이번 변경이 원인이다. + +V3은 baseline에서 실행하지 않았다 — 변경 후 1회 실행이 필요하다. hunk A의 +`type ModelMetadata` import가 `verbatimModuleSyntax` 계열 설정과 충돌하지 않는지, +그리고 `callerFallback` 리네임이 호출부(`:2153`, positional)와 어긋나지 않는지를 +잡는 유일한 게이트다. + +### 실행 순서 + +```bash +bun run typecheck +bun test tests/codex-catalog.test.ts +bun test tests/anthropic-reasoning.test.ts +``` + +`bun run test:changed` 는 이 변경에 대해 위 두 파일을 포함하는 상위집합이므로 +추가 정보가 없다. 스코프 변경이므로 전체 스위트는 돌리지 않는다. + +### 빨간지 먼저 확인 (vacuous 방지) + +5.2와 5.3의 신규 테스트는 **프로덕션 코드를 고치기 전에** 먼저 추가해서 +빨간 것을 확인한 뒤 코드를 고친다. 5.3의 기대 실패는 8192 vs 64000 / 8192 vs 32000 / +40192 vs 64000 세 줄이다(실측 표 참조). + +--- + +## 10. Accept criteria + +1. `bun run typecheck` exit 0. +2. `bun test tests/codex-catalog.test.ts` exit 0, 신규 테스트 2개 포함해 268 pass 이상. +3. `bun test tests/anthropic-reasoning.test.ts` exit 0, 신규 테스트 1개 포함해 67 pass 이상. +4. `rg -n 'maxInputTokens: metadata.maxTokens' src/` 가 **아무것도 찾지 못한다** — + 결함이 재유입되지 않았다는 기계적 증거. +5. `rg -n 'maxOutputTokens: metadata.maxTokens' src/codex/catalog/provider-fetch.ts` 가 1건 매치. +6. thin Claude row가 `inputModalities: ["text","image"]` 와 5단 ladder를 얻고 + `maxInputTokens` 는 1,000,000을 유지한다 (5.2 단정). +7. combo 집계 결과 `autoCompactTokenLimit` 이 900,000이다 (128,000이 아니다). +8. budget이 설정된 Anthropic 프로바이더에서 caller가 `maxOutputTokens` 를 생략하면 + wire `max_tokens` 가 64,000이다. +9. budget이 **없는** 프로바이더의 기존 단정(40192 / 32768 / 24576)이 전부 그대로 통과한다 — + 회귀 없음. +10. 커밋 본문에 co-author 트레일러가 있다 (11장). +11. `git status` 에 `.tmp/` 산출물이 스테이징되지 않았다. + +--- + +## 11. 커밋 / PR 규약 + +원 저자 정보를 `gh api` 로 확인했다: + +``` +$ gh api repos/lidge-jun/opencodex/pulls/3332/commits --jq '.[].commit.author' +{"date":"2026-09-03T05:47:19Z","email":"daiki.furutani@walker-s.co.jp","name":"full999"} +``` + +커밋 본문에 반드시 포함(스쿼시 후에도 살아남도록 브랜치 커밋 본문에 넣는다): + +``` +Co-authored-by: full999 +``` + +`AGENTS.md` "Landing another author's work" 규정상 재구현/carry는 트레일러가 필수다. +산문으로 "reimplements #3332"라고만 쓰면 contributor 그래프에 잡히지 않는다 +(`CREDITS.md` 의 27건이 그렇게 누락된 사례). + +PR 설명에는 `.github/PULL_REQUEST_TEMPLATE.md` 의 Summary / Verification / Checklist를 +모두 채우고, 다음을 명시한다: + +- 이 PR이 #3332의 재구현이며 원 PR은 CONFLICTING/DIRTY라 체리픽 불가였다는 점. +- 원 PR 대비 **의도적으로 바꾼 한 줄**(`maxInputTokens` → `maxOutputTokens`)과 + 그것이 없으면 Claude combo 입력창이 1M → 128k로 붕괴한다는 근거. +- `Closes #3332` 가 아니라, dev 타깃이므로 머지 후 #3332를 수동으로 닫는다. +- base는 `dev`. + +--- + +## 12. 실행 순서 요약 + +1. 신규 테스트 2개를 먼저 추가하고 V1/V2를 돌려 **빨간 것을 확인**한다. +2. hunk A/B/C 적용 (`provider-fetch.ts`). +3. hunk D/E/F 적용 (`anthropic.ts`). +4. hunk G/H/I 적용 (`registry.ts`). +5. V3 → V1 → V2 순서로 실행, 전부 exit 0 확인. +6. Accept criteria 4/5의 `rg` 두 줄 확인. +7. `.tmp/` 스크래치 삭제 확인, `git status` 로 의도한 5파일만 변경되었는지 확인. +8. co-author 트레일러 포함해 커밋, `dev` 타깃 PR 생성. + diff --git a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md new file mode 100644 index 0000000000..d4010a2849 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md @@ -0,0 +1,78 @@ +# wp4 — PR #3461 provider-specific context-cap failover 스쿼시 머지 + +대상: PR [#3461](https://github.com/lidge-jun/opencodex/pull/3461) (@RHODIZSECURITY), +head `4e16f889b`, +46/-0, 3파일. + +## 결함 + +`src/lib/errors.ts:183-189`가 `"maximum context"` 문자열을 무조건 +`context_length_exceeded`로 remap하고, `src/combos/failover.ts`의 stop 리스트가 +그 코드를 잡는다. 그래서 vendor code 5059를 쓰는 프로바이더가 +`Prompt 346030 > 262144 maximum context length`를 돌려주면, 뒤에 더 큰 컨텍스트 +타깃이 남아 있어도 combo 체인이 첫 타깃에서 끝난다. + +## 왜 이 PR이 맞는가 + +매처가 좁다. `isProviderTargetContextOverflow`는 status 400 **그리고** +(`invalid_request_prompt_too_long` 문자열 **또는** 코드 5059 **및** +`Prompt N > M maximum context length` 정규식)을 요구한다. 5059 단독은 여전히 stop이다. +PR의 테스트가 이 두 방향을 모두 단언한다. + +삽입 위치도 옳다. `isCyberPolicyCode` 뒤, stop 리스트 앞이라 정책 코드가 우선한다. + +`options.code`의 출처는 업스트림 응답 JSON이다 — +`normalizeUpstreamErrorText`(`src/server/responses/core.ts:770`)가 error 객체에서 +뽑는다. 최종 사용자가 주입하는 값은 아니지만, **업스트림 프로바이더는 이 코드를 +의도적으로 세울 수 있다.** 저장소는 이미 그 사실을 알고 판정을 내려 두었다 — +`src/combos/failover.ts:337`의 주석이 "an upstream can still SET this code +deliberately, since both extractors read the upstream error object. That is bounded +rather than dangerous"라고 적는다. + +이 판정을 그대로 승계한다. 최악의 경우가 "다음 combo 타깃으로 한 번 더 넘어간다"이고, +순회는 `pick.attempted`로 유한하므로(`src/combos/resolve.ts:224`) 무한 루프가 없다. + +## 기존 단정 보존 확인 + +`tests/combos.test.ts`의 다음 단정이 그대로 통과해야 한다. 셋 다 구조화 코드가 +없으므로 새 매처가 발화하지 않는다. + +- `comboFailureDecision(400, "context_length_exceeded")` → `stop` +- `comboFailureDecision(413, "request too large")` → `stop` +- `comboFailureDecision(400, "ordinary invalid request", { code: "5059" })` → `stop` + +이것이 #3348과 결정적으로 다른 점이다. #3348은 같은 문제를 풀면서 generic 410/413을 +통째로 hop 리스트에 넣어 위 단정 셋을 뒤집는다. + +## 스코프 + +- IN: PR을 있는 그대로 스쿼시 머지. +- OUT: 코드 수정, 리베이스, 추가 매처 확장. + +## 실행 절차 + +1. `git fetch origin dev` — dev가 움직였는지 확인. +2. `gh pr checks 3461` — exact head에서 required green 재확인. +3. `gh pr merge 3461 --squash --admin`, 커밋 본문에 트레일러 포함: + `Co-authored-by: RHODIZ IT ` +4. `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD` +5. `git log -1 --format=%B | rg Co-authored-by` — 스쿼시 후 트레일러 생존 확인. + +## Accept criteria + +- 머지 SHA가 `origin/dev`의 조상이다. +- 스쿼시 커밋 본문에 `Co-authored-by: RHODIZ IT`가 남아 있다. +- 머지 후 `bun test tests/combos.test.ts`가 green (focused, 전체 스위트 아님). + +## Activation scenario + +새 분기 `isProviderTargetContextOverflow`는 조건 분기다. 발화 방법: +PR이 이미 넣은 e2e가 400 + 5059 + 정규식 일치 본문을 내는 가짜 업스트림을 세우고, +두 번째 타깃이 정확히 1회 히트(`backupHits === 1`)하며 200이 나오는 것을 관측한다. +반대로 코드 없는 generic 400은 hop하지 않고 stop한다는 것이 같은 테스트에서 단언된다. + +## Verifier 사전 확인 + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun test tests/combos.test.ts` | 0 (머지 전 dev 기준) | 예 — `src/combos/failover.ts`를 import | +| `gh pr checks 3461` | 0 | 예 — head `4e16f889b`의 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md b/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md new file mode 100644 index 0000000000..97db7fc90b --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md @@ -0,0 +1,822 @@ +# wp5 — #3375 축 D: reset-credit 안정 operation identity + +상태: **READY** (BLOCKED 아님. 반증 검증 결과는 아래 0장 참조) +대상 브랜치: `codex/priority65-closeout` (base `origin/dev` = 2421e44ce, package 2.43.0) +작성 시점 확인 커밋: 2421e44ce + +--- + +## 0. 반증 먼저 — 원장은 "의도적 미배선"인가? + +결론: **의도적 미배선이라는 증거는 없다.** 미완성 배선이다. 근거는 넷이다. + +**(1) 원장은 단일 커밋으로 들어왔고, 배선 유보를 언급하지 않는다.** + +``` +$ git log --oneline -S openManualResetCreditOperation -- src/ +7c68768ca feat(codex): add durable reset-credit operation ledger (#1829) +``` + +7c68768ca 본문 전문: + +``` +feat(codex): add durable reset-credit operation ledger +fix(codex): snapshot reset-credit ledger generations +fix(codex): bound reset-credit ledger recovery validation +test(codex): harden reset-credit ledger invariants +fix(codex): reject partial reset-credit ledger loss +test(codex): prove reset-credit ledger rollback boundaries +``` + +전부 원장 자체의 내구성/불변식 문구다. "배선은 후속", "호출자 없음 의도" 같은 +유보 문장이 없다. 되돌림 커밋도 없다 — `git log -- src/codex/reset-credit-operation-ledger.ts` +는 7c68768ca 한 건뿐이다. + +**(2) manual 경로만 죽은 게 아니라 recovery 경로도 같이 죽어 있다.** + +``` +$ rg -n 'openResetCreditOperation|settleResetCreditOperation|markResetCreditOperationAmbiguous' -g '!node_modules' src/ | grep -v 'reset-credit-operation-ledger.ts' +(출력 없음) + +$ rg -n 'reset-credit-operation-ledger' -g '!node_modules' . +devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6 +tests/codex-reset-credit-operation-ledger.test.ts:24 +``` + +"manual만 일부러 안 붙였다"면 recovery는 붙어 있어야 한다. 파일 전체가 +테스트에서만 import된다. 이건 특정 분기의 정책적 유보가 아니라 파일 단위 미배선이다. + +**(3) `setResetCreditOperationMigrationFaultForTests`(:485)의 존재 의미는 반대 방향 증거다.** + +이 함수는 `migrateLegacyTable`(:500) — legacy `recovery` 전용 스키마를 +`operation_kind IN ('recovery','manual')` 스키마로 올리는 마이그레이션 — 의 +첫 쓰기 직후에 합성 실패를 주입한다(:479 `failMigrationAfterFirstWriteForTests`). +즉 **디스크에 이미 legacy 원장 행이 존재하는 배포본**을 전제로, 그 행을 manual +지원 스키마로 승격하는 경로의 롤백을 증명한다. 배선할 생각이 없는 코드에 +legacy 행 승격 + 부분 실패 롤백을 넣지 않는다. 이건 "언젠가 켠다"가 아니라 +"켜진 상태의 데이터가 이미 있을 수 있다"를 방어하는 코드다. +또한 `process.env.OCX_TEST_HOME_GUARD !== "1"`이면 throw하므로(:487) 프로덕션 +오염 위험도 차단되어 있다. + +**(4) 별도 devlog가 이 원장을 "unused"로 명시하고 재사용을 계획한다.** + +`devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6`: + +> An unused #657 ledger (`reset-credit-operation-ledger.ts`, kinds `recovery|manual`) exists. + +같은 문서가 slice 1에서 `"auto-redeem"` kind 추가를 계획한다. 즉 이 원장은 +폐기 대상이 아니라 확장 대상으로 취급되고 있다. + +**wp13과의 충돌 경계**: wp13은 새 kind `"auto-redeem"`을 추가하려 하고, 이 +wp5는 기존 `"manual"` kind를 배선한다. 두 작업은 스키마의 `operation_kind` +CHECK 제약을 공유한다. **wp5는 스키마를 건드리지 않는다**(3장 OUT 참조). +wp13이 먼저 착지하면 CHECK 문자열만 확장되고 wp5 diff는 그대로 적용된다. + +### 현재 결함 (배선 부재의 실제 관측 가능한 증상) + +`src/codex/auth-api.ts:2177`: + +```ts +const idempotencyKey = crypto.randomUUID(); +``` + +이 값이 :2187에서 업스트림으로 나간다: + +```ts +body: JSON.stringify({ redeem_request_id: idempotencyKey }), +``` + +**매 HTTP 요청마다 새 UUID가 생성된다.** 따라서 동일한 논리적 사용자 의도(버튼 +한 번 누름)가 네트워크 타임아웃으로 재시도되면 업스트림은 서로 다른 +`redeem_request_id` 두 개를 보고 **크레딧 2개를 소비한다.** 크레딧은 되돌릴 수 +없는 외부 상태이므로 이 결함의 비용은 비가역이다. + +대조군: `src/codex/reset-credit-auto-redeem.ts:163`은 같은 문제를 이미 +해결했다 — `redeemRequestId`를 저널에 먼저 쓰고 재시작 후 재사용한다. +manual 경로에만 그 보호가 없다. + +--- + +## 1. 스코프 경계 + +### IN + +1. `POST /api/codex-auth/reset-credits/consume` 가 body의 **선택적** `operationId`를 수용. +2. 그 id로 `openManualResetCreditOperation`을 호출해 원장 예약. +3. 예약된 id를 업스트림 `redeem_request_id`로 사용(랜덤 UUID 대체). +4. 업스트림 결과에 따라 `settleManualResetCreditOperation` / 실패 시 `markManualResetCreditOperationAmbiguous`. +5. 원장 분기(`terminal`/`capacity`/`identity-mismatch`/`unavailable`)별 HTTP 응답 확정. +6. CLI `ocx account reset-credits --consume --yes --operation-id `. +7. 회귀 테스트를 **red 우선**으로 추가. +8. **docs-site 관리 API 레퍼런스 8개 로케일 갱신** (감사 지적으로 IN 승격). 근거는 + AGENTS.md 리뷰 규정 "user-facing behavior changes should update `docs-site/`". + 이 유닛은 consume 엔드포인트에 **새 400 사유 + 신규 409 + 신규 503 2종**을 + 추가하므로 명백한 user-facing 계약 변경이다. 대상 줄은 4c 표에 정확히 열거했다. + +### OUT (이번 유닛에서 건드리지 않음) + +- **원장 SQL 스키마 / `CREATE_TABLE` / `operation_kind` CHECK** — wp13과 충돌. +- **recovery 경로 배선** (`openResetCreditOperation` 등). 별도 유닛. +- **GUI 자동 operationId 생성.** 3장 D-4 참조: GUI는 이번에 변경하지 않는다. +- `reset-credit-auto-redeem.ts` 저널을 원장으로 통합하는 작업. +- `GET /api/codex-auth/reset-credits` (inspect 경로). +- 업스트림 재시도 로직 자체 추가. 이 유닛은 **재시도가 안전해지도록** 만들 뿐, + 재시도를 구현하지 않는다. + +### 하위호환 계약 (필수) + +`operationId`가 **없으면 현재 동작과 바이트 단위로 동일해야 한다** — +원장을 열지 않고, `crypto.randomUUID()`로 `redeem_request_id`를 만든다. +기존 GUI/CLI/테스트가 그대로 통과해야 한다. 이것이 회귀 위험을 0으로 만드는 장치다. + +--- + +## 2. 원장 반환 타입 → HTTP 응답 결정표 + +`OpenManualResetCreditOperationResult` (`src/codex/reset-credit-operation-ledger.ts:98`): + +```ts +export type OpenManualResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "capacity" | "identity-mismatch" | "unavailable" }>; +``` + +| kind | 의미 | HTTP | 응답 body | 업스트림 호출 | +|---|---|---|---|---| +| `execute` (`resumed:false`) | 신규 예약 성공 | — (계속 진행) | 정상 consume 결과 | **한다** | +| `execute` (`resumed:true`) | 같은 id 재시도, 미정산 | — (계속 진행) | 정상 consume 결과 | **한다** (같은 `redeem_request_id`이므로 업스트림이 멱등 처리) | +| `terminal` | 이미 정산된 id | **200** | `{ code: <저장된 code>, replayed: true }` | **안 한다** | +| `identity-mismatch` | 다른 계정이 소유한 id | **409** | `{ error: "operation_id_owned_by_another_account", code: "identity_mismatch" }` | **안 한다** | +| `capacity` | 원장 용량 초과 | **503** + `Retry-After: 1` | `{ error: "reset_credit_ledger_capacity", code: "capacity" }` | **안 한다** | +| `unavailable` | 원장 IO 실패 | **503** + `Retry-After: 1` | `{ error: "reset_credit_ledger_unavailable", code: "unavailable" }` | **안 한다** | + +### `unavailable`을 503으로 fail-closed 하는 이유 (설계 결정) + +대안은 "원장이 죽으면 랜덤 UUID로 폴백해서 서비스 지속"이다. **채택하지 않는다.** +호출자가 `operationId`를 명시했다는 것은 멱등성을 요구했다는 뜻이고, 폴백은 그 +요구를 조용히 무시하면서 정확히 이 유닛이 막으려는 이중소비를 재현한다. +크레딧은 비가역이므로 가용성보다 정확성이 우선이다. `operationId`를 안 보낸 +호출자는 애초에 이 경로에 들어오지 않으므로 영향받지 않는다. + +### `terminal`에서 업스트림을 재호출하지 않는 이유 + +`terminal`은 원장이 "이 id는 이미 끝났고 결과는 X"를 내구성 있게 아는 상태다. +재호출하면 업스트림 멱등성에만 의존하게 되는데, 그 보장은 우리 것이 아니다. +저장된 code를 그대로 재생하는 편이 강하다. + +**`remaining` 필드 주의**: `terminal` 재생 응답에는 `remaining`을 넣지 않는다. +현재 코드(:2199~2211)는 신선하게 파싱된 `available_count`가 있을 때만 `remaining`을 +넣는 규약이고(:2196-2198 주석), 재생 시점에는 그 신선한 값이 없다. `replayed: true`로 +구분만 준다. + +`UpdateResetCreditOperationResult` (:88) — `settle`/`ambiguous`의 반환: + +```ts +export type UpdateResetCreditOperationResult = + | Readonly<{ kind: "updated" }> + | Readonly<{ kind: "mismatch" | "unavailable" }>; +``` + +정산은 **업스트림 호출이 이미 끝난 뒤**에 일어난다. 즉 크레딧은 이미 쓰였다. +따라서 `mismatch`/`unavailable`이 와도 **사용자 응답을 실패로 바꾸지 않는다** — +실제로 성공한 소비를 실패로 보고하면 사용자가 다시 누르고, 그게 이중소비다. +정산 실패는 서버 로그로만 남긴다. 이 비대칭(열기는 fail-closed, 닫기는 fail-open)이 +이 설계의 핵심이다. + +--- + +## 3. 파일 변경 맵 + +### D-1. `src/codex/auth-api.ts` — MODIFY + +#### D-1a. import 추가 (파일 상단 import 블록) + +**AFTER (추가할 줄):** + +```ts +import { + markManualResetCreditOperationAmbiguous, + openManualResetCreditOperation, + settleManualResetCreditOperation, +} from "./reset-credit-operation-ledger"; +import { isCodexResetCreditOperationId } from "./reset-credit-recovery"; +``` + +주의: `auth-api.ts`가 이미 `./reset-credit-recovery`에서 import하는 게 있으면 +기존 절에 병합할 것. + +#### D-1b. consume 핸들러 — `src/codex/auth-api.ts:2168` 부터 + +**BEFORE (현재 :2168-2171):** + +```ts + if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { + const body = (await req.json().catch(() => ({}))) as { accountId?: string }; + if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + const accountId = body.accountId; +``` + +**AFTER:** + +```ts + if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { + const body = (await req.json().catch(() => ({}))) as { + accountId?: string; + operationId?: unknown; + }; + if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + const accountId = body.accountId; + // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy + // behavior: a fresh random redeem_request_id and no durable ledger row. + const hasOperationId = body.operationId !== undefined; + if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) { + return jsonResponse({ error: "Invalid operationId format" }, 400); + } + const requestedOperationId = hasOperationId ? body.operationId as string : undefined; +``` + +**중요 — 검증 순서**: `isCodexResetCreditOperationId`는 UUIDv4 형식만 통과시킨다 +(`src/codex/reset-credit-recovery.ts:36` `CODEX_RESET_CREDIT_OPERATION_ID_PATTERN`). +원장의 `snapshotManualIdentity`(:1154)는 형식 위반 시 **`TypeError`를 throw**하지 +result를 반환하지 않는다. 그러므로 반드시 원장 호출 **전에** 400으로 걷어내야 한다. +이걸 빠뜨리면 500 + 스택트레이스가 난다. + +#### D-1c. 업스트림 호출부 — `src/codex/auth-api.ts:2174-2194` + +**BEFORE (현재 :2174-2194):** + +```ts + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + const idempotencyKey = crypto.randomUUID(); + const resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), + }, + ); + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const result = safeResetCreditConsumeDto(await resp.json()); +``` + +**AFTER:** + +```ts + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + // The ledger keys manual operations by the *physical* ChatGPT account, which is + // only known after the auth wrapper resolves credentials. Open here, not earlier. + const identity = requestedOperationId === undefined + ? undefined + : { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + } as const; + let idempotencyKey: string; + if (identity) { + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "terminal") { + // Durably settled already: replay the recorded outcome instead of + // trusting upstream idempotency for an irreversible spend. + return jsonResponse({ code: opened.code, replayed: true }); + } + if (opened.kind === "identity-mismatch") { + return jsonResponse({ + error: "operation_id_owned_by_another_account", + code: "identity_mismatch", + }, 409); + } + if (opened.kind !== "execute") { + // capacity | unavailable -> fail closed. Falling back to a random id + // would silently reintroduce the double-spend this identity prevents. + const response = jsonResponse({ + error: opened.kind === "capacity" + ? "reset_credit_ledger_capacity" + : "reset_credit_ledger_unavailable", + code: opened.kind, + }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + idempotencyKey = opened.operationId; + } else { + idempotencyKey = crypto.randomUUID(); + } + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), + }, + ); + } catch (error) { + // Dispatch outcome unknown: the credit may or may not have been spent. + // Mark ambiguous so a replay of this same id is never treated as new. + if (identity) markManualResetCreditOperationAmbiguous(identity); + throw error; + } + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const result = safeResetCreditConsumeDto(await resp.json()); + if (identity) { + if (result.code === "reset" || result.code === "already_redeemed" + || result.code === "nothing_to_reset" || result.code === "no_credit") { + // Settlement failure never downgrades the user-visible outcome: the + // spend already happened upstream, and reporting failure would invite + // a manual retry -- the exact double-spend this unit removes. + settleManualResetCreditOperation(identity, result.code); + } else { + markManualResetCreditOperationAmbiguous(identity); + } + } +``` + +이후 `if (result.code === "reset" || result.code === "already_redeemed") { ... }` +(현재 :2199) 이하는 **변경 없음**. + +**`safeResetCreditConsumeDto`의 타입 주의** (:450): 반환은 `{ code: string }`이지 +`CodexResetCreditConsumeCode`가 아니다. 알 수 없는 code는 `"unknown"`으로 +정규화된다(:452). 그래서 위에서 네 개 리터럴을 명시적으로 좁혀야 `settle`의 +`code: CodexResetCreditConsumeCode` 파라미터에 타입이 맞는다. `as` 캐스트로 +우회하지 말 것 — `"unknown"`이 원장에 들어가면 `settle`이 `mismatch`를 반환한다. + +### D-2. `src/cli/account-auth.ts` — MODIFY + +#### D-2a. USAGE 문자열 — `src/cli/account-auth.ts:37` + +**BEFORE:** + +``` + ocx account reset-credits [--consume --yes] [--json] +``` + +**AFTER:** + +``` + ocx account reset-credits [--consume --yes [--operation-id ]] [--json] +``` + +#### D-2b. `resetCredits` 함수 — `src/cli/account-auth.ts:249-262` + +**BEFORE:** + +```ts +async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const rawId = args.shift()?.trim(); + const wantsJson = takeFlag(args, "--json"); + const consume = takeFlag(args, "--consume"); + const yes = takeFlag(args, "--yes"); + if (!rawId) throw new CliUsageError("account id is required", USAGE); + if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); + rejectArgs(args, USAGE); + const accountId = rawId === "main" ? "__main__" : rawId; + const result = consume + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { method: "POST", body: JSON.stringify({ accountId }) }, deps) + : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + printData(result, wantsJson); +} +``` + +**AFTER:** + +```ts +async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const rawId = args.shift()?.trim(); + const wantsJson = takeFlag(args, "--json"); + const consume = takeFlag(args, "--consume"); + const yes = takeFlag(args, "--yes"); + const operationId = takeOption(args, "--operation-id"); + if (!rawId) throw new CliUsageError("account id is required", USAGE); + if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); + if (operationId !== undefined && !consume) { + throw new CliUsageError("--operation-id requires --consume", USAGE); + } + if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) { + throw new CliUsageError("--operation-id must be a UUIDv4", USAGE); + } + rejectArgs(args, USAGE); + const accountId = rawId === "main" ? "__main__" : rawId; + const result = consume + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { + method: "POST", + body: JSON.stringify({ accountId, ...(operationId === undefined ? {} : { operationId }) }), + }, deps) + : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + printData(result, wantsJson); +} +``` + +import 추가: + +```ts +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +``` + +**`takeOption` 확정 사실** (감사 지적으로 재확인 — 이전 초안의 ":227" 인용은 오류였다): + +- 정의처는 `src/cli/account-auth.ts`가 **아니다**. `src/cli/runtime-api.ts:141`에 + 정의되어 있고, `account-auth.ts:11`에서 `takeOptionWithSyntax`(:12)와 함께 import된다. +- 실제 시그니처(`src/cli/runtime-api.ts:141`, 전문): + +```ts +export function takeOption(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new CliUsageError(`${flag} requires a value`); + args.splice(index, 2); + return value; +} +``` + +- 반환 타입은 `string | undefined`로 **확정**. 미지정 시 `undefined`. +- `account-auth.ts` 내 기존 사용처는 :98(`--id`), :208(`--flow`), :238(`--flow`)이다. +- 부작용 3가지가 설계에 영향을 준다: + 1. `args`를 `splice`로 **변형**한다. 그래서 `rejectArgs` 앞에서 호출해야 남은 + 인자가 정확히 계산된다(위 코드가 그 순서다). + 2. 값이 없거나 다음 토큰이 `--`로 시작하면 **`CliUsageError`를 스스로 throw**한다. + 따라서 `ocx account reset-credits main --consume --yes --operation-id` 는 + 우리 코드가 아니라 `takeOption`이 `--operation-id requires a value`로 처리한다. + 3. `--flag=value` 형태는 이해하지 못한다(`runtime-api.ts:180-181` 주석). 즉 + `--operation-id=`는 `rejectArgs`로 떨어져 usage 에러가 된다. 이는 + 기존 `--id`/`--flow`와 동일한 동작이므로 새 예외를 만들지 않는다. +- `takeOptionWithSyntax`(:272)는 `--code` 같은 **비밀값** 전용(값 redaction 목적)이다. + operationId는 비밀이 아니므로 평범한 `takeOption`이 맞다. + +**import 비용 경고**: `src/cli/account-auth.ts`가 `../codex/reset-credit-recovery`를 +새로 import한다. 이 파일은 core-lab boundary 대상(`src/router.ts`, +`src/server/lifecycle.ts`, `src/server/responses/core.ts`)이 아니지만, +`tests/core-lab-boundary.test.ts`를 5장 verifier에 포함해 그래프 변화를 확인한다. +`reset-credit-recovery.ts`는 `./quota-rejection`과 `./account-id`만 import하는 +얕은 모듈이라 위험은 낮다. + +### D-3. `tests/codex-auth-api.test.ts` — MODIFY (테스트 추가) + +6장의 red-first 순서 참조. 기존 테스트는 수정하지 않는다 — 하위호환 계약이 +지켜지면 전부 그대로 통과해야 하고, 통과하지 않으면 그게 회귀 신호다. + +### D-4. `gui/src/components/codex-account-pool-handlers.ts` — **변경 없음 (의도적)** + +GUI에 operationId를 붙이려면 "언제 새 id를 만들고 언제 재사용하는가"를 정해야 +한다. 재사용 창을 잘못 잡으면 **사용자가 의도한 두 번째 소비가 첫 번째의 재생으로 +삼켜진다** — 이중소비의 정반대 방향 버그이고, 크레딧이 안 쓰였는데 쓰였다고 +보고한다. 이 정책은 서버 배선이 착지하고 실제 재시도 로그를 본 뒤 별도 유닛에서 +정한다. 이번 유닛의 GUI 동작은 현행 유지(=operationId 미전송=legacy 경로)다. + +### D-5. `docs-site/**/reference/management-api.md` — MODIFY (8개 로케일) + +정확한 파일/줄과 BEFORE/AFTER 전문은 **4c**에 있다. 중복 기재하지 않는다. +구현 순서상 D-1(서버 계약)이 확정된 뒤에 쓴다 — 상태코드가 바뀌면 8곳을 다시 +고쳐야 하므로 마지막이다. + +### D-6. `src/server/management/route-registry.ts` — **변경 없음 (확인만)** + +근거는 4c. 레지스트리는 body 스키마를 기술하지 않고, method/path/module/mutates가 +모두 그대로다. V6와 `git diff --exit-code`로 무변경을 증명한다. + +--- + +## 4. 필드 체인 (PLAN-FIELD-CHAIN-01) + +### 4a. `operationId` — 요청 body 신규 선택 필드 + +| 단계 | 위치 | 처리 | +|---|---|---| +| creation (CLI) | `src/cli/account-auth.ts` `resetCredits` | `takeOption(args,"--operation-id")`, 미지정 시 body에서 **키 자체를 생략** | +| creation (GUI) | `gui/src/components/codex-account-pool-handlers.ts:19` | **전송 안 함** (D-4) | +| serialization | `JSON.stringify({ accountId, ...(operationId===undefined?{}:{operationId}) })` | 스프레드로 키 부재 보장. `operationId: undefined`를 넣으면 안 됨 — 직렬화 결과는 같지만 부재/무효 구분이 흐려진다 | +| transport | `POST /api/codex-auth/reset-credits/consume` | — | +| deserialization | `auth-api.ts:2169` `await req.json()` | 타입 `{ accountId?: string; operationId?: unknown }`. `unknown`으로 받아 형식검증을 강제 | +| validation | `isCodexResetCreditOperationId` (`reset-credit-recovery.ts:40`) | 실패 → 400. **원장 호출 전** | +| consumer 1 | `openManualResetCreditOperation(identity)` | `identity.operationId` | +| consumer 2 | 업스트림 `redeem_request_id` | `opened.operationId` (원장이 반환한 canonical id — 요청 id와 **다를 수 있다**, 아래 주의) | +| consumer 3 | `settleManualResetCreditOperation(identity, code)` | `identity` (요청 id) | +| consumer 4 | `markManualResetCreditOperationAmbiguous(identity)` | `identity` (요청 id) | +| persistence | `reset_credit_operations` + manual id 히스토리 테이블 | 스키마 변경 없음 | +| consumer 5 (라우트 표면) | `src/server/management/route-registry.ts:97` | `{ method:"POST", path:"/api/codex-auth/reset-credits/consume", module:"codex/auth-api", mutates:true }` — **변경 불필요**, 근거는 4c | +| consumer 6 (docs, 8 로케일) | `docs-site/.../reference/management-api.md` | **변경 필요**. 4c 표 참조 | +| consumer 7 (CLI 인자 파싱) | `src/cli/runtime-api.ts:141` `takeOption` | `args` 배열을 splice로 변형, 값 누락 시 자체 `CliUsageError`. D-2b 참조 | + +### 4c. 라우트 레지스트리 및 docs 표면 (감사 Blocker #3 반영) + +**`src/server/management/route-registry.ts:97` — MODIFY 아님, 확인만 (명시적 OUT).** + +실제 등재 줄을 읽었다: + +```ts +{ method: "POST", path: "/api/codex-auth/reset-credits/consume", module: "codex/auth-api", mutates: true }, +``` + +레지스트리가 고정하는 것은 **method + path + module + mutates** 네 필드뿐이고 +**요청 body 스키마는 기술하지 않는다.** 이 유닛은 path/method/module을 바꾸지 않고, +`mutates`는 이미 `true`다(operationId 추가로 부작용 성격이 바뀌지 않는다 — 여전히 +크레딧을 소비한다). 따라서 이 파일은 **변경하지 않는다.** +다만 표면이 이 경로를 소유하므로 **회귀 검증자로는 포함한다**: +`bun test tests/management-route-registry.test.ts` (5장 V6). 이 표에 넣는 이유는 +"바꿔야 해서"가 아니라 "바뀌지 않았음을 증명해야 해서"다. + +**docs-site — MODIFY (8개 로케일).** 감사 지적은 5개 로케일이라 했으나 실제로 세어 +보니 **8개**다. `rg -n 'reset-credits/consume' docs-site/src/content/docs` 결과: + +| 로케일 | 파일 | 줄 | +|---|---|---| +| en | `docs-site/src/content/docs/reference/management-api.md` | 280 | +| tr | `docs-site/src/content/docs/tr/reference/management-api.md` | 275 | +| fr | `docs-site/src/content/docs/fr/reference/management-api.md` | 261 | +| ru | `docs-site/src/content/docs/ru/reference/management-api.md` | 250 | +| ko | `docs-site/src/content/docs/ko/reference/management-api.md` | 225 | +| zh-cn | `docs-site/src/content/docs/zh-cn/reference/management-api.md` | 224 | +| ja | `docs-site/src/content/docs/ja/reference/management-api.md` | 222 | +| zh-tw | `docs-site/src/content/docs/zh-tw/reference/management-api.md` | 217 | + +**BEFORE (en:280, 현재 전문):** + +``` +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +``` + +**AFTER (en:280):** + +``` +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit. Optional `operationId` (UUIDv4) makes the redemption idempotent: the same id replays one durable outcome instead of spending a second credit. | 400 missing account id or invalid `operationId`; 409 `identity_mismatch` when the id belongs to another account; upstream status passthrough; 503 `server_busy`, `capacity`, or `unavailable`; 500 consume failure | +``` + +나머지 7개 로케일은 같은 셀에 **각 언어로** 동일 내용을 반영한다. 영어 원문과 +모순되지 않게 하는 것이 AGENTS.md 요구사항("keep translated locales from +contradicting the English source")이다. 번역 시 `operationId`, `identity_mismatch`, +`capacity`, `unavailable`, `server_busy`는 **식별자이므로 번역하지 않는다.** + +ko:225 예시: + +``` +| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다. 선택적 `operationId`(UUIDv4)를 보내면 소비가 멱등해집니다 — 같은 id는 크레딧을 다시 쓰지 않고 저장된 결과 하나를 재생합니다. | 400 누락된 account id 또는 잘못된 `operationId`; id가 다른 계정 소유이면 409 `identity_mismatch`; upstream 상태 전달; 503 `server_busy`/`capacity`/`unavailable`; 500 소비 실패 | +``` + +**canonical id 주의 (놓치기 쉬움)**: `openManualResetCreditOperation`은 같은 +계정에 이미 활성 manual 작업이 있으면 요청 id를 **alias로 join**하고 +`current.operationId`(원래 id)를 반환한다(:1290-1318, "The upstream request keeps +the original durable id"). 그래서: + +- 업스트림에는 반드시 **`opened.operationId`** 를 보낸다 (요청 id 아님). +- `settle`/`ambiguous`에는 반드시 **`identity`(요청 id)** 를 넘긴다. + `settleManualResetCreditOperation`의 `SETTLE_MANUAL_IDS`가 canonical 기준으로 + 히스토리 전체를 정산하도록 설계되어 있다. 원장 테스트 :755-794가 이 join/정산 + 동작을 고정한다. + +반대로 하면 조용히 `mismatch`가 나고 원장이 pending으로 남는다. + +### 4b. `replayed` — 응답 body 신규 선택 필드 + +| 단계 | 위치 | 처리 | +|---|---|---| +| creation | `auth-api.ts` `terminal` 분기 | `{ code, replayed: true }` | +| 그 외 모든 응답 | 기존 경로 | **키 없음** (`replayed`를 false로 넣지 않는다) | +| consumer (CLI) | `printData(result, wantsJson)` | passthrough. `--json`이면 그대로 노출 | +| consumer (GUI) | `readJsonIfOk<{ code: string; remaining?: number }>` (handlers:23) | 타입에 없으므로 무시됨. **GUI 동작 변화 없음** — `code`가 `reset`/`already_redeemed`면 기존 성공 분기를 탄다 | +| consumer (테스트) | `toEqual` 쓰는 기존 assertion | `terminal`은 operationId 없이는 발생 불가하므로 기존 테스트 영향 없음 | + +`enum` / 영속 타입에 새 필드를 추가하지 않는다. `CodexResetCreditConsumeCode`, +`ResetCreditOperationState`, SQL 스키마 전부 불변이다. + +--- + +## 5. Verifier 커맨드 (PLAN-VERIFIER-REAL-01 — 실제 실행 결과) + +아래 V1~V3은 **베이스라인(변경 전) 실측치**다. 이 워크트리에서 실행했다. + +| # | 커맨드 | 실측 exit | 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun test tests/codex-reset-credit-operation-ledger.test.ts` | **0** (44 pass / 0 fail, 5.26s) | 예 — `src/codex/reset-credit-operation-ledger.ts`를 :24에서 직접 import하며, 배선이 호출할 세 함수를 모두 실행한다 | +| V2 | `bun test tests/codex-auth-api.test.ts` | **0** (199 pass / 0 fail, 1.12s) | 예 — `handleCodexAuthAPI`를 통해 `src/codex/auth-api.ts`의 consume 라우트를 실제 호출한다(consume 요청 26건, 예: :2453, :2488) | +| V3 | `bun run typecheck` | **0** (`bun x tsc --noEmit`, 0.99s) | 예 — 저장소 전체 strict 타입체크. `auth-api.ts`와 `account-auth.ts`의 신규 타입 좁히기를 커버한다 | +| V4 | `bun test tests/cli-account.test.ts` | **0** (110 pass / 0 fail, 396ms) | 예 — **A8의 실제 검증자**. `run()`(:410)이 `cmdAccount`를 직접 호출해 인자 파싱을 실행하고, `requests`(:76) 배열이 `{method, path, search, body}`(:140)를 기록한다. 즉 `--operation-id`가 body에 실렸는지 **관측 가능하다**. 이미 `reset-credits`를 인자로 실행하는 케이스가 있다(:1580) | +| V5 | `bun test tests/core-lab-boundary.test.ts` | (구현 단계에서 실행) | 예 — 런타임 import 그래프를 실제로 걷는다. D-2의 신규 import 파급을 검출한다 | +| V6 | `bun test tests/management-route-registry.test.ts` | (구현 단계에서 실행) | 예 — `route-registry.ts`를 읽어 등재 목록을 고정한다. 4c의 "레지스트리 무변경" 주장을 증명한다 | +| V7 | `bun test tests/cli-capabilities.test.ts` | (구현 단계에서 실행) | 예 — 단 **A8은 검증하지 못한다**. 아래 경고 참조 | + +### V7 경고 — `cli-capabilities.test.ts`는 A8의 검증자가 아니다 (감사 Blocker #3-3) + +이전 초안은 V4로 `tests/cli-capabilities.test.ts`를 지목하면서 A8("CLI +`--operation-id`가 body에 실린다")을 검증한다고 적었다. **틀렸다.** 이 파일의 +:220/:275는 다음과 같은 **라우트 문자열 목록**을 고정한다: + +``` +"POST /api/codex-auth/reset-credits/consume", +``` + +즉 고정 대상은 method+path 문자열이지 CLI 플래그가 아니다. `--operation-id`를 +추가해도 이 목록은 한 글자도 바뀌지 않으므로, 이 테스트는 **통과하든 실패하든 +A8에 대해 아무것도 말해주지 않는다.** 회귀 감시용으로 실행 목록에는 남기되 +(라우트 표면이 실수로 바뀌지 않았음을 확인), A8의 근거로 인용하지 않는다. + +V1/V2/V3 실행 로그 요약: + +``` +$ bun test tests/codex-reset-credit-operation-ledger.test.ts + 44 pass / 0 fail / 201 expect() calls [5.26s] -> exit 0 + +$ bun test tests/codex-auth-api.test.ts + 199 pass / 0 fail / 719 expect() calls [1120.00ms] -> exit 0 + +$ bun run typecheck + $ bun x tsc --noEmit -> exit 0 +``` + +**금지**: `bun run test`, 인자 없는 `bun test`(전체 스위트). 이 유닛의 변경은 +위 focused 집합으로 충분히 덮인다. AGENTS.md의 간접 의존 예외(서브프로세스, +데이터로 읽히는 소스, 골든 파일)에 해당하는 경로를 건드리지 않는다. + +--- + +## 6. 실행 순서 — red 먼저 (비가역 외부 상태이므로 필수) + +크레딧 소비는 되돌릴 수 없다. 구현 후 테스트를 쓰면 "구현이 하는 일"을 그대로 +베끼게 되고, 멱등성 결함은 정확히 그 방식으로 통과한다. 그래서 **테스트가 먼저 +실패하는 것을 눈으로 확인한 뒤** 구현한다. + +### 단계 1 — red 테스트 작성 (프로덕션 코드 손대지 않음) + +`tests/codex-auth-api.test.ts`에 추가. 기존 테스트 패턴(:2463-2500)을 그대로 +따른다: `globalThis.fetch` 스텁 + `handleCodexAuthAPI` 직접 호출 + `finally` 복원. + +R1. **같은 operationId 두 번 = 업스트림 소비 1회** (핵심 회귀) + +``` +let consumeCalls = 0; +const opId = "11111111-1111-4111-8111-111111111111"; +// 1회차: {accountId:"pool-x", operationId:opId} -> 200 {code:"reset", remaining:N} +// 2회차: 동일 body -> 200 {code:"reset", replayed:true} +expect(consumeCalls).toBe(1); +``` + +red 근거: 현재 `operationId`는 무시되므로 2회차도 업스트림을 때려 +`consumeCalls === 2`가 된다. **이 숫자가 이 유닛의 존재 이유다.** + +R2. **동일 operationId면 업스트림 `redeem_request_id`가 동일** + +스텁에서 요청 body를 파싱해 `redeem_request_id`를 수집. +red 근거: 현재는 매번 새 UUID → 두 값이 다르다. + +R3. **operationId 없으면 legacy 동작 그대로** (하위호환 계약) + +`{accountId}`만 보낸 요청이 원장을 만들지 않고 매번 새 `redeem_request_id`를 +쓰는지. 이건 처음부터 green이며 **구현 후에도 green이어야 한다** — red로 +만들지 않는다. 회귀 감시용이다. + +R4. **형식 위반 operationId → 400**, 원장/업스트림 미호출 + +`{accountId, operationId: "not-a-uuid"}` → 400 `Invalid operationId format`, +`consumeCalls === 0`. + +R5. **다른 계정의 operationId → 409** + +계정 A로 opId 예약 후 계정 B(다른 `chatgptAccountId`)로 같은 opId → 409 +`identity_mismatch`, 업스트림 미호출. + +R6. **업스트림 실패 후 재시도가 새 소비를 만들지 않음** + +1회차 스텁이 500 반환 → 응답 500, 원장은 ambiguous. +2회차 같은 opId → `resumed` 경로로 **같은** `redeem_request_id` 재전송. +red 근거: 현재는 2회차가 새 UUID를 만들어 이중소비 창을 연다. + +R7. **CLI 인자 파싱 — `tests/cli-account.test.ts`에 추가** (A8 전용, 감사 Blocker #3-3) + +이 테스트만이 CLI 표면을 실제로 관측한다. 기존 하네스를 그대로 쓴다: +`run()`(:410)이 `cmdAccount`를 호출하고, `requests`(:76)가 `body`(:140)를 기록한다. + +``` +// R7-a: --operation-id가 consume body에 실린다 +const opId = "11111111-1111-4111-8111-111111111111"; +requests.length = 0; +const ok = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", opId]); +const sent = requests.find(r => r.path === "/api/codex-auth/reset-credits/consume"); +expect((sent?.body as { operationId?: string })?.operationId).toBe(opId); + +// R7-b: --operation-id 없으면 키 자체가 없다 (하위호환) +expect(Object.prototype.hasOwnProperty.call(bodyOf(sent2), "operationId")).toBe(false); + +// R7-c: --consume 없이 쓰면 usage 에러 + 요청 0건 +const bad = await run(["reset-credits", "main", "--operation-id", opId]); +expect(bad.code).toBe(2); +expect(requests.length).toBe(0); + +// R7-d: 형식 위반은 CLI에서 걷힌다 (서버 왕복 없음) +const badId = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", "nope"]); +expect(badId.code).toBe(2); + +// R7-e: 값 누락은 takeOption 자체가 CliUsageError를 던진다 (runtime-api.ts:145) +const noValue = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id"]); +expect(noValue.code).toBe(2); +``` + +red 근거: 현재 `resetCredits`는 `--operation-id`를 파싱하지 않으므로 R7-a는 +`undefined`가 되어 fail하고, R7-c는 `rejectArgs`가 알 수 없는 인자로 걷어내 +**우연히** exit 2가 될 수 있다 — 그래서 R7-c는 단독 근거로 쓰지 말고 R7-a와 함께 본다. + +### 단계 2 — red 확인 (증거 기록) + +``` +bun test tests/codex-auth-api.test.ts +bun test tests/cli-account.test.ts +``` + +R1, R2, R4, R5, R6, R7-a가 **fail**, R3이 **pass**. 이 출력을 유닛 노트에 붙인다. +R1이 fail하지 않으면 테스트가 결함을 못 잡고 있다는 뜻이니 **구현하지 말고** +테스트를 고친다. + +### 단계 3 — 구현 + +D-1 → D-2 → D-5(docs 8 로케일) 순서. 각 파일 후 `bun run typecheck`. + +### 단계 4 — green 확인 + +``` +bun test tests/codex-auth-api.test.ts +bun test tests/codex-reset-credit-operation-ledger.test.ts +bun test tests/cli-account.test.ts +bun test tests/management-route-registry.test.ts +bun test tests/cli-capabilities.test.ts +bun test tests/core-lab-boundary.test.ts +bun run typecheck +git diff --exit-code src/server/management/route-registry.ts +``` + +기대: R1~R7 전부 pass, `codex-auth-api` 기존 199건 유지, 원장 44건 유지, +`cli-account` 기존 110건 유지, 레지스트리 diff 없음(exit 0). + +--- + +## 7. Accept criteria (검증 가능) + +| # | 기준 | 검증 방법 | +|---|---|---| +| A1 | 같은 `operationId` 재요청이 업스트림 consume을 1회만 호출 | R1, `consumeCalls === 1` | +| A2 | 같은 `operationId`면 `redeem_request_id`가 동일 | R2, 수집값 비교 | +| A3 | `operationId` 부재 시 기존 동작 동일 | R3 + 기존 199건 무수정 통과 | +| A4 | 형식 위반 → 400, 500 아님 | R4 | +| A5 | 타 계정 id → 409, 업스트림 미호출 | R5 | +| A6 | 원장 unavailable/capacity → 503 + `Retry-After`, 랜덤 폴백 없음 | 원장 IO 실패 주입 후 상태코드 및 `consumeCalls===0` | +| A7 | 정산 실패가 성공 응답을 실패로 바꾸지 않음 | settle이 `mismatch`를 반환하도록 유도한 뒤 200 유지 확인 | +| A8 | CLI `--operation-id`가 body에 실린다 / `--consume` 없이 쓰면 usage 에러 | **`tests/cli-account.test.ts`** (R7). `run(["reset-credits","main","--consume","--yes","--operation-id",opId])` 후 `requests`에서 consume 요청을 찾아 `body.operationId === opId` assert. `cli-capabilities`가 아니다 — V7 경고 참조 | +| A9 | typecheck green | V3 | +| A10 | core-lab import 경계 유지 | V5 | +| A11 | docs-site 8개 로케일이 새 400/409/503 계약을 기술 | 4c 표의 8개 줄 수동 확인. 영어 원문과 모순 없음 | +| A12 | 라우트 레지스트리 무변경 | V6 green + `git diff --exit-code src/server/management/route-registry.ts` | + +--- + +## 8. 조건 분기 activation scenario + +분기를 추가하면 "누가 어떻게 발화시키고 무엇이 관측되는가"를 명시한다. + +| 분기 | 누가 발화 | 어떻게 | 관측 결과 | +|---|---|---|---| +| `hasOperationId === false` | 현재 GUI 버튼, `--operation-id` 없는 CLI | body에 키 없음 | 원장 행 0개, 매 요청 새 `redeem_request_id`, 응답에 `replayed` 없음 | +| 형식 위반 400 | 손으로 만든 curl, 잘못된 스크립트 | `operationId:"abc"` | 400 `Invalid operationId format`, 업스트림 0회, 원장 미변경 | +| `execute resumed:false` | `--operation-id <새 uuid>` 첫 호출 | 신규 id | 원장 pending 행 1개, 업스트림 1회, 200 | +| `execute resumed:true` | 타임아웃 후 같은 id 재실행 | 동일 id, 미정산 상태 | 업스트림에 **동일** `redeem_request_id` 재전송, 크레딧 추가 소비 없음 | +| `execute resumed:true` (alias join) | 다른 id로 재시도했는데 이전 작업이 아직 pending | 새 id, 같은 계정 | 업스트림에는 **원래** id가 나감. 새 id는 히스토리에 alias로 기록 | +| `terminal` | 정산 완료 후 같은 id 재실행 | 동일 id | **업스트림 0회**, 200 `{code, replayed:true}`, `remaining` 없음 | +| `identity-mismatch` | 계정 A의 id를 계정 B로 사용 | `chatgptAccountId` 상이 | 409, 업스트림 0회 | +| `capacity` | manual id 4096개 또는 계정 128개 초과 | 장기 운용 축적 | 503 + `Retry-After: 1`, 업스트림 0회 | +| `unavailable` | 원장 DB 파일 손상/권한 상실 | sqlite 열기 실패 | 503 + `Retry-After: 1`, 업스트림 0회, `[opencodex] Reset-credit operation ledger is unavailable.` 로그 | +| `ambiguous` (throw) | fetch 예외/타임아웃 | 네트워크 단절 | 원장 state=ambiguous, 500. 같은 id 재시도는 `resumed`로 복귀 | +| `ambiguous` (!resp.ok) | 업스트림 5xx | 스텁 500 | 원장 ambiguous, 상태코드 passthrough | +| `ambiguous` (unknown code) | 업스트림 스키마 변경 | `{code:"weird"}` → `"unknown"` | settle 아님, ambiguous. 200 + `{code:"unknown"}` | + +--- + +## 9. 리스크 + +1. **alias join의 canonical id 혼동** — 4a 주의 참조. 업스트림엔 `opened.operationId`, + 원장엔 `identity`. 뒤바꾸면 조용한 `mismatch`. R6가 이걸 잡는다. +2. **`snapshotManualIdentity`의 throw** — 형식 사전검증 누락 시 500. R4가 잡는다. +3. **`withResetCreditAuth` 내부에서 원장을 여는 구조** — `chatgptAccountId`가 + auth 해석 이후에만 존재하므로 불가피하다. main 계정 경로는 + `nativeMainLease`를 잡은 상태(:385)이므로 원장 IO가 길어지면 lease 보유가 + 길어진다. 원장은 로컬 sqlite 단일 트랜잭션이라 실측 영향은 무시 가능하지만, + 여기에 네트워크 호출을 추가하지 말 것. +4. **wp13과의 스키마 경합** — 3장 OUT으로 회피. 착지 순서 무관. diff --git a/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md new file mode 100644 index 0000000000..d5e2de7277 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md @@ -0,0 +1,81 @@ +# wp6 — PR #3251 / #3327 게이트 해제 후 랜딩 + +두 PR 모두 코드는 정확하고, `enforce-target`이 GUI 스크린샷 게이트 하나 때문에 +빨간 상태다. 이 work-phase는 게이트를 정직하게 푸는 것이지 코드를 고치는 것이 아니다. + +## PR #3251 (@abhisheksharma2411, +194/-2, 12파일) + +요청한 service tier가 실제로 granted 됐는지 로그 UI에 표시한다. + +백엔드는 이미 `tierOutcome`을 GUI로 보낸다 — `src/server/request-log.ts:424`가 +`RequestLogEntry`에 싣고, 타입은 `src/types/provider.ts:129-133`의 +`confirmation: "confirmed" | "assumed" | "downgraded" | "unknown"` + `fastDowngradeReason`. +GUI 소비는 0건이다(`rg tierOutcome gui/src/`). 계산은 되는데 표시가 없는 공백이고, +이 PR은 순수 소비자 추가다. + +저자가 `assumed`를 `confirmed`로 바꾸지 않은 것이 중요하다. 그렇게 하면 #2558을 +재도입한다. 불확실성을 감추지 않고 드러내는 쪽이 맞다. + +### UX 판정 (cxc-dev-uiux-design) + +`UX-STATE-01` 관점에서 이 변경은 **불확실성 표현**이다. `assumed`는 로딩도 에러도 +아닌 제3의 상태이고, 이것을 `confirmed`처럼 보이게 하는 것이 원래 결함이었다. +세 문자열이 서로 구분되고 각각이 무엇을 뜻하는지 툴팁에서 읽히면 계약을 만족한다. + +i18n 키를 새로 만들지 않은 결정도 지지한다. 9개 로케일에 검증 불가능한 번역 8개를 +지어내는 것보다, 이웃 필드가 이미 번역 없이 출력하는 기술 식별자와 같은 취급이 정직하다. + +## PR #3327 (@olddonkey, +70/-10, 2파일) + +`tests/provider-quota.test.ts`(+26/-10)와 `gui/tests/provider-capacity-shell.test.tsx`(+44/-0). +프로덕션 파일 0개. `gui/tests/`를 건드려서 스크린샷 게이트에 걸린 것이고, 테스트만 +바꾼 PR에 UI 스크린샷을 요구하는 것은 게이트의 오탐이다. + +캐시 identity 단정이 실제 계약과 일치한다: `src/providers/quota.ts:2497`의 +`if (!forceRefresh && cacheFresh) return cache!.response`가 클론 없이 같은 객체를 +돌려주므로 `toBe` 비교가 옳다. + +### 남는 지적 하나 + +`expect(JSON.stringify(refreshed)).not.toContain("tier")`는 그물이 너무 넓다. +무관한 필드에 "tier" 문자열이 들어오면 오탐한다. 커밋을 하나 더 얹어 +`aggregation`/`currentAccount` 경로로 좁힌다. 형제 테스트와 스타일이 같아 +판정을 뒤집을 정도는 아니지만, 게이트를 여는 김에 정리한다. + +## 게이트 해제 방법 + +`.github/workflows/enforce-pr-target.yml`에 waiver 경로가 **두 개** 있다(`:755`의 +`screenshotWaivedByLabel || hasGuiOverride({ comments })`). + +1. **라벨 경로** — `gui-screenshot-waived` 라벨이 존재하고, **그 라벨을 붙인 사람이 + MAINTAINERS.md에 등재**되어야 한다(`:738-743`). 등재되지 않은 사람이 붙이면 + 무시하고 경고만 남긴다(`:744-747`). 라벨 provenance는 이벤트 로그의 최신 + `labeled`에서 읽는다. +2. **코멘트 경로** — `hasGuiOverride({ comments })`. 라벨보다 가벼우므로 일회성 + false positive에는 이쪽이 적절할 수 있다. + +`@lidge-jun`은 MAINTAINERS.md `:10`에 Project owner로 등재되어 있으므로 두 경로 모두 +유효하다. + +#3327은 이 waiver가 정확히 의도된 용례다 — 테스트 전용 PR에 대한 false positive. +#3251은 실제 UI 변경이므로 waiver가 아니라 스크린샷이 옳다. 스크린샷은 사람 산출물이라 +이 세션이 만들 수 없다. 따라서 #3251은 리베이스 + 게이트 상태 보고까지만 하고, +스크린샷 첨부 또는 waiver 판단은 메인테이너에게 남긴다. + +## 스코프 + +- IN: #3327 waiver + 그물 좁히는 커밋 + 머지. #3251 리베이스 + 현재 상태 보고. +- OUT: #3251에 가짜 스크린샷을 만들어 붙이는 것. #3379의 나머지 두 항목. + +## Accept criteria + +- #3327이 dev의 조상이 되고, 스쿼시 커밋에 `Co-authored-by: olddonkey `이 남는다. +- 좁힌 단정이 `bun test tests/provider-quota.test.ts`에서 green. +- #3251은 dev 최신 위로 리베이스되고, 남은 차단이 스크린샷 하나임이 PR에 기록된다. + +## Verifier 사전 확인 + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun test tests/provider-quota.test.ts` | 0 | 예 — `src/providers/quota.ts`를 import | +| `gh pr checks 3327` | 게이트 상태 | 예 — head SHA 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md b/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md new file mode 100644 index 0000000000..4e53d787c4 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md @@ -0,0 +1,1054 @@ +# WP7 — 롤백 저널 항목 삭제 CRUD (#3379 중 1/3) + +대상 이슈: #3379 "dashboard management gaps" (우선순위 57). +base: `origin/dev` = 2421e44ce, package 2.43.0, 브랜치 `codex/priority65-closeout`. + +이 문서는 **copy-paste 실행용 PRD**다. 아래 모든 코드 인용은 위 base에서 실제로 읽은 +현재 라인이다. 라인 번호는 그 시점 기준이며, 구현 시 앵커 문자열로 다시 찾을 것. + +--- + +## 0. 스코프 경계 + +### IN + +1. 롤백 저널 항목 **하나**를 대시보드에서 삭제하는 경로. + - `DELETE /api/client-integrations/journal` (opId 지정) + - `RollbackHistory.tsx` 행 단위 삭제 어포던스 + 확인 다이얼로그 + - 삭제 시 남아 있던 스냅샷 파일도 함께 제거 +2. 위 라우트가 통과해야 하는 기존 게이트 4종: 라우트 레지스트리, CLI capability 패리티, + 저널 불변식, locale 키 패리티. +3. i18n 9개 로케일 신규 키 전량. + +### OUT (이 work-phase에서 건드리지 않음) + +1. **커스텀 사용량 범위** (#2748 흡수분). `gui/src/pages/Usage.tsx`, `src/usage/summary.ts`, + 시간 단위 버킷팅, 범위 스코프 비용 추정 — 전부 다른 work-phase. +2. **계정 셀렉터 이름 변경** (#3017 흡수분). `gui/src/components/CodexAccountPickerSetting.tsx`, + `src/server/management/config-routes.ts`, `PATCH /api/config/codex/accounts/` — 전부 다른 + work-phase. +3. **일괄 삭제 / "전체 비우기"**. 아래 §2에서 설명하는 최신 행 보호 규칙 때문에 일괄 + 삭제는 별도 정책 결정이 필요하다. 이번엔 단건만. +4. 저널 보존 기간(TTL) 정책, 자동 만료. `SNAPSHOT_RETENTION`은 그대로 10. +5. `ocx` CLI 신규 서브커맨드 구현. §4.4에서 capability 게이트를 **exemption으로** 해소하며, + verb 자체는 후속 phase 소유로 명시한다. + +--- + +## 1. 현재 상태 — 실제 코드에서 확인한 공백 + +### 1.1 라우트는 GET 전용 + +`src/server/management/integration-routes.ts:322-323`: + +```ts + if (url.pathname === "/api/client-integrations/journal") { + if (req.method !== "GET") return null; +``` + +`return null`이므로 다른 메서드는 이 핸들러를 그냥 통과해 `management-api.ts:236`의 +`??` 체인 다음 후보로 넘어간다. 405도 아니고 404로 끝난다. + +### 1.2 GUI에 삭제 어포던스 없음 + +`gui/src/pages/integrations/RollbackHistory.tsx:44-57` — 행이 그리는 것은 두 가지뿐이다: +`expired` 배지, 아니면 복원 버튼 하나. + +```tsx + {row.snapshot === "expired" ? ( + // The only genuinely impossible case: the bytes are gone. + {t("integrations.action.snapshotExpired")} + ) : ( + + )} + +``` + +after: + +```tsx + {row.snapshot === "expired" ? ( + // The only genuinely impossible case: the bytes are gone. + {t("integrations.action.snapshotExpired")} + ) : ( + + )} + {/* + Delete sits AFTER restore, and only when the server says so. An expired + row keeps its badge and gains this button — that pairing is the point of + the feature: a row whose bytes are gone was previously a dead entry with + no action at all. + */} + {row.deletable && onDelete && ( + + )} + +``` + +props 확장 — `RollbackRow`와 `RollbackHistory` **양쪽**: + +```tsx + onRestore: (row: IntegrationJournalRow) => void; + /** Optional: a surface that cannot refresh the journal must not offer it. */ + onDelete?: (row: IntegrationJournalRow) => void; +``` + +`RollbackHistory`는 `onDelete`를 두 `RollbackRow` 호출부(현재 82행, 89행) 모두에 전달한다. +82행은 최신 행이라 서버가 `deletable: false`를 주지만, prop을 빠뜨리면 §2.3 규칙이 +*prop 누락*이라는 우연에 의존하게 된다. 규칙은 데이터가 강제한다. + +**CSS 변경 없음.** `gui/src/styles-integrations.css:151`의 모바일 규칙 +`.integration-history-row .btn { margin-left: auto; }`은 버튼이 둘일 때 첫 버튼만 밀어낸다. +420px 이하에서 두 버튼이 나란히 오른쪽 정렬되므로 새 규칙이 필요 없다. +구현 후 좁은 폭에서 눈으로 확인할 것. + +**색상.** `btn-danger`(styles.css:677)를 **쓰지 않는다.** D4-D5 밀도의 운영 도구에서 행마다 +빨간 버튼이 반복되면 목록 전체가 경고처럼 읽힌다. VARIANCE 2-4 범위를 지켜 `btn-ghost`로 두고, +파괴성은 확인 다이얼로그에서 전달한다. 다이얼로그 확인 버튼은 `ConsequenceDialog`가 이미 +`btn-primary`로 렌더한다(ConsequenceDialog.tsx:96) — 그것도 바꾸지 않는다. + +**MOTION 1-2.** 새 트랜지션·애니메이션 없음. 행 제거는 목록 재조회로 자연 반영된다. + +### 4.7 MODIFY `gui/src/pages/integrations/IntegrationsOverview.tsx` + +삭제 확인 상태 추가(179행 `restoring` 옆): + +```tsx + const [deleting, setDeleting] = useState(null); +``` + +646행: + +```tsx + +``` + +`RestoreDialog` 블록(649-655) 뒤에 확인 다이얼로그 추가: + +```tsx + {deleting && ( + setDeleting(null)} + onConfirm={async () => { + // ConsequenceDialog keeps the dialog open and renders a thrown + // message as a Notice (ConsequenceDialog.tsx:54-59), which is the + // error path this feature needs: not a dead end, and the confirm + // button re-enables so the same press is the retry. + await deleteJournalEntry(apiBase, deleting.opId); + setDeleting(null); + await historyResource.refresh(); + }} + /> + )} +``` + +`ConsequenceDialog`는 이 파일이 이미 import한다(pendingToggle/pendingOverwrite에서 사용 중). +`deleteJournalEntry`만 28행 import 블록에 추가한다. + +### 4.8 MODIFY `gui/src/pages/integrations/FileIntegrationPage.tsx` + +동일 패턴. 87행 옆에 `deleting` 상태, 273행에 `onDelete={setDeleting}`, +`RestoreDialog` 블록(276-282) 뒤에 §4.7과 같은 `ConsequenceDialog` 블록. +이 파일도 `ConsequenceDialog`를 이미 import하고 있다(overwriting에서 사용). + +### 4.9 MODIFY `gui/src/pages/integrations/refusal-copy.ts` + +`CODE_KEYS`(refusal-copy.ts:12-14)에 두 코드 등록. 근거는 §5.2. + +before: + +```ts +const CODE_KEYS: Record = { + integration_mutation_busy: "integrations.error.busy", +}; +``` + +after: + +```ts +const CODE_KEYS: Record = { + integration_mutation_busy: "integrations.error.busy", + integration_journal_newest_protected: "integrations.rollback.deleteNewest", + integration_operation_not_found: "integrations.rollback.deleteGone", +}; +``` + +### 4.10 MODIFY `gui/src/i18n/en.ts` + 8개 로케일 + +§5.3 참조. 신규 키 9개 × 로케일 9개 = 81개 항목. + +### 4.11 NEW `tests/management-integration-journal-delete.test.ts` + +§7의 분기 1~6. 기존 `tests/management-integration-routes.test.ts`의 픽스처 패턴 +(`setIntegrationMutationFlightTestHooks`로 임시 store 바인딩, +`setIntegrationPathTestHooks`로 임시 home 바인딩)을 그대로 따른다. + +### 4.12 DELETE + +없음. + +--- + +## 5. UX 명세 (cxc-dev-uiux-design) + +### 5.1 확인 단계 — UX-LAZY-01 STRICT 예외 + +삭제는 비가역이다. 스냅샷 바이트가 사라지고 툼스톤을 되돌리는 UI는 없다. +**magic default로 흡수 금지.** 반드시 확인 다이얼로그를 거친다. + +재사용 컴포넌트: `gui/src/pages/integrations/ConsequenceDialog.tsx`. +이 저장소가 이미 파괴적 액션에 쓰는 컴포넌트이며 4개 슬롯(변경/파급/되돌리기/확인)을 강제한다. +새 모달을 만들면 포커스 트랩·백드롭·pending 처리를 다시 구현하게 된다. + +**정확한 문구:** + +| 슬롯 | English | 한국어 | +|---|---|---| +| title | Delete this rollback entry? | 이 롤백 기록을 삭제할까요? | +| changes | This entry disappears from the rollback list, and any backup it still holds for {path} is deleted from disk. | 이 기록이 롤백 목록에서 사라지고, {path}에 대해 남아 있던 백업 파일도 디스크에서 삭제됩니다. | +| breakage | You will no longer be able to restore the file to this point. Newer entries and the file itself are untouched. | 이 시점으로는 더 이상 파일을 되돌릴 수 없습니다. 더 최근 기록과 파일 자체는 그대로입니다. | +| undo | This cannot be undone. The most recent entry for each client is kept and cannot be deleted. | 되돌릴 수 없습니다. 클라이언트별 가장 최근 기록은 삭제되지 않고 남습니다. | +| confirm | Delete entry | 기록 삭제 | + +`undo` 슬롯이 최신 행 보호를 말하는 것이 중요하다. 사용자가 "그럼 최근 것도 실수로 +지워지나?"를 묻기 전에 답한다. + +행 버튼 라벨: **Delete** / **삭제**. `aria-label`은 스크린리더 사용자가 어느 행인지 +구분할 수 있도록 시각을 포함한다: "Delete the rollback entry from {at}" / +"{at} 롤백 기록 삭제". + +### 5.2 상태별 화면 (UX-STATE-01) + +| 상태 | 무엇을 보여주는가 | 근거 | +|---|---|---| +| **empty** (저널 0행) | 변경 없음. 기존 `integrations.rollback.empty` + `emptyBody`를 그대로 쓴다. Overview는 `integration-empty` 블록(640-644행), 클라이언트 탭은 `page-sub` 한 줄(270-271행). | 삭제 기능은 빈 상태에 새 문구를 요구하지 않는다. 새 문구를 넣으면 "방금 지워서 비었다"와 "원래 비었다"가 구분되는 척하게 되는데, 응답은 그 구분을 담지 않는다. 그리고 최신 행은 삭제 불가이므로 **삭제만으로 목록이 비는 일은 애초에 없다**. | +| **삭제 진행 중** (loading) | `ConsequenceDialog`의 기존 pending 동작: 확인 버튼 `disabled`(96행), 닫기·백드롭 비활성(80·90행). 목록은 그대로 두고 스켈레톤으로 바꾸지 않는다. | optimistic 제거는 실패 시 행을 되살려야 해서 깜빡임을 만든다. 다이얼로그가 열려 있는 동안 목록이 안정적으로 남는 편이 D4-D5 밀도에 맞다. | +| **삭제 실패** (error) | 다이얼로그가 **열린 채로** 남고 `Notice tone="err"`에 사유가 뜬다(ConsequenceDialog.tsx:93). 확인 버튼은 다시 활성화된다(58행 `setPending(false)`). → **재시도 = 같은 버튼 다시 누르기.** 포기하려면 Close. | dead-end 금지. 실패가 다이얼로그를 닫아버리면 사용자는 무엇이 남았는지 모른 채 목록으로 돌아간다. | +| **409 최신 행 보호** | 정상 경로에서는 버튼이 없어 도달 불가. 탭 두 개를 열어두면 도달 가능하며 위 error 상태로 표시된다. 문구는 `integrations.rollback.deleteNewest`. | 서버가 진실이므로 UI가 뒤처져도 안전하게 실패한다. | +| **404 이미 삭제됨** | 같은 error 표시, 문구는 `integrations.rollback.deleteGone`. Close하면 갱신된 목록을 본다. | 탭 두 개 시나리오. 재시도해도 같은 404이므로 문구가 "목록을 새로 불러옵니다"로 다음 행동을 지정한다. | + +**실패 사유 문구 연결.** `describeRefusal`(refusal-copy.ts:78-109)은 `reason` 필드가 있는 +writer refusal만 로컬라이즈한다. 이 라우트의 409/404는 `reason`이 없으므로 refusal이 아니다. +그래서 §4.9에서 `CODE_KEYS`에 두 코드를 등록한다. 등록하지 않으면 서버의 영어 `error` +문자열이 모든 로케일에 그대로 노출된다 — refusal-copy.ts:5-11 주석이 정확히 그 사고를 기록한다. + +단, `ConsequenceDialog`의 catch는 `error.message`를 쓴다(57행). 따라서 둘 중 하나를 택한다: + +- **(권장)** `onConfirm` 안에서 잡아 `new Error(describeRefusal(t, error))`로 다시 던진다. + 다른 다이얼로그 3곳에 영향이 없다. +- `ConsequenceDialog`가 `describeRefusal`을 직접 쓰게 한다. 영향 범위가 넓다. + +선택을 §7 시나리오 10의 테스트로 고정할 것. + +### 5.3 i18n 키 전량 + +이 저장소는 로케일을 `gui/src/i18n/.ts` 9개 파일로 관리하고 +`gui/src/i18n/catalogs.ts:24-34`가 등록한다. `en`이 `TKey`의 원천이고(en.ts:8), +나머지는 `Record`(예: ko.ts:6)이므로 **키를 빠뜨리면 타입 에러**가 난다. +추가로 `gui/tests/i18n-locales.test.ts:33-40`이 키 집합 완전 일치를 강제한다. + +대상 파일 9개: `en.ts`, `ko.ts`, `ja.ts`, `zh.ts`, `zh-TW.ts`, `de.ts`, `fr.ts`, `ru.ts`, `tr.ts`. +삽입 위치는 기존 `integrations.rollback.*` 블록 옆(en.ts:1636-1642 부근). + +신규 키 **9개** (en 값): + +```ts + "integrations.rollback.delete": "Delete", + "integrations.rollback.deleteAria": "Delete the rollback entry from {at}", + "integrations.rollback.deleteNewest": "The most recent entry for this client is kept so you can still undo it.", + "integrations.rollback.deleteGone": "This entry was already deleted. The list will refresh.", + "integrations.dialog.deleteEntry.title": "Delete this rollback entry?", + "integrations.dialog.deleteEntry.changes": "This entry disappears from the rollback list, and any backup it still holds for {path} is deleted from disk.", + "integrations.dialog.deleteEntry.breakage": "You will no longer be able to restore the file to this point. Newer entries and the file itself are untouched.", + "integrations.dialog.deleteEntry.undo": "This cannot be undone. The most recent entry for each client is kept and cannot be deleted.", + "integrations.dialog.deleteEntry.confirm": "Delete entry", +``` + +ko 값: + +```ts + "integrations.rollback.delete": "삭제", + "integrations.rollback.deleteAria": "{at} 롤백 기록 삭제", + "integrations.rollback.deleteNewest": "이 클라이언트의 가장 최근 기록은 되돌리기를 위해 남겨 둡니다.", + "integrations.rollback.deleteGone": "이미 삭제된 기록입니다. 목록을 새로 불러옵니다.", + "integrations.dialog.deleteEntry.title": "이 롤백 기록을 삭제할까요?", + "integrations.dialog.deleteEntry.changes": "이 기록이 롤백 목록에서 사라지고, {path}에 대해 남아 있던 백업 파일도 디스크에서 삭제됩니다.", + "integrations.dialog.deleteEntry.breakage": "이 시점으로는 더 이상 파일을 되돌릴 수 없습니다. 더 최근 기록과 파일 자체는 그대로입니다.", + "integrations.dialog.deleteEntry.undo": "되돌릴 수 없습니다. 클라이언트별 가장 최근 기록은 삭제되지 않고 남습니다.", + "integrations.dialog.deleteEntry.confirm": "기록 삭제", +``` + +나머지 7개 로케일은 같은 의미로 번역한다. 영어를 복사해 넣지 말 것 — +`i18n-locales.test.ts`는 키 존재만 보므로 영어 복붙을 잡지 못한다. +`{path}`와 `{at}` 자리표시자는 모든 로케일에서 유지해야 한다. + +--- + +## 6. 권한 · 감사 · 동시성 + +### 6.1 권한 + +이 저장소에는 admin/editor 같은 **역할(role) 모델이 없다.** 실제 principal 유니온은 +`src/server/management-auth.ts:282-289`: + +```ts +export type ManagementPrincipal = + | "admin-token" + | "gui-session" + | "gui-pair-capability" + | "local-read-capability" + | "local-provider-reload-capability" + | "system-restart-capability"; +``` + +따라서 "admin이냐 editor냐"에 대한 이 저장소에서의 답은 다음과 같다: + +- **DELETE에 별도 권한 게이트를 추가하지 않는다.** `requireManagementAuth` + (management-auth.ts:507-521)가 관리 API 전체를 덮고, 이 라우트는 `admin-token`과 + `gui-session` 둘 다 허용한다 — `PUT /api/client-integrations/{clientId}`(실제 클라이언트 + 설정 파일을 고쳐 쓰는, 훨씬 파괴적인 라우트)와 같은 수준이다. 메타데이터 한 줄 삭제에 + 더 높은 문턱을 두는 것은 일관성이 없다. +- **`session-only`로 좁히지 않는다.** 그 exemption은 사용자의 계정·평판을 쓰는 경계 + (GitHub star, AGENTS.md "User-consent actions")에 쓰인다. 저널 삭제는 로컬 파일 정리이므로 + 해당 없다. +- **`local-read-capability`는 자동으로 배제된다.** 그 principal은 정확한 두 개의 GET + 경로에서만 인정된다(management-auth.ts:277-280, 330행 `req.method !== "GET"`). + DELETE는 도달하지 못한다. +- **CSRF는 자동 적용된다.** `gui-session`은 origin + per-session CSRF 토큰 일치를 요구한다 + (management-auth.ts:274-277). GUI 쪽은 api.ts:199가 GET/HEAD가 아닌 모든 메서드에 헤더를 + 붙이므로 DELETE도 포함된다. 기존 테스트 `management-integration-routes.test.ts`의 + "a GUI-session mutation without CSRF is rejected before integration dispatch"가 이 계층을 + 이미 지킨다. + +### 6.2 감사 로그 + +**전용 감사 로그를 새로 만들지 않는다.** §2.2의 툼스톤 자체가 감사 기록이다: +누가(`by`) 언제(`at`) 어느 opId를 은퇴시켰는지가 저널에 영구히 남는다. 툼스톤은 append-only +파일에 있으므로 이후 삭제 요청으로도 지워지지 않는다 — 삭제는 `JournalEntry`만 대상으로 한다. + +- `by`에는 **principal 이름만** 넣는다(`"gui-session"` / `"admin-token"`). + 토큰·세션 ID·경로는 절대 넣지 않는다. `tests/integrations-invariants.test.ts:123-143`이 + 저널을 "메타데이터일 뿐 파일 사본이 아님"으로 고정하고, `bun run privacy:scan`이 + 자격증명 유출을 막는다. +- 별도 파일을 만들지 않는 이유는 §2.2 표 마지막 행과 같다 — 저장소 루트 엔트리 목록이 + 테스트로 고정되어 있다. +- `console.error` 등 stdout 로깅도 추가하지 않는다. 정상 동작이지 실패가 아니다. + +### 6.3 동시성 + +네 가지 경합을 구분해 다룬다. + +**(a) 다른 프로세스가 저널에 append 중일 때.** +안전하다. 툼스톤도 append이고, `appendFileSync`는 `O_APPEND`로 열어 쓰므로 줄 단위 추가가 +서로를 덮지 않는다 — append-only 설계가 이미 이 경우를 위해 존재한다. 재작성 방식이었다면 +여기서 줄이 유실된다. 이것이 §2.2를 채택한 첫 번째 이유다. + +**(b) 삭제와 복원이 동시에.** +`restoreIntegrationCoordinated`는 `writer.ts:582`에서 `store.findOperation(input.opId)`로 +행을 다시 읽는다. 툼스톤이 먼저 커밋되면 그 조회가 null이 되어 복원은 기존 거절 경로를 탄다. +반대 순서면 복원이 끝난 뒤 삭제된다 — 둘 다 일관된 결과다. +**중요:** 복원이 성공하면 새 `restore` 행이 append되어 그 클라이언트의 최신 행이 바뀐다. +따라서 삭제 대상이 그 사이 최신 행이 될 수 있고, 그때는 409가 정답이다. 핸들러가 +`listOperations(clientId, 1)`을 **툼스톤 쓰기 직전에** 다시 읽는 이유가 이것이다. +다이얼로그가 열려 있던 동안의 상태를 신뢰하지 않는다. + +**(c) 삭제와 apply/disable 토글이 동시에.** +`runIntegrationMutationFlight`(mutation-flight.ts:27-64)에 **넣지 않는다.** +그 비행은 클라이언트당 하나이며 설정 파일 쓰기를 직렬화하기 위한 것이다. +저널 삭제는 설정 파일을 만지지 않으므로 토글을 막을 이유가 없고, 막으면 사용자가 토글 중에 +목록 정리를 못 하게 된다. 대신 (b)의 재조회가 정합성을 책임진다. +**결과적으로 이 라우트는 409 `integration_mutation_busy`를 절대 반환하지 않는다.** + +**(d) 스냅샷 삭제와 prune이 동시에.** +`pruneSnapshots`(journal.ts:222-250)를 그대로 재사용하므로 새 경합이 없다. +`rmSync(..., { force: true })`라 이미 사라진 파일은 성공으로 처리된다. +실패하면 `markPruneFailure` → `retentionDegraded`로 사용자에게 드러난다(state.ts:402-413). + +--- + +## 7. 조건 분기 activation 시나리오 + +이 변경이 추가하는 분기와, 각 분기를 **무엇이 발화시키고 무엇이 관측되는지**. + +| # | 분기 | C(호출자)가 어떻게 발화시키는가 | 관측되는 것 | +|---|---|---|---| +| 1 | `isTombstone(parsed)` = true | 저널에 `{"tombstone":"X",...}` 줄이 존재 | `listOperations`가 opId X 행을 반환하지 않음 | +| 2 | `retired.size === 0` 빠른 경로 | 툼스톤이 하나도 없는 기존 저널 | 반환 배열이 필터 전과 동일 — 기존 동작 무변경 | +| 3 | `!opId` → 400 | `DELETE /api/client-integrations/journal` (쿼리 없음) | `{code:"invalid_op_id"}`, 400 | +| 4 | `!operation` → 404 | 없는 opId, 또는 이미 삭제된 opId로 두 번째 DELETE | `{code:"integration_operation_not_found"}`, 404 | +| 5 | `newest?.opId === opId` → 409 | 클라이언트 최신 행 opId로 DELETE | `{code:"integration_journal_newest_protected"}`, 409, 저널에 툼스톤이 **추가되지 않음** | +| 6 | `pruned.ok === false` | 스냅샷 디렉터리를 읽을 수 없게 만든 뒤 DELETE | 200 + `snapshotRemoved:false`, 이후 상태 응답의 `retentionDegraded:true` | +| 7 | `row.deletable === false` | 최신 행 렌더 | 그 행에 삭제 버튼 DOM 없음 | +| 8 | `row.deletable && onDelete` | 과거 행 렌더 + prop 전달 | 삭제 버튼 존재, aria-label에 시각 포함 | +| 9 | `snapshot === "expired"` + `deletable` | 스냅샷 파일 제거 후 목록 조회 | 만료 배지 **와** 삭제 버튼이 함께 존재 — 이 기능의 핵심 가치 | +| 10 | `ConsequenceDialog` catch 경로 | 서버가 409를 반환하도록 최신 행 삭제 시도 | 다이얼로그 유지 + `Notice tone="err"` + 확인 버튼 재활성, 문구가 영어 원문이 아닌 로케일 문구 | + +분기 1~6은 §4.11의 새 테스트가, 7~10은 GUI 테스트(`gui/tests/`)가 담당한다. + +--- + +## 8. Accept criteria + +전부 관측 가능한 조건으로 쓴다. + +1. `bun run typecheck` exit 0. +2. `bun test tests/integrations-journal.test.ts` exit 0 — 툼스톤 append 후 + `listOperations`가 해당 행만 감추고, 나머지 행 순서(newest first)가 보존됨. +3. `bun test tests/management-integration-journal-delete.test.ts` exit 0 — §7 분기 1~6. +4. `bun test tests/management-integration-routes.test.ts` exit 0 — 기존 30개 회귀 없음. +5. `bun test tests/management-route-registry.test.ts` exit 0 — 신규 DELETE가 레지스트리에 + 선언되고 exemption `why`가 40자 이상, `ownerDoc`이 실존. +6. `bun test tests/cli-capabilities.test.ts` exit 0 — ratchet이 커지지 않음. +7. `bun test tests/integrations-invariants.test.ts` exit 0 — 저장소 루트가 여전히 + `["journal.jsonl","records.json","snapshots"]`. +8. `cd gui && bun test tests/i18n-locales.test.ts` exit 0 — 9개 로케일 키 집합 일치. +9. `bun run lint:gui` exit 0 — 하드코딩 UI 문자열 규칙(`local-i18n/no-hardcoded-ui-strings`) 통과. +10. `bun run privacy:scan` exit 0 — 툼스톤의 `by`가 자격증명을 담지 않음. +11. 수동: 만료된 롤백 행에 삭제 버튼이 보이고, 최신 행에는 보이지 않는다. +12. 수동: 420px 폭에서 복원·삭제 두 버튼이 겹치지 않는다(§4.6). +13. PR 설명에 GUI 스크린샷 첨부 — `enforce-target`이 gui 언급 PR에 요구한다(AGENTS.md). + +--- + +## 9. Verifier 커맨드 (PLAN-VERIFIER-REAL-01) + +아래는 **이 문서를 작성하며 base 2421e44ce에서 실제로 실행한** 결과다. +전부 변경 전 상태이므로 green이며, 구현 후에도 green이어야 한다. +금지된 `bun run test` / bare `bun test`는 사용하지 않았다. + +| # | 커맨드 | exit | 실제 결과 | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---|---| +| 1 | `bun test tests/management-integration-routes.test.ts` | 0 | 30 pass / 0 fail, 5.85s | 읽는다 — `integration-routes.ts`의 저널·복원 라우트를 실서버로 호출하고 CSRF 없는 gui-session 변경 거절까지 통과시킨다(§4.3 수정 대상). | +| 2 | `bun test tests/integrations-journal.test.ts` | 0 | 21 pass / 0 fail, 133ms | 읽는다 — `journal.ts`의 append/list/prune을 직접 호출하고, §3.2 근거인 3-트리 kind 유니온 일치를 소스 파싱으로 검사한다(§4.1 수정 대상). | +| 3 | `bun test tests/management-route-registry.test.ts` | 0 | 13 pass / 0 fail, 72ms | 읽는다 — `route-registry.ts`를 import하고 라우트 소스를 스캔해 대조한다. §4.4에서 DELETE를 선언하지 않으면 여기서 실패한다. | +| 4 | `bun test tests/cli-capabilities.test.ts` | 0 | 17 pass / 0 fail, 122ms | 읽는다 — `MANAGEMENT_ROUTES`를 동적 import해 capability/exemption/ratchet 3자를 대조한다. §4.4의 exemption이 없으면 실패한다. | +| 5 | `bun test tests/integrations-invariants.test.ts` | 0 | 40 pass / 0 fail, 250ms | 읽는다 — 저장소 루트 엔트리 목록을 고정해 §2.2의 "사이드카 파일 금지" 결정을 강제한다. | +| 6 | `cd gui && bun test tests/i18n-locales.test.ts` | 0 | 9 pass / 0 fail, 50ms | 읽는다 — `DICTS`와 `en`을 import해 9개 로케일 키 집합 동일성을 검사한다. §4.10에서 로케일 하나라도 빠지면 실패한다. | + +아직 실행하지 않은 것(구현 후 최초 실행): + +- `bun run typecheck` — 이번 세션 미실행. 변경 전 green을 가정하지 않았다. +- `bun run lint:gui`, `bun run privacy:scan` — 동일. +- `bun test tests/management-integration-journal-delete.test.ts` — §4.11에서 새로 만드는 파일. + +**`bun run test` / bare `bun test`는 이 work-phase에서 실행 금지.** +PR-ready 게이트에서만 유지관리자가 돌린다(AGENTS.md "Commands"). + +--- + +## 10. 구현 순서 + +1. §4.1 `journal.ts` (툼스톤 + listOperations) → verifier 2 +2. §4.2 `store.ts` 시임 +3. §4.3 라우트 + §4.4 레지스트리 → verifier 1, 3, 4 +4. §4.11 신규 테스트 → §7 분기 1~6 고정 +5. §4.5 GUI API + §4.9 refusal-copy + §4.10 i18n → verifier 6 +6. §4.6~4.8 GUI 렌더 → `lint:gui` +7. §8 전체 통과 후 PR (스크린샷 필수, `Closes #3379`는 **쓰지 않는다** — #3379는 셋 중 + 하나만 해결되므로 열린 채로 둔다) + +--- + +## 11. 미해결 — 구현자가 결정해야 할 것 + +1. **`journalDeletePrincipal` 구현** (§4.3). `ManagementContext`에 principal 필드가 + 있는지 먼저 확인하고, 없으면 `managementPrincipal()` 호출로 해석할지 `"management"` + 상수로 둘지 선택. 후자를 고르면 감사 가치가 줄어드므로 §6.2에 그 사실을 남길 것. +2. **`ConsequenceDialog`의 에러 포맷터** (§5.2 마지막). `onConfirm`에서 감싸 던지는 쪽을 + 권한다 — 다른 다이얼로그 3곳에 영향이 없다. diff --git a/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md b/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md new file mode 100644 index 0000000000..088413d787 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md @@ -0,0 +1,97 @@ +# wp8 — 처분 기록과 최종 회귀 증명 + +구현이 끝난 뒤 남는 것을 종결시키고, `dev`가 이번 작업으로 깨지지 않았음을 증명한다. + +## 처분 1 — PR #3061 close + +저자는 메인테이너 본인(@lidge-jun)이고, 이 PR은 이미 무의미하다. + +`78c630a93` (#3351 "test: stop three CI timing flakes from blocking the release train")이 +`origin/dev`의 조상이고, 같은 파일에 같은 세 가지를 더 나은 형태로 이미 적용했다: + +| 항목 | #3061이 하려던 것 | dev에 이미 있는 것 | +|------|------------------|-------------------| +| stdio 캡처 | 추가 | `tests/shutdown-launcher.test.ts:119` | +| 예산 상향 | 하드코딩 | `:84` `process.env.CI ? 60_000 : 20_000` | +| 진단 | `console.error` | `:143-150` exit code/signal/launcher 출력 | +| 테스트 타임아웃 | 하드코딩 `130_000` | `:174` `STARTUP_BUDGET_MS + 40_000` | + +게다가 전제가 반증됐다. macOS job 99447051971에서 SIGINT 케이스가 **90초를 다 쓰고** +실패했다(`[90209.88ms]`). 20초 천장이 원인이라면 90초에서는 통과했어야 한다. +바로 다음 SIGTERM이 1623ms, SIGHUP이 825ms에 통과했으므로 "러너가 바빴다"도 성립하지 않는다. + +@Ingwannu의 CHANGES_REQUESTED가 추가로 지적한 것도 현재 파일에서 확인된다: +`scripts/ci/run-bun-test-batches.sh:6`이 +`BATCH_TIMEOUT_SECONDS="${BUN_TEST_BATCH_TIMEOUT_SECONDS:-120}"`이고 `:113-114`가 +`"${BATCH_TIMEOUT_SECONDS}s"`로 배치를 감싼 뒤 `bun test --isolate --timeout 60000`을 +돌린다. 따라서 #3061이 넣으려던 130초 per-test 예산은 120초 배치 봉투를 초과해 +절대 소진될 수 없다. + +## 처분 3 — 식별된 결함의 배정 + +#3425 조사 중 별도 결함 하나가 나왔다. `tests/codex-routing.test.ts:527`의 +characterization 테스트가 `reconcileCodexRoutingHealth`를 한 번도 호출하지 않아 +`liveHealthAccountIds`가 빈 Set인 상태를 고정한다. 프로덕션에서는 재현되지 않는 +상태이므로, 이 테스트는 도달 불가능한 경로를 초록으로 굳히는 false confidence다. + +이것은 "결정이 필요한 항목"이 아니라 식별된 결함이다. 처분을 미루지 않고 +#3425 코멘트에 별도 항목으로 명시해 기록한다. 이번 유닛에서 테스트를 고치지 않는 +이유는 그 수정이 #3425의 근본 원인 판정과 함께 가야 하기 때문이고, 그 사실 자체를 +코멘트에 적는다. + +처분: 근거를 단 close. 되살릴 이유가 없다. + +## 처분 2 — 설계 결정이 선행하는 항목의 근거 게시 + +아래는 이번 유닛에서 코드를 건드리지 않되, 왜 안 하는지를 해당 PR/이슈에 남긴다. +침묵은 처분이 아니다. + +- **PR #3348** — blocker 잔존 확인. head `928841669`의 `src/combos/failover.ts:627`이 + `[401, 402, 403, 404, 408, 410, 413, 425, 429]`로 generic 410/413을 hop 리스트에 넣는다. + dev는 `[401, 403, 404, 408, 429]`다. 무관한 application 410과 진짜 과대 요청 413이 + 다음 프로바이더로 재전송된다. `devlog/_fin/260703_sse-midstream-reset-tail/00_plan.md:19-23`이 + 중복 완료·중복 과금을 이유로 이미 거부한 동작이다. 안전하게 떼어낼 조각 3개 + (DeepSeek quota 9줄, pacing 503, preflight 502)를 PR에 나열해 저자가 직접 쪼갤 기회를 준다. +- **PR #3389** — 전제 반증. "0바이트면 업스트림이 아무것도 커밋 안 했다"가 안전성 논거인데, + Bun에서 2청크 방출 후 리셋 시 리더가 0바이트를 관측하는 것이 4/4 재현됐다. + 커밋된 비멱등 턴(`store: true`, `mcp`, `web_search`)이 중복 전송될 수 있다. + 게이트를 바이트 수가 아니라 SSE 프로토콜 이벤트 증거로 바꿔야 한다. +- **PR #3329** — 신규 노브에 더해 `coolComboTarget` 쿨다운 우선순위를 무단 역전한다. + `options.cooldownMs ?? parseRetryAfterMs(...)` → `parseRetryAfterMs(..., {preserveImmediate: true}) ?? ...`. + 모든 기존 combo 사용자에게 적용되는 동작 변경이고, "Retry-After가 항상 설정값을 이긴다"는 + 제품 판단이다. 3조각 분할 제안. +- **이슈 #3425** — 이전 REJECT 유효, 새 용의자도 반증. `src/codex/routing.ts:2195`의 + 가드는 `writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)` + 라는 AND 조건인데, `liveHealthAccountIds`는 `:331`에서 `context.codexAccountIds`로 + 채워지고 그 값은 `listLiveCodexAccountIds`(`:282`)가 config 전체 id를 담아 만든다. + 보고자의 계정 A는 등록된 채 살아 있었으므로 두 번째 조건이 false이고 early return이 + 일어나지 않는다. 삭제된 계정에만 발화하는 가드다. + 보고자 질문을 "config reload"에서 **"계정 삭제/재등록 여부"와 + "Codex App이 계정을 고정(fixed)해 보내는 구성인지"**로 교체한다. +- **이슈 #3245** — BLOCKED 유지. 실패가 첫 POST 이전 지점이라 SSE relay/repair/timeout + 코드에 도달조차 하지 않는다. `stale-needs-info`가 타임아웃을 소유한다. + +## 최종 회귀 증명 + +`dev`는 이 세션 중에도 움직인다. 따라서 회귀 확인은 "머지 전 green"이 아니라 +**최종 dev head에서의 green**이어야 한다. + +1. `git fetch origin dev` — 최종 head SHA를 기록. +2. 이번에 랜딩한 모든 머지 SHA에 대해 `git merge-base --is-ancestor FETCH_HEAD`. +3. 최종 head의 CI run을 `gh run list --branch dev --limit 5`로 찾아 결론 확인. +4. **작업 시작 시점 대비 비교**: base `2421e44ce`의 CI 결론과 최종 head의 결론을 + 나란히 놓는다. 시작 시점이 이미 빨간색이었다면 그 실패가 그대로인지(회귀 아님) + 새 실패가 붙었는지(회귀)를 job 이름 단위로 구분한다. +5. 실패가 있으면 그것이 이번 변경에서 온 것인지 focused 테스트로 좁힌다. + 전체 스위트는 여전히 금지 — CI가 3개 OS에서 이미 돌린다. + +시작 시점 확인이 특히 중요하다. base `2421e44ce`의 CI run `33867257170`이 이미 +`failure`였다는 관측이 있으므로, 그 실패의 정체를 먼저 확정하지 않으면 +"내가 깼는지"를 판정할 수 없다. + +## Accept criteria + +- #3061이 근거 코멘트와 함께 closed. +- #3348 / #3389 / #3329 / #3425 / #3245에 각각 판정 코멘트가 게시됨. +- 랜딩한 모든 SHA가 최종 `origin/dev`의 조상임이 증명됨. +- 최종 dev head의 CI 결론이 base 대비 새 실패 0건임이 job 단위로 확인됨. From 6fcbbf94d69f0d60d29901e685e3ea9a7210ce08 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:28:36 +0900 Subject: [PATCH 02/17] docs(devlog): correct the handleResponses caller count in the wp2 verifier table The plan claimed 55 of the 63 candidate files reach the guard. Measured: 18. The other 45 are adapter/parser unit tests that never enter handleResponses, so V5 proves exhaustive candidate collection rather than broad guard execution. --- .../260904_priority65_closeout/010_wp2_responses_boundary.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md index 99ab1cf15d..cf833917c4 100644 --- a/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md +++ b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md @@ -519,7 +519,7 @@ bun test <63개 파일> | V2 | `bun test tests/responses-parser.test.ts` | 0 | 읽는다 — §4.2 새 블록이 이 파일에 있고 `parseRequest` 를 직접 호출한다. **단 `core.ts` 가드는 보지 못한다** — 파서 단위 관측 전용이다 | | V3 | `bun test tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — `handleResponses` 를 통과하므로 §4.1 가드 코드를 **실제로 실행**한다. routedCompaction이 가드에 걸리지 않아야 함을 여기서 판정한다. 안 B 초기 위치(무조건 검사)에서 실제 red를 봤다 | | V4 | `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — **V4가 이 변경을 관측하는 커맨드다**. 초판이 지목한 `:2343` 단독 테스트는 `adapter.buildRequest({_rawBody})` 직접 호출이라(`:2368`) `core.ts` 를 우회해 **아무것도 관측하지 못한다**. compaction 파일이 `handleResponses` 경유로 가드를 실행하므로 둘을 **함께** 돌려야 passthrough 보존을 실제로 증명한다. 실측 176 pass / 0 fail | -| V5 | 63파일 전수 (§7.1 목록) | 3 fail = baseline | 읽는다 — 55개 파일이 `handleResponses` 를 호출하므로 가드 코드가 광범위하게 실행된다. baseline과 동일해야 통과 | +| V5 | 63파일 전수 (§7.1 목록) | 3 fail = baseline | 읽는다 — 63개 중 **18개**가 `handleResponses` 를 호출하므로 가드 코드가 실제로 실행된다(실측: `xargs rg -l 'handleResponses' < 후보목록 \| wc -l` → 18). 나머지 45개는 어댑터/파서 단위라 가드에 도달하지 않지만, 후보 수집이 표본이 아니라 전수임을 보이는 것이 이 행의 목적이다. baseline과 동일해야 통과 | **금지**: `bun run test`, 인자 없는 `bun test`. V5는 rg로 수집한 명시적 파일 목록 실행이지 전체 스위트가 아니다(전체는 ~850파일, V5는 63파일). @@ -639,4 +639,3 @@ PR은 `dev` 를 target하고 설명에 `Closes #3259` 를 넣는다. `dev` 는 d 초판은 6개 파일을 골라 돌리고 "추가 피해 0건"이라고 적었다. v2는 rg로 65개 후보를 전수 수집해 63개를 돌렸고, 그 과정에서 **baseline에 이미 3건의 기존 실패가 있다**는 사실을 발견했다(§7.2). 표본만 돌렸다면 이 3건을 자기 회귀로 오인하거나, 반대로 진짜 회귀를 놓쳤을 것이다. - From 6b43fa4a25fde18038afc8e3d93f3ffdf47672bb Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:29:29 +0900 Subject: [PATCH 03/17] docs(devlog): keep contributor emails out of the tracked plan privacy:scan reads devlog/, so a Co-authored-by trailer spelled out in a plan document fails CI. The trailer still ships in the commit; the plan now names the gh query that resolves the address at implementation time. --- .../020_wp3_combo_metadata_carry.md | 10 +++++----- .../030_wp4_combo_context_cap.md | 4 +++- .../260904_priority65_closeout/050_wp6_gate_unblock.md | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md index 86d42d1768..ae63ba1408 100644 --- a/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md +++ b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md @@ -789,17 +789,18 @@ bun test tests/anthropic-reasoning.test.ts ## 11. 커밋 / PR 규약 -원 저자 정보를 `gh api` 로 확인했다: +원 저자는 `full999`(PR #3332). 트레일러에 쓸 이메일은 구현 시점에 다음으로 조회한다 — +**이 문서에 평문으로 적지 않는다.** `privacy:scan`이 devlog를 스캔하므로 여기에 이메일을 +박으면 CI가 막는다. ``` -$ gh api repos/lidge-jun/opencodex/pulls/3332/commits --jq '.[].commit.author' -{"date":"2026-09-03T05:47:19Z","email":"daiki.furutani@walker-s.co.jp","name":"full999"} +gh api repos/lidge-jun/opencodex/pulls/3332/commits --jq '.[].commit.author' ``` 커밋 본문에 반드시 포함(스쿼시 후에도 살아남도록 브랜치 커밋 본문에 넣는다): ``` -Co-authored-by: full999 +Co-authored-by: full999 <조회한 이메일> ``` `AGENTS.md` "Landing another author's work" 규정상 재구현/carry는 트레일러가 필수다. @@ -827,4 +828,3 @@ PR 설명에는 `.github/PULL_REQUEST_TEMPLATE.md` 의 Summary / Verification / 6. Accept criteria 4/5의 `rg` 두 줄 확인. 7. `.tmp/` 스크래치 삭제 확인, `git status` 로 의도한 5파일만 변경되었는지 확인. 8. co-author 트레일러 포함해 커밋, `dev` 타깃 PR 생성. - diff --git a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md index d4010a2849..81747cc03b 100644 --- a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md +++ b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md @@ -53,7 +53,9 @@ rather than dangerous"라고 적는다. 1. `git fetch origin dev` — dev가 움직였는지 확인. 2. `gh pr checks 3461` — exact head에서 required green 재확인. 3. `gh pr merge 3461 --squash --admin`, 커밋 본문에 트레일러 포함: - `Co-authored-by: RHODIZ IT ` + `Co-authored-by: RHODIZ IT <조회한 이메일>` — 이메일은 + `gh api repos/lidge-jun/opencodex/pulls/3461/commits --jq '.[].commit.author'`로 + 구현 시점에 조회한다. `privacy:scan`이 devlog를 읽으므로 평문으로 적지 않는다. 4. `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD` 5. `git log -1 --format=%B | rg Co-authored-by` — 스쿼시 후 트레일러 생존 확인. diff --git a/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md index d5e2de7277..5588404b98 100644 --- a/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md +++ b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md @@ -69,7 +69,8 @@ i18n 키를 새로 만들지 않은 결정도 지지한다. 9개 로케일에 ## Accept criteria -- #3327이 dev의 조상이 되고, 스쿼시 커밋에 `Co-authored-by: olddonkey `이 남는다. +- #3327이 dev의 조상이 되고, 스쿼시 커밋에 `Co-authored-by: olddonkey` 트레일러가 남는다 + (이메일은 `gh api .../pulls/3327/commits`로 구현 시점 조회 — devlog에 평문 금지). - 좁힌 단정이 `bun test tests/provider-quota.test.ts`에서 green. - #3251은 dev 최신 위로 리베이스되고, 남은 차단이 스크린샷 하나임이 PR에 기록된다. From d9a29db10c0644e2afdc36b92b3a7b6bad692b8a Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:32:32 +0900 Subject: [PATCH 04/17] docs(devlog): amend wp4 from a direct squash to a carry PR #3461 is a fork PR whose head has only the four gate checks; Cross-platform CI never ran and fork workflows wait on approval. Merging on gate-green alone would violate the unit's own exact-head-CI criterion. --- .../030_wp4_combo_context_cap.md | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md index 81747cc03b..dbceccddc3 100644 --- a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md +++ b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md @@ -48,16 +48,33 @@ rather than dangerous"라고 적는다. - IN: PR을 있는 그대로 스쿼시 머지. - OUT: 코드 수정, 리베이스, 추가 매처 확장. -## 실행 절차 - -1. `git fetch origin dev` — dev가 움직였는지 확인. -2. `gh pr checks 3461` — exact head에서 required green 재확인. -3. `gh pr merge 3461 --squash --admin`, 커밋 본문에 트레일러 포함: +## 실행 절차 (P-phase 개정 — 직접 스쿼시에서 carry로) + +**개정 사유.** 계획 수립 시점에는 `gh pr merge 3461 --squash --admin`을 예정했다. +실행 직전 확인에서 전제 하나가 무너졌다: #3461은 **fork PR**이고 +(`head.repo.fork = true`, author `RHODIZSECURITY`), 그 head `4e16f889b`에는 +게이트 4개(enforce-target/hygiene/label/resolve-pr)만 돌았을 뿐 +**Cross-platform CI가 한 번도 실행되지 않았다.** `gh run list --commit 4e16f889b`가 +빈 출력이고, fork PR의 워크플로는 `action_required` 승인 대기로 걸린다. + +AGENTS.md가 적은 그대로다 — "fork contributors cannot start repository CI; a +maintainer has to". 게이트 4개만 초록인 상태를 "CI green"이라고 부르고 머지하면 +이 유닛의 criterion c-2(각 PR이 exact head에서 required CI green)를 위반한다. + +**개정안: 이 유닛의 stacked PR 체인에 carry한다.** + +1. `git fetch origin dev` — dev head를 다시 읽는다(이미 `2421e44ce` → `1a5c9ab23`으로 움직였다). +2. `git fetch origin pull/3461/head` 로 원 커밋을 가져와 `codex/priority65-closeout` + 위에 cherry-pick한다. 코드는 한 줄도 바꾸지 않는다 — 감사에서 그대로 머지 가능 판정이 났다. +3. cherry-pick은 원 author를 보존하지만, 스쿼시 머지에서 살아남는 것은 **트레일러**다. + `--no-commit` 후 트레일러를 붙여 커밋하거나 `git commit --amend`로 본문에 추가한다: `Co-authored-by: RHODIZ IT <조회한 이메일>` — 이메일은 `gh api repos/lidge-jun/opencodex/pulls/3461/commits --jq '.[].commit.author'`로 - 구현 시점에 조회한다. `privacy:scan`이 devlog를 읽으므로 평문으로 적지 않는다. -4. `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD` -5. `git log -1 --format=%B | rg Co-authored-by` — 스쿼시 후 트레일러 생존 확인. + 구현 시점에 조회한다. `privacy:scan`이 devlog를 읽으므로 여기에 평문으로 적지 않는다. +4. 이 브랜치의 PR에서 Cross-platform CI가 실제로 돌고 green인지 확인한다. +5. 머지 후 `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD`. +6. `git log -1 --format=%B | rg Co-authored-by` — 스쿼시 후 트레일러 생존 확인. +7. #3461은 `landed-via-maintainer` 라벨과 함께 close하고, 랜딩 SHA를 코멘트로 남긴다. ## Accept criteria From c880017e74f00057423feaf80a0f555b134a1e6b Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:32:56 +0900 Subject: [PATCH 05/17] fix(combos): fail over provider-specific context caps A provider can report its own target hard cap with a non-semantic vendor code (5059 + invalid_request_prompt_too_long). classifyError remaps any "maximum context" text to context_length_exceeded, which the stop list catches, so the chain ended at the first target even when a larger-context target was still queued behind it. The matcher is deliberately narrow: status 400 AND (the type string OR code 5059 together with the "Prompt N > M maximum context length" shape). A bare 5059 still stops, as do a generic 400 context refusal and a generic 413. Carried from #3461 because that fork PR's head only ever ran the four gate checks -- Cross-platform CI never ran on it, and fork workflows wait on approval, so gate-green was not merge evidence. Co-authored-by: RHODIZ IT --- src/combos/failover.ts | 17 +++++++++++++++++ tests/combos.test.ts | 8 ++++++++ tests/server-combo-failover-e2e.test.ts | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/combos/failover.ts b/src/combos/failover.ts index ae0c044be2..4cb9cdc55a 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -307,6 +307,19 @@ function isModelLifecycleGone( ); } +function isProviderTargetContextOverflow( + status: number, + message: string, + code?: string | null, +): boolean { + if (status !== 400) return false; + const normalizedCode = normalizedFailureCode(code); + const text = message.toLowerCase(); + if (text.includes("invalid_request_prompt_too_long")) return true; + return normalizedCode === "5059" + && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); +} + export function comboFailureDecision( status: number, message: string, @@ -324,6 +337,10 @@ export function comboFailureDecision( if (isModelLifecycleGone(status, message, options?.code)) return "hop"; const error = classifyError(status, "upstream_error", message); if (isCyberPolicyCode(error.code)) return "stop"; + // A provider can expose its own target hard cap with a non-semantic vendor code + // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this + // target is too small, not that every later combo target is incapable of serving it. + if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop"; // A local input-admission refusal (#1524) says "this candidate cannot fit the request", // not "the request is impossible": the next candidate may have a larger context window. // diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 511bdae1a2..51ae4fa1a9 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -500,6 +500,14 @@ describe("combo failure policy and advancement", () => { // An UPSTREAM context verdict still stops: retrying that elsewhere is guesswork, and a // generic 413 with no structured code keeps its existing conservative handling. expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + const providerHardCap = JSON.stringify({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + }}); + expect(comboFailureDecision(400, providerHardCap, { code: "5059" })).toBe("hop"); + expect(comboFailureDecision(400, "ordinary invalid request", { code: "5059" })).toBe("stop"); expect(comboFailureDecision(413, "request too large")).toBe("stop"); }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 39053f39fb..1c3f69b778 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1547,6 +1547,27 @@ describe("server combo failover 030 activation matrix", () => { expect(await exhausted.text()).not.toContain("sk-a-should-redact"); }); + test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { + let backupHits = 0; + const capped = serve(() => Response.json({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + } }, { status: 400 })); + const backup = serve(() => { + backupHits += 1; + return chatSuccess("larger context backup", "m2"); + }); + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + })); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(await response.text()).toContain("larger context backup"); + }); + test("429 Retry-After 120 keeps A cooling at 60 seconds and restores it at 120", async () => { const t0 = Date.parse("2026-07-18T00:00:00.000Z"); let now = t0; From eb0edfe973cf41598916a8375ede4a04841d4c40 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:36:47 +0900 Subject: [PATCH 06/17] fix(responses): reject unpaired tool results on the translating path A tool result is paired by call_id, but inputItemSchema's permissive catch-all (schema.ts:106) accepts a tool item whose strict alternative failed only for a missing call_id, and parser.ts:738/752 then assigns it unchecked. A translating adapter consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends "[tool_result without adjacent tool_use: undefined]" upstream. Guard in handleResponsesInner after the passthrough branch, keyed on the adapter rather than on position. The check cannot live in the schema: parseRequest runs before the passthrough branch, so a parse-time rejection would also kill forward/key passthrough and routed compaction - paths that build from _rawBody, never read context.messages, and already degrade an unpaired output to "[tool output for unknown call]" on their own. routedCompaction skips the passthrough branch yet is still _rawBody-based, which is why the condition tests the adapter instead of the code position. Closes #3259 --- src/server/responses/core.ts | 30 +++++ tests/responses-compaction-routing.test.ts | 128 +++++++++++++++++++++ tests/responses-parser.test.ts | 56 +++++++++ 3 files changed, 214 insertions(+) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a17702fb90..d0b3f94572 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5152,6 +5152,36 @@ async function handleResponsesInner( } } + // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId + // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all + // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A + // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 + // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends + // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259). + // + // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch + // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed + // compaction — paths that never read context.messages, build from _rawBody, and already + // degrade an unpaired output to "[tool output for unknown call]" on their own. + // + // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above + // yet still builds from _rawBody (see the :3703 comment). + if (!("passthrough" in adapter && adapter.passthrough)) { + const unpaired = parsed.context.messages.find( + message => message.role === "toolResult" + && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string" + || (message as { toolCallId: string }).toolCallId.length === 0), + ); + if (unpaired) { + // Never interpolate the tool output: this message reaches the client and the logs. + return formatErrorResponse( + 400, + "invalid_request_error", + "tool result requires a non-empty string call_id", + ); + } + } + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 8dea3094ab..0673f7dcb1 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -1523,3 +1523,131 @@ test("a no-eligible policy compact request persists the evaluation trace", async expect(logCtx.routeDecision!.selected.reason).toBe("no-eligible-candidate"); expect(logCtx.routeDecision!.candidates).toHaveLength(1); }); + +/** + * Issue #3259: a tool result is PAIRED by call_id. inputItemSchema's permissive catch-all + * (schema.ts:106) accepts a tool item whose strict alternative failed only for a missing + * call_id, and parser.ts:738 then assigns it unchecked, so a translating adapter consumes + * `toolCallId: string` holding undefined. Anthropic is the worst case: it does not throw, + * it sends "[tool_result without adjacent tool_use: undefined]" upstream. + * + * The guard cannot live in the schema. parseRequest runs before the passthrough branch, and + * passthrough / routed compaction build from _rawBody, never reading context.messages — they + * already degrade an unpaired output to "[tool output for unknown call]" on their own. + */ +describe("unpaired tool result boundary (#3259)", () => { + function unpairedBody(item: Record): Record { + return { + model: "gw/some-model", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + item, + ], + }; + } + + const anthropicConfig = () => ({ + defaultProvider: "gw", + providers: { + gw: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "test-key", + }, + }, + } as unknown as OcxConfig); + + test("a translating adapter rejects a call_id-less tool result with 400 and sends nothing upstream", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + throw new Error("the guard must reject before any upstream request"); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "function_call_output", output: "bootstrap result" })), + anthropicConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(400); + const json = await res.json() as { error?: { message?: string; type?: string; code?: string } }; + expect(json.error?.message).toBe("tool result requires a non-empty string call_id"); + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.code).toBe("invalid_request_error"); + // The tool output itself must never be interpolated into a client-visible message. + expect(JSON.stringify(json)).not.toContain("bootstrap result"); + expect(fetches).toBe(0); + }); + + test("an empty-string call_id is rejected identically (it can never pair)", async () => { + globalThis.fetch = (async () => { + throw new Error("the guard must reject before any upstream request"); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "custom_tool_call_output", call_id: "", output: "x" })), + anthropicConfig(), + { model: "", provider: "" }, + ); + expect(res.status).toBe(400); + }); + + test("a paired tool result on the same translating route still reaches the upstream", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest({ + model: "gw/some-model", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "paired result" }, + ], + }), + anthropicConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(bodies.length).toBe(1); + expect(bodies[0]).toContain("paired result"); + expect(bodies[0]).not.toContain("undefined"); + }); + + test("the same unpaired body on a passthrough route stays 200 and self-degrades", async () => { + // This contrast is the core claim of the design: passthrough builds from _rawBody, is + // unaffected by the defect, and must not be killed by the guard. + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse(completedPayload("ok")); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "function_call_output", output: "bootstrap result" })), + keyProviderConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(bodies.length).toBe(1); + expect(bodies[0]).toContain("[tool output for unknown call]"); + expect(bodies[0]).not.toContain("undefined"); + }); +}); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 26d380d541..2b53d2272c 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -795,3 +795,59 @@ describe("codex-rs compat surface (260707)", () => { expect(call?.namespace).toBeUndefined(); }); }); + +describe("unpaired tool result boundary (#3259)", () => { + // The real delegation-history shape that produced the defect: a subagent bootstrap turn + // whose FIRST tool result has no originating call in the same request. + const delegationHistory = (toolItem: Record) => ({ + model: "test-model", + input: [ + { type: "message", role: "developer", content: [{ type: "input_text", text: "You are a subagent." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + toolItem, + ], + }); + + const toolResultOf = (item: Record) => + parseRequest(delegationHistory(item)).context.messages.find(m => m.role === "toolResult") as + | { toolCallId?: unknown; content?: unknown } + | undefined; + + test("a function_call_output with no call_id still parses, and yields an unusable toolCallId", () => { + // This is the state src/server/responses/core.ts guards on. `toolCallId` is declared + // `string` (src/types/request.ts:168) but is undefined here — the schema catch-all + // (schema.ts:106) accepted the item and parser.ts:738 assigned it unchecked. + const result = toolResultOf({ type: "function_call_output", output: "bootstrap result" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("an empty-string call_id is equally unusable", () => { + // findToolById (parser.ts:328) matches by identity, so "" can never pair. The guard + // must treat it exactly like undefined. + const result = toolResultOf({ type: "function_call_output", call_id: "", output: "x" }); + expect(result?.toolCallId).toBe(""); + }); + + test("a well-formed tool result on the same history pairs normally", () => { + const result = toolResultOf({ type: "function_call_output", call_id: "call_1", output: "ok" }); + expect(result).toMatchObject({ toolCallId: "call_1", content: "ok" }); + }); + + test("custom_tool_call_output has the identical hole (parser.ts:752)", () => { + const result = toolResultOf({ type: "custom_tool_call_output", output: "x" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("tolerances unrelated to call_id stay intact", () => { + // parser.ts:611-621 deliberately tolerates non-JSON arguments; nothing here may 400 it. + expect(() => parseRequest(delegationHistory({ + type: "function_call", call_id: "c1", name: "shell", arguments: "not json", + }))).not.toThrow(); + // Unknown future item types must keep flowing through the catch-all untouched. + expect(() => parseRequest(delegationHistory({ + type: "brand_new_item_2027", foo: 1, + }))).not.toThrow(); + }); +}); From c691c2565bb7d6987f7acca656a1142244730594 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 22:42:52 +0900 Subject: [PATCH 07/17] feat(anthropic): carry combo vendor capabilities and provider output budget Reimplementation of #3332, which could not be cherry-picked: the PR is CONFLICTING/DIRTY because dev added modelReasoningEfforts to both Anthropic registry entries after it was written. Thin Claude discovery rows carry only id + context window, so the combo intersection collapsed to text-only with no effort ladder and the Codex app hid image attachments and the effort picker for every Claude combo. Fall back to the generated vendor table when the caller supplies no fallback, tolerating point releases by trimming to the family row (claude-fable-5-1 -> claude-fable-5). Codex never sends max_output_tokens, so the Anthropic adapter's omitted-limit default of 8192 truncated long answers with stop_reason=max_tokens. Honor the provider's configured budget and register 64k for both Anthropic entries. One line is deliberately changed from the original PR. It mapped the vendor metadata.maxTokens OUTPUT ceiling onto maxInputTokens; that value is read by the combo intersection's Math.min over member input ceilings, collapsing a 1M Claude combo window to 128k and its autoCompactTokenLimit from 900k to 128k (measured). It fills maxOutputTokens here instead. The original test used toMatchObject on contextWindow only and could not see the defect, so a dedicated regression asserts the input window and autoCompact budget survive. Verification: bun run typecheck, bun test tests/codex-catalog.test.ts (268 pass), bun test tests/anthropic-reasoning.test.ts (67 pass) - all exit 0. Co-authored-by: full999 --- src/adapters/anthropic.ts | 15 ++++-- src/codex/catalog/provider-fetch.ts | 61 ++++++++++++++++++++- src/providers/registry.ts | 8 +++ tests/anthropic-reasoning.test.ts | 14 +++++ tests/codex-catalog.test.ts | 84 +++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index de4c7df150..e8e80dab1c 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -895,11 +895,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti enforceAnthropicImageLimits(messages); const tools = toolsToAnthropicFormat(parsed, toolNames); + // Codex never sends `max_output_tokens`, so the omitted-limit default decides how + // long a Claude answer may run. Honor the provider's configured output budget + // (`modelMaxOutputTokens` / `defaultMaxOutputTokens`) before falling back to the + // conservative 8192, which truncates long answers with stop_reason=max_tokens. + const configuredMaxOut = modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + const omittedMaxTokens = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? configuredMaxOut + : DEFAULT_MAX_TOKENS; const body: Record = { model: parsed.modelId, messages, stream: parsed.stream, - max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, + max_tokens: parsed.options.maxOutputTokens ?? omittedMaxTokens, }; if (isOAuth) { // Claude OAuth (Pro/Max) requires the first system block to be the Claude Code identity. @@ -942,13 +951,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // so effort=max (budget=32k) still leaves OUTPUT_HEADROOM tokens for visible output. body.max_tokens = explicitMaxOut !== undefined ? explicitMaxOut - : Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)); + : Math.max(omittedMaxTokens, Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor))); } else { // Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking + // visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which // 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens // always exceeds the budget within a model-safe ceiling, reserving room for visible output. - const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; + const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens; const wantBudget = reasoningBudget(effectiveReasoning); const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 8e8f417bc9..fee52029ec 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,7 +32,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; -import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { captureFastPolicyAuthority, @@ -862,6 +862,62 @@ interface ComboCatalogMemberFallback { readonly reasoningEfforts?: readonly string[]; } +/** + * Ladder advertised for a combo member whose vendor metadata says it reasons but + * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show + * the effort control; the routed adapters clamp to the real upstream top rung. + */ +const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Vendor-table lookup tolerant of point releases and date pins. Configured combo + * targets often name a variant the table does not carry (`claude-fable-5-1`, + * `claude-opus-4-5-20251101`); the base family row still describes its modality + * and reasoning capability, so fall back to it before giving up. + */ +function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { + const exact = getModelMetadataCaseInsensitive(provider, modelId); + if (exact) return exact; + let candidate = modelId.replace(/\[[^\]]*\]$/, ""); + while (true) { + const trimmed = candidate.replace(/-\d+$/, ""); + if (trimmed === candidate || !trimmed.includes("-")) return undefined; + const hit = getModelMetadataCaseInsensitive(provider, trimmed); + if (hit) return hit; + candidate = trimmed; + } +} + +/** + * Combo members are usually thin discovery rows (id + context window). Without a + * capability source the combo intersection collapses to text-only / no effort ladder, + * and the Codex app then refuses image attachments and hides the effort picker for + * every Claude combo. The generated vendor table knows both, so use it as the + * last-resort fallback when the caller supplied none. + * + * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`. + * Mapping it onto `maxInputTokens` would be read by the combo intersection + * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and + * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it. + */ +function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { + const metadataProvider = resolveMetadataProvider(target.provider); + const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined; + if (!metadata) return undefined; + return { + ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ? { contextWindow: metadata.contextWindow } + : {}), + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxOutputTokens: metadata.maxTokens } + : {}), + ...(Array.isArray(metadata.input) && metadata.input.length > 0 + ? { inputModalities: [...metadata.input] } + : {}), + ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), + }; +} + /** * Resolve a combo target to a catalog member for derivation. * Prefer discovery metadata; when the target is missing from the gather map or @@ -877,11 +933,12 @@ export function resolveComboCatalogMember( memberByKey: ReadonlyMap, providers: ReadonlyMap, contextCap?: number, - fallback?: ComboCatalogMemberFallback, + callerFallback?: ComboCatalogMemberFallback, metadataModelIdCaseFold?: boolean, ): CatalogModel | undefined { const existing = memberByKey.get(targetKey(target)); const prov = providers.get(target.provider); + const fallback = callerFallback ?? vendorMetadataComboFallback(target); // Disabled providers never contribute members — even a complete discovery row // is unusable for catalog derivation while the provider is off. if (prov?.disabled === true) return undefined; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index ea599a3ea9..5598f4f531 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -349,6 +349,10 @@ export type ProviderConfigSeed = Pick< // always on, per the official models overview and pricing page (platform.claude.com). const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; +// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x +// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a +// larger request never over-allocates; it only stops the 8192 truncation. +const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; /** * The effort rungs opencodex exposes for native Anthropic models. Without this the * providers advertised no ladder at all, so every client that keys its effort control off @@ -1369,6 +1373,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + // Codex omits max_output_tokens; without a provider budget the Anthropic adapter + // falls back to 8192, which truncates long answers with stop_reason=max_tokens. + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, defaultModel: "claude-sonnet-5", }, { @@ -1386,6 +1393,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ liveModels: true, modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, defaultModel: "claude-sonnet-5", }, { diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index c6ba14c239..a1e2eacc17 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -281,6 +281,20 @@ describe("anthropic extended-thinking gate", () => { expect(b.max_tokens as number).toBe(64000); }); + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { + const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; + // No reasoning: the configured budget is the wire max_tokens. + expect((await bodyOf(parsed("none", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + expect((await bodyOf(parsed("none", {}, "claude-fable-5"), budgeted)).max_tokens).toBe(32_000); + // Adaptive thinking: the budget still wins over the headroom-derived ceiling. + expect((await bodyOf(parsed("max", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + // Budget thinking on an older family keeps max_tokens above the thinking budget. + const legacy = await bodyOf(parsed("high", {}, "claude-haiku-4-5"), budgeted); + expect(legacy.max_tokens as number).toBeGreaterThan((legacy.thinking as { budget_tokens: number }).budget_tokens); + // An explicit caller limit still wins over the configured budget. + expect((await bodyOf(parsed("none", { maxOutputTokens: 512 }, "claude-opus-5"), budgeted)).max_tokens).toBe(512); + }); + test.each([ ["high", 24_576], ["xhigh", 32_768], diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 4328c10e0b..a46fc64115 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1556,6 +1556,90 @@ describe("combo catalog capability intersection", () => { )).toBeUndefined(); }); + test("resolveComboCatalogMember restores vendor image and effort capabilities for thin Claude rows", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + // A discovery row that only carries id + window (the live Anthropic /models shape). + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // Point-release ids fall back to their family row in the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-fable-5-1" }, + new Map([["anthropic/claude-fable-5-1", { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // An explicit caller fallback still wins over the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + undefined, + { inputModalities: ["text"], reasoningEfforts: [] }, + )).toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] }); + // Unknown ids keep their unknown ladder rather than inventing one. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat" as const, baseUrl: "https://a.example/v1" }]]), + )).not.toHaveProperty("reasoningEfforts"); + }); + + // Sniper for the OUTPUT-vs-INPUT mapping defect carried over from PR #3332. The test + // above uses toMatchObject, which only inspects the keys it names, so without this a + // regression that puts the OUTPUT ceiling into the INPUT slot passes green. + test("vendor metadata fills the OUTPUT ceiling and never shrinks the combo input window", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + const member = resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + ); + // claude-opus-5 vendor row is { contextWindow: 1e6, maxTokens: 128_000 }. + // maxTokens is the OUTPUT ceiling: it must land on maxOutputTokens, never maxInputTokens. + expect(member?.maxOutputTokens).toBe(128_000); + expect(member?.maxInputTokens ?? 1_000_000).toBe(1_000_000); + + // The intersection in deriveComboCatalogModel takes Math.min over member input + // ceilings, so a misplaced 128k would collapse a 1M combo window (and its + // autoCompact budget) for every other target in the group. + const peer = { + provider: "xai", + id: "grok-4.6", + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }; + const derived = deriveComboCatalogModel("claude-grok", normalizedCombo({ + targets: [ + { provider: "anthropic", model: "claude-opus-5", weight: 1 }, + { provider: "xai", model: "grok-4.6", weight: 1 }, + ], + }), [member!, peer]); + expect(derived).toMatchObject({ + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + autoCompactTokenLimit: 900_000, + }); + }); + test("still omits combos when synthesis cannot recover hard failures", async () => { const config: OcxConfig = { port: 10100, From 94e970c04b82b8eb03f195d3af38c502fa922c0f Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 23:07:05 +0900 Subject: [PATCH 08/17] feat(codex): give a manual reset-credit redeem a stable operation identity The durable ledger for manual reset-credit operations was complete and had no production caller: openManualResetCreditOperation, settleManualResetCreditOperation, and markManualResetCreditOperationAmbiguous were referenced only by their own test file. Meanwhile the consume endpoint minted a fresh crypto.randomUUID() per call and sent it as redeem_request_id, so a retry of the same logical redeem looked like a new one to upstream. Spending a reset credit is irreversible, which is the case where idempotency has to be the caller's to assert. An optional operationId in the request body now opens a ledger row keyed by the physical ChatGPT account, and the canonical id becomes the redeem_request_id. Opening fails closed: capacity and unavailable return 503 rather than falling back to a random id, because that fallback is exactly the double-spend the identity exists to prevent. A row that is already terminal replays its recorded code instead of trusting upstream idempotency, and an id owned by another account returns 409. Settling fails open. By then the credit is already spent, so reporting a ledger failure to the user would invite a manual retry -- the double-spend again, from the other direction. Dispatch errors and non-2xx responses mark the row ambiguous so a later replay is never mistaken for a new operation. Omitting operationId keeps today's behavior exactly, including the random id, so no existing caller changes. The CLI gains --operation-id; the GUI is unchanged, since guessing a reuse window there could swallow a genuinely intended second redeem. --- .../docs/fr/reference/management-api.md | 2 +- .../docs/ja/reference/management-api.md | 2 +- .../docs/ko/reference/management-api.md | 2 +- .../content/docs/reference/management-api.md | 2 +- .../docs/ru/reference/management-api.md | 2 +- .../docs/tr/reference/management-api.md | 3 +- .../docs/zh-cn/reference/management-api.md | 2 +- .../docs/zh-tw/reference/management-api.md | 2 +- src/cli/account-auth.ts | 18 +- src/codex/auth-api.ts | 110 ++++++- tests/cli-account.test.ts | 64 ++++ tests/codex-auth-api.test.ts | 285 ++++++++++++++++++ 12 files changed, 469 insertions(+), 25 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 42a9b3f78c..606f354145 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -258,7 +258,7 @@ Codex. Ses routes sont les suivantes : | `PUT /api/codex-auth/failover` | Définir le seuil de basculement du compte | 400 seuil invalide | | `GET /api/codex-auth/quota` | Lire l'état du quota mis en cache par compte | — | | `GET /api/codex-auth/reset-credits` | Inspecter l'éligibilité au crédit de réinitialisation pour un compte | 400 identifiant de compte manquant ; transmission du statut en amont ; 500 échec de recherche | -| `POST /api/codex-auth/reset-credits/consume` | Consommer un crédit de réinitialisation éligible | 400 identifiant de compte manquant ; transmission du statut en amont ; 503 `server_busy` ; 500 consommer l'échec | +| `POST /api/codex-auth/reset-credits/consume` | Consommer un crédit de réinitialisation éligible. L'`operationId` facultatif (UUIDv4) rend la consommation idempotente : le même id rejoue un unique résultat durable au lieu de consommer un second crédit. | 400 identifiant de compte manquant ou `operationId` invalide ; 409 `identity_mismatch` si l'id appartient à un autre compte ; transmission du statut en amont ; 503 `server_busy`, `capacity` ou `unavailable` ; 500 consommer l'échec | | `POST /api/codex-auth/login` | Démarrer une connexion ou une réauthentification Codex | 400 requête invalide ; état de connexion en conflit ou occupé | | `POST /api/codex-auth/login/code` | Soumettre manuellement un code pour un flux de connexion Codex | 400 flux ou code invalide | | `POST /api/codex-auth/login/cancel` | Annuler un flux de connexion Codex | — | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 2eb3bdd362..6bff10bb65 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -219,7 +219,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` |アカウントのフェイルオーバーしきい値を設定する | 400 無効なしきい値 | | `GET /api/codex-auth/quota` |キャッシュされたクォータ状態をアカウントごとに読み取る | — | | `GET /api/codex-auth/reset-credits` |アカウントのリセット クレジット資格を検査する | 400 アカウント ID がありません。アップストリームステータスパススルー。 500 検索失敗 | -| `POST /api/codex-auth/reset-credits/consume` |対象となるリセット クレジットを消費する | 400 アカウント ID がありません。アップストリームステータスパススルー。 503 `server_busy`; 500 消費失敗 | +| `POST /api/codex-auth/reset-credits/consume` |対象となるリセット クレジットを消費する。任意の `operationId`(UUIDv4)を指定すると消費が冪等になります。同じ id は 2 つ目のクレジットを消費せず、保存済みの結果を 1 回再生します。 | 400 アカウント ID がありません、または `operationId` が不正です。id が別のアカウントに属する場合は 409 `identity_mismatch`。アップストリームステータスパススルー。 503 `server_busy`、`capacity`、`unavailable`; 500 消費失敗 | | `POST /api/codex-auth/login` | Codex のログインまたは再認証を開始する | 400 無効なリクエスト。競合/ビジー ログイン状態 | | `POST /api/codex-auth/login/code` | Codex ログイン フローの手動コードを送信する | 400 無効なフロー/コード | | `POST /api/codex-auth/login/cancel` | Codex ログイン フローをキャンセルする | — | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 20c5fc7379..56a0240843 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -222,7 +222,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` | account failover threshold를 설정합니다 | 400 잘못된 threshold | | `GET /api/codex-auth/quota` | 계정별 캐시된 quota 상태를 읽습니다 | — | | `GET /api/codex-auth/reset-credits` | 계정의 reset-credit 자격을 확인합니다 | 400 누락된 account id; upstream 상태 전달; 500 조회 실패 | -| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다 | 400 누락된 account id; upstream 상태 전달; 503 `server_busy`; 500 소비 실패 | +| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다. 선택적 `operationId`(UUIDv4)를 보내면 소비가 멱등해집니다 — 같은 id는 크레딧을 다시 쓰지 않고 저장된 결과 하나를 재생합니다. | 400 누락된 account id 또는 잘못된 `operationId`; id가 다른 계정 소유이면 409 `identity_mismatch`; upstream 상태 전달; 503 `server_busy`/`capacity`/`unavailable`; 500 소비 실패 | | `POST /api/codex-auth/login` | Codex 로그인 또는 재인증을 시작합니다 | 400 잘못된 요청; 충돌/바쁨 로그인 상태 | | `POST /api/codex-auth/login/code` | Codex 로그인 흐름용 수동 코드를 제출합니다 | 400 잘못된 흐름/code | | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 716636bfdc..1a40b9f37e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -277,7 +277,7 @@ manager. Its routes are: | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | | `GET /api/codex-auth/quota` | Read cached quota state by account | — | | `GET /api/codex-auth/reset-credits` | Inspect reset-credit eligibility for an account | 400 missing account id; upstream status passthrough; 500 lookup failure | -| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit. Optional `operationId` (UUIDv4) makes the redemption idempotent: the same id replays one durable outcome instead of spending a second credit. | 400 missing account id or invalid `operationId`; 409 `identity_mismatch` when the id belongs to another account; upstream status passthrough; 503 `server_busy`, `capacity`, or `unavailable`; 500 consume failure | | `POST /api/codex-auth/login` | Start Codex login or reauthentication | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Submit a manual code for a Codex login flow | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 4090e91768..e6125c36f7 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -247,7 +247,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `PUT /api/codex-auth/failover` | Задать порог failover аккаунтов | 400 invalid threshold | | `GET /api/codex-auth/quota` | Прочитать кэшированное состояние квоты по аккаунтам | — | | `GET /api/codex-auth/reset-credits` | Проверить право аккаунта на reset credit | 400 missing account id; upstream status passthrough; 500 lookup failure | -| `POST /api/codex-auth/reset-credits/consume` | Израсходовать доступный reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +| `POST /api/codex-auth/reset-credits/consume` | Израсходовать доступный reset credit. Необязательный `operationId` (UUIDv4) делает списание идемпотентным: тот же id воспроизводит один сохранённый результат вместо расходования второго кредита. | 400 missing account id или некорректный `operationId`; 409 `identity_mismatch`, если id принадлежит другому аккаунту; upstream status passthrough; 503 `server_busy`, `capacity` или `unavailable`; 500 consume failure | | `POST /api/codex-auth/login` | Запустить login или reauthentication для Codex | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Отправить manual code для login-flow Codex | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Отменить login-flow Codex | — | diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index e79e7be260..0c5f230839 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -272,7 +272,7 @@ devreder. Rotaları şunlardır: | `PUT /api/codex-auth/failover` | Hesap yük devretme eşiğini ayarlayın | 400 geçersiz eşik | | `GET /api/codex-auth/quota` | Hesaba göre önbelleğe alınmış kota durumunu okuyun | — | | `GET /api/codex-auth/reset-credits` | Bir hesap için sıfırlama kredisi uygunluğunu inceleyin | 400 eksik hesap kimliği; yukarı akış durum doğrudan geçişi; 500 arama hatası | -| `POST /api/codex-auth/reset-credits/consume` | Uygun bir sıfırlama kredisini tüketin | 400 eksik hesap kimliği; yukarı akış durum doğrudan geçişi; 503 `server_busy`; 500 tüketme hatası | +| `POST /api/codex-auth/reset-credits/consume` | Uygun bir sıfırlama kredisini tüketin. İsteğe bağlı `operationId` (UUIDv4) kullanımı işlemi idempotent yapar: aynı kimlik ikinci bir kredi harcamak yerine tek bir kalıcı sonucu yeniden oynatır. | 400 eksik hesap kimliği veya geçersiz `operationId`; kimlik başka bir hesaba aitse 409 `identity_mismatch`; yukarı akış durum doğrudan geçişi; 503 `server_busy`, `capacity` veya `unavailable`; 500 tüketme hatası | | `POST /api/codex-auth/login` | Codex girişini veya yeniden kimlik doğrulamasını başlatın | 400 geçersiz istek; çakışma/meşgul giriş durumları | | `POST /api/codex-auth/login/code` | Bir Codex giriş akışı için manuel bir kod gönderin | 400 geçersiz akış/kod | | `POST /api/codex-auth/login/cancel` | Bir Codex giriş akışını iptal edin | — | @@ -311,4 +311,3 @@ entegrasyonlar için en yararlıdır. ## Uzak oturumlar ve veri anahtarı döndürme `POST /api/keys/rotate {id}` on dakikalık geçişi başlatır ve yeni sırrı yalnızca bir kez döndürür. `POST /api/keys/rotate/commit {id,rotationId}` onaylar, `DELETE /api/keys/rotate {id,rotationId}` iptal eder. Yönetim kimlik doğrulaması gerekir; veri anahtarı bunları çağıramaz. `POST /api/session/logout` mevcut `gui-session`, eşleşen Origin ve CSRF ister. Admin token 403 alır ve onay oturumu oluşturamaz. - diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index d92ec60a41..d674ed1554 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -221,7 +221,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` | 设置账户故障转移阈值 | 400 阈值无效 | | `GET /api/codex-auth/quota` | 按账户读取缓存的配额状态 | — | | `GET /api/codex-auth/reset-credits` | 检查某个账户是否具备 reset-credit 资格 | 400 缺少账户 id;上游状态透传;500 查询失败 | -| `POST /api/codex-auth/reset-credits/consume` | 消耗一个符合条件的 reset credit | 400 缺少账户 id;上游状态透传;503 `server_busy`;500 消耗失败 | +| `POST /api/codex-auth/reset-credits/consume` | 消耗一个符合条件的 reset credit。可选的 `operationId`(UUIDv4)让兑换具备幂等性:相同 id 会重放同一条持久化结果,而不会再消耗一个 credit。 | 400 缺少账户 id 或无效的 `operationId`;若该 id 属于其他账户则 409 `identity_mismatch`;上游状态透传;503 `server_busy`、`capacity` 或 `unavailable`;500 消耗失败 | | `POST /api/codex-auth/login` | 启动 Codex 登录或重新认证 | 400 请求无效;登录状态冲突/忙碌 | | `POST /api/codex-auth/login/code` | 为 Codex 登录流程提交手动代码 | 400 流程/代码无效 | | `POST /api/codex-auth/login/cancel` | 取消一个 Codex 登录流程 | — | diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index a113afac3c..dd0b155697 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -214,7 +214,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `PUT /api/codex-auth/failover` | 設定帳號容錯移轉閾值 | 400 無效閾值 | | `GET /api/codex-auth/quota` | 依帳號讀取快取配額狀態 | — | | `GET /api/codex-auth/reset-credits` | 檢查帳號的 reset-credit 資格 | 400 缺失帳號 id;上游狀態 passthrough;500 查詢失敗 | -| `POST /api/codex-auth/reset-credits/consume` | 消耗一個合格的 reset credit | 400 缺失帳號 id;上游狀態 passthrough;503 `server_busy`;500 消耗失敗 | +| `POST /api/codex-auth/reset-credits/consume` | 消耗一個合格的 reset credit。選用的 `operationId`(UUIDv4)可讓兌換具備冪等性:相同 id 會重播同一筆持久化結果,而不會再消耗一個 credit。 | 400 缺失帳號 id 或無效的 `operationId`;若該 id 屬於其他帳號則 409 `identity_mismatch`;上游狀態 passthrough;503 `server_busy`、`capacity` 或 `unavailable`;500 消耗失敗 | | `POST /api/codex-auth/login` | 啟動 Codex 登入或重新認證 | 400 無效請求;衝突/忙碌登入狀態 | | `POST /api/codex-auth/login/code` | 為 Codex 登入流程提交手動碼 | 400 無效流程/碼 | | `POST /api/codex-auth/login/cancel` | 取消 Codex 登入流程 | — | diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 550227dbeb..7ca6b04f41 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,5 +1,6 @@ import { writeSync } from "node:fs"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; import { CliUsageError, printData, @@ -34,7 +35,7 @@ const USAGE = `Usage: ocx account login [--id ] [--reauth] [--device] [--code -] [--no-wait] [--json] ocx account code [--flow ] [--json] (reads the code from stdin) ocx account cancel [--flow ] [--json] - ocx account reset-credits [--consume --yes] [--json] + ocx account reset-credits [--consume --yes [--operation-id ]] [--json] --device runs the OpenAI device-code login instead of the browser callback: use it when the proxy has no browser or nothing can reach localhost:1455, such as a @@ -252,12 +253,25 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise const wantsJson = takeFlag(args, "--json"); const consume = takeFlag(args, "--consume"); const yes = takeFlag(args, "--yes"); + // Before rejectArgs: takeOption splices its two tokens out of `args`. + const operationId = takeOption(args, "--operation-id"); if (!rawId) throw new CliUsageError("account id is required", USAGE); if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); + if (operationId !== undefined && !consume) { + throw new CliUsageError("--operation-id requires --consume", USAGE); + } + if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) { + throw new CliUsageError("--operation-id must be a UUIDv4", USAGE); + } rejectArgs(args, USAGE); const accountId = rawId === "main" ? "__main__" : rawId; const result = consume - ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { method: "POST", body: JSON.stringify({ accountId }) }, deps) + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { + method: "POST", + // Spread, not `operationId: undefined`: the server distinguishes an absent + // key (legacy random id) from a caller who asked for a stable identity. + body: JSON.stringify({ accountId, ...(operationId === undefined ? {} : { operationId }) }), + }, deps) : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); printData(result, wantsJson); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 36295027e9..809004927e 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -120,6 +120,12 @@ import { isValidCodexAccountId, } from "./account-id"; import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; +import { + markManualResetCreditOperationAmbiguous, + openManualResetCreditOperation, + settleManualResetCreditOperation, +} from "./reset-credit-operation-ledger"; +import { isCodexResetCreditOperationId } from "./reset-credit-recovery"; import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; import { withNativeMainSharedClaim } from "./native-main-claim"; @@ -2168,31 +2174,107 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { accountId?: string }; + const body = (await req.json().catch(() => ({}))) as { + accountId?: string; + operationId?: unknown; + }; if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); const accountId = body.accountId; + // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy + // behavior: a fresh random redeem_request_id and no durable ledger row. + // The ledger throws TypeError on a malformed id, so the format check has to + // happen here rather than at the call site, or it surfaces as a 500. + const hasOperationId = body.operationId !== undefined; + if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) { + return jsonResponse({ error: "Invalid operationId format" }, 400); + } + const requestedOperationId = hasOperationId ? body.operationId as string : undefined; try { const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - const idempotencyKey = crypto.randomUUID(); - const resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", - { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - "Content-Type": "application/json", + // The ledger keys manual operations by the *physical* ChatGPT account, which is + // only known after the auth wrapper resolves credentials. Open here, not earlier. + const identity = requestedOperationId === undefined + ? undefined + : { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + } as const; + let idempotencyKey: string; + if (identity) { + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "terminal") { + // Durably settled already: replay the recorded outcome instead of + // trusting upstream idempotency for an irreversible spend. No + // `remaining` — that field is only reported from a freshly parsed + // available_count, and a replay has none. + return jsonResponse({ code: opened.code, replayed: true }); + } + if (opened.kind === "identity-mismatch") { + return jsonResponse({ + error: "operation_id_owned_by_another_account", + code: "identity_mismatch", + }, 409); + } + if (opened.kind !== "execute") { + // capacity | unavailable -> fail closed. Falling back to a random id + // would silently reintroduce the double-spend this identity prevents. + const response = jsonResponse({ + error: opened.kind === "capacity" + ? "reset_credit_ledger_capacity" + : "reset_credit_ledger_unavailable", + code: opened.kind, + }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + // Canonical id, which an alias join may map to an earlier caller id. + idempotencyKey = opened.operationId; + } else { + idempotencyKey = crypto.randomUUID(); + } + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), }, - body: JSON.stringify({ redeem_request_id: idempotencyKey }), - signal: AbortSignal.timeout(10_000), - }, - ); + ); + } catch (error) { + // Dispatch outcome unknown: the credit may or may not have been spent. + // Mark ambiguous so a replay of this same id is never treated as new. + if (identity) markManualResetCreditOperationAmbiguous(identity); + throw error; + } if (!resp.ok) { await resp.body?.cancel().catch(() => {}); + if (identity) markManualResetCreditOperationAmbiguous(identity); return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } const result = safeResetCreditConsumeDto(await resp.json()); + if (identity) { + // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` + // normalizes anything unrecognized to "unknown", and settling that + // would come back as a mismatch and leave the row pending anyway. + // Settlement failure never downgrades the user-visible outcome: the + // spend already happened upstream, and reporting failure would invite + // a manual retry -- the exact double-spend this unit removes. + if (result.code === "reset" || result.code === "already_redeemed" + || result.code === "nothing_to_reset" || result.code === "no_credit") { + settleManualResetCreditOperation(identity, result.code); + } else { + markManualResetCreditOperationAmbiguous(identity); + } + } // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage // and return remaining only when that refresh freshly parsed available_count. // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 5bdedca5c0..3aacf76b43 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -363,6 +363,14 @@ async function mockManagementApi(req: Request): Promise { return json(oauthLoginStatus); } + if (req.method === "POST" && url.pathname === "/api/codex-auth/reset-credits/consume") { + return json({ code: "reset", remaining: 1 }); + } + + if (req.method === "GET" && url.pathname === "/api/codex-auth/reset-credits") { + return json({ credits: [], available_count: 0 }); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -2063,4 +2071,60 @@ describe("ocx account CLI (issue #180 matrix)", () => { sleepSpy.mockRestore(); } }); + + /** + * #3375 axis D. A reset credit is an irreversible spend, so a retried consume + * has to carry the same caller-owned identity rather than a fresh random one. + * This is the only place the CLI surface itself is observable: `requests` + * records the body the command actually sent. + */ + describe("account reset-credits --operation-id", () => { + const OP_ID = "11111111-1111-4111-8111-111111111111"; + + function consumeRequests(): RecordedRequest[] { + return requests.filter(entry => entry.path === "/api/codex-auth/reset-credits/consume"); + } + + test("R7-a: the id reaches the consume body verbatim", async () => { + const result = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", OP_ID]); + + expect(result.code).toBe(0); + const sent = consumeRequests(); + expect(sent).toHaveLength(1); + expect(sent[0]!.body).toEqual({ accountId: "__main__", operationId: OP_ID }); + }); + + test("R7-b: omitting the flag omits the key, not sends undefined", async () => { + const result = await run(["reset-credits", "main", "--consume", "--yes"]); + + expect(result.code).toBe(0); + const sent = consumeRequests(); + expect(sent).toHaveLength(1); + // Absent key, not `operationId: undefined`: the server distinguishes a + // caller who wants idempotency from one who did not ask for it. + expect(Object.prototype.hasOwnProperty.call(sent[0]!.body as object, "operationId")).toBe(false); + }); + + test("R7-c: the id without --consume is a usage error, not a silent read", async () => { + const result = await run(["reset-credits", "main", "--operation-id", OP_ID]); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(0); + }); + + test("R7-d: a malformed id is refused locally, before any server round trip", async () => { + const result = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", "nope"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("UUIDv4"); + expect(requests).toHaveLength(0); + }); + + test("R7-e: a missing value is refused by the option parser", async () => { + const result = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id"]); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(0); + }); + }); }); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 5aa534fbe9..75cf062c5e 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -2818,6 +2818,291 @@ describe("codex-auth API", () => { } }); + // #3375 axis D: an optional caller-owned `operationId` gives one logical manual + // redemption a stable identity, so a retried request cannot spend a second + // irreversible credit. Absent the field, the legacy random-id path must be byte + // identical -- that is what keeps the existing consume tests above meaningful. + describe("reset-credit consume operation identity", () => { + const OP_ID = "11111111-1111-4111-8111-111111111111"; + const OTHER_OP_ID = "22222222-2222-4222-9222-222222222222"; + + function consumeRequest(body: Record): Request { + return new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + /** Records every upstream consume dispatch so double-spend is directly countable. */ + function stubUpstream(consume: (redeemRequestId: string, call: number) => Response): { + redeemRequestIds: string[]; + calls: () => number; + } { + const redeemRequestIds: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + const parsed = JSON.parse(String(init?.body ?? "{}")) as { redeem_request_id?: string }; + redeemRequestIds.push(String(parsed.redeem_request_id)); + return consume(String(parsed.redeem_request_id), redeemRequestIds.length); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + rate_limit: { primary_window: { used_percent: 10, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 2 }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + return { redeemRequestIds, calls: () => redeemRequestIds.length }; + } + + test("R1/R2: replaying one operationId spends a single upstream credit", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-idem", email: "idem@example.test" }); + const upstream = stubUpstream(() => Response.json({ code: "reset" })); + try { + const first = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-idem", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(first!.status).toBe(200); + expect(await first!.json()).toEqual({ code: "reset", remaining: 2 }); + + const second = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-idem", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(second!.status).toBe(200); + // Replayed from the durable ledger: no fresh available_count, so no remaining. + expect(await second!.json()).toEqual({ code: "reset", replayed: true }); + + // The whole point of this unit. A second dispatch is a second lost credit. + expect(upstream.calls()).toBe(1); + expect(upstream.redeemRequestIds[0]).toBe(OP_ID); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("R3: without operationId the legacy random-id path is unchanged", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-legacy", email: "legacy@example.test" }); + const upstream = stubUpstream(() => Response.json({ code: "reset" })); + try { + for (let i = 0; i < 2; i += 1) { + const resp = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-legacy" }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset", remaining: 2 }); + } + expect(upstream.calls()).toBe(2); + expect(upstream.redeemRequestIds[0]).not.toBe(upstream.redeemRequestIds[1]); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("R4: a malformed operationId is refused before the ledger or upstream is touched", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-badid", email: "badid@example.test" }); + const upstream = stubUpstream(() => Response.json({ code: "reset" })); + try { + // The ledger throws TypeError on a malformed id, so validating late would + // surface a 500 with a stack trace instead of a contract error. + for (const operationId of ["not-a-uuid", "", 42, null, {}]) { + const resp = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-badid", operationId }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid operationId format" }); + } + expect(upstream.calls()).toBe(0); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("R5: an operationId owned by another account is refused with 409", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-owner", email: "owner@example.test", chatgptAccountId: "acct-owner" }); + seedPoolAccount(config, { id: "pool-other", email: "other@example.test", chatgptAccountId: "acct-other" }); + const upstream = stubUpstream(() => Response.json({ code: "reset" })); + try { + const first = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-owner", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(first!.status).toBe(200); + + const stolen = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-other", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(stolen!.status).toBe(409); + expect(await stolen!.json()).toEqual({ + error: "operation_id_owned_by_another_account", + code: "identity_mismatch", + }); + expect(upstream.calls()).toBe(1); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("R6: retrying after an upstream failure reuses the same redeem_request_id", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-retry", email: "retry@example.test" }); + const upstream = stubUpstream((_id, call) => call === 1 + ? new Response("upstream down", { status: 500 }) + : Response.json({ code: "reset" })); + try { + const failed = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-retry", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(failed!.status).toBe(500); + + const retried = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-retry", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(retried!.status).toBe(200); + + expect(upstream.calls()).toBe(2); + // Ambiguous first dispatch: the retry must present the SAME id so upstream + // can recognize it, rather than opening a fresh double-spend window. + expect(upstream.redeemRequestIds[0]).toBe(OP_ID); + expect(upstream.redeemRequestIds[1]).toBe(OP_ID); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("a distinct id retried while the first is pending keeps the original durable id", async () => { + // Alias join: the ledger keeps the canonical id for the upstream call and + // records the new caller id in history. Sending the new id instead would + // present upstream with an unseen key and spend a second credit. + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-alias", email: "alias@example.test" }); + const upstream = stubUpstream((_id, call) => call === 1 + ? new Response("upstream down", { status: 500 }) + : Response.json({ code: "reset" })); + try { + await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-alias", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + const retried = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-alias", operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(retried!.status).toBe(200); + expect(upstream.redeemRequestIds).toEqual([OP_ID, OP_ID]); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("an unknown upstream code stays ambiguous instead of settling the ledger", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-weird", email: "weird@example.test" }); + const upstream = stubUpstream(() => Response.json({ code: "weird" })); + try { + const first = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-weird", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(first!.status).toBe(200); + expect(await first!.json()).toEqual({ code: "weird" }); + + // Not terminal, so the same id resumes rather than replaying a stored code. + const second = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-weird", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(second!.status).toBe(200); + expect(upstream.redeemRequestIds).toEqual([OP_ID, OP_ID]); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("A6: an unusable ledger fails closed rather than falling back to a random id", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-nodb", email: "nodb@example.test" }); + const upstream = stubUpstream(() => Response.json({ code: "reset" })); + // A directory where the ledger expects its SQLite file: the open fails the + // way a corrupt file or lost permission would, without mocking the module. + // Seeding the account already created the real database, so replace it. + rmSync(join(TEST_DIR, "config-mutation.sqlite"), { recursive: true, force: true }); + mkdirSync(join(TEST_DIR, "config-mutation.sqlite"), { recursive: true }); + try { + const resp = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-nodb", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + + expect(resp!.status).toBe(503); + expect(resp!.headers.get("Retry-After")).toBe("1"); + expect(await resp!.json()).toEqual({ + error: "reset_credit_ledger_unavailable", + code: "unavailable", + }); + // The point of failing closed: a caller who asked for idempotency must + // never have the request silently downgraded to an unprotected spend. + expect(upstream.calls()).toBe(0); + } finally { + globalThis.fetch = previousFetch; + rmSync(join(TEST_DIR, "config-mutation.sqlite"), { recursive: true, force: true }); + } + }); + + test("A7: a settlement failure does not downgrade a spend that already happened", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-settlefail", email: "settlefail@example.test" }); + // Break the ledger between open and settle: the credit is gone upstream, so + // reporting failure here would invite the retry this unit exists to remove. + const upstream = stubUpstream(() => { + rmSync(join(TEST_DIR, "config-mutation.sqlite"), { force: true }); + mkdirSync(join(TEST_DIR, "config-mutation.sqlite"), { recursive: true }); + return Response.json({ code: "reset" }); + }); + try { + const resp = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-settlefail", operationId: OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ code: "reset" }); + expect(upstream.calls()).toBe(1); + } finally { + globalThis.fetch = previousFetch; + rmSync(join(TEST_DIR, "config-mutation.sqlite"), { recursive: true, force: true }); + } + }); + }); + test("unmatched route returns null", async () => { const req = new Request("http://localhost/api/codex-auth/unknown", { method: "GET" }); const url = new URL(req.url); From 370ea4ae34a07b8ed0a2fb8df04cfa234455cc6e Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 23:08:50 +0900 Subject: [PATCH 09/17] test(quota): close the #3200 coverage holes and correct the contract comment Carried from #3327. Two gaps the original #3198 tests left open: the uncalibrated-plan notice was never pinned independently of the incomplete-coverage gate, so folding it under that branch would have passed every existing fixture while silently hiding it; and the malformed-plan path was described as if it reached aggregation the same way an unlisted plan name does, when poolAccountDto strips it earlier via codexPlanValue and the aggregate sees an absent plan instead. One assertion is narrowed from the original. over the whole envelope also matches any unrelated field whose name contains that substring -- serviceTier, tierOutcome -- so it would fail on changes with nothing to do with plan leakage. Scoped to the report rows and to the quoted key, which is where the malformed value could actually surface. Carried rather than merged in place: #3327 is a fork PR, and Cross-platform CI never ran on its head. enforce-target was also red there because touching gui/tests/ trips the UI-screenshot gate on a test-only change. Co-authored-by: olddonkey --- gui/tests/provider-capacity-shell.test.tsx | 44 ++++++++++++++++++++++ tests/provider-quota.test.ts | 36 +++++++++++++----- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/gui/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 7531833f13..fdf9c5c09d 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -328,6 +328,50 @@ test("all-stale response renders coverage only without a numeric fallback", asyn expect(text).toContain("Incomplete coverage: 2 account(s) excluded"); }); +test("a fully included pool still surfaces the uncalibrated-plan notice", async () => { + // The #3155 reporter's own shape: every seat included, complete coverage, one seat counted + // at the baseline weight. The uncalibrated notice is the ONLY remaining uncertainty signal + // here, so it must render independently of the incomplete gate — folding it under the + // incomplete branch would pass every other fixture in this file and silently hide it. + quotaPayload = { + reports: [{ + provider: "openai", + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: Date.now(), + quota: { weeklyPercent: 44, updatedAt: Date.now() }, + aggregation: { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + includedAccounts: 2, + excludedAccounts: 0, + unknownPlanAccounts: 1, + missingQuotaAccounts: 0, + pausedAccounts: 0, + reauthAccounts: 0, + staleQuotaAccounts: 0, + incomplete: false, + weekly: { + usedPercent: 44, + includedAccounts: 2, + excludedAccounts: 0, + incomplete: false, + updatedAt: Date.now(), + }, + currentAccount: { isMain: false, quota: { weeklyPercent: 77, updatedAt: Date.now() } }, + }, + }], + }; + + await mountShell(); + + const text = host.textContent ?? ""; + expect(text).toContain("1 account(s) on an uncalibrated plan are counted at the baseline seat weight"); + expect(text).not.toContain("Incomplete coverage"); + expect(text).toContain("44% used"); +}); + test("coverage-only API report remains visible in the rate-limit overview", async () => { quotaPayload = { reports: [{ diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 2768ead96f..4c659df5b8 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1859,13 +1859,16 @@ describe("fetchProviderQuotaReports", () => { expect(JSON.stringify(openai?.aggregation)).not.toMatch(/(?:total|consumed|remaining)Weight|projectedUsedPercent/i); }); - // #3198 changed what "tolerate" means here: an uncalibrated plan — a name the weight map - // does not list, or a malformed non-string value like the `{ tier: "pro" }` below (both - // normalize to undefined via codexPlanKey) — is now counted at the baseline seat weight - // instead of being excluded from the aggregate. Exclusion silently overstated coverage; - // baseline counting is the visibly conservative estimate. The account still shows up in - // `unknownPlanAccounts` so the operator can see the estimate is conservative for that seat. - test("pool reports tolerate a malformed persisted plan through cache and aggregation", async () => { + // #3155 (PR #3198) changed what "tolerate" means here: an uncalibrated plan is counted at + // the baseline seat weight instead of being excluded (rationale on + // CODEX_DEFAULT_CAPACITY_WEIGHT in src/providers/codex-capacity.ts). Two distinct inputs + // converge on that weight by different routes: an unlisted plan NAME normalizes to a defined + // key that fails the weight-map lookup, while a malformed non-string like the + // `{ tier: "pro" }` below never reaches the aggregation at all — poolAccountDto strips it via + // codexPlanValue, so the aggregate sees an ABSENT plan. This test pins the second route end + // to end: baseline weight, `unknownPlanAccounts` flagged, and the malformed value kept out of + // the public report shape. + test("pool reports count a malformed persisted plan at baseline and keep it out of the public shape", async () => { saveCodexAccountCredential("added", { accessToken: "added-access", refreshToken: "added-refresh", @@ -1886,6 +1889,9 @@ describe("fetchProviderQuotaReports", () => { calls += 1; const added = (init?.headers as Record | undefined)?.["ChatGPT-Account-Id"] === "added-chatgpt-id"; return new Response(JSON.stringify({ + // Load-bearing: a fetched plan_type outranks the persisted plan (auth-api.ts freshPlan), + // so this mock must stay non-string too — a string here would recalibrate the weight and + // change every aggregate value asserted below. plan_type: added ? { tier: "pro" } : "plus", rate_limit: { secondary_window: { used_percent: added ? 77 : 11, reset_at: 1_999_000_000 } }, }), { status: 200, headers: { "content-type": "application/json" } }); @@ -1893,21 +1899,31 @@ describe("fetchProviderQuotaReports", () => { const refreshed = await fetchProviderQuotaReports(config, true); const openai = refreshed.reports.find(row => row.provider === "openai"); - // Both seats weigh the same (malformed -> baseline, "plus" -> calibrated baseline), so the - // blend of 77 and 11 lands at 44 — not the 11 the old exclusion contract produced. + // The blend leans on CODEX_DEFAULT_CAPACITY_WEIGHT equalling the calibrated "plus" weight + // (both 1 today), so 77 and 11 land at 44 — not the 11 the old exclusion contract produced. expect(openai?.quota.weeklyPercent).toBe(44); expect(openai?.aggregation).toMatchObject({ includedAccounts: 2, excludedAccounts: 0, unknownPlanAccounts: 1, incomplete: false, + // The per-window flags are what the dashboard renders per bar; #3155 flipped them + // together with the top-level flag, so pin both. + weekly: { includedAccounts: 2, excludedAccounts: 0, incomplete: false }, currentAccount: { quota: { weeklyPercent: 77 } }, }); expect(openai?.aggregation?.currentAccount).not.toHaveProperty("plan"); + // The malformed value must not escape into the public report shape anywhere, and the + // internal weight fields must not either (same guard as the sibling test above). + expect(JSON.stringify(refreshed)).not.toContain("tier"); + expect(JSON.stringify(openai?.aggregation)).not.toMatch(/(?:total|consumed|remaining)Weight|projectedUsedPercent/i); expect(calls).toBe(2); + // The unforced path returns the cached response BY IDENTITY (no clone, no recompute), so + // re-asserting its fields would only re-read the object checked above. Pin the identity + // contract itself plus the call count; content is covered once, honestly, up there. const cached = await fetchProviderQuotaReports(config); - expect(cached.reports[0]?.aggregation?.unknownPlanAccounts).toBe(1); + expect(cached.reports[0]?.aggregation).toBe(openai?.aggregation); expect(calls).toBe(2); }); From 081570785a13e4836ba64b747ae3a4921964590e Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 23:09:56 +0900 Subject: [PATCH 10/17] feat(gui): show whether the requested service tier was actually granted Carried from #3251 (both commits, in order). The backend already computed `tierOutcome` and shipped it to the GUI on every log entry via requestLogEntryFromPersistedUsage, and the GUI consumed it nowhere -- `rg tierOutcome gui/src/` returned zero hits before this change. So a bare `responseTier=default` read as a denial even when the turn had in fact been scheduled as priority. The tooltip now qualifies the echoed tier with its confirmation: responseTier=default (assumed) responseTier=default (downgraded: response-declined) responseTier=priority (confirmed) Deliberately not turning `assumed` into `confirmed` for the ChatGPT-internal Codex backend. That backend answers `service_tier: "default"` on turns it scheduled as priority, and reading the echo as authoritative is what #2558 was. The point is to show the uncertainty rather than to paper over it. Carried rather than merged in place: #3251 is a fork PR whose head never ran Cross-platform CI, and its enforce-target failure is the UI-screenshot gate. Co-authored-by: Abhishek Sharma --- gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/pages/Logs.tsx | 5 +- gui/src/pages/logs-model-title.ts | 31 +++++- tests/logs-model-tier-confirmation.test.ts | 124 +++++++++++++++++++++ 12 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/logs-model-tier-confirmation.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7a26642061..cf283cf371 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -744,6 +744,10 @@ export const de: Record = { "logs.modelTooltip.configuredTier": "konfigurierte Stufe", "logs.modelTooltip.responseTier": "Antwortstufe", "logs.modelTooltip.supportsTier": "Stufenunterstützung", + "logs.modelTooltip.tierOutcome.confirmed": "bestätigt", + "logs.modelTooltip.tierOutcome.assumed": "angenommen", + "logs.modelTooltip.tierOutcome.downgraded": "herabgestuft", + "logs.modelTooltip.tierOutcome.unknown": "unbekannt", "logs.tokens.reported": "gemeldet", "logs.tokens.unreported": "nicht gemeldet", "logs.tokens.unsupported": "nicht unterstützt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 549bd2b885..7b7f514ccc 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -777,6 +777,10 @@ export const en = { "logs.modelTooltip.configuredTier": "configured tier", "logs.modelTooltip.responseTier": "response tier", "logs.modelTooltip.supportsTier": "tier support", + "logs.modelTooltip.tierOutcome.confirmed": "confirmed", + "logs.modelTooltip.tierOutcome.assumed": "assumed", + "logs.modelTooltip.tierOutcome.downgraded": "downgraded", + "logs.modelTooltip.tierOutcome.unknown": "unknown", "logs.tokens.reported": "reported", "logs.tokens.unreported": "unreported", "logs.tokens.unsupported": "unsupported", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 050b40fa67..6f13d14fab 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -758,6 +758,10 @@ export const fr: Record = { "logs.modelTooltip.configuredTier": "niveau configuré", "logs.modelTooltip.responseTier": "niveau de réponse", "logs.modelTooltip.supportsTier": "prise en charge du niveau", + "logs.modelTooltip.tierOutcome.confirmed": "confirmé", + "logs.modelTooltip.tierOutcome.assumed": "supposé", + "logs.modelTooltip.tierOutcome.downgraded": "rétrogradé", + "logs.modelTooltip.tierOutcome.unknown": "inconnu", "logs.tokens.reported": "communiqués", "logs.tokens.unreported": "non communiqués", "logs.tokens.unsupported": "non pris en charge", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e9860ff537..c25a629f22 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -720,6 +720,10 @@ export const ja: Record = { "logs.modelTooltip.configuredTier": "設定ティア", "logs.modelTooltip.responseTier": "応答ティア", "logs.modelTooltip.supportsTier": "ティア対応", + "logs.modelTooltip.tierOutcome.confirmed": "確認済み", + "logs.modelTooltip.tierOutcome.assumed": "推定", + "logs.modelTooltip.tierOutcome.downgraded": "降格", + "logs.modelTooltip.tierOutcome.unknown": "不明", "logs.tokens.reported": "報告済み", "logs.tokens.unreported": "未報告", "logs.tokens.unsupported": "非対応", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8d1e3bc8ea..67e5da0435 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -763,6 +763,10 @@ export const ko: Record = { "logs.modelTooltip.configuredTier": "설정 티어", "logs.modelTooltip.responseTier": "응답 티어", "logs.modelTooltip.supportsTier": "티어 지원", + "logs.modelTooltip.tierOutcome.confirmed": "확인됨", + "logs.modelTooltip.tierOutcome.assumed": "추정됨", + "logs.modelTooltip.tierOutcome.downgraded": "강등됨", + "logs.modelTooltip.tierOutcome.unknown": "알 수 없음", "logs.tokens.reported": "측정됨", "logs.tokens.unreported": "미보고", "logs.tokens.unsupported": "미지원", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a00d5dcbe4..f38a7e45da 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -761,6 +761,10 @@ export const ru: Record = { "logs.modelTooltip.configuredTier": "настроенный уровень", "logs.modelTooltip.responseTier": "уровень ответа", "logs.modelTooltip.supportsTier": "поддержка уровня", + "logs.modelTooltip.tierOutcome.confirmed": "подтверждено", + "logs.modelTooltip.tierOutcome.assumed": "предполагается", + "logs.modelTooltip.tierOutcome.downgraded": "понижено", + "logs.modelTooltip.tierOutcome.unknown": "неизвестно", "logs.tokens.reported": "сообщено", "logs.tokens.unreported": "не сообщено", "logs.tokens.unsupported": "не поддерживается", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 981bacb56b..9ac2f2e272 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -768,6 +768,10 @@ export const tr: Record = { "logs.modelTooltip.configuredTier": "yapılandırılan katman", "logs.modelTooltip.responseTier": "yanıt katmanı", "logs.modelTooltip.supportsTier": "katman desteği", + "logs.modelTooltip.tierOutcome.confirmed": "doğrulandı", + "logs.modelTooltip.tierOutcome.assumed": "varsayıldı", + "logs.modelTooltip.tierOutcome.downgraded": "düşürüldü", + "logs.modelTooltip.tierOutcome.unknown": "bilinmiyor", "logs.tokens.reported": "bildirilen", "logs.tokens.unreported": "bildirilmeyen", "logs.tokens.unsupported": "desteklenmeyen", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5eb6557302..22edb043c7 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -596,6 +596,10 @@ export const zhTW: Record = { "logs.modelTooltip.configuredTier": "設定層級", "logs.modelTooltip.responseTier": "回應層級", "logs.modelTooltip.supportsTier": "支援層級", + "logs.modelTooltip.tierOutcome.confirmed": "已確認", + "logs.modelTooltip.tierOutcome.assumed": "假定", + "logs.modelTooltip.tierOutcome.downgraded": "已降級", + "logs.modelTooltip.tierOutcome.unknown": "未知", "logs.tokens.reported": "已上報", "logs.tokens.unreported": "未上報", "logs.tokens.unsupported": "不支援", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f53e53ce39..5c1dee017a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -756,6 +756,10 @@ export const zh: Record = { "logs.modelTooltip.configuredTier": "配置层级", "logs.modelTooltip.responseTier": "响应层级", "logs.modelTooltip.supportsTier": "支持层级", + "logs.modelTooltip.tierOutcome.confirmed": "已确认", + "logs.modelTooltip.tierOutcome.assumed": "假定", + "logs.modelTooltip.tierOutcome.downgraded": "已降级", + "logs.modelTooltip.tierOutcome.unknown": "未知", "logs.tokens.reported": "已上报", "logs.tokens.unreported": "未上报", "logs.tokens.unsupported": "不支持", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 0bbe286a88..40b3a265c8 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -15,7 +15,7 @@ import Debug from "./Debug"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; -import { modelTitle } from "./logs-model-title"; +import { modelTitle, type ModelTitleTierOutcome } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; @@ -152,6 +152,9 @@ export interface LogEntry { configuredServiceTier?: string; configuredSpeedLabel?: string; responseServiceTier?: string; + // #2455: qualifies responseServiceTier in the model tooltip — the echoed tier alone + // cannot say whether Fast was granted on a backend whose echo is not authoritative. + tierOutcome?: ModelTitleTierOutcome; resolvedModel?: string; modelSupportsServiceTier?: boolean; status: number; diff --git a/gui/src/pages/logs-model-title.ts b/gui/src/pages/logs-model-title.ts index 77b1fe0577..8bcf314119 100644 --- a/gui/src/pages/logs-model-title.ts +++ b/gui/src/pages/logs-model-title.ts @@ -1,5 +1,10 @@ import type { TFn } from "../i18n/shared"; +export interface ModelTitleTierOutcome { + confirmation?: "confirmed" | "assumed" | "downgraded" | "unknown"; + fastDowngradeReason?: string; +} + export interface ModelTitleEntry { model: string; resolvedModel?: string; @@ -7,6 +12,28 @@ export interface ModelTitleEntry { configuredServiceTier?: string; responseServiceTier?: string; modelSupportsServiceTier?: boolean; + tierOutcome?: ModelTitleTierOutcome; +} + +/** + * #2455: the echoed tier alone does not say whether Fast was granted. The ChatGPT + * backend answers `default` on turns it in fact scheduled as priority, so its echo is + * marked non-authoritative and the outcome stays `assumed` (#2558) — which is the + * honest answer, but only if the operator can see it. Qualify the echoed value with + * how much it is worth, and name the reason when the tier was actually declined. + * + * The confirmation word is this proxy's own judgement about the turn, not a value the + * upstream returned, so it is translated like any other visible string. The downgrade + * reason stays verbatim: it is a diagnostic identifier (`response-declined`) that maps + * to `fastDowngradeReason` in the source, and translating it would break that link. + */ +function tierConfirmationSuffix(outcome: ModelTitleEntry["tierOutcome"], t: TFn): string { + const confirmation = outcome?.confirmation; + if (!confirmation) return ""; + const reason = confirmation === "downgraded" && outcome?.fastDowngradeReason + ? `: ${outcome.fastDowngradeReason}` + : ""; + return ` (${t(`logs.modelTooltip.tierOutcome.${confirmation}`)}${reason})`; } export function modelTitle(log: ModelTitleEntry, t: TFn): string { @@ -15,7 +42,9 @@ export function modelTitle(log: ModelTitleEntry, t: TFn): string { log.resolvedModel ? `${t("logs.modelTooltip.resolvedModel")}=${log.resolvedModel}` : undefined, log.requestedServiceTier ? `${t("logs.modelTooltip.requestedTier")}=${log.requestedServiceTier}` : undefined, log.configuredServiceTier ? `${t("logs.modelTooltip.configuredTier")}=${log.configuredServiceTier}` : undefined, - log.responseServiceTier ? `${t("logs.modelTooltip.responseTier")}=${log.responseServiceTier}` : undefined, + log.responseServiceTier + ? `${t("logs.modelTooltip.responseTier")}=${log.responseServiceTier}${tierConfirmationSuffix(log.tierOutcome, t)}` + : undefined, log.modelSupportsServiceTier !== undefined ? `${t("logs.modelTooltip.supportsTier")}=${log.modelSupportsServiceTier}` : undefined, diff --git a/tests/logs-model-tier-confirmation.test.ts b/tests/logs-model-tier-confirmation.test.ts new file mode 100644 index 0000000000..995b76f793 --- /dev/null +++ b/tests/logs-model-tier-confirmation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test"; +import { modelTitle, type ModelTitleEntry } from "../gui/src/pages/logs-model-title"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * #2455: a user routing gpt-5.x through the proxy asked for `service_tier: priority`, + * saw the backend echo something else, and had no way to tell whether Fast was granted. + * + * The echo alone cannot answer it. The ChatGPT-internal Codex backend returns + * `service_tier: "default"` on turns it in fact scheduled as priority, so its echo is + * marked non-authoritative and the outcome stays `assumed` rather than being read as a + * downgrade (#2558). That is the honest answer — but it was computed in `tierOutcome` + * and never shown, so the tooltip printed a bare echoed value that looked like a denial. + */ + +/** + * Stub translator. The tier-outcome labels are marked with a `t:` prefix on purpose: a + * hardcoded English label would render the same bare word as a translated one, so + * without the marker these assertions could not tell the two apart. The real catalogs + * are checked separately, by file, in the locale-coverage test. + */ +const t = ((key: string) => { + const leaf = key.split(".").pop() ?? key; + return key.includes("tierOutcome.") ? `t:${leaf}` : leaf; +}) as never; + +const CONFIRMATIONS = ["confirmed", "assumed", "downgraded", "unknown"] as const; + +function entry(over: Partial = {}): ModelTitleEntry { + return { model: "gpt-5.6-terra", ...over }; +} + +describe("model tooltip tier confirmation (#2455)", () => { + test("qualifies the echoed tier with the outcome", () => { + const title = modelTitle( + entry({ responseServiceTier: "default", tierOutcome: { confirmation: "assumed" } }), + t, + ); + expect(title).toContain("=default (t:assumed)"); + }); + + test("names the reason when the tier was actually declined", () => { + const title = modelTitle( + entry({ + responseServiceTier: "default", + tierOutcome: { confirmation: "downgraded", fastDowngradeReason: "response-declined" }, + }), + t, + ); + expect(title).toContain("=default (t:downgraded: response-declined)"); + }); + + test("a confirmed grant reads as confirmed", () => { + const title = modelTitle( + entry({ responseServiceTier: "priority", tierOutcome: { confirmation: "confirmed" } }), + t, + ); + expect(title).toContain("=priority (t:confirmed)"); + }); + + test("no outcome leaves the existing tooltip unchanged", () => { + const before = modelTitle(entry({ responseServiceTier: "default" }), t); + expect(before).toContain("=default"); + expect(before).not.toContain("("); + }); + + test("an outcome without an echoed tier adds nothing", () => { + // The qualifier explains an echoed value; with nothing echoed there is nothing to + // qualify, and a lone parenthesis would read as a malformed field. + const title = modelTitle(entry({ tierOutcome: { confirmation: "assumed" } }), t); + expect(title).not.toContain("assumed"); + expect(title).toBe("model=gpt-5.6-terra"); + }); + + test("a downgrade with no recorded reason omits the colon", () => { + const title = modelTitle( + entry({ responseServiceTier: "default", tierOutcome: { confirmation: "downgraded" } }), + t, + ); + expect(title).toContain("=default (t:downgraded)"); + expect(title).not.toContain("downgraded:"); + }); + + test("every confirmation value is translated in every locale", () => { + // The label is this proxy's own judgement about the turn, so it is visible text and + // must not fall back to a raw key in any dashboard language. Read the catalogs as + // files: importing the barrel pulls in the whole GUI dependency graph. + // Named explicitly: the directory also holds label modules that are not catalogs, + // and this list is the same one LOCALES declares in i18n/shared.ts. + const LOCALE_FILES = [ + "en.ts", "de.ts", "fr.ts", "ko.ts", "zh.ts", "zh-TW.ts", "ru.ts", "ja.ts", "tr.ts", + ]; + const dir = join(import.meta.dir, "..", "gui", "src", "i18n"); + const present = readdirSync(dir); + for (const file of LOCALE_FILES) { + expect(present, `${file} disappeared from i18n/`).toContain(file); + } + + for (const file of LOCALE_FILES) { + const text = readFileSync(join(dir, file), "utf8"); + for (const confirmation of CONFIRMATIONS) { + const key = `"logs.modelTooltip.tierOutcome.${confirmation}":`; + const line = text.split("\n").find(l => l.includes(key)); + expect(line, `${file} is missing ${key}`).toBeTruthy(); + const value = line!.slice(line!.indexOf(key) + key.length).trim().replace(/^"|",?$/g, ""); + expect(value.length, `${file} ${key} is empty`).toBeGreaterThan(0); + } + } + }); + + test("the downgrade reason stays a verbatim identifier", () => { + // `response-declined` maps to fastDowngradeReason in the source; translating it + // would break the link between what the operator reads and what to grep for. + const title = modelTitle( + entry({ + responseServiceTier: "default", + tierOutcome: { confirmation: "downgraded", fastDowngradeReason: "wire-unavailable" }, + }), + t, + ); + expect(title).toContain("wire-unavailable"); + }); +}); From e3f01df6f6062a8e32466ac8a5a6052578574bf1 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 4 Sep 2026 23:51:19 +0900 Subject: [PATCH 11/17] feat(integrations): let an operator delete one rollback journal entry An expired rollback row was a dead entry: its snapshot bytes were gone, so restore was refused, and nothing could remove it. The journal route was GET-only and the list offered no delete affordance anywhere. Deletion is expressed as an APPEND. Rewriting journal.jsonl without the row would break all three things the file header promises -- appendOperation commits and nothing else, so a read-modify-write races concurrent appends; a torn rewrite truncates the whole log rather than the one trailing line listOperations tolerates; and no lock covers this file, because append-only never needed one. A tombstone record retires an opId instead, and listOperations collects tombstones in the same pass before any client filtering, then filters after the whole file is read. Both orderings matter: a tombstone carries an opId and no clientId, so filtering by client first would resurrect the row on the per-client route while the global route hid it, and an in-loop check would miss every tombstone because it is always appended after the row it retires. The newest row per client is refused with 409 by the SERVER, re-read immediately before the write rather than trusted from the rendered list. It is the undo entry point, and an admin-token caller has no GUI to hide a button in. The GUI mirrors that with a server-computed deletable field so the rule does not exist in two places. Snapshot bytes are pruned AFTER the tombstone commits, so a failed cleanup leaves the row retired and the leftover disclosed through retentionDegraded, rather than destroying a backup for a deletion that then failed to record. Documented fallout: pruneSnapshots builds its keep set from listOperations, so a retired row no longer occupies a retention slot. Snapshots survive longer, never disappear early, but "ten backups per client" now counts live rows. The DELETE route carries a deferred-verb exemption; the CLI verb is owed by a later work-phase. Confirmation reuses the existing ConsequenceDialog, and the 409/404 codes are registered in refusal-copy so the server English never reaches a non-English locale. Design of record: devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md Refs #3379 --- gui/src/i18n/de.ts | 9 + gui/src/i18n/en.ts | 9 + gui/src/i18n/fr.ts | 9 + gui/src/i18n/ja.ts | 9 + gui/src/i18n/ko.ts | 9 + gui/src/i18n/ru.ts | 9 + gui/src/i18n/tr.ts | 9 + gui/src/i18n/zh-TW.ts | 9 + gui/src/i18n/zh.ts | 9 + .../integrations/FileIntegrationPage.tsx | 29 +- .../integrations/IntegrationsOverview.tsx | 34 +- .../pages/integrations/RollbackHistory.tsx | 30 +- gui/src/pages/integrations/integration-api.ts | 31 ++ gui/src/pages/integrations/refusal-copy.ts | 2 + .../integrations-rollback-history.test.tsx | 94 ++++- src/integrations/journal.ts | 67 +++- src/integrations/store.ts | 5 + src/server/management/integration-routes.ts | 107 ++++++ src/server/management/route-registry.ts | 1 + tests/integrations-journal.test.ts | 114 ++++++ ...agement-integration-journal-delete.test.ts | 355 ++++++++++++++++++ tests/management-integration-routes.test.ts | 3 +- 22 files changed, 944 insertions(+), 9 deletions(-) create mode 100644 tests/management-integration-journal-delete.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index cf283cf371..6453e3eb5a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1101,6 +1101,15 @@ export const de: Record = { "integrations.rollback.older": "Frühere Vorgänge", "integrations.rollback.showMore": "{n} weitere anzeigen", "integrations.rollback.failed": "Der Rollback-Verlauf konnte nicht geladen werden.", + "integrations.rollback.delete": "Löschen", + "integrations.rollback.deleteAria": "Rollback-Eintrag vom {at} löschen", + "integrations.rollback.deleteNewest": "Der neueste Eintrag dieses Clients bleibt erhalten, damit du ihn noch rückgängig machen kannst.", + "integrations.rollback.deleteGone": "Dieser Eintrag wurde bereits gelöscht. Die Liste wird neu geladen.", + "integrations.dialog.deleteEntry.title": "Diesen Rollback-Eintrag löschen?", + "integrations.dialog.deleteEntry.changes": "Dieser Eintrag verschwindet aus der Rollback-Liste, und eine noch vorhandene Sicherung für {path} wird von der Festplatte gelöscht.", + "integrations.dialog.deleteEntry.breakage": "Du kannst die Datei dann nicht mehr auf diesen Stand zurücksetzen. Neuere Einträge und die Datei selbst bleiben unberührt.", + "integrations.dialog.deleteEntry.undo": "Das lässt sich nicht rückgängig machen. Der neueste Eintrag jedes Clients bleibt erhalten und kann nicht gelöscht werden.", + "integrations.dialog.deleteEntry.confirm": "Eintrag löschen", "integrations.restore.title": "Diese Momentaufnahme wiederherstellen?", "integrations.restore.body": "Die aktuelle Datei wird zuerst gesichert und dann durch die ausgewählte Momentaufnahme ersetzt.", "integrations.restore.driftTitle": "Neuere Änderungen wurden erkannt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7b7f514ccc..0f33a26593 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1643,6 +1643,15 @@ export const en = { "integrations.rollback.older": "Earlier operations", "integrations.rollback.showMore": "Show {n} more", "integrations.rollback.failed": "Could not load the rollback history.", + "integrations.rollback.delete": "Delete", + "integrations.rollback.deleteAria": "Delete the rollback entry from {at}", + "integrations.rollback.deleteNewest": "The most recent entry for this client is kept so you can still undo it.", + "integrations.rollback.deleteGone": "This entry was already deleted. The list will refresh.", + "integrations.dialog.deleteEntry.title": "Delete this rollback entry?", + "integrations.dialog.deleteEntry.changes": "This entry disappears from the rollback list, and any backup it still holds for {path} is deleted from disk.", + "integrations.dialog.deleteEntry.breakage": "You will no longer be able to restore the file to this point. Newer entries and the file itself are untouched.", + "integrations.dialog.deleteEntry.undo": "This cannot be undone. The most recent entry for each client is kept and cannot be deleted.", + "integrations.dialog.deleteEntry.confirm": "Delete entry", "integrations.restore.title": "Restore this snapshot?", "integrations.restore.body": "The current file is backed up first, then the selected snapshot replaces it.", "integrations.restore.driftTitle": "Newer edits were detected", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6f13d14fab..6768e82dbf 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1575,6 +1575,15 @@ export const fr: Record = { "integrations.rollback.older": "Opérations antérieures", "integrations.rollback.showMore": "Afficher {n} de plus", "integrations.rollback.failed": "Impossible de charger l’historique des restaurations.", + "integrations.rollback.delete": "Supprimer", + "integrations.rollback.deleteAria": "Supprimer l’entrée de restauration du {at}", + "integrations.rollback.deleteNewest": "L’entrée la plus récente de ce client est conservée pour que vous puissiez encore l’annuler.", + "integrations.rollback.deleteGone": "Cette entrée a déjà été supprimée. La liste va être actualisée.", + "integrations.dialog.deleteEntry.title": "Supprimer cette entrée de restauration ?", + "integrations.dialog.deleteEntry.changes": "Cette entrée disparaît de la liste des restaurations, et toute sauvegarde encore conservée pour {path} est supprimée du disque.", + "integrations.dialog.deleteEntry.breakage": "Vous ne pourrez plus restaurer le fichier à ce point. Les entrées plus récentes et le fichier lui-même ne sont pas touchés.", + "integrations.dialog.deleteEntry.undo": "Cette action est irréversible. L’entrée la plus récente de chaque client est conservée et ne peut pas être supprimée.", + "integrations.dialog.deleteEntry.confirm": "Supprimer l’entrée", "integrations.restore.title": "Restaurer cet instantané ?", "integrations.restore.body": "Le fichier actuel est d’abord sauvegardé, puis remplacé par l’instantané sélectionné.", "integrations.restore.driftTitle": "Des modifications plus récentes ont été détectées", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c25a629f22..ec2a8b7279 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1535,6 +1535,15 @@ export const ja: Record = { "integrations.rollback.older": "以前の操作", "integrations.rollback.showMore": "さらに {n} 件表示", "integrations.rollback.failed": "ロールバック履歴を読み込めませんでした。", + "integrations.rollback.delete": "削除", + "integrations.rollback.deleteAria": "{at} のロールバック履歴を削除", + "integrations.rollback.deleteNewest": "このクライアントの最新の履歴は、元に戻せるように残されます。", + "integrations.rollback.deleteGone": "この履歴はすでに削除されています。一覧を再読み込みします。", + "integrations.dialog.deleteEntry.title": "このロールバック履歴を削除しますか?", + "integrations.dialog.deleteEntry.changes": "この履歴がロールバック一覧から消え、{path} 用に残っているバックアップもディスクから削除されます。", + "integrations.dialog.deleteEntry.breakage": "この時点にファイルを戻すことはできなくなります。より新しい履歴とファイル自体はそのままです。", + "integrations.dialog.deleteEntry.undo": "元に戻せません。クライアントごとの最新の履歴は削除されずに残ります。", + "integrations.dialog.deleteEntry.confirm": "履歴を削除", "integrations.restore.title": "このスナップショットを復元しますか?", "integrations.restore.body": "現在のファイルを先にバックアップしてから、選択したスナップショットで置き換えます。", "integrations.restore.driftTitle": "新しい編集が検出されました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 67e5da0435..9ad6bd966d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1125,6 +1125,15 @@ export const ko: Record = { "integrations.rollback.older": "이전 작업", "integrations.rollback.showMore": "{n}개 더 보기", "integrations.rollback.failed": "롤백 기록을 불러오지 못했습니다.", + "integrations.rollback.delete": "삭제", + "integrations.rollback.deleteAria": "{at} 롤백 기록 삭제", + "integrations.rollback.deleteNewest": "이 클라이언트의 가장 최근 기록은 되돌리기를 위해 남겨 둡니다.", + "integrations.rollback.deleteGone": "이미 삭제된 기록입니다. 목록을 새로 불러옵니다.", + "integrations.dialog.deleteEntry.title": "이 롤백 기록을 삭제할까요?", + "integrations.dialog.deleteEntry.changes": "이 기록이 롤백 목록에서 사라지고, {path}에 대해 남아 있던 백업 파일도 디스크에서 삭제됩니다.", + "integrations.dialog.deleteEntry.breakage": "이 시점으로는 더 이상 파일을 되돌릴 수 없습니다. 더 최근 기록과 파일 자체는 그대로입니다.", + "integrations.dialog.deleteEntry.undo": "되돌릴 수 없습니다. 클라이언트별 가장 최근 기록은 삭제되지 않고 남습니다.", + "integrations.dialog.deleteEntry.confirm": "기록 삭제", "integrations.restore.title": "이 스냅샷으로 복원할까요?", "integrations.restore.body": "현재 파일을 먼저 백업한 뒤 선택한 스냅샷으로 교체합니다.", "integrations.restore.driftTitle": "스냅샷 이후 변경이 감지되었습니다", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f38a7e45da..e578e30574 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1586,6 +1586,15 @@ export const ru: Record = { "integrations.rollback.older": "Более ранние операции", "integrations.rollback.showMore": "Показать ещё {n}", "integrations.rollback.failed": "Не удалось загрузить историю откатов.", + "integrations.rollback.delete": "Удалить", + "integrations.rollback.deleteAria": "Удалить запись отката от {at}", + "integrations.rollback.deleteNewest": "Последняя запись этого клиента сохраняется, чтобы вы могли её отменить.", + "integrations.rollback.deleteGone": "Эта запись уже удалена. Список будет обновлён.", + "integrations.dialog.deleteEntry.title": "Удалить эту запись отката?", + "integrations.dialog.deleteEntry.changes": "Запись исчезнет из списка откатов, а сохранившаяся резервная копия для {path} будет удалена с диска.", + "integrations.dialog.deleteEntry.breakage": "Вернуть файл к этому состоянию больше не получится. Более новые записи и сам файл останутся нетронутыми.", + "integrations.dialog.deleteEntry.undo": "Это действие необратимо. Последняя запись каждого клиента сохраняется и не может быть удалена.", + "integrations.dialog.deleteEntry.confirm": "Удалить запись", "integrations.restore.title": "Восстановить этот снимок?", "integrations.restore.body": "Сначала будет создана резервная копия текущего файла, затем выбранный снимок заменит его.", "integrations.restore.driftTitle": "Обнаружены более новые изменения", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9ac2f2e272..61089ab46a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1593,6 +1593,15 @@ export const tr: Record = { "integrations.rollback.older": "Önceki işlemler", "integrations.rollback.showMore": "{n} tane daha göster", "integrations.rollback.failed": "Geri alma geçmişi yüklenemedi.", + "integrations.rollback.delete": "Sil", + "integrations.rollback.deleteAria": "{at} tarihli geri alma kaydını sil", + "integrations.rollback.deleteNewest": "Bu istemcinin en son kaydı, hâlâ geri alabilmeniz için saklanır.", + "integrations.rollback.deleteGone": "Bu kayıt zaten silinmiş. Liste yenilenecek.", + "integrations.dialog.deleteEntry.title": "Bu geri alma kaydı silinsin mi?", + "integrations.dialog.deleteEntry.changes": "Bu kayıt geri alma listesinden kaybolur ve {path} için hâlâ tutulan yedek diskten silinir.", + "integrations.dialog.deleteEntry.breakage": "Dosyayı artık bu noktaya geri döndüremezsiniz. Daha yeni kayıtlar ve dosyanın kendisi etkilenmez.", + "integrations.dialog.deleteEntry.undo": "Bu işlem geri alınamaz. Her istemcinin en son kaydı saklanır ve silinemez.", + "integrations.dialog.deleteEntry.confirm": "Kaydı sil", "integrations.restore.title": "Bu anlık görüntü geri yüklensin mi?", "integrations.restore.body": "Mevcut dosya önce yedeklenir.", "integrations.restore.driftTitle": "Daha yeni düzenlemeler algılandı", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 22edb043c7..097cb77b91 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2157,6 +2157,15 @@ export const zhTW: Record = { "integrations.rollback.older": "較早的操作", "integrations.rollback.showMore": "再顯示 {n} 個", "integrations.rollback.failed": "無法載入還原紀錄。", + "integrations.rollback.delete": "刪除", + "integrations.rollback.deleteAria": "刪除 {at} 的還原紀錄", + "integrations.rollback.deleteNewest": "此用戶端最近的一筆紀錄會保留,讓你仍可復原。", + "integrations.rollback.deleteGone": "這筆紀錄已被刪除。列表將會重新載入。", + "integrations.dialog.deleteEntry.title": "要刪除這筆還原紀錄嗎?", + "integrations.dialog.deleteEntry.changes": "這筆紀錄將從還原列表中消失,且它為 {path} 保留的備份也會從磁碟刪除。", + "integrations.dialog.deleteEntry.breakage": "你將無法再把檔案還原到這個時間點。較新的紀錄與檔案本身不受影響。", + "integrations.dialog.deleteEntry.undo": "此操作無法復原。每個用戶端最近的一筆紀錄會保留且無法刪除。", + "integrations.dialog.deleteEntry.confirm": "刪除紀錄", "integrations.restore.title": "要還原此快照?", "integrations.restore.body": "系統會先備份目前的檔案,再用所選快照取代它。", "integrations.restore.driftTitle": "偵測到較新的編輯", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5c1dee017a..2359b4adf8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1118,6 +1118,15 @@ export const zh: Record = { "integrations.rollback.older": "较早的操作", "integrations.rollback.showMore": "再显示 {n} 个", "integrations.rollback.failed": "无法加载回滚记录。", + "integrations.rollback.delete": "删除", + "integrations.rollback.deleteAria": "删除 {at} 的回滚记录", + "integrations.rollback.deleteNewest": "该客户端最近的一条记录会保留,以便你仍能撤销。", + "integrations.rollback.deleteGone": "该记录已被删除。列表将会刷新。", + "integrations.dialog.deleteEntry.title": "要删除这条回滚记录吗?", + "integrations.dialog.deleteEntry.changes": "该记录将从回滚列表中消失,并且它为 {path} 保留的备份也会从磁盘删除。", + "integrations.dialog.deleteEntry.breakage": "你将无法再把文件恢复到这个时间点。更新的记录和文件本身不受影响。", + "integrations.dialog.deleteEntry.undo": "此操作无法撤销。每个客户端最近的一条记录会保留且不能删除。", + "integrations.dialog.deleteEntry.confirm": "删除记录", "integrations.restore.title": "恢复此快照?", "integrations.restore.body": "系统会先备份当前文件,再用所选快照替换它。", "integrations.restore.driftTitle": "检测到较新的编辑", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 377a94eeed..e1d9e6ee7d 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -14,6 +14,7 @@ import { loadIntegrationJournal, loadIntegrationState, toggleIntegration, + deleteJournalEntry, type FileIntegrationClientId, type IntegrationJournalRow, type IntegrationStatus, @@ -85,6 +86,8 @@ export default function FileIntegrationPage({ const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); const [restoring, setRestoring] = useState(null); + /* The row awaiting delete confirmation. */ + const [deleting, setDeleting] = useState(null); /* Open only while the user is confirming an overwrite. */ const [overwriting, setOverwriting] = useState(false); @@ -270,7 +273,7 @@ export default function FileIntegrationPage({ ) : history.length === 0 ? (

{t("integrations.rollback.empty")}

) : ( - + )} {restoring && ( @@ -281,6 +284,30 @@ export default function FileIntegrationPage({ onRestored={refresh} /> )} + {deleting && ( + setDeleting(null)} + onConfirm={async () => { + try { + await deleteJournalEntry(apiBase, deleting.opId); + } catch (error) { + // Localized before it reaches the dialog, which renders + // `error.message` as-is; see the twin block in IntegrationsOverview. + throw new Error(describeRefusal(t, error), { cause: error }); + } + setDeleting(null); + await historyResource.refresh(); + }} + /> + )} {overwriting && ( (null); const [restoring, setRestoring] = useState(null); + /* The row awaiting delete confirmation. */ + const [deleting, setDeleting] = useState(null); const [cardResults, setCardResults] = useState>>({}); const [pendingToggle, setPendingToggle] = useState(null); /* The conflicted row awaiting overwrite confirmation. */ @@ -643,7 +646,7 @@ export default function IntegrationsOverview({

{t("integrations.rollback.emptyBody")}

) : ( - + )} {restoring && ( @@ -654,6 +657,35 @@ export default function IntegrationsOverview({ onRestored={refresh} /> )} + {deleting && ( + setDeleting(null)} + onConfirm={async () => { + try { + await deleteJournalEntry(apiBase, deleting.opId); + } catch (error) { + /* + * Rethrown as a localized message because ConsequenceDialog renders + * `error.message` verbatim. The 409 and 404 here carry a `code` and + * no `reason`, so without this the server English reaches every + * locale. The dialog stays open and re-enables its confirm button, + * which makes the same press the retry. + */ + throw new Error(describeRefusal(t, error), { cause: error }); + } + setDeleting(null); + await historyResource.refresh(); + }} + /> + )} {pendingToggle && ( void; + /** Optional: a surface that cannot refresh the journal must not offer it. */ + onDelete?: (row: IntegrationJournalRow) => void; }) { const t = useT(); return ( @@ -55,6 +58,22 @@ export function RollbackRow({ {row.undoable ? t("integrations.action.undo") : t("integrations.action.restorePoint")} )} + {/* + Delete sits AFTER restore, and only when the server says so. An expired + row keeps its badge and gains this button -- that pairing is the point of + the feature: a row whose bytes are gone was previously a dead entry with + no action at all. + */} + {row.deletable && onDelete && ( + + )} ); } @@ -63,10 +82,12 @@ export function RollbackHistory({ rows, showClient, onRestore, + onDelete, }: { rows: readonly IntegrationJournalRow[]; showClient?: boolean; onRestore: (row: IntegrationJournalRow) => void; + onDelete?: (row: IntegrationJournalRow) => void; }) { const t = useT(); const [shown, setShown] = useState(PAGE); @@ -79,14 +100,19 @@ export function RollbackHistory({ return (
    - + {/* + The newest row gets the prop too. The server answers `deletable: false` + for it, so no button appears -- but withholding the prop here would make + that rule depend on an omission rather than on the data. + */} +
{older.length > 0 && (
{t("integrations.rollback.older")}
    {visible.map(row => ( - + ))}
{remaining > 0 && ( diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 9277840b61..432155693a 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -59,6 +59,12 @@ export interface IntegrationJournalRow { configPath: string; snapshot: "none" | "stored" | "expired"; undoable: boolean; + /** + * Server-computed. The DELETE route enforces the same rule, and a second + * copy of it here would drift; false for a client newest row, which stays + * available as the undo entry point. + */ + deletable: boolean; } export interface IntegrationJournalEnvelope { @@ -253,6 +259,31 @@ export async function restoreIntegration( ); } +/** + * Retire one rollback row. + * + * The opId rides in the query string because the route reads it there. CSRF is + * not set here on purpose: api.ts attaches the header to every method that is + * not GET or HEAD, so a second copy would only be able to disagree. + */ +export async function deleteJournalEntry( + apiBase: string, + opId: string, + signal?: AbortSignal, +) { + return readResponse<{ + ok: true; + opId: string; + clientId: FileIntegrationClientId; + snapshotRemoved: boolean; + }>( + await fetch(`${apiBase}/api/client-integrations/journal?opId=${encodeURIComponent(opId)}`, { + method: "DELETE", + signal, + }), + ); +} + /* * Overview-only readers for the five surfaces that are not file clients. * diff --git a/gui/src/pages/integrations/refusal-copy.ts b/gui/src/pages/integrations/refusal-copy.ts index 6415be022f..4b623102e5 100644 --- a/gui/src/pages/integrations/refusal-copy.ts +++ b/gui/src/pages/integrations/refusal-copy.ts @@ -11,6 +11,8 @@ import { NativeApiError, type NativeRefusalEnvelope } from "./native-api"; */ const CODE_KEYS: Record = { integration_mutation_busy: "integrations.error.busy", + integration_journal_newest_protected: "integrations.rollback.deleteNewest", + integration_operation_not_found: "integrations.rollback.deleteGone", }; /** diff --git a/gui/tests/integrations-rollback-history.test.tsx b/gui/tests/integrations-rollback-history.test.tsx index 57446d2044..2449c65382 100644 --- a/gui/tests/integrations-rollback-history.test.tsx +++ b/gui/tests/integrations-rollback-history.test.tsx @@ -36,6 +36,7 @@ function row(overrides: Partial & { opId: string }): Inte configPath: "/tmp/home/.hermes/config.yaml", snapshot: "stored", undoable: false, + deletable: false, ...overrides, }; } @@ -77,7 +78,11 @@ afterEach(async () => { async function mount( journal: IntegrationJournalRow[], - options: { showClient?: boolean; onRestore?: (value: IntegrationJournalRow) => void } = {}, + options: { + showClient?: boolean; + onRestore?: (value: IntegrationJournalRow) => void; + onDelete?: (value: IntegrationJournalRow) => void; + } = {}, ) { await act(async () => { root = createRoot(container); @@ -87,6 +92,7 @@ async function mount( rows={journal} showClient={options.showClient} onRestore={options.onRestore ?? (() => {})} + onDelete={options.onDelete} /> , ); @@ -186,6 +192,92 @@ test("an expired snapshot offers no control anywhere in the list", async () => { expect(expired!.querySelector("button")).toBeNull(); }); +test("the delete control appears only where the server allows it", async () => { + /* + * `deletable` is the server answer, not a GUI inference. The newest row is + * the undo entry point and keeps no delete affordance; an older row gets one. + * Recomputing the rule here would put a second copy of it in the client, + * which is exactly what the flag exists to prevent. + */ + await mount([ + row({ opId: "op-newest", undoable: true, deletable: false }), + row({ opId: "op-older", deletable: true }), + ], { onDelete: () => {} }); + await act(async () => { disclosure()!.open = true; }); + + const [newest, older] = visibleRows(); + expect(newest!.textContent).not.toContain("Delete"); + expect(older!.textContent).toContain("Delete"); +}); + +test("a deletable row passes ITS row to the handler, not the newest one", async () => { + // The same defect class the restore test guards: the fold maps a sliced copy, + // so a mis-bound handler deletes a different point in the user history than + // the one they chose, with a dialog that names the row they picked. + let deleted: IntegrationJournalRow | null = null; + const journal = [ + row({ opId: "op-newest", undoable: true }), + ...rows(12).map(entry => ({ ...entry, deletable: true })), + ]; + await mount(journal, { onDelete: value => { deleted = value; } }); + await act(async () => { disclosure()!.open = true; }); + + const folded = visibleRows().filter(node => node.closest(".integration-history-older")); + const third = folded[2]!; + const deleteButton = (Array.from(third.querySelectorAll("button")) as unknown as HTMLButtonElement[]) + .find(button => (button.textContent ?? "").trim() === "Delete")!; + await act(async () => { deleteButton.click(); }); + + expect(deleted).not.toBeNull(); + expect(deleted!.opId).toBe(journal[3]!.opId); +}); + +test("an expired row is deletable, which is the pairing the feature adds", async () => { + /* + * Before this, an expired row rendered a badge and nothing else: it could not + * be restored and could not be removed. It is the state that motivated the + * whole change, so the badge and the button have to coexist. + */ + await mount([ + row({ opId: "op-newest", undoable: true }), + row({ opId: "op-gone", snapshot: "expired", deletable: true }), + ], { onDelete: () => {} }); + await act(async () => { disclosure()!.open = true; }); + + const expired = visibleRows().find(node => (node.textContent ?? "").includes("Backup expired"))!; + expect(expired.textContent).toContain("Delete"); + // The restore control is still absent: the bytes really are gone. + expect(expired.textContent).not.toContain("Restore point"); +}); + +test("a surface that passes no handler renders no delete control at all", async () => { + // The prop is optional so a read-only surface cannot offer an action it has + // no way to complete. + await mount([ + row({ opId: "op-newest", undoable: true }), + row({ opId: "op-older", deletable: true }), + ]); + await act(async () => { disclosure()!.open = true; }); + expect(container.textContent).not.toContain("Delete"); +}); + +test("each delete control names its own entry for a screen reader", async () => { + // Every row renders the same visible word, so the accessible name is the only + // thing that distinguishes them. + await mount([ + row({ opId: "op-newest", undoable: true }), + row({ opId: "op-older", deletable: true, at: "2026-08-31T09:00:00.000Z" }), + ], { onDelete: () => {} }); + await act(async () => { disclosure()!.open = true; }); + + const labels = (Array.from(container.querySelectorAll("button")) as unknown as HTMLButtonElement[]) + .filter(button => (button.textContent ?? "").trim() === "Delete") + .map(button => button.getAttribute("aria-label") ?? ""); + expect(labels).toHaveLength(1); + expect(labels[0]).toContain("Delete the rollback entry from"); + expect(labels[0]!.length).toBeGreaterThan("Delete the rollback entry from".length); +}); + test("restoring from inside the fold passes that row, not the newest one", async () => { /* * The newest row's Undo was covered; a folded row's control was not, and the diff --git a/src/integrations/journal.ts b/src/integrations/journal.ts index b5d341ac17..1507276cd9 100644 --- a/src/integrations/journal.ts +++ b/src/integrations/journal.ts @@ -64,6 +64,44 @@ export interface JournalEntry { export const SNAPSHOT_RETENTION = 10; +/** + * A deletion, expressed as an APPEND. + * + * The alternative -- rewriting journal.jsonl without the row -- breaks all three + * things this file's header promises. `appendOperation` commits and nothing + * else, so a read-modify-write would race any concurrent append; a torn write + * would truncate the whole log rather than one trailing line, which + * `listOperations` is built to tolerate; and no lock covers this file, because + * append-only never needed one (writer-lock.ts guards `.lock`, + * which is a client config, not this). + * + * "journal rows always survive" (pruneSnapshots below) is a promise about + * RETENTION, not about the user. An operator deleting their own row is not + * retention, and the physical line does in fact survive -- this record is laid + * over it. + */ +export interface JournalTombstone { + /** opId this row retires. */ + tombstone: string; + at: string; + /** Management principal that asked. Never a token, never a path. */ + by: string; +} + +function isTombstone(value: unknown): value is JournalTombstone { + return typeof value === "object" && value !== null + && typeof (value as { tombstone?: unknown }).tombstone === "string"; +} + +/** Retire one operation. Append-only, exactly like `appendOperation`. */ +export function appendTombstone( + record: JournalTombstone, + dir: string = integrationsDir(), +): void { + ensureDir(journalPath(dir)); + appendFileSync(journalPath(dir), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }); +} + /** * Does the file on disk still hold what this operation left behind? * @@ -159,16 +197,34 @@ export function listOperations( return []; } const rows: JournalEntry[] = []; + /* + * Collected in the SAME pass, before any filtering. A tombstone carries no + * clientId -- it names an opId -- so a pass that filtered by client first + * would drop the tombstone and resurrect the row on the per-client route + * while the global route hid it. The two routes read the same log and must + * agree. + */ + const retired = new Set(); for (const line of raw.split("\n")) { if (!line.trim()) continue; try { - const parsed = JSON.parse(line) as JournalEntry; - if (!clientId || parsed.clientId === clientId) rows.push(parsed); + const parsed: unknown = JSON.parse(line); + if (isTombstone(parsed)) { + retired.add(parsed.tombstone); + continue; + } + const entry = parsed as JournalEntry; + if (!clientId || entry.clientId === clientId) rows.push(entry); } catch { // Torn line from an interrupted append; the rest of the log is still good. } } - return rows.reverse().slice(0, limit); + /* + * Filter AFTER the whole file is read, never during. A tombstone is always + * appended after the row it retires, so an in-loop check would miss every one. + */ + const live = retired.size === 0 ? rows : rows.filter(row => !retired.has(row.opId)); + return live.reverse().slice(0, limit); } export function findOperation(opId: string, dir: string = integrationsDir()): JournalEntry | null { @@ -211,6 +267,11 @@ export function countSnapshots( * Keep the newest N snapshot files per client; journal rows always survive. * Structured rather than throwing or swallowing: a swallowed failure would let * credential-bearing snapshots pile up while every operation reported success. + * + * A user-deleted row no longer occupies a retention slot: `listOperations` + * hides retired rows, so the keep window below slides down by one for each + * deletion. The direction is safe -- an older backup is kept rather than + * collected -- but "ten backups per client" counts LIVE rows, not operations. */ export function pruneSnapshots( clientId: IntegrationClientId, diff --git a/src/integrations/store.ts b/src/integrations/store.ts index 1ab1ab9a92..a8c9310531 100644 --- a/src/integrations/store.ts +++ b/src/integrations/store.ts @@ -12,6 +12,7 @@ */ import { appendOperation, + appendTombstone, captureSnapshot, clearPruneFailure, countSnapshots, @@ -22,6 +23,7 @@ import { readMaintenance, readSnapshot, type JournalEntry, + type JournalTombstone, type MaintenanceState, type SnapshotRef, } from "./journal"; @@ -42,6 +44,8 @@ export interface IntegrationStateStore { putRecord(record: OwnershipRecord): void; dropRecord(clientId: IntegrationClientId): void; appendJournal(entry: JournalEntry): void; + /** Retire one operation. Append-only; see journal.ts `appendTombstone`. */ + retireOperation(record: JournalTombstone): void; listOperations(clientId?: IntegrationClientId, limit?: number): JournalEntry[]; findOperation(opId: string): JournalEntry | null; captureSnapshot(clientId: IntegrationClientId, opId: string, text: string | null): SnapshotRef; @@ -78,6 +82,7 @@ export function createIntegrationStateStore(root: string = integrationsDir()): I putRecord: record => writeRecord(record, dir), dropRecord: clientId => deleteRecord(clientId, dir), appendJournal: entry => appendOperation(entry, dir), + retireOperation: record => appendTombstone(record, dir), listOperations: (clientId, limit) => listOperations(clientId, limit, dir), findOperation: opId => findOperation(opId, dir), captureSnapshot: (clientId, opId, text) => captureSnapshot(clientId, opId, text, dir), diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index bc635b9743..746e6755ad 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -76,6 +76,15 @@ export interface IntegrationJournalRow { configPath: string; snapshot: "none" | "stored" | "expired"; undoable: boolean; + /** + * May the operator retire this row? + * + * Computed HERE, not in the GUI, because the DELETE route enforces the same + * rule and two copies of it would drift. False for a client newest row: it + * is the undo entry point (`undoable` above keys off exactly this), and it + * is what a user reaches for right after the mistake. + */ + deletable: boolean; } export interface IntegrationToggleBody { @@ -293,6 +302,93 @@ function writerFailureResponse( }, 500, ctx.req, ctx.config); } +/** + * Who asked for this deletion, as an audit value that is safe to persist. + * + * The tombstone lives in an append-only log the user can read, so this must be + * a principal NAME and nothing else -- never the admin token, a session id, or + * a filesystem path. `principal` is undefined only in direct-dispatch tests, + * which the auth gate documents as the untrusted admin-token case. + */ +function journalDeletePrincipal(ctx: ManagementContext): string { + return ctx.principal ?? "admin-token"; +} + +/** + * Retire one journal row at the operator request. + * + * The opId travels in the QUERY STRING, matching DELETE + * /api/codex-auth/accounts?id= -- the repository other DELETE-by-identifier. A + * body on DELETE is legal but unevenly handled by intermediaries, and there is + * nothing here a query cannot carry. + */ +async function handleJournalDelete(ctx: ManagementContext): Promise { + const { req, url } = ctx; + const opId = url.searchParams.get("opId")?.trim(); + if (!opId) { + return jsonResponse({ + error: "opId must be a non-empty string", + code: "invalid_op_id", + }, 400, req, ctx.config); + } + try { + const store = integrationStore(); + const operation = store.findOperation(opId); + if (!operation) { + // Already retired, or never existed. Both are 404: the tombstone hides + // the row from findOperation, so a double-click is idempotent here + // rather than a second deletion of something. + return jsonResponse({ + error: "integration operation not found", + code: "integration_operation_not_found", + opId, + }, 404, req, ctx.config); + } + /* + * The newest row per client is refused, and refused by the SERVER even + * though the GUI already hides its button. The button is a courtesy; this + * is the rule. An admin-token caller has no GUI at all. + * + * Re-read immediately before the write: a restore that landed while the + * dialog was open appends a new row and changes which opId is newest. + */ + const newest = store.listOperations(operation.clientId, 1)[0]; + if (newest?.opId === opId) { + return jsonResponse({ + error: "the newest operation for a client cannot be deleted", + code: "integration_journal_newest_protected", + clientId: operation.clientId, + opId, + }, 409, req, ctx.config); + } + + store.retireOperation({ + tombstone: opId, + at: new Date().toISOString(), + by: journalDeletePrincipal(ctx), + }); + + /* + * Snapshot bytes go too, and go AFTER the tombstone -- the same post-commit + * ordering appendOperation uses (journal.ts rule 1). If this fails, the row + * is still retired and retentionDegraded discloses the leftover file; the + * reverse order would delete a user backup for a deletion that then failed + * to record. + */ + const pruned = store.pruneSnapshots(operation.clientId); + if (!pruned.ok) store.markPruneFailure(operation.clientId, pruned.error); + + return jsonResponse({ + ok: true, + opId, + clientId: operation.clientId, + snapshotRemoved: pruned.ok, + }, 200, req, ctx.config); + } catch (error) { + return internalErrorResponse(error, ctx); + } +} + export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const { req, url } = ctx; @@ -319,6 +415,10 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + test("a retired row disappears from list and find, and the file only grew", () => { + store.appendJournal(entry({ opId: "keep-me" })); + store.appendJournal(entry({ opId: "retire-me" })); + const before = readFileSync(join(root, "journal.jsonl"), "utf8"); + + store.retireOperation({ tombstone: "retire-me", at: new Date().toISOString(), by: "gui-session" }); + + expect(store.listOperations().map(row => row.opId)).toEqual(["keep-me"]); + expect(store.findOperation("retire-me")).toBeNull(); + expect(store.findOperation("keep-me")).not.toBeNull(); + + // The log is append-only: every prior byte is still there, in order. + const after = readFileSync(join(root, "journal.jsonl"), "utf8"); + expect(after.startsWith(before)).toBe(true); + expect(after.length).toBeGreaterThan(before.length); + // And the retired row's own line was never touched. + expect(after).toContain('"opId":"retire-me"'); + }); + + test("the tombstone never leaks into the JournalEntry stream", () => { + /* + * The retirement record has no clientId and no kind. If it reached a + * consumer it would render as an unknown row with a raw i18n key and no + * type error anywhere -- exactly the failure the three-tree union test + * upstream exists to prevent for kinds. + */ + store.appendJournal(entry({ opId: "a" })); + store.retireOperation({ tombstone: "a", at: new Date().toISOString(), by: "admin-token" }); + + for (const row of store.listOperations(undefined, Number.MAX_SAFE_INTEGER)) { + expect(row).not.toHaveProperty("tombstone"); + expect(typeof row.clientId).toBe("string"); + } + expect(store.listOperations()).toEqual([]); + }); + + test("a tombstone hides the row on the per-client read too", () => { + /* + * The tombstone carries an opId, not a clientId, so a pass that filtered by + * client BEFORE collecting tombstones would drop it and resurrect the row on + * the filtered route while the global route hid it. The two routes read the + * same log and must agree. + */ + store.appendJournal(entry({ opId: "pi-a", clientId: "pi" })); + store.appendJournal(entry({ opId: "pi-b", clientId: "pi" })); + store.appendJournal(entry({ opId: "kimi-a", clientId: "kimi" })); + store.retireOperation({ tombstone: "pi-a", at: new Date().toISOString(), by: "gui-session" }); + + expect(store.listOperations("pi").map(row => row.opId)).toEqual(["pi-b"]); + expect(store.listOperations().map(row => row.opId)).toEqual(["kimi-a", "pi-b"]); + // Another client's rows are untouched. + expect(store.listOperations("kimi").map(row => row.opId)).toEqual(["kimi-a"]); + }); + + test("order and newest-first survive a retirement in the middle", () => { + for (const opId of ["one", "two", "three", "four"]) store.appendJournal(entry({ opId })); + store.retireOperation({ tombstone: "three", at: new Date().toISOString(), by: "gui-session" }); + expect(store.listOperations().map(row => row.opId)).toEqual(["four", "two", "one"]); + }); + + test("a log with no tombstones reads exactly as before", () => { + // The fast path. Adding a filter that ran unconditionally would be the + // cheapest possible way to change every existing read. + store.appendJournal(entry({ opId: "x" })); + store.appendJournal(entry({ opId: "y" })); + expect(store.listOperations().map(row => row.opId)).toEqual(["y", "x"]); + }); + + test("a retired row stops occupying a retention slot", () => { + /* + * Documented fallout, not an accident (060 §3.1 consumer 3). `pruneSnapshots` + * builds its keep set from `listOperations`, so retiring one row slides the + * slice(0, N) window down by one and an older backup that was next in line + * for collection is kept instead. The direction is safe -- snapshots survive + * LONGER, never disappear early -- but "ten backups per client" now means ten + * LIVE rows, not ten operations. + */ + const opIds: string[] = []; + for (let index = 0; index < SNAPSHOT_RETENTION; index += 1) { + const opId = `op-${String(index).padStart(3, "0")}`; + opIds.push(opId); + const snapshot = store.captureSnapshot("pi", opId, `bytes ${index}\n`); + store.appendJournal(entry({ opId, snapshot })); + } + // Exactly at the bound: every backup is still on disk, none collected yet. + expect(store.countSnapshots("pi")).toBe(SNAPSHOT_RETENTION); + const oldest = opIds[0]!; + + // Retire a row in the middle, then perform one more operation. Without the + // retirement this eleventh backup evicts `oldest`; with it, the live count + // is still ten, so `oldest` keeps its slot and the retired row's bytes are + // what get collected instead. + store.retireOperation({ tombstone: opIds[5]!, at: new Date().toISOString(), by: "gui-session" }); + const snapshot = store.captureSnapshot("pi", "op-new", "bytes new\n"); + store.appendJournal(entry({ opId: "op-new", snapshot })); + + expect(store.listOperations("pi", Number.MAX_SAFE_INTEGER)).toHaveLength(SNAPSHOT_RETENTION); + expect(store.countSnapshots("pi")).toBe(SNAPSHOT_RETENTION); + // The retired row's backup went; the oldest survivor kept the freed slot. + expect(existsSync(join(root, "snapshots", "pi", opIds[5]!))).toBe(false); + expect(existsSync(join(root, "snapshots", "pi", oldest))).toBe(true); + expect(existsSync(join(root, "snapshots", "pi", "op-new"))).toBe(true); + }); +}); diff --git a/tests/management-integration-journal-delete.test.ts b/tests/management-integration-journal-delete.test.ts new file mode 100644 index 0000000000..0ab66f0712 --- /dev/null +++ b/tests/management-integration-journal-delete.test.ts @@ -0,0 +1,355 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExportModel } from "../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../src/integrations/store"; +import { handleManagementAPI } from "../src/server/management-api"; +import { + setIntegrationMutationFlightTestHooks, + setIntegrationPathTestHooks, +} from "../src/server/management/integration-routes"; +import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; + +/** + * DELETE /api/client-integrations/journal -- the branches of + * devlog/_plan/260904_priority65_closeout/060 §7 (1-6). + * + * Every row here is produced by the REAL writer against a real temp HOME and a + * real temp store, matching management-integration-routes.test.ts. A fixture + * that hand-wrote journal lines would let the newest-row rule pass against a + * shape the writer never actually produces. + */ +let base = ""; +let home = ""; +let storeRoot = ""; +let store: IntegrationStateStore; + +const routeEnv = {} as NodeJS.ProcessEnv; + +/** Shaped like a real key, assembled at runtime so no literal secret is committed. */ +const REAL_LOOKING_KEY = ["ocx", "live", "9f3c7a2b41d84e6fa05c8e17b3d92764"].join("_"); + +const MODELS_FIXTURE: ExportModel[] = [ + { namespaced: "a/m1", provider: "a", id: "m1", contextWindow: 128_000 }, +]; + +function baseConfig(): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "a", + apiKeys: [{ id: "key-1", name: "default", key: REAL_LOOKING_KEY, createdAt: new Date(0).toISOString() }], + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: REAL_LOOKING_KEY, + liveModels: false, + models: [MODELS_FIXTURE[0]!.id], + modelContextWindows: { m1: 128_000 }, + modelReasoningEfforts: { m1: ["minimal", "low", "high"] }, + }, + }, + } as unknown as OcxConfig; +} + +let config: OcxConfig; + +beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "ocx-journal-delete-")); + home = join(base, "home"); + storeRoot = join(base, "store", "integrations"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(storeRoot); + config = baseConfig(); + setIntegrationMutationFlightTestHooks({ store }); + setIntegrationPathTestHooks({ env: routeEnv, home }); +}); + +afterEach(() => { + setIntegrationMutationFlightTestHooks(null); + setIntegrationPathTestHooks(null); + removeTreeWithRetry(base); +}); + +function installHermes(): string { + const spec = INTEGRATION_CLIENTS.hermes; + mkdirSync(spec.detectDir(routeEnv, home), { recursive: true }); + return spec.configPath(routeEnv, home); +} + +async function api(path: string, init: RequestInit = {}): Promise { + const url = new URL("http://127.0.0.1:10100" + path); + const response = await handleManagementAPI( + new Request(url, { ...init, headers: { Host: url.host, ...(init.headers ?? {}) } }), + url, + config, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(response).not.toBeNull(); + return response!; +} + +function put(clientId: string, enabled: boolean): Promise { + return api("/api/client-integrations/" + clientId, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); +} + +interface JournalRow { + opId: string; + clientId: string; + kind: string; + at: string; + configPath: string; + snapshot: "none" | "stored" | "expired"; + undoable: boolean; + deletable: boolean; +} + +async function journal(query = ""): Promise { + const response = await api("/api/client-integrations/journal" + query); + expect(response.status).toBe(200); + return (await response.json() as { operations: JournalRow[] }).operations; +} + +function del(query: string): Promise { + return api("/api/client-integrations/journal" + query, { method: "DELETE" }); +} + +function byId(opId: string): string { + return "?opId=" + encodeURIComponent(opId); +} + +/** Two hermes operations, returned oldest first. */ +async function twoOperations(): Promise<[JournalRow, JournalRow]> { + installHermes(); + expect((await put("hermes", true)).status).toBe(200); + expect((await put("hermes", false)).status).toBe(200); + const rows = await journal("?client=hermes"); + expect(rows).toHaveLength(2); + return [rows[1]!, rows[0]!]; +} + +describe("the journal row carries a server-computed deletable flag", () => { + test("the newest row per client is not deletable and older rows are", async () => { + const [older, newest] = await twoOperations(); + expect(newest.deletable).toBe(false); + expect(older.deletable).toBe(true); + }); + + test("the row shape gains exactly one field and no others", async () => { + await twoOperations(); + const text = await (await api("/api/client-integrations/journal")).text(); + for (const row of JSON.parse(text).operations as Record[]) { + expect(Object.keys(row).sort()).toEqual([ + "at", "clientId", "configPath", "deletable", "kind", "opId", "snapshot", "undoable", + ]); + } + // The snapshots on disk really do hold the serializable secret, so "no key + // in the response" is a claim about the serializer, not an empty fixture. + expect(text).not.toContain(REAL_LOOKING_KEY); + }); + + test("deletable is decided per client, not across the whole list", async () => { + /* + * The newest row overall belongs to ONE client. Computing the rule against + * the whole list would strip every other client of its undo entry point. + */ + mkdirSync(INTEGRATION_CLIENTS.hermes.detectDir(routeEnv, home), { recursive: true }); + mkdirSync(INTEGRATION_CLIENTS.dsh.detectDir(routeEnv, home), { recursive: true }); + expect((await put("dsh", true)).status).toBe(200); + expect((await put("hermes", true)).status).toBe(200); + + const rows = await journal(); + const protectedClients = rows.filter(row => !row.deletable).map(row => row.clientId).sort(); + expect(protectedClients).toEqual(["dsh", "hermes"]); + }); +}); + +describe("DELETE /api/client-integrations/journal", () => { + test("retires an older row and hides it from every read", async () => { + const [older] = await twoOperations(); + + const response = await del(byId(older.opId)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + opId: older.opId, + clientId: "hermes", + snapshotRemoved: true, + }); + + // Gone from the global route, the per-client filter, and the store. + expect((await journal()).map(row => row.opId)).not.toContain(older.opId); + expect((await journal("?client=hermes")).map(row => row.opId)).not.toContain(older.opId); + expect(store.findOperation(older.opId)).toBeNull(); + }); + + test("the log is appended to, never rewritten, and the record names a principal", async () => { + const [older] = await twoOperations(); + const logPath = join(storeRoot, "journal.jsonl"); + const before = readFileSync(logPath, "utf8"); + + expect((await del(byId(older.opId))).status).toBe(200); + + const after = readFileSync(logPath, "utf8"); + // Every prior byte survives in order, including the deleted row own line. + expect(after.startsWith(before)).toBe(true); + expect(after).toContain(JSON.stringify(older.opId)); + + const tombstone = JSON.parse(after.trim().split("\n").at(-1)!) as Record; + expect(tombstone.tombstone).toBe(older.opId); + expect(typeof tombstone.at).toBe("string"); + // A principal name, never a token, a session id, or a filesystem path. + expect(tombstone.by).toBe("admin-token"); + expect(after).not.toContain(REAL_LOOKING_KEY); + expect(String(tombstone.by)).not.toContain("/"); + }); + + test("a missing or blank opId is a 400 with an exact envelope", async () => { + await twoOperations(); + const response = await del(""); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "opId must be a non-empty string", + code: "invalid_op_id", + }); + expect((await del("?opId=%20%20")).status).toBe(400); + }); + + test("an unknown opId is a 404, and a repeated delete is idempotent", async () => { + const [older] = await twoOperations(); + + const unknown = await del("?opId=op-does-not-exist"); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toEqual({ + error: "integration operation not found", + code: "integration_operation_not_found", + opId: "op-does-not-exist", + }); + + expect((await del(byId(older.opId))).status).toBe(200); + // The tombstone hides the row from findOperation, so a double-click lands + // on the same 404 rather than deleting something else. + expect((await del(byId(older.opId))).status).toBe(404); + expect(store.listOperations("hermes")).toHaveLength(1); + }); + + test("the newest row is refused with 409 and nothing is written", async () => { + const [, newest] = await twoOperations(); + const logPath = join(storeRoot, "journal.jsonl"); + const before = readFileSync(logPath, "utf8"); + + const response = await del(byId(newest.opId)); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: "the newest operation for a client cannot be deleted", + code: "integration_journal_newest_protected", + clientId: "hermes", + opId: newest.opId, + }); + + // The rule is enforced BEFORE the append: the refusal wrote nothing at all. + expect(readFileSync(logPath, "utf8")).toBe(before); + expect(store.findOperation(newest.opId)).not.toBeNull(); + expect((await journal("?client=hermes")).map(row => row.opId)).toContain(newest.opId); + }); + + test("newest is re-read at write time rather than trusted from the list", async () => { + /* + * A dialog can sit open while another operation lands. The formerly-newest + * row stops being newest and becomes deletable, which only holds because + * the handler re-reads immediately before the write instead of relying on + * the flag the client was rendered with. + */ + const [, newest] = await twoOperations(); + expect((await del(byId(newest.opId))).status).toBe(409); + + expect((await put("hermes", true)).status).toBe(200); + expect((await del(byId(newest.opId))).status).toBe(200); + expect(store.findOperation(newest.opId)).toBeNull(); + }); + + test("a row whose snapshot bytes are already gone is expired AND deletable", async () => { + /* + * This pairing is the reason the feature exists: such a row could not be + * restored and could not be removed, so it sat in the list forever with no + * action on it at all. + */ + const configPath = installHermes(); + writeFileSync(configPath, "providers:\n mine:\n api: http://keep\n"); + expect((await put("hermes", true)).status).toBe(200); + expect((await put("hermes", false)).status).toBe(200); + + const older = (await journal("?client=hermes"))[1]!; + const resolved = store.readSnapshot(store.findOperation(older.opId)!); + expect(resolved.kind).toBe("stored"); + if (resolved.kind === "stored") rmSync(resolved.path, { force: true }); + + const expiredRow = (await journal("?client=hermes")).find(row => row.opId === older.opId)!; + expect(expiredRow.snapshot).toBe("expired"); + expect(expiredRow.undoable).toBe(false); + expect(expiredRow.deletable).toBe(true); + + expect((await del(byId(older.opId))).status).toBe(200); + expect(store.findOperation(older.opId)).toBeNull(); + }); + + test("a prune failure still retires the row and discloses the leftover", async () => { + const [older] = await twoOperations(); + const snapshotDir = join(storeRoot, "snapshots", "hermes"); + expect(existsSync(snapshotDir)).toBe(true); + + chmodSync(snapshotDir, 0o000); + let refused = false; + try { + // Running as root defeats the permission bit; only assert the contract + // when the failure genuinely occurred. + refused = !store.pruneSnapshots("hermes").ok; + if (refused) { + const response = await del(byId(older.opId)); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, snapshotRemoved: false }); + // The tombstone is committed first, so a failed cleanup can never take + // the recorded deletion down with it. + expect(store.findOperation(older.opId)).toBeNull(); + // The leftover is recorded for a retry while it is still stranded. + expect(store.readMaintenance().pruneFailures.hermes).toBeDefined(); + } + } finally { + chmodSync(snapshotDir, 0o700); + } + if (refused) { + /* + * Reading the state retries pending prunes, so once the directory is + * readable again the leftover is collected and the marker clears. That + * is the disclosure working, not the absence of one: the row stays + * retired either way. + */ + const state = await (await api("/api/client-integrations/hermes")).json() as { retentionDegraded: boolean }; + expect(state.retentionDegraded).toBe(false); + expect(store.readMaintenance().pruneFailures.hermes).toBeUndefined(); + expect(store.findOperation(older.opId)).toBeNull(); + } + }); + + test("an unsupported method on the journal path still falls through", async () => { + // Accepting DELETE must not turn the path into a catch-all: PATCH keeps + // travelling the dispatch chain exactly as it did before. + const url = new URL("http://127.0.0.1:10100/api/client-integrations/journal"); + const response = await handleManagementAPI( + new Request(url, { method: "PATCH", headers: { Host: url.host } }), + url, + config, + { saveConfigPreservingClaudeCode: () => {}, createManagementConvergeCodex: catalogConvergenceFactory() }, + ); + expect(response).toBeNull(); + }); +}); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index cd23c799e2..9f620e3245 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -188,6 +188,7 @@ interface JournalRow { configPath: string; snapshot: "none" | "stored" | "expired"; undoable: boolean; + deletable: boolean; } async function journal(query = ""): Promise { @@ -872,7 +873,7 @@ describe("GET /api/client-integrations/journal", () => { const text = await (await api("/api/client-integrations/journal")).text(); for (const row of JSON.parse(text).operations as Record[]) { expect(Object.keys(row).sort()).toEqual([ - "at", "clientId", "configPath", "kind", "opId", "snapshot", "undoable", + "at", "clientId", "configPath", "deletable", "kind", "opId", "snapshot", "undoable", ]); } // The snapshots on disk really do hold the serializable secret, so "no key From 2e336cb66ee060b6c9d1af3dc90e16bdc326a1cc Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:06:13 +0900 Subject: [PATCH 12/17] docs(devlog): record the wp3 carry outcome --- .../260904_priority65_closeout/080_outcome.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/080_outcome.md diff --git a/devlog/_plan/260904_priority65_closeout/080_outcome.md b/devlog/_plan/260904_priority65_closeout/080_outcome.md new file mode 100644 index 0000000000..96558d9d87 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/080_outcome.md @@ -0,0 +1,26 @@ +# 080 — 결과 기록 + +base `2421e44ce` → 최종 `origin/dev` `413227888`. + +## 랜딩 + +| PR | squash | 내용 | 트레일러 | +|----|--------|------|----------| +| #3471 | `4968d0f26` | wp2 responses 경계 + wp4 combo context cap | RHODIZ IT | +| #3474 | `00834d710` | wp3 combo metadata + wp5 reset-credit + wp6 게이트 해제 | full999, olddonkey, Abhishek Sharma | +| #3477 | `413227888` | wp7 롤백 저널 삭제 CRUD | — (자체 작성) | + +세 SHA 모두 `git merge-base --is-ancestor origin/dev`로 조상 확인. + +## wp3 — #3332 carry + +커밋 `c691c2565`. PR 원안이 `metadata.maxTokens`(OUTPUT 상한)를 `maxInputTokens`에 +매핑해 combo 입력창을 1M → 128k로, `autoCompactTokenLimit`을 900k → 128k로 무너뜨리는 +것을 실측으로 확인한 뒤 `maxOutputTokens`로 교정했다. + +검증이 3단계였던 것이 이 phase의 값이다. 무수정 코드에서 2 fail을 본 뒤, +**PR의 결함 매핑을 적용한 중간 상태**에서 저격 단언이 여전히 red로 남는 것을 확인하고, +그 다음에야 고쳤다. 초록으로만 끝나는 테스트는 무엇을 겨냥하는지 증명하지 못한다. + +원 PR 테스트가 이 결함을 못 잡은 이유도 기록해 둔다. `toMatchObject`는 열거한 키만 보고 +`contextWindow`는 1M으로 살아남는다. 붕괴는 한 필드 옆에서 일어난다. From 310e8727bd70e78b9a6062f3bc12f9e2e32bf4f5 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:07:32 +0900 Subject: [PATCH 13/17] docs(devlog): record the wp4 outcome --- .../260904_priority65_closeout/081_outcome_wp4.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/081_outcome_wp4.md diff --git a/devlog/_plan/260904_priority65_closeout/081_outcome_wp4.md b/devlog/_plan/260904_priority65_closeout/081_outcome_wp4.md new file mode 100644 index 0000000000..ef16437e1e --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/081_outcome_wp4.md @@ -0,0 +1,13 @@ +# 081 — wp4 — #3461 carry + +커밋 `c880017e7`. 계획은 `gh pr merge 3461 --squash --admin`을 예정했는데, 실행 직전 +확인에서 전제가 무너졌다. #3461은 fork PR이고 head `4e16f889b`에는 게이트 4개만 돌았다. +`gh run list --commit 4e16f889b`가 빈 출력이었다 — Cross-platform CI가 한 번도 실행되지 +않았고, fork 워크플로는 승인 대기로 걸린다. + +게이트 4개만 초록인 것을 "CI green"이라 부르고 머지했다면 이 유닛 자신의 criterion c-2를 +위반한다. 그래서 계획을 carry로 개정(`d9a29db10`)하고 메인테이너 브랜치에서 전체 매트릭스를 +돌렸다. 23/23 통과. + +코드는 한 줄도 바꾸지 않았다. 매처가 좁아 기존 stop 단정 3건을 전부 보존하는 것이 +이 PR을 그대로 받을 수 있는 이유다. From 901eaae5d25fa79c8985d4886b6c33318b173c75 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:07:34 +0900 Subject: [PATCH 14/17] docs(devlog): record the wp5 outcome --- .../260904_priority65_closeout/082_outcome_wp5.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/082_outcome_wp5.md diff --git a/devlog/_plan/260904_priority65_closeout/082_outcome_wp5.md b/devlog/_plan/260904_priority65_closeout/082_outcome_wp5.md new file mode 100644 index 0000000000..9678419473 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/082_outcome_wp5.md @@ -0,0 +1,12 @@ +# 082 — wp5 — #3375 축 D + +커밋 `94e970c04`. 원장 3함수가 완성돼 있는데 프로덕션 호출자가 0개였고, +`auth-api.ts:2177`이 매 호출 새 UUID를 만들어 재시도가 같은 논리적 작업으로 인식되지 +않았다. + +방향에 따라 실패 모드를 다르게 잡은 것이 핵심이다. **열기는 fail-closed** — capacity와 +unavailable은 랜덤 UUID로 폴백하지 않고 503을 낸다. 그 폴백이 정확히 이 identity가 +막으려는 이중소비다. **닫기는 fail-open** — 그 시점엔 크레딧이 이미 나갔고, 원장 실패를 +사용자에게 알리면 수동 재시도를 유발해 반대 방향에서 같은 이중소비가 된다. + +`operationId`를 생략하면 오늘 동작이 바이트 단위로 그대로다. docs-site 8개 로케일도 갱신했다. From d4faf8237a03e0d3afeaeb9d55b51b27d7ec181b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:07:36 +0900 Subject: [PATCH 15/17] docs(devlog): record the wp6 outcome --- .../260904_priority65_closeout/083_outcome_wp6.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/083_outcome_wp6.md diff --git a/devlog/_plan/260904_priority65_closeout/083_outcome_wp6.md b/devlog/_plan/260904_priority65_closeout/083_outcome_wp6.md new file mode 100644 index 0000000000..995fae8784 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/083_outcome_wp6.md @@ -0,0 +1,15 @@ +# 083 — wp6 — #3327 / #3251 게이트 해제 + +커밋 `370ea4ae3`(#3327 carry)와 `081570785`(#3251 carry, 두 커밋 순서대로). + +두 PR 모두 코드는 정확했고 `enforce-target`의 GUI 스크린샷 게이트 하나만 빨간색이었다. +#3327은 테스트만 바꿨는데 `gui/tests/`를 건드려 걸린 오탐이다. + +#3327에서 한 군데를 좁혔다. `not.toContain("tier")`를 봉투 전체에 걸면 `serviceTier`나 +`tierOutcome` 같은 무관한 필드 이름에도 걸려, plan 누출과 상관없는 변경에서 터진다. +report rows와 따옴표 키로 한정했다. + +#3251은 백엔드가 `tierOutcome`을 이미 보내는데 GUI 소비가 0건이던 진짜 공백이다. +스크린샷은 실제 `gpt-5.6-luna` priority 요청을 프록시로 보내 렌더링을 확인한 뒤 찍었다: +`response tier=default (assumed)`. `assumed`를 `confirmed`로 올리지 않은 저자 판단이 +옳다 — 그렇게 하면 #2558을 재도입한다. From 26a8489092101b458470d38fee4ca43e8806e0cf Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:07:37 +0900 Subject: [PATCH 16/17] docs(devlog): record the wp7 outcome --- .../084_outcome_wp7.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/084_outcome_wp7.md diff --git a/devlog/_plan/260904_priority65_closeout/084_outcome_wp7.md b/devlog/_plan/260904_priority65_closeout/084_outcome_wp7.md new file mode 100644 index 0000000000..c824eae4f7 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/084_outcome_wp7.md @@ -0,0 +1,21 @@ +# 084 — wp7 — 롤백 저널 삭제 CRUD + +커밋 `e3f01df6f`, 22파일 +944/-9. PR #3477 → squash `413227888`. + +툼스톤 append로 구현했다. `journal.jsonl` 재작성은 파일 헤더가 약속한 세 가지를 동시에 +깨고, 툼스톤은 `appendFileSync` 한 번이라 불변식이 유지된다. + +클라이언트별 최신 행은 서버가 409로 거절한다. UI가 `deletable`로 버튼을 감추지만 규칙의 +원천은 라우트다 — admin-token 호출자에게는 GUI가 없다. + +GUI 테스트가 컴포넌트보다 나중에 작성됐으므로 `RollbackHistory.tsx`를 HEAD로 되돌려 +4 fail을 확인하고 복원해 15/15를 받았다. 비공허성을 사후에라도 증명하지 않으면 그 초록은 +아무것도 말하지 않는다. + +기존 테스트 하나가 의도대로 빨개졌다. `management-integration-routes.test.ts`가 저널 행의 +키 집합을 정확히 고정하고 있어 `deletable`을 잡았다. 단언을 느슨하게 하지 않고 키 목록을 +확장했다. + +라이브 확인은 격리 홈(`CODEX_HOME`/`OPENCODEX_HOME` 분리)에서 했다. 처음에 홈을 완전히 +격리하지 않아 사용자 `~/.codex`와 `~/.grok` 설정이 죽은 포트를 가리키게 됐고, 즉시 +되돌렸다. 로컬 프록시를 띄우는 실험은 두 환경변수를 모두 격리해야 한다. From 3656bb74ff173a4c2e450bfd001c86c481f61bb2 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 01:07:38 +0900 Subject: [PATCH 17/17] docs(devlog): record the wp8 outcome --- .../085_outcome_wp8.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 devlog/_plan/260904_priority65_closeout/085_outcome_wp8.md diff --git a/devlog/_plan/260904_priority65_closeout/085_outcome_wp8.md b/devlog/_plan/260904_priority65_closeout/085_outcome_wp8.md new file mode 100644 index 0000000000..c88fbd3d76 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/085_outcome_wp8.md @@ -0,0 +1,37 @@ +# 085 — wp8 — 처분과 최종 회귀 증명 + +## 처분 + +| 대상 | 처분 | +|------|------| +| #3061 | close — `78c630a93`이 상위 구현을 이미 랜딩, 90초 예산으로도 실패해 전제 반증 | +| #3461 #3332 #3327 #3251 | close + `landed-via-maintainer`, 각각 랜딩 SHA와 변경 사유 코멘트 | +| #3348 | 근거 코멘트 — head `928841669` `failover.ts:627`에 410/413 hop 잔존, 체리픽 가능 조각 3개 명시 | +| #3389 | 근거 코멘트 — Bun 1.4.0에서 2청크 방출 후 리셋 시 리더가 0바이트를 관측하는 것 4/4 재현, 전제 반증 | +| #3329 | 근거 코멘트 — `coolComboTarget` 쿨다운 우선순위 무단 역전, 3조각 분할 제안 | +| #3425 | 근거 코멘트 — `liveHealthAccountIds`가 config 전체를 담아 가드 미발화, 질문을 삭제/재등록과 fixedAccount로 교체 | +| #3245 | 근거 코멘트 — 실패가 첫 POST 이전 지점이라 코드로 갈 곳 없음 | + +## 최종 회귀 증명 + +| | SHA | Cross-platform CI | +|---|-----|-------------------| +| 세션 시작 | `2421e44ce` | **FAILURE** — macos `tests/codex-auth-context.test.ts:1461` | +| 최종 | `413227888` | **SUCCESS** | + +회귀가 없을 뿐 아니라, 시작 시점이 이미 빨간색이었고 최종은 초록이다. 시작 상태를 먼저 +확정하지 않았다면 "내가 깼는지"를 판정할 수 없었을 것이다. + +랜딩 3건 모두 최종 `origin/dev`의 조상임을 `git merge-base --is-ancestor`로 확인했다. + +## macOS flake 관찰 + +이번 유닛에서 macOS 잡이 두 번 실패했고 두 번 다 재실행으로 통과했다. 실패한 테스트는 +`tests/codex-shim.test.ts`의 detached redispatch 하나와 `tests/lab-fabric-task.test.ts`의 +CL-07 inactivity timeout 하나로, 둘 다 이 브랜치의 diff에 없는 파일이고 로컬에서는 +각각 77/77, 49/49로 통과한다. 타이밍에 민감한 두 테스트가 macOS 러너에서 간헐적으로 +무너진다는 뜻이고, 별도 조사 대상이다. + +## 로컬 스위트 + +`bun run test`와 인자 없는 `bun test`는 이 유닛에서 **0회** 실행했다.