From c4965ad80b0baebdcf1406202931b76c0e7a6fea Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 17 Aug 2026 00:42:49 +0000 Subject: [PATCH 001/121] docs(codex): explain deferred tool search boundary --- .../content/docs/guides/codex-integration.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 39e33fc0c4..81f8eb43cf 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -279,6 +279,45 @@ enabled, also pass `x-opencodex-api-key` from `OPENCODEX_API_AUTH_TOKEN`, matchi provider form above. To let OpenCodex inject routing directly, first switch Codex back to its built-in `openai` provider and remove any user-owned root `openai_base_url`, then rerun `ocx start`. +### Deferred `tool_search` troubleshooting + +`tool_search` is a client-executed Codex discovery tool for loading deferred MCP/app tools. It is +not an OpenCodex feature flag, and an upstream `tool_choice: "auto"` value does not create or enable +it. OpenCodex can relay the tool only when Codex already included a declaration like this in the +incoming Responses request: + +```json +{ + "tools": [ + { "type": "tool_search", "description": "Load deferred tools" } + ] +} +``` + +For routed chat/local models, OpenCodex exposes that declaration as a normal function named +`tool_search`. If the model calls it, OpenCodex converts the call back to a Responses +`tool_search_call`; Codex executes the search and supplies the resulting tool definitions in a +later `tool_search_output`. Definitions loaded that way are then available on the next model turn. + +Check the failure boundary before changing provider settings: + +1. **No `type: "tool_search"` in the incoming request:** the active Codex client/session did not + advertise deferred discovery. OpenCodex cannot invent the tool. Update/restart Codex and verify + the client's MCP/app configuration and feature availability. +2. **The incoming declaration exists, but no `tool_search` function reaches the routed request:** + capture only the redacted tool-type/name list and open an OpenCodex bug. Never attach the bearer, + account id, conversation input, full headers, or complete request body. +3. **The routed request contains `tool_search`, but the local model never calls it:** the relay is + working. Use a model/template with reliable function calling and instructions that explicitly + tell it to search for a needed deferred tool. LM Studio's `tool_choice: "auto"` permits tool use; + it does not force the model to call this function. +4. **A call is emitted repeatedly or loaded tools never become usable:** capture the redacted + `tool_search_call` / `tool_search_output` item types and call ids. OpenCodex preserves both in + history so the model should see the completed search instead of issuing it forever. + +See [The parser and bridge](/reference/architecture/#the-parser) for the wire mapping. There is no +provider-level setting that can compensate for a missing client declaration. + ### Catalog troubleshooting If a model is missing from Codex, or the catalog order/visibility looks wrong, check in order: From b3eda11c5aaea7e6ece5f24c0c3171ac60d27b01 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 12:32:45 +0000 Subject: [PATCH 002/121] docs(codex): separate code-mode and tool search discovery --- .../content/docs/guides/codex-integration.md | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 81f8eb43cf..c0a4217953 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -279,12 +279,16 @@ enabled, also pass `x-opencodex-api-key` from `OPENCODEX_API_AUTH_TOKEN`, matchi provider form above. To let OpenCodex inject routing directly, first switch Codex back to its built-in `openai` provider and remove any user-owned root `openai_base_url`, then rerun `ocx start`. -### Deferred `tool_search` troubleshooting +### Explicit `tool_search` troubleshooting -`tool_search` is a client-executed Codex discovery tool for loading deferred MCP/app tools. It is -not an OpenCodex feature flag, and an upstream `tool_choice: "auto"` value does not create or enable -it. OpenCodex can relay the tool only when Codex already included a declaration like this in the -incoming Responses request: +Routed local tooling has two distinct discovery paths. In normal routed code mode, Codex can expose +deferred MCP/app tools through the official `exec` tool's `tools` global and `ALL_TOOLS`; that path +does not require the model to see or call `tool_search`. + +Separately, `tool_search` is a client-executed Codex discovery surface. It is not an OpenCodex +feature flag, and an upstream `tool_choice: "auto"` value does not create or enable it. OpenCodex can +relay the explicit surface only when Codex already included a declaration like this in the incoming +Responses request: ```json { @@ -302,21 +306,23 @@ later `tool_search_output`. Definitions loaded that way are then available on th Check the failure boundary before changing provider settings: 1. **No `type: "tool_search"` in the incoming request:** the active Codex client/session did not - advertise deferred discovery. OpenCodex cannot invent the tool. Update/restart Codex and verify - the client's MCP/app configuration and feature availability. + advertise the explicit `tool_search` surface. OpenCodex cannot invent that declaration. This + does not mean normal code-mode tools are unavailable: check whether the routed model can use + `exec` and discover the needed nested tool through `tools` / `ALL_TOOLS` first. 2. **The incoming declaration exists, but no `tool_search` function reaches the routed request:** capture only the redacted tool-type/name list and open an OpenCodex bug. Never attach the bearer, account id, conversation input, full headers, or complete request body. 3. **The routed request contains `tool_search`, but the local model never calls it:** the relay is working. Use a model/template with reliable function calling and instructions that explicitly - tell it to search for a needed deferred tool. LM Studio's `tool_choice: "auto"` permits tool use; + tell it to search for a deferred tool it needs. LM Studio's `tool_choice: "auto"` permits tool use; it does not force the model to call this function. 4. **A call is emitted repeatedly or loaded tools never become usable:** capture the redacted `tool_search_call` / `tool_search_output` item types and call ids. OpenCodex preserves both in history so the model should see the completed search instead of issuing it forever. -See [The parser and bridge](/reference/architecture/#the-parser) for the wire mapping. There is no -provider-level setting that can compensate for a missing client declaration. +See [The parser and bridge](/reference/architecture/#the-parser) for the explicit wire mapping. +There is no provider-level setting that can add a missing `tool_search` declaration; ordinary +code-mode discovery remains a separate path. ### Catalog troubleshooting From 01ca3ae7daf5d1bc812783892d91d2db2ce79161 Mon Sep 17 00:00:00 2001 From: jenfonro Date: Mon, 17 Aug 2026 15:55:49 +0000 Subject: [PATCH 003/121] fix(tools): support namespaced custom tool aliases --- src/adapters/anthropic.ts | 2 +- src/adapters/command-code.ts | 7 +- src/adapters/google.ts | 2 +- src/adapters/openai-chat.ts | 2 +- src/adapters/tool-catalog-nudge.ts | 2 +- src/bridge.ts | 16 ++-- src/images/loop.ts | 15 +++- src/responses/parser.ts | 2 +- src/server/responses/collaboration.ts | 36 ++++++-- src/types.ts | 41 +++++++-- src/web-search/loop.ts | 15 +++- tests/command-code-provider.test.ts | 8 +- tests/helpers/responses-conformance.ts | 2 +- tests/reasoning-effort.test.ts | 54 ++++++++++++ tests/responses-parser.test.ts | 101 +++++++++++++++++++++++ tests/responses-tool-conformance.test.ts | 39 ++++++++- tests/tool-catalog-nudge.test.ts | 12 +++ 17 files changed, 318 insertions(+), 38 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index fd141ccfb8..e012a78198 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -743,7 +743,7 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( ? new Set(parsed.options.toolChoice.allowedTools) : undefined; const tools = allowed - ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed)) + ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) : parsed.context.tools; if (tools.length === 0) return undefined; const converted = tools.map(t => ({ diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 156ba5130a..8f2edbdb55 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { opendir } from "node:fs/promises"; import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types"; +import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import type { TranslatorBudget } from "../lib/translator-budget"; import { readBoundedResponseBody } from "../lib/bounded-body"; @@ -157,10 +157,11 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] { const tools = parsed.context.tools ?? []; if (isAllowedToolChoice(choice)) { const allowed = new Set(choice.allowedTools); - return tools.filter(tool => toolAllowedByChoice(tool, allowed)); + return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools)); } if (choice && typeof choice !== "string") { - return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name)); + const selected = resolveToolChoiceWireName(tools, choice.name); + return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected); } return tools; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ef0ad66341..94c9248488 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -255,7 +255,7 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { ? new Set(parsed.options.toolChoice.allowedTools) : undefined; const tools = allowed - ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed)) + ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) : parsed.context.tools; if (tools.length === 0) return undefined; return [{ diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a6d13fa1f4..1a3f8464dc 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1192,7 +1192,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record { diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 9325125691..626bd93d8d 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools( toolChoice?: OcxRequestOptions["toolChoice"], toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name), ): string | undefined { - const visible = tools?.filter(toolChoiceToolPredicate(toolChoice)); + const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools)); const visibleNames = visible?.map(toWireName); // Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing // to wire names first throws away the only thing that distinguishes Codex's JavaScript diff --git a/src/bridge.ts b/src/bridge.ts index 0a1dc860b3..bac73ae293 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -166,7 +166,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete"; export function bridgeToResponsesSSE( events: AsyncIterable, modelId: string, - toolNsMap?: Map, + toolNsMap?: Map, freeformToolNames?: Set, toolSearchToolNames?: Set, onCancel?: () => void, @@ -1049,7 +1049,9 @@ export function bridgeToResponsesSSE( } const ns = mapped?.namespace; const toolSearch = toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (freeformToolNames?.has(realName) ?? false)); const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`; const item = toolSearch ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" } @@ -1439,7 +1441,7 @@ function buildResponseJSONWithBudget( modelId: string, options?: { hideThinkingSummary?: boolean; - toolNsMap?: Map; + toolNsMap?: Map; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ @@ -1620,7 +1622,9 @@ function buildResponseJSONWithBudget( const realName = mapped?.name ?? currentToolCallName; const ns = mapped?.namespace; const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (options?.freeformToolNames?.has(realName) ?? false)); // #1611: same integral-float repair as the streaming path. Keyed by the wire name // the request declared, which is the pre-namespace-mapping `currentToolCallName`. const coercedArgs = coerceIntegerToolArguments( @@ -1784,7 +1788,9 @@ function buildResponseJSONWithBudget( const mapped = options?.toolNsMap?.get(currentToolCallName); const realName = mapped?.name ?? currentToolCallName; const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (options?.freeformToolNames?.has(realName) ?? false)); if (!freeform && !toolSearch) { flushToolCall("incomplete"); errorEvent = { diff --git a/src/images/loop.ts b/src/images/loop.ts index 1699906d94..834bcced93 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -676,13 +676,20 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise(); + const toolNsMap = new Map(); const freeform = new Set(); const toolSearch = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + for (const t of requestedTools) { if (!toolAllowed(t)) continue; - if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); + if (t.namespace) { + toolNsMap.set(namespacedToolName(t.namespace, t.name), { + namespace: t.namespace, + name: t.name, + ...(t.freeform ? { freeform: true } : {}), + }); + } if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7c2393bd2e..f450cd4356 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -203,7 +203,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined; for (const inner of t.tools as unknown[]) { if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns); - else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner); + else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns); } } else if (t.type === "custom" && typeof t.name === "string") { diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 37fca4777f..f408f5a546 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -101,22 +101,23 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): { - toolNsMap: Map; + toolNsMap: Map; declaredToolNames: Set; /** Declared parameter schema per request-visible tool name (#1611 integer repair). */ toolParameterSchemas: Map>; freeformToolNames: Set; toolSearchToolNames: Set; } { - const toolNsMap = new Map(); + const toolNsMap = new Map(); const declaredToolNames = new Set(); const toolParameterSchemas = new Map>(); const freeformToolNames = new Set(); const toolSearchToolNames = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + const authorizedTools = requestedTools.filter(toolAllowed); + for (const t of authorizedTools) { // Upstream output is untrusted: only restore calls for tools the caller authorized. - if (!toolAllowed(t)) continue; const wireName = namespacedToolName(t.namespace, t.name); budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" }); declaredToolNames.add(wireName); @@ -125,7 +126,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters); if (t.namespace) { budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); - toolNsMap.set(wireName, { namespace: t.namespace, name: t.name }); + toolNsMap.set(wireName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) }); } if (t.freeform) { budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" }); @@ -136,6 +137,29 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato toolSearchToolNames.add(t.name); } } + // Some routed providers echo a bare tool_choice selector instead of the flattened catalog + // name. Accept only selectors the client actually sent and only when the full request catalog + // contains one tool with that logical name. + const choice = parsed.options.toolChoice; + const bareChoiceNames = new Set( + choice && typeof choice === "object" + ? ("allowedTools" in choice ? choice.allowedTools : [choice.name]) + : [], + ); + const bareNameCounts = new Map(); + for (const t of requestedTools) { + bareNameCounts.set(t.name, (bareNameCounts.get(t.name) ?? 0) + 1); + } + for (const t of authorizedTools) { + if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue; + budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" }); + declaredToolNames.add(t.name); + budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); + toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) }); + if (t.parameters && typeof t.parameters === "object") { + toolParameterSchemas.set(t.name, t.parameters); + } + } return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames }; } diff --git a/src/types.ts b/src/types.ts index ecf375f265..9a6e5ac5ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -246,13 +246,39 @@ export function toolChoiceAliases(tool: Pick): st return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; } -export function toolAllowedByChoice(tool: Pick, allowedTools: ReadonlySet): boolean { - return toolChoiceAliases(tool).some(name => allowedTools.has(name)); +function uniqueBareToolMatch( + tools: readonly Pick[] | undefined, + name: string, +): Pick | undefined { + if (!tools) return undefined; + const matches = tools.filter(tool => tool.name === name); + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that + * shorthand only when the request contains one tool with the logical name, so an ambiguous name + * cannot authorize a tool from an unintended namespace. + */ +export function toolAllowedByChoice( + tool: Pick, + allowedTools: ReadonlySet, + tools?: readonly Pick[], +): boolean { + if (toolChoiceAliases(tool).some(name => allowedTools.has(name))) return true; + if (!tool.namespace || !allowedTools.has(tool.name)) return false; + const match = uniqueBareToolMatch(tools, tool.name); + return match?.namespace === tool.namespace && match.name === tool.name; } export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { - const match = tools?.find(tool => toolChoiceAliases(tool).includes(name)); - return match ? namespacedToolName(match.namespace, match.name) : name; + const exactMatches = tools?.filter(tool => toolChoiceAliases(tool).includes(name)) ?? []; + if (exactMatches.length === 1) { + const match = exactMatches[0]; + return namespacedToolName(match.namespace, match.name); + } + const bareMatch = uniqueBareToolMatch(tools, name); + return bareMatch ? namespacedToolName(bareMatch.namespace, bareMatch.name) : name; } /** @@ -281,14 +307,17 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is /** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ export function toolChoiceToolPredicate( choice: OcxToolChoice | undefined, + tools?: readonly Pick[], ): (tool: Pick) => boolean { if (!choice || choice === "auto" || choice === "required") return () => true; if (choice === "none") return () => false; if (isAllowedToolChoice(choice)) { const allowed = new Set(choice.allowedTools); - return tool => toolAllowedByChoice(tool, allowed); + return tool => toolAllowedByChoice(tool, allowed, tools); } - return tool => toolChoiceAliases(tool).includes(choice.name); + if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); + const selected = resolveToolChoiceWireName(tools, choice.name); + return tool => namespacedToolName(tool.namespace, tool.name) === selected; } export interface OcxRequestOptions { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e6129bfc2c..4c4ad772ac 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -741,13 +741,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(); + const toolNsMap = new Map(); const freeform = new Set(); const toolSearch = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + for (const t of requestedTools) { if (!toolAllowed(t)) continue; - if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); + if (t.namespace) { + toolNsMap.set(namespacedToolName(t.namespace, t.name), { + namespace: t.namespace, + name: t.name, + ...(t.freeform ? { freeform: true } : {}), + }); + } if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); } diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index 867d5dc21e..3453fc6d77 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -392,7 +392,7 @@ describe("Command Code provider", () => { expect(JSON.parse(built.body).params.tools).toEqual([]); }); - test("matches a forced namespaced tool choice by dot alias", async () => { + test("matches a forced namespaced tool choice by dot or unique bare alias", async () => { const namespacedParsed = { ...parsed(), context: { @@ -404,6 +404,12 @@ describe("Command Code provider", () => { const built = await builtRequest(namespacedParsed); const tools = JSON.parse(built.body).params.tools; expect(tools).toEqual([{ name: "functions__exec_command", description: "exec", input_schema: { type: "object" } }]); + + const bareBuilt = await builtRequest({ + ...namespacedParsed, + options: { toolChoice: { name: "exec_command" } }, + }); + expect(JSON.parse(bareBuilt.body).params.tools).toEqual(tools); }); test("refreshes a stale official effort record only after a reasoning rejection and retries without it", async () => { diff --git a/tests/helpers/responses-conformance.ts b/tests/helpers/responses-conformance.ts index 0991a7afda..520de93ceb 100644 --- a/tests/helpers/responses-conformance.ts +++ b/tests/helpers/responses-conformance.ts @@ -72,7 +72,7 @@ const isToolItem = (item: Record): boolean => String(item.type ?? "").includes("call"); type BridgeMaps = [ - toolNsMap?: Map, + toolNsMap?: Map, freeformToolNames?: Set, toolSearchToolNames?: Set, ]; diff --git a/tests/reasoning-effort.test.ts b/tests/reasoning-effort.test.ts index e2115beb3f..269da1a81a 100644 --- a/tests/reasoning-effort.test.ts +++ b/tests/reasoning-effort.test.ts @@ -487,6 +487,32 @@ describe("provider-specific reasoning effort mapping", () => { expect(body.tool_choice).toBe("required"); }); + test("OpenAI-compatible chat accepts a bare allowed_tools name for a unique namespace tool", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.umans.ai/v1", + }; + + const req = createOpenAIChatAdapter(provider).buildRequest({ + modelId: "umans-kimi-k2.7", + context: { + messages: [{ role: "user", content: "run it", timestamp: 0 }], + tools: [{ + namespace: "functions", + name: "exec", + description: "Run a command", + parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] }, + }], + }, + stream: false, + options: { toolChoice: { allowedTools: ["exec"], mode: "required" } }, + }); + const body = JSON.parse(req.body as string) as { tools: Array<{ function: { name: string } }>; tool_choice: string }; + + expect(body.tools.map(t => t.function.name)).toEqual(["functions__exec"]); + expect(body.tool_choice).toBe("required"); + }); + test("named namespaced tool_choice resolves to the chat wire name", async () => { const provider: OcxProviderConfig = { adapter: "openai-chat", @@ -551,6 +577,34 @@ describe("provider-specific reasoning effort mapping", () => { expect(body.tool_choice).toEqual({ type: "any" }); }); + test("Anthropic accepts a bare allowed_tools name for a unique namespace tool", async () => { + const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + apiKey: "test-key", + }; + + const req = await createAnthropicAdapter(provider).buildRequest({ + modelId: "claude-sonnet", + context: { + messages: [{ role: "user", content: "run it", timestamp: 0 }], + tools: [{ + namespace: "functions", + name: "exec", + description: "Run a command", + parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] }, + freeform: true, + }], + }, + stream: false, + options: { toolChoice: { allowedTools: ["exec"], mode: "required" } }, + }); + const body = JSON.parse(req.body as string) as { tools: Array<{ name: string }>; tool_choice: { type: string } }; + + expect(body.tools.map(t => t.name)).toEqual(["functions__exec"]); + expect(body.tool_choice).toEqual({ type: "any" }); + }); + test("sanitizeCodexReasoningEfforts keeps max and strips unknown catalog labels", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "test", id: "model-with-max", reasoningEfforts: ["low", "max", "turbo", "high"] }, diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index be6258f6a1..d6bc9f0377 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { buildResponseJSON } from "../src/bridge"; import { parseRequest } from "../src/responses/parser"; import { buildToolBridgeMaps } from "../src/server/responses"; @@ -190,6 +191,106 @@ describe("Responses parser", () => { expect([...maps.toolSearchToolNames]).toEqual([]); }); + test("accepts a unique bare selector for a namespaced custom tool and rejects ambiguity", () => { + const parsed = parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: "Run a command" }], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "custom", name: "exec" }], + }, + }); + + let maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap]).toEqual([ + ["functions__exec", { namespace: "functions", name: "exec", freeform: true }], + ["exec", { namespace: "functions", name: "exec", freeform: true }], + ]); + expect([...maps.declaredToolNames]).toEqual(["functions__exec", "exec"]); + expect([...maps.freeformToolNames]).toEqual(["exec"]); + + const bridged = buildResponseJSON([ + { type: "tool_call_start", id: "call_exec", name: "exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", maps); + expect(bridged.status).toBe("completed"); + expect((bridged.output as Record[])[0]).toMatchObject({ + type: "custom_tool_call", + call_id: "call_exec", + name: "exec", + input: "pwd", + status: "completed", + }); + + parsed.options.toolChoice = { name: "exec" }; + maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap.keys()]).toEqual(["functions__exec", "exec"]); + + const ambiguous = parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec" }], + }, { + type: "namespace", + name: "other", + tools: [{ type: "custom", name: "exec" }], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "custom", name: "exec" }], + }, + }); + + const ambiguousMaps = buildToolBridgeMaps(ambiguous); + expect([...ambiguousMaps.declaredToolNames]).toEqual([]); + expect([...ambiguousMaps.toolNsMap]).toEqual([]); + + const mixedKinds = parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec" }], + }, { + type: "namespace", + name: "mcp__remote", + tools: [{ type: "function", name: "exec", parameters: { type: "object" } }], + }], + }); + const mixedMaps = buildToolBridgeMaps(mixedKinds); + const customCall = buildResponseJSON([ + { type: "tool_call_start", id: "call_custom", name: "functions__exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", mixedMaps); + const functionCall = buildResponseJSON([ + { type: "tool_call_start", id: "call_function", name: "mcp__remote__exec" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", mixedMaps); + expect((customCall.output as Record[])[0]?.type).toBe("custom_tool_call"); + expect((functionCall.output as Record[])[0]).toMatchObject({ + type: "function_call", + name: "exec", + namespace: "mcp__remote", + }); + }); + test("maps hosted allowed_tools entries to their synthetic routed tool names", () => { const parsed = parseRequest({ model: "umans/umans-kimi-k2.7", diff --git a/tests/responses-tool-conformance.test.ts b/tests/responses-tool-conformance.test.ts index aa23b3ee52..fd9763cbbe 100644 --- a/tests/responses-tool-conformance.test.ts +++ b/tests/responses-tool-conformance.test.ts @@ -172,18 +172,24 @@ describe("Responses tool-kind discrimination", () => { expect(parsed.context.tools ?? []).toEqual([]); }); - it("CURRENT BEHAVIOR: a non-function child inside a namespace disappears", () => { + it("preserves custom namespace children while unknown child kinds still disappear", () => { const parsed = parseRequest(request([], [ { type: "namespace", name: "ns", tools: [ { type: "function", name: "kept", parameters: { type: "object", properties: {} } }, - { type: "custom", name: "dropped" }, + { type: "custom", name: "freeform" }, + { type: "computer_use_preview", name: "dropped" }, ], }, ])); - expect(toolNames(parsed)).toEqual(["kept"]); + expect(toolNames(parsed)).toEqual(["kept", "freeform"]); + expect(parsed.context.tools?.[1]).toMatchObject({ + name: "freeform", + namespace: "ns", + freeform: true, + }); }); }); @@ -386,6 +392,33 @@ describe("streaming and non-streaming tool parity", () => { ]); }); + it("keeps namespaced custom and function tools distinct when their logical names collide", async () => { + const collidingNsMap = new Map([ + ["functions__exec", { namespace: "functions", name: "exec", freeform: true }], + ["mcp__remote__exec", { namespace: "mcp__remote", name: "exec" }], + ]); + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_custom", name: "functions__exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_function", name: "mcp__remote__exec" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + + const view = await streamedView(events, MODEL, collidingNsMap, new Set(["exec"])); + const json = jsonToolItems(events, MODEL, { + toolNsMap: collidingNsMap, + freeformToolNames: new Set(["exec"]), + }); + + expect(view.incremental).toEqual(view.snapshot); + expect(view.snapshot).toEqual(json); + expect(view.snapshot.map(item => item.type)).toEqual(["custom_tool_call", "function_call"]); + expect(view.snapshot[1]).toMatchObject({ name: "exec", namespace: "mcp__remote" }); + }); + it("emits the exact custom input fragments on the streamed path only", async () => { const custom = cases.find(entry => entry.label.startsWith("custom"))!; const view = await streamedView(custom.events, MODEL, nsMap, freeform, toolSearch); diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index f764f001fa..6316892034 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -196,6 +196,18 @@ describe("non-OpenAI tool catalog nudge", () => { expect(note).not.toContain("`exec_command`,"); }); + test("keeps a uniquely named namespace tool visible when allowed_tools uses its bare name", () => { + const tools: OcxTool[] = [ + { name: "exec", namespace: "functions", description: "Run", parameters: {} }, + { name: "read_file", namespace: "mcp__fs", description: "Read", parameters: {} }, + ]; + + const note = buildNonOpenAIToolCatalogNudgeForTools(tools, { mode: "required", allowedTools: ["exec"] }); + + expect(note).toContain("`functions__exec`"); + expect(note).not.toContain("`mcp__fs__read_file`"); + }); + test("skips OpenAI and ChatGPT hosts", () => { expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://api.openai.com/v1" })).toBe(false); expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false); From 1259c798b84dde52303a997766dda2dadba0fb45 Mon Sep 17 00:00:00 2001 From: jenfonro Date: Tue, 18 Aug 2026 18:28:14 +0000 Subject: [PATCH 004/121] fix(tools): reject ambiguous namespaced aliases --- src/responses/parser.ts | 19 +++++- src/types.ts | 52 +++++++++----- tests/adapter-tool-conformance.test.ts | 93 ++++++++++++++++++++++++++ tests/responses-parser.test.ts | 55 +++++++++++---- 4 files changed, 188 insertions(+), 31 deletions(-) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index f450cd4356..f7f6326d3d 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -11,7 +11,7 @@ import type { OcxToolCall, OcxReasoningReplayScopeRef, } from "../types"; -import { namespacedToolName } from "../types"; +import { namespacedToolName, toolChoiceCandidates } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; @@ -691,6 +691,15 @@ export function parseRequest( const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? []; const loadedTools = buildTools(loadedToolSpecs) ?? []; const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name))); + const wireOwners = new Map(); + for (const tool of [...declaredTools, ...loadedTools]) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const previous = wireOwners.get(wireName); + if (previous && (previous.namespace !== tool.namespace || previous.name !== tool.name || previous.freeform !== tool.freeform || previous.toolSearch !== tool.toolSearch)) { + throw new Error(`ambiguous tool catalog: multiple logical tools map to wire name ${wireName}`); + } + wireOwners.set(wireName, tool); + } const seenTools = new Set(); const mergedTools = [...declaredTools, ...loadedTools] .filter(t => { @@ -716,6 +725,14 @@ export function parseRequest( options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop; } const tc = mapToolChoice(data.tool_choice); + if (tc && typeof tc === "object") { + const selectors = "allowedTools" in tc ? tc.allowedTools : [tc.name]; + for (const selector of selectors) { + if (toolChoiceCandidates(mergedTools, selector).length > 1) { + throw new Error(`ambiguous tool_choice name: ${selector}`); + } + } + } if (tc !== undefined) options.toolChoice = tc; if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls; // Upstream codex-rs converts "ultra" to "max" at the inference boundary (core/src/client.rs diff --git a/src/types.ts b/src/types.ts index 9a6e5ac5ac..d06bf89302 100644 --- a/src/types.ts +++ b/src/types.ts @@ -246,13 +246,29 @@ export function toolChoiceAliases(tool: Pick): st return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; } -function uniqueBareToolMatch( +function sameToolIdentity( + left: Pick, + right: Pick, +): boolean { + return left.namespace === right.namespace && left.name === right.name; +} + +/** + * All tools that could be selected by one client-facing name. Bare logical names are included + * here because they are a compatibility selector for namespaced tools, while wire and dotted + * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. + */ +export function toolChoiceCandidates( tools: readonly Pick[] | undefined, name: string, -): Pick | undefined { - if (!tools) return undefined; - const matches = tools.filter(tool => tool.name === name); - return matches.length === 1 ? matches[0] : undefined; +): Pick[] { + if (!tools) return []; + const candidates: Pick[] = []; + for (const tool of tools) { + if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; + if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); + } + return candidates; } /** @@ -265,20 +281,24 @@ export function toolAllowedByChoice( allowedTools: ReadonlySet, tools?: readonly Pick[], ): boolean { - if (toolChoiceAliases(tool).some(name => allowedTools.has(name))) return true; - if (!tool.namespace || !allowedTools.has(tool.name)) return false; - const match = uniqueBareToolMatch(tools, tool.name); - return match?.namespace === tool.namespace && match.name === tool.name; + if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); + for (const name of [...toolChoiceAliases(tool), tool.name]) { + if (!allowedTools.has(name)) continue; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; + } + return false; } export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { - const exactMatches = tools?.filter(tool => toolChoiceAliases(tool).includes(name)) ?? []; - if (exactMatches.length === 1) { - const match = exactMatches[0]; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1) { + const match = candidates[0]; return namespacedToolName(match.namespace, match.name); } - const bareMatch = uniqueBareToolMatch(tools, name); - return bareMatch ? namespacedToolName(bareMatch.namespace, bareMatch.name) : name; + // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The + // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. + return name; } /** @@ -316,8 +336,8 @@ export function toolChoiceToolPredicate( return tool => toolAllowedByChoice(tool, allowed, tools); } if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); - const selected = resolveToolChoiceWireName(tools, choice.name); - return tool => namespacedToolName(tool.namespace, tool.name) === selected; + const candidates = toolChoiceCandidates(tools, choice.name); + return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); } export interface OcxRequestOptions { diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index 3e23c13f26..f70374ee47 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -100,6 +100,26 @@ function freeformParsed(wire: AdapterWire): OcxParsedRequest { }), wire); } +function namespacedCollisionParsed(wire: AdapterWire): OcxParsedRequest { + return prepareForWire(parseRequest({ + model: WIRE_MODELS[wire], + input: "Run the requested tool.", + stream: true, + tools: [ + { + type: "namespace", + name: "mcp__custom", + tools: [{ type: "custom", name: "exec", description: "Freeform execution." }], + }, + { + type: "namespace", + name: "mcp__remote", + tools: [{ type: "function", name: "exec", description: "Structured execution.", parameters: { type: "object", properties: {} } }], + }, + ], + }), wire); +} + function toolChoiceParsed(wire: AdapterWire, toolChoice?: "none"): OcxParsedRequest { return prepareForWire(parseRequest({ model: WIRE_MODELS[wire], @@ -431,6 +451,79 @@ describe("registry-derived routed tool conformance", () => { } }); + test("every buffered adapter preserves same-name tools from different namespaces", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + if (contract.wire === "openai-responses" || contract.wire === "cursor") { + // Native Responses passthrough and Cursor's protobuf transport do not use the routed + // adapter tool declaration surface exercised by this registry-wide check. + continue; + } + const body = await outbound(adapterId, namespacedCollisionParsed(contract.wire)); + const names = advertisedToolNames(contract.wire, body).filter(name => name.includes("exec")); + expect(new Set(names).size, adapterId).toBe(2); + } + }); + + test("every routed adapter fails closed for an ambiguous bare selector", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; + const parsed = namespacedCollisionParsed(contract.wire); + // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here + // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. + parsed.options.toolChoice = { allowedTools: ["exec"], mode: "required" }; + if (contract.wire === "kiro") { + await expect(outbound(adapterId, parsed)).rejects.toThrow("Kiro supports only automatic tool choice or tool_choice:none"); + continue; + } + const body = await outbound(adapterId, parsed); + expect(advertisedToolNames(contract.wire, body), adapterId).toHaveLength(0); + } + }); + + test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const driver = TOOL_WIRE_DRIVERS[contract.wire]; + if (!driver.streamingToolCall || !driver.extractWireToolName) { + expect(["openai-responses", "cursor"]).toContain(contract.wire); + continue; + } + + const parsed = namespacedCollisionParsed(contract.wire); + const body = await outbound(adapterId, parsed); + const maps = buildToolBridgeMaps(parsed); + const cases = [ + { logicalName: "mcp__custom__exec", type: "custom_tool_call" }, + { logicalName: "mcp__remote__exec", namespace: "mcp__remote", type: "function_call" }, + ] as const; + for (const testCase of cases) { + const wireName = driver.extractWireToolName(body, testCase.logicalName); + const bridged = bridgeToResponsesSSE( + createRegisteredAdapter(providerFixture(adapterId, contract.wire)).parseStream( + driver.streamingToolCall(wireName, JSON.stringify({ input: "ok" })), + createTestTranslatorBudget(), + ), + parsed.modelId, + maps.toolNsMap, + maps.freeformToolNames, + maps.toolSearchToolNames, + undefined, + 2_000, + { declaredToolNames: maps.declaredToolNames }, + ); + const frames = parseResponsesFrames(await new Response(bridged).text()); + const item = frames.find(frame => frame.event === "response.output_item.added")?.data.item as Record | undefined; + expect(item, `${adapterId}:${testCase.logicalName}`).toMatchObject({ + type: testCase.type, + name: "exec", + ...(testCase.namespace ? { namespace: testCase.namespace } : {}), + }); + } + } + }); + test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { const contract = effectiveAdapterContract(adapterId); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index d6bc9f0377..98443126f2 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -197,7 +197,7 @@ describe("Responses parser", () => { input: "run it", tools: [{ type: "namespace", - name: "functions", + name: "mcp__functions", tools: [{ type: "custom", name: "exec", description: "Run a command" }], }], tool_choice: { @@ -209,10 +209,10 @@ describe("Responses parser", () => { let maps = buildToolBridgeMaps(parsed); expect([...maps.toolNsMap]).toEqual([ - ["functions__exec", { namespace: "functions", name: "exec", freeform: true }], - ["exec", { namespace: "functions", name: "exec", freeform: true }], + ["mcp__functions__exec", { namespace: "mcp__functions", name: "exec", freeform: true }], + ["exec", { namespace: "mcp__functions", name: "exec", freeform: true }], ]); - expect([...maps.declaredToolNames]).toEqual(["functions__exec", "exec"]); + expect([...maps.declaredToolNames]).toEqual(["mcp__functions__exec", "exec"]); expect([...maps.freeformToolNames]).toEqual(["exec"]); const bridged = buildResponseJSON([ @@ -232,14 +232,14 @@ describe("Responses parser", () => { parsed.options.toolChoice = { name: "exec" }; maps = buildToolBridgeMaps(parsed); - expect([...maps.toolNsMap.keys()]).toEqual(["functions__exec", "exec"]); + expect([...maps.toolNsMap.keys()]).toEqual(["mcp__functions__exec", "exec"]); - const ambiguous = parseRequest({ + expect(() => parseRequest({ model: "claude-opus-5", input: "run it", tools: [{ type: "namespace", - name: "functions", + name: "mcp__functions", tools: [{ type: "custom", name: "exec" }], }, { type: "namespace", @@ -251,18 +251,14 @@ describe("Responses parser", () => { mode: "required", tools: [{ type: "custom", name: "exec" }], }, - }); - - const ambiguousMaps = buildToolBridgeMaps(ambiguous); - expect([...ambiguousMaps.declaredToolNames]).toEqual([]); - expect([...ambiguousMaps.toolNsMap]).toEqual([]); + })).toThrow("ambiguous tool_choice name: exec"); const mixedKinds = parseRequest({ model: "claude-opus-5", input: "run it", tools: [{ type: "namespace", - name: "functions", + name: "mcp__functions", tools: [{ type: "custom", name: "exec" }], }, { type: "namespace", @@ -272,7 +268,7 @@ describe("Responses parser", () => { }); const mixedMaps = buildToolBridgeMaps(mixedKinds); const customCall = buildResponseJSON([ - { type: "tool_call_start", id: "call_custom", name: "functions__exec" }, + { type: "tool_call_start", id: "call_custom", name: "mcp__functions__exec" }, { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, { type: "tool_call_end" }, { type: "done" }, @@ -307,6 +303,37 @@ describe("Responses parser", () => { expect(parsed.options.toolChoice).toEqual({ allowedTools: ["web_search"], mode: "required" }); }); + test("rejects wire-name collisions instead of dropping one logical tool", () => { + expect(() => parseRequest({ + model: "gpt-5.5", + input: "run it", + tools: [ + { + type: "namespace", + name: "foo", + tools: [{ type: "function", name: "bar", parameters: { type: "object" } }], + }, + { type: "function", name: "foo__bar", parameters: { type: "object" } }, + ], + })).toThrow("ambiguous tool catalog: multiple logical tools map to wire name foo__bar"); + }); + + test("rejects a dotted alias that also names a flat tool", () => { + expect(() => parseRequest({ + model: "gpt-5.5", + input: "run it", + tools: [ + { + type: "namespace", + name: "foo", + tools: [{ type: "function", name: "bar", parameters: { type: "object" } }], + }, + { type: "function", name: "foo.bar", parameters: { type: "object" } }, + ], + tool_choice: { type: "function", name: "foo.bar" }, + })).toThrow("ambiguous tool_choice name: foo.bar"); + }); + test("maps type-only hosted image_generation tool_choice to required image_gen", () => { const parsed = parseRequest({ model: "claude-opus-4-6", From d75a2402f7752724ecc24ecf8d439e7f702d388b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 09:48:29 +0900 Subject: [PATCH 005/121] docs(devlog): plan response-state temp reclaim as a two-layer stack --- .../000_plan.md | 71 +++++++++++ .../010_phase1_periodic_sweeper.md | 119 ++++++++++++++++++ .../020_phase2_doctor_reclaim.md | 106 ++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/000_plan.md create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md new file mode 100644 index 0000000000..a10cab2830 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md @@ -0,0 +1,71 @@ +# 260819 — response-state temp reclaim + +## Objective + +Abandoned `responses-state.json.ocx...tmp` files can accumulate without +bound. A field report described ~19.6 GB of these files on one machine. Make the +existing reclaim run on a schedule that does not depend on serving traffic, and give +an operator a way to reclaim them when the proxy will not start at all. + +## Evidence (verified against this tree at 59964ad77) + +- `src/config.ts:293` — `atomicWriteFileAsync` names its temp + `${target}.ocx.${process.pid}.${++_atomicSeq}.tmp`. This is the exact reported shape. +- `src/responses/state.ts:26` — `SNAPSHOT_TOTAL_MAX_BYTES` is 24 MiB, and the snapshot + is rewritten whole on every persist. One abandoned temp is therefore up to 24 MiB, + which matches the reported 20–27 MB per file. +- `src/responses/state.ts:548` — `recoverStaleResponseStateTemps` already implements + the reclaim, with a 15-minute age gate, a PID-liveness check, a regular-file check, + and bounded scan/cleanup counts. **The reclaim logic is correct and is not the defect.** +- `src/responses/state.ts:621` — its ONLY caller is `ensureLoaded()`, which is lazy and + runs on first continuation access (`state.ts:991`, `:1073`, `:1185`). + +## Root cause + +The reclaim is attached to the request path. A proxy that crashes before serving a +continuation request leaves its temp behind and never reaches the code that would +reclaim it. The condition that produces the garbage is the same condition that +disables the collector, so the file count only ever grows. + +This is a scheduling defect, not a missing-feature defect. Both layers below move or +add a CALLER; neither changes reclaim semantics. + +## Scope + +IN: caller placement for the existing reclaim; an operator-facing reclaim path. + +OUT: the 24 MiB whole-file rewrite. Incremental snapshotting would reduce the blast +radius per failure, but it changes the durability contract of the continuation cache +and is a much larger risk surface. It is recorded here as a known residual, not +silently dropped. + +OUT: `src/storage/cleanup.ts` temps (`:1073`, `:2420`). Different owner, different +lifecycle; if they share the defect it is a separate unit. + +## Work-phase map (dependency-ordered — PHASE-SPLIT-01) + +| # | Phase | Doc | Depends on | +|---|-------|-----|------------| +| 1 | Periodic reclaim via the state-store sweeper | `010_phase1_periodic_sweeper.md` | — | +| 2 | Operator reclaim via `ocx doctor` | `020_phase2_doctor_reclaim.md` | phase 1 | + +Phase 1 makes a RUNNING proxy self-healing. Phase 2 covers the case phase 1 cannot +reach — a proxy that will not start — and reuses the reporting shape phase 1 +establishes. The dependency runs upward, so the stack lands bottom-up. + +## Stack plan (DEV-STACK-01) + +Two layers. Phase 1 is mergeable alone and fixes the reported accumulation for every +user whose proxy runs at all; phase 2 builds on it. + +``` +codex/tmp-reclaim-2-doctor → PR #2 (base: codex/tmp-reclaim-1-sweeper) +codex/tmp-reclaim-1-sweeper → PR #1 (base: dev) +``` + +## Terminal criteria + +- A proxy that never serves a continuation request still reclaims abandoned temps. +- An operator whose proxy will not start can reclaim them with a documented command. +- No live temp is ever removed: the age gate and PID-liveness check stay intact. +- `bun run typecheck` and `bun run test` green before either PR is review-ready. diff --git a/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md new file mode 100644 index 0000000000..980c23eefb --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md @@ -0,0 +1,119 @@ +# Phase 1 — periodic reclaim via the state-store sweeper + +## Thesis + +Abandoned response-state temps are reclaimed on a timer, so a proxy that never serves +a continuation request still cleans up after a previous crash. + +## Why the sweeper is the right owner + +`src/lib/state-store-sweeper.ts` already runs every 60 s (`STATE_SWEEP_INTERVAL_MS`), +is started once per process from `startProcessLoops` in +`src/server/background-lifecycle.ts:59`, is `unref`'d so it cannot hold the process +open, and wraps every callback in try/catch with `logCallbackFailure`. The +`responses-continuation` store is ALREADY registered there +(`src/lib/state-store-registrations.ts:87`) for TTL eviction. Disk reclaim for the +same subsystem belongs on the same tick. + +`sweepExpired` is the wrong slot: it is called by `sweepExpiredOnWrite` +(`state-store-sweeper.ts:91`) on write paths, and filesystem scans do not belong on a +write. `sweepLiveness` is the correct slot — it runs only on the interval tick +(`:162`), and "is the process that owns this temp still alive" is precisely a +liveness question. + +## Change map + +### MODIFY `src/responses/state.ts` + +Add an exported wrapper next to `sweepExpiredResponseStates` (after line 899). It +resolves the same two directories `ensureLoaded` sweeps (literal + symlink-resolved), +and returns a removed count so the sweeper's `rowsRemoved` accounting stays truthful. + +```ts +/** + * Periodic disk reclaim for abandoned atomic-write temps. `ensureLoaded` sweeps once on + * first continuation access, which never happens in the case that produces the garbage: + * a proxy that crashes before serving a continuation request leaves its temp behind and + * never reaches that path. Registered on the sweeper's liveness tick so reclaim does not + * depend on serving traffic. + */ +export function sweepAbandonedResponseStateTemps(): number { + const path = snapshotPath(); + let resolvedDir = dirname(path); + try { + resolvedDir = dirname(resolveWriteTarget(path)); + } catch { + /* unresolvable link: sweep the literal dir only */ + } + let removed = 0; + for (const dir of new Set([dirname(path), resolvedDir])) { + try { + removed += recoverStaleResponseStateTemps(dir).removed; + } catch { + /* best-effort: disk reclaim must never destabilize the sweeper tick */ + } + } + return removed; +} +``` + +No new imports: `dirname`, `resolveWriteTarget`, and `recoverStaleResponseStateTemps` +are all already in scope in this module. + +### MODIFY `src/lib/state-store-registrations.ts` + +Line 37 — extend the existing import: + +```diff +-import { sweepExpiredResponseStates } from "../responses/state"; ++import { sweepAbandonedResponseStateTemps, sweepExpiredResponseStates } from "../responses/state"; +``` + +Line 87 — extend the existing registration rather than adding a second store, so one +subsystem keeps one row: + +```diff +- { name: "responses-continuation", sweepExpired: sweepExpiredResponseStates }, ++ { ++ name: "responses-continuation", ++ sweepExpired: sweepExpiredResponseStates, ++ sweepLiveness: sweepAbandonedResponseStateTemps, ++ }, +``` + +### MODIFY `tests/responses-state.test.ts` + +Add a regression test asserting the reclaim runs without any continuation access — +the exact property that was missing. It must prove the negative: a stale temp is +removed while a live-PID temp and a young temp survive, with `ensureLoaded` never +driven. + +## Scope boundary + +IN: the wrapper, the registration, the test. + +OUT: any change to `recoverStaleResponseStateTemps` itself — its age gate, PID check, +file-type check, and bounds are already correct and independently tested +(`tests/responses-state.test.ts:1522`, `:1575`). Touching them would widen the blast +radius of a scheduling fix into a safety-critical one. + +OUT: startup one-shot reclaim. The first tick lands 60 s after start, which is +adequate for a defect measured in months of accumulation, and adding a startup call +would put a filesystem scan on the boot path. + +## Accept criteria + +| # | Scenario | Observable proof | +|---|----------|------------------| +| 1 | Sweeper tick with no continuation traffic | stale temp gone; `ensureLoaded` never invoked | +| 2 | Temp owned by a live PID | survives the tick | +| 3 | Temp younger than the 15-minute grace | survives the tick | +| 4 | Reclaim throws (unreadable dir) | tick completes; other stores still swept | + +Criterion 4 is the activation scenario for the new catch block +(C-ACTIVATION-GROUNDING-01): force `list` to throw and assert the tick still returns. + +## Verification + +`bun test tests/responses-state.test.ts`, then `bun run typecheck` and +`bun run test` before the PR is review-ready (shared runtime + registration table). diff --git a/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md b/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md new file mode 100644 index 0000000000..4bb61a0d1c --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md @@ -0,0 +1,106 @@ +# Phase 2 — operator reclaim via `ocx doctor` + +## Thesis + +An operator whose proxy will not start can reclaim abandoned response-state temps with +a documented command, instead of being told to hand-craft a `find -delete`. + +## Why this layer exists on top of phase 1 + +Phase 1 covers every proxy that runs. It cannot cover the reported case at its worst: +a proxy stuck in a crash loop never reaches a sweeper tick either, because the tick +lives in the same process. The field report described exactly that state — scheduled +task installed, proxy not running, disk filling. For that operator the only in-product +recovery is a command that runs WITHOUT the server. + +Depends on phase 1 for `sweepAbandonedResponseStateTemps`: the doctor path reuses the +same two-directory resolution, so the two surfaces cannot disagree about which files +are reclaimable. + +## Change map + +### MODIFY `src/cli/doctor.ts` + +`runDoctor` (`:768`) currently branches on `--fix-codex-runtime`. Add a reporting +section to the default path, and reclaim only when explicitly asked. + +- Default `ocx doctor`: REPORT matched temps and their total bytes. Read-only. +- `ocx doctor --reclaim-response-temps`: perform the reclaim and print what was freed. + +Report-by-default is deliberate. `doctor` is a diagnostic an operator runs to +understand a machine; deleting files as a side effect of asking a question is the +wrong default, even for cache files. + +```ts +const reclaim = args.includes("--reclaim-response-temps"); +const result = reclaim + ? reclaimAbandonedResponseStateTemps() + : inspectAbandonedResponseStateTemps(); +if (result.matched === 0) { + console.log("Response-state temps: none abandoned."); +} else if (reclaim) { + console.log(`Response-state temps: reclaimed ${result.removed} file(s), ${formatBytes(result.bytesRemoved)} freed.`); + if (result.failed > 0) console.log(` ${result.failed} file(s) could not be removed (in use or locked).`); +} else { + console.log(`Response-state temps: ${result.matched} abandoned file(s), ${formatBytes(result.bytes)} reclaimable.`); + console.log(" Run: ocx doctor --reclaim-response-temps"); +} +``` + +### MODIFY `src/responses/state.ts` + +Export a dry-run counterpart so doctor can report without deleting. It reuses +`recoverStaleResponseStateTemps` with an injected no-op `unlink`, so the SAME +selection predicate decides both report and reclaim — a separate matcher would drift. + +```ts +/** Report-only counterpart: same selection predicate, no removal. */ +export function inspectAbandonedResponseStateTemps(): { matched: number; bytes: number } { + // ... resolve both dirs as in sweepAbandonedResponseStateTemps, + // call recoverStaleResponseStateTemps(dir, { unlink: () => {} }) and sum. +} +``` + +Note the existing accounting detail: `bytesRemoved` only accrues on a successful +`unlink` (`state.ts:590`), so with a no-op unlink the byte total must come from +`inspect`. Confirm against the implementation during B and adjust the wrapper — this +is the one place the reuse is not free. + +### MODIFY `docs-site/` + +Document the flag on the troubleshooting/disk-usage page, including what the files are +and why they are safe to remove (continuation cache, not durable state). + +### MODIFY `tests/` + +Doctor-level test: report mode leaves files intact; reclaim mode removes only stale +ones. Live-PID and young-file protection is already covered by phase 1's tests and is +not re-asserted here. + +## Scope boundary + +IN: the doctor surface, the dry-run export, docs, tests. + +OUT: an auto-reclaim-on-start behavior. That would run before the crash that is being +diagnosed, and silently deleting evidence during a crash loop is hostile to whoever is +debugging it. + +OUT: reclaiming any other subsystem's temps under the same flag. The flag names +response temps and reclaims only those. + +## Accept criteria + +| # | Scenario | Observable proof | +|---|----------|------------------| +| 1 | `ocx doctor` with abandoned temps present | count + bytes reported; files still on disk | +| 2 | `ocx doctor --reclaim-response-temps` | stale files removed; freed bytes printed | +| 3 | No abandoned temps | clean single-line report, no flag suggestion | +| 4 | Proxy not running | both paths work — no server dependency | + +Criterion 4 is the whole point of the layer: assert the code path imports nothing that +requires a live server. + +## Verification + +Focused doctor + state tests, then `bun run typecheck` and `bun run test` before the +PR is review-ready. From b999f1d204cc9821f99077852af9a4a668f99e06 Mon Sep 17 00:00:00 2001 From: iF2007 Date: Wed, 19 Aug 2026 08:52:14 +0800 Subject: [PATCH 006/121] fix: preserve discovered Google effort wire mappings --- src/providers/antigravity-models.ts | 75 +++++++++++++++++++++++++-- tests/google-antigravity-wire.test.ts | 37 +++++++++++-- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index bf155b543c..9b9045cb9b 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -72,6 +72,12 @@ const ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL: Record = Object.en }, {}); const ANTIGRAVITY_DISCOVERY_EFFORTS = ["low", "medium", "high"] as const; +type AntigravityDiscoveryEffort = typeof ANTIGRAVITY_DISCOVERY_EFFORTS[number]; +type AntigravityEffortWireModelIds = Partial>; + +function isAntigravityDiscoveryEffort(value: string): value is AntigravityDiscoveryEffort { + return (ANTIGRAVITY_DISCOVERY_EFFORTS as readonly string[]).includes(value); +} function pickerModelIdForDiscoveredWireId( wireId: string, @@ -151,6 +157,25 @@ const ANTIGRAVITY_EFFORT_WIRE_MAP: Record> = { }, }; +function completeDiscoveredEffortWireModelIds( + pickerId: string, + available: ReadonlyMap>, +): AntigravityEffortWireModelIds | undefined { + const explicitEffortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[pickerId]; + if (explicitEffortMap && Object.values(explicitEffortMap).every(wireId => available.has(wireId))) { + return { ...explicitEffortMap }; + } + + if (!isKnownAntigravityPickerModelId(pickerId)) return undefined; + const suffixEffortMap: AntigravityEffortWireModelIds = {}; + for (const effort of ANTIGRAVITY_DISCOVERY_EFFORTS) { + const wireId = `${pickerId}-${effort}`; + if (!available.has(wireId)) return undefined; + suffixEffortMap[effort] = wireId; + } + return suffixEffortMap; +} + // ── Default effort per Gemini base model ── const ANTIGRAVITY_DEFAULT_EFFORT: Record = { "gemini-3.1-pro": "high", @@ -270,6 +295,8 @@ export interface AntigravityAvailableModel { id: string; /** CCA model id used by the agent envelope when `id` comes from display metadata. */ wireModelId: string; + /** Complete effort-to-wire mapping retained for collapsed discovered tier sets. */ + effortWireModelIds?: AntigravityEffortWireModelIds; contextWindow?: number; inputModalities?: string[]; } @@ -286,6 +313,7 @@ function antigravityPositiveInteger(value: unknown): number | undefined { interface DiscoveredWireModelMapping { readonly models: ReadonlyMap; + readonly effortModels: ReadonlyMap; readonly generation?: { provider: string; cacheGeneration: string }; } @@ -327,17 +355,21 @@ export function registerAntigravityDiscoveredWireModels( const key = antigravityBaseUrlKey(baseUrl); if (!key) return; const wireModels = new Map(); - for (const model of models) wireModels.set(model.id, model.wireModelId); + const effortModels = new Map(); + for (const model of models) { + wireModels.set(model.id, model.wireModelId); + if (model.effortWireModelIds) effortModels.set(model.id, { ...model.effortWireModelIds }); + } discoveredWireModelsByBaseUrl.set(key, { models: wireModels, + effortModels, ...(generation ? { generation } : {}), }); } -function discoveredAntigravityWireModelId( - modelId: string, +function discoveredAntigravityMapping( baseUrl: string | undefined, -): string | undefined { +): DiscoveredWireModelMapping | undefined { const key = antigravityBaseUrlKey(baseUrl); if (!key) return undefined; const mapping = discoveredWireModelsByBaseUrl.get(key); @@ -347,7 +379,35 @@ function discoveredAntigravityWireModelId( discoveredWireModelsByBaseUrl.delete(key); return undefined; } - return mapping.models.get(modelId); + return mapping; +} + +function discoveredAntigravityWireModelId( + modelId: string, + baseUrl: string | undefined, +): string | undefined { + return discoveredAntigravityMapping(baseUrl)?.models.get(modelId); +} + +function discoveredAntigravityEffortWireModelId( + modelId: string, + effort: string | undefined, + baseUrl: string | undefined, +): string | undefined { + const effortMap = discoveredAntigravityMapping(baseUrl)?.effortModels.get(modelId); + if (!effortMap) return undefined; + + const requestedEffort = effort ? resolveAntigravityThinkingLevel(effort) : undefined; + if (requestedEffort && isAntigravityDiscoveryEffort(requestedEffort) && effortMap[requestedEffort]) { + return effortMap[requestedEffort]; + } + + const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId] + ?? ANTIGRAVITY_THINKING_LEVEL_MODELS[modelId]; + if (defaultEffort && isAntigravityDiscoveryEffort(defaultEffort) && effortMap[defaultEffort]) { + return effortMap[defaultEffort]; + } + return Object.values(effortMap)[0]; } /** @@ -465,9 +525,11 @@ export function parseAntigravityAvailableModels( const id = pickerModelIdForDiscoveredWireId(wireId, info, available); if (seen.has(id)) continue; seen.add(id); + const effortWireModelIds = completeDiscoveredEffortWireModelIds(id, available); out.push({ id, wireModelId: wireId, + ...(effortWireModelIds ? { effortWireModelIds } : {}), ...(antigravityPositiveInteger(info.maxTokens) ? { contextWindow: antigravityPositiveInteger(info.maxTokens) } : {}), // Tri-state, deliberately not a ternary: `true` asserts image support, // `false` asserts against it, and ABSENT is unknown. Collapsing absent into @@ -522,6 +584,9 @@ export function resolveAntigravityEffortWireModel( effort?: string, baseUrl?: string, ): { wireModelId: string; thinkingLevel?: string } { + const discoveredEffortWireModelId = discoveredAntigravityEffortWireModelId(modelId, effort, baseUrl); + if (discoveredEffortWireModelId) return { wireModelId: discoveredEffortWireModelId }; + // A collapsed picker row reports ONE representative wire id (whichever tier CCA // listed first), so live discovery cannot describe a ladder — it can only name a // single rung. Letting it answer for a base model we already have a ladder for diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 986b3eef45..8aefb92d46 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -133,11 +133,28 @@ describe("antigravity CCA envelope", () => { agentModelSorts: [{ groups: [{ modelIds }] }], }); - expect(parseAntigravityAvailableModels(payload([ + const rows = parseAntigravityAvailableModels(payload([ "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-high", - ]))?.map(model => model.id)).toEqual(["gemini-3.7-flash"]); + ]))!; + expect(rows.map(model => model.id)).toEqual(["gemini-3.7-flash"]); + expect(rows[0]?.wireModelId).toBe("gemini-3.7-flash-low"); + expect(rows[0]?.effortWireModelIds).toEqual({ + low: "gemini-3.7-flash-low", + medium: "gemini-3.7-flash-medium", + high: "gemini-3.7-flash-high", + }); + const baseUrl = "https://cca-tiered-set.example"; + registerAntigravityDiscoveredWireModels(baseUrl, rows); + for (const [effort, wireModelId] of [ + ["low", "gemini-3.7-flash-low"], + ["medium", "gemini-3.7-flash-medium"], + ["high", "gemini-3.7-flash-high"], + ] as const) { + expect(resolveAntigravityEffortWireModel("gemini-3.7-flash", effort, baseUrl)) + .toEqual({ wireModelId }); + } expect(parseAntigravityAvailableModels(payload([ "future-flash-low", "future-flash-medium", @@ -232,14 +249,24 @@ describe("antigravity CCA envelope", () => { ]); // The display label still resolves an id Google renamed on the wire. expect(rows.find(model => model.id === "gemini-nebula")?.wireModelId).toBe("internal-codename-x7"); + expect(rows.find(model => model.id === "gemini-3.1-pro")?.effortWireModelIds).toEqual({ + low: "gemini-3.1-pro-low", + high: "gemini-pro-agent", + }); const baseUrl = "https://cca.example"; registerAntigravityDiscoveredWireModels(baseUrl, rows); - // A discovered representative wire id must NOT override a real effort ladder. + // A complete discovery preserves each discovered suffix for the requested effort. expect(resolveAntigravityEffortWireModel("gemini-3.1-pro", "low", baseUrl)) - .toEqual({ wireModelId: "gemini-3.1-pro-low", thinkingLevel: "low" }); + .toEqual({ wireModelId: "gemini-3.1-pro-low" }); + expect(resolveAntigravityEffortWireModel("gemini-3.1-pro", "high", baseUrl)) + .toEqual({ wireModelId: "gemini-pro-agent" }); expect(resolveAntigravityEffortWireModel("gemini-3.7-flash", "low", baseUrl)) - .toEqual({ wireModelId: "gemini-3.7-flash-tiered", thinkingLevel: "low" }); + .toEqual({ wireModelId: "gemini-3.7-flash-low" }); + expect(resolveAntigravityEffortWireModel("gemini-3.7-flash", "medium", baseUrl)) + .toEqual({ wireModelId: "gemini-3.7-flash-medium" }); + expect(resolveAntigravityEffortWireModel("gemini-3.7-flash", "high", baseUrl)) + .toEqual({ wireModelId: "gemini-3.7-flash-high" }); expect(resolveAntigravityEffortWireModel("gemini-nebula", undefined, baseUrl)) .toEqual({ wireModelId: "internal-codename-x7" }); expect(resolveAntigravityEffortWireModel("claude-sonnet-4-6", "high", baseUrl)) From 265a21abe375fcc7fce549879c62ed8e8328328d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:07:17 +0900 Subject: [PATCH 007/121] docs(devlog): fold audit round 1 into the reclaim roadmap --- .../001_audit_round1.md | 93 +++++++++++++++++++ .../010_phase1_periodic_sweeper.md | 87 ++++++++++++++--- .../020_phase2_doctor_reclaim.md | 34 ++++--- 3 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/001_audit_round1.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/001_audit_round1.md b/devlog/_plan/260819_response_state_temp_reclaim/001_audit_round1.md new file mode 100644 index 0000000000..45f4795ade --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/001_audit_round1.md @@ -0,0 +1,93 @@ +# Audit round 1 — independent adversarial review of the roadmap + +Reviewer: independent `explorer` subagent, read-only, dispatched against this worktree +at `d75a2402f`. Verdict: **GO-WITH-FIXES (blockers=3)**. Main-agent judgment: +**near-pass** — every blocker is folded below as a concrete amendment; no blocker was +rebutted. + +A first reviewer produced nothing across four wait cycles (~11 min) and was retired as +a failed dispatch; this is the replacement's round, with a tighter falsify-this packet. + +## Confirmed by the reviewer + +- **Root cause holds (Q1).** `recoverStaleResponseStateTemps` has exactly one call + site — `state.ts:621` inside `ensureLoaded` — and `ensureLoaded` is reached only from + `:991`, `:1073`, `:1185`, all request-path. The 60 s tick's existing + `sweepExpiredResponseStates` (`:890`) touches only the in-memory map and never disk. + No off-request-path caller exists, so the plan is not misdirected. +- **`sweepLiveness` is the right slot (Q2).** `sweepExpiredOnWrite` + (`state-store-sweeper.ts:91`) is called from write paths — `key-failover.ts:171`, + `subagent-model-fallback.ts:321`, `gcp-adc.ts:324` — and `runCallbacks` fans out to + every registration, so a directory scan on `sweepExpired` would run `opendir` plus up + to 4096 `lstat`s on hot write paths. `sweepLiveness` has exactly one caller, the + interval body at `:161-162`, with `sweepDeadOcxStartProcessCache` as precedent for + syscall work in that slot. +- **The sweeper is ungated (Q3).** `startStateStoreSweeper()` runs unconditionally via + `background-lifecycle.ts:59` ← `:129` ← `index.ts:718`. Independently re-verified. + Phase 1 therefore reaches every affected user. +- **Windows liveness is correct (Q5).** `process.kill(pid, 0)` maps to `OpenProcess`; + `ESRCH` means gone, `EPERM` means alive-but-unsignallable, and `state.ts:520` treats + only `ESRCH` as dead. No change needed. + +## Blocker 1 (accepted, HIGH) — pid reuse makes the skip permanent + +`state.ts:582` skips a temp whose pid is alive. The 15-minute gate at `:581` is a +LOWER bound, so it never expires the skip: once a dead writer's pid is reused by any +live process, that temp is skipped on every future pass forever. + +This matters more than the original scheduling defect for the reported case. Reboots +recycle low pids deterministically, and the field report was specifically about +**per-reboot accumulation**. The scheduling defect explains why nothing cleaned up; +pid reuse explains why the files survived even the passes that did run. + +**Amendment (phase 1, additive):** add a boot-time floor. A temp whose `mtimeMs` +predates system boot cannot belong to any currently-live pid, so the liveness check is +provably vacuous for it. Reclaim when `file.mtimeMs < bootMs - skew` in ADDITION to the +existing gates; every original guard stays intact. `bootMs` derives from +`os.uptime()` and becomes an injectable IO member for testability. + +This moves phase 1 from "runs on a timer" to "actually reclaims the reported files", +so it belongs in the bottom layer, not deferred. + +## Blocker 2 (accepted) — the callback must be synchronous and self-bounding + +`runCallbacks` (`state-store-sweeper.ts:66-84`) discards a returned promise, so an +`async` reclaim would swallow every error and defeat its `try/catch`. The signature is +`() => number`, so the wrapper must be sync and return a real removed count. + +The reviewer also notes the inverted risk: a synchronous scan BLOCKS the event loop, so +the startup-scale `maxEntries = 4096` budget is wrong for a 60 s repeating tick on a +slow or network-mounted config dir. + +**Amendment (phase 1):** the wrapper stays synchronous, and the periodic path passes a +smaller `maxEntries`/`maxCleanups` budget than the startup path. Reclaim is idempotent +and repeats every 60 s, so a smaller per-tick budget loses nothing. + +## Blocker 3 (accepted) — symlink resolution must be shared, not duplicated + +The two-directory resolution lives inside `ensureLoaded` (`:604-625`). A callback that +swept only `getConfigDir()` would miss temps stranded in a symlinked snapshot's real +directory — the exact case the comment at `:606-610` documents. + +**Amendment (phase 1):** extract `new Set([dirname(path), resolvedDir])` into one shared +helper used by BOTH `ensureLoaded` and the new callback, so the two surfaces cannot +drift. The 010 doc already sweeps both directories; this makes it a single source. + +## Self-found defects (main agent, during WP0 verification) + +- **(a) Phase 2's dry run is wrong as written.** `020` proposed reusing + `recoverStaleResponseStateTemps` with a no-op `unlink`. But `state.ts:586-590` + increments `removed` and accrues `bytesRemoved` only INSIDE the successful-unlink + branch, so a no-op unlink reports `removed` as if files were deleted while + `bytesRemoved` stays truthful — inverted from what the doc claims. `maxCleanups` also + bounds a report-only pass. Phase 2 needs an explicit `dryRun` mode with its own + accounting, not injected-IO trickery. +- **(b) The options type is not exported.** `ResponseStateTempRecoveryOptions` + (`state.ts:507`) is module-private, so out-of-module IO injection does not typecheck. + Phase 2 must export it or expose a purpose-built wrapper. + +## Residual (not blocking, recorded) + +The 24 MiB whole-file rewrite stays out of scope. It bounds the SIZE of each leaked +file, not the leak; changing it alters the continuation cache's durability contract and +deserves its own unit. diff --git a/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md index 980c23eefb..d0dc0aeb0b 100644 --- a/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md +++ b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md @@ -5,6 +5,10 @@ Abandoned response-state temps are reclaimed on a timer, so a proxy that never serves a continuation request still cleans up after a previous crash. +Amended after audit round 1 (`001_audit_round1.md`): the timer alone does not reclaim +the reported files, because a reused pid makes the liveness skip permanent. This layer +therefore ships the boot-time floor with it. + ## Why the sweeper is the right owner `src/lib/state-store-sweeper.ts` already runs every 60 s (`STATE_SWEEP_INTERVAL_MS`), @@ -23,13 +27,70 @@ liveness question. ## Change map +### MODIFY `src/responses/state.ts` — boot-time floor (audit blocker 1) + +`recoverStaleResponseStateTemps` skips a temp whose pid is alive (`:582`). The 15-minute +gate at `:581` is a LOWER bound and never expires that skip, so a pid reused after a +reboot strands the file forever. A temp whose `mtimeMs` predates system boot cannot +belong to any live pid, so the liveness probe is provably vacuous for it. + +Add `bootTime: () => number` to `ResponseStateTempRecoveryIO` (default +`() => Date.now() - os.uptime() * 1000`) and reclaim when the file predates boot, in +ADDITION to the existing gates: + +```diff +- if (pid === process.pid || io.isProcessAlive(pid)) continue; ++ // A temp written before the current boot cannot belong to any live pid: after a ++ // reboot the original writer's pid is routinely reused, which would otherwise make ++ // the liveness skip permanent (the 15-minute gate is a lower bound, so it never ++ // expires it). Every other guard still applies. ++ const predatesBoot = file.mtimeMs < io.bootTime() - BOOT_FLOOR_SKEW_MS; ++ if (!predatesBoot && (pid === process.pid || io.isProcessAlive(pid))) continue; ++ if (predatesBoot && pid === process.pid) continue; +``` + +`BOOT_FLOOR_SKEW_MS = 60_000` absorbs clock skew and `os.uptime()` granularity. The +`pid === process.pid` guard is kept unconditionally: this process is by definition +younger than boot, and must never unlink its own in-flight temp. + +### MODIFY `src/responses/state.ts` — shared directory resolution (audit blocker 3) + +The literal + symlink-resolved pair is computed inside `ensureLoaded` (`:604-625`). A +callback sweeping only `getConfigDir()` would miss temps stranded in a symlinked +snapshot's real directory. Extract it once and use it from BOTH callers: + +```ts +/** Literal config dir plus the snapshot's resolved dir; identical when nothing is symlinked. */ +function responseStateSweepDirectories(): Set { + const path = snapshotPath(); + let resolvedDir = dirname(path); + try { + resolvedDir = dirname(resolveWriteTarget(path)); + } catch { + /* unresolvable link: sweep the literal dir only */ + } + return new Set([dirname(path), resolvedDir]); +} +``` + ### MODIFY `src/responses/state.ts` Add an exported wrapper next to `sweepExpiredResponseStates` (after line 899). It resolves the same two directories `ensureLoaded` sweeps (literal + symlink-resolved), and returns a removed count so the sweeper's `rowsRemoved` accounting stays truthful. +It MUST be synchronous (audit blocker 2): `runCallbacks` discards a returned promise, +so an `async` reclaim would swallow every error and defeat its `try/catch`. It also +passes a smaller per-tick budget than the startup path — 4096 entries is a startup-scale +budget, and a synchronous scan blocks the event loop. Reclaim is idempotent and repeats +every 60 s, so a smaller budget costs nothing. + ```ts +/** Per-tick budget. Smaller than the startup budget: this runs every 60 s, synchronously, + * on the event loop, and any remainder is reclaimed by the next tick. */ +const PERIODIC_TEMP_MAX_ENTRIES = 512; +const PERIODIC_TEMP_MAX_CLEANUPS = 64; + /** * Periodic disk reclaim for abandoned atomic-write temps. `ensureLoaded` sweeps once on * first continuation access, which never happens in the case that produces the garbage: @@ -38,17 +99,13 @@ and returns a removed count so the sweeper's `rowsRemoved` accounting stays trut * depend on serving traffic. */ export function sweepAbandonedResponseStateTemps(): number { - const path = snapshotPath(); - let resolvedDir = dirname(path); - try { - resolvedDir = dirname(resolveWriteTarget(path)); - } catch { - /* unresolvable link: sweep the literal dir only */ - } let removed = 0; - for (const dir of new Set([dirname(path), resolvedDir])) { + for (const dir of responseStateSweepDirectories()) { try { - removed += recoverStaleResponseStateTemps(dir).removed; + removed += recoverStaleResponseStateTemps(dir, { + maxEntries: PERIODIC_TEMP_MAX_ENTRIES, + maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS, + }).removed; } catch { /* best-effort: disk reclaim must never destabilize the sweeper tick */ } @@ -57,8 +114,8 @@ export function sweepAbandonedResponseStateTemps(): number { } ``` -No new imports: `dirname`, `resolveWriteTarget`, and `recoverStaleResponseStateTemps` -are all already in scope in this module. +New import: `uptime` from `node:os` for the boot floor. `dirname`, `resolveWriteTarget`, +and `recoverStaleResponseStateTemps` are already in scope. ### MODIFY `src/lib/state-store-registrations.ts` @@ -109,11 +166,19 @@ would put a filesystem scan on the boot path. | 2 | Temp owned by a live PID | survives the tick | | 3 | Temp younger than the 15-minute grace | survives the tick | | 4 | Reclaim throws (unreadable dir) | tick completes; other stores still swept | +| 5 | Temp predating boot whose pid is now LIVE (reuse) | reclaimed — the permanent-skip case | +| 6 | Temp predating boot owned by THIS process | survives; never unlink our own in-flight temp | +| 7 | Symlinked snapshot dir | temp in the resolved real dir is reclaimed | Criterion 4 is the activation scenario for the new catch block (C-ACTIVATION-GROUNDING-01): force `list` to throw and assert the tick still returns. +Criterion 5 is the activation scenario for the boot floor: without it the file is +skipped forever, so the test must fail if the floor is removed. ## Verification `bun test tests/responses-state.test.ts`, then `bun run typecheck` and `bun run test` before the PR is review-ready (shared runtime + registration table). +Also `bun test tests/state-store-sweeper.test.ts`: its "global fake-clock sweep" +assertion derives from `STATE_STORE_REGISTRATIONS`, so adding a `sweepLiveness` member +changes what that test expects. diff --git a/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md b/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md index 4bb61a0d1c..a81e632c12 100644 --- a/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md +++ b/devlog/_plan/260819_response_state_temp_reclaim/020_phase2_doctor_reclaim.md @@ -49,22 +49,32 @@ if (result.matched === 0) { ### MODIFY `src/responses/state.ts` -Export a dry-run counterpart so doctor can report without deleting. It reuses -`recoverStaleResponseStateTemps` with an injected no-op `unlink`, so the SAME -selection predicate decides both report and reclaim — a separate matcher would drift. +Export a dry-run counterpart so doctor can report without deleting. It must share the +SAME selection predicate as the reclaim — a separate matcher would drift and the two +surfaces would disagree about which files are reclaimable. + +**Corrected after WP0 self-verification.** The original proposal (inject a no-op +`unlink`) is wrong: `state.ts:586-590` increments `removed` and accrues +`bytesRemoved` only inside the successful-unlink branch, so a no-op `unlink` still +reports `removed` as though files were deleted. `maxCleanups` also bounds a +report-only pass, truncating the count an operator is shown. And +`ResponseStateTempRecoveryOptions` (`state.ts:507`) is module-private, so +out-of-module IO injection does not typecheck at all. + +Add an explicit `dryRun` option to the shared function instead, with its own +accounting branch: ```ts -/** Report-only counterpart: same selection predicate, no removal. */ -export function inspectAbandonedResponseStateTemps(): { matched: number; bytes: number } { - // ... resolve both dirs as in sweepAbandonedResponseStateTemps, - // call recoverStaleResponseStateTemps(dir, { unlink: () => {} }) and sum. +// inside the loop, replacing the unconditional unlink: +if (dryRun) { + result.wouldRemove += 1; + result.bytesReclaimable += file.size; + continue; } ``` -Note the existing accounting detail: `bytesRemoved` only accrues on a successful -`unlink` (`state.ts:590`), so with a no-op unlink the byte total must come from -`inspect`. Confirm against the implementation during B and adjust the wrapper — this -is the one place the reuse is not free. +A dry run keeps every selection gate (basename, regular-file, age, boot floor, pid +liveness) and changes only the action. Report and reclaim then cannot disagree. ### MODIFY `docs-site/` @@ -96,6 +106,8 @@ response temps and reclaims only those. | 2 | `ocx doctor --reclaim-response-temps` | stale files removed; freed bytes printed | | 3 | No abandoned temps | clean single-line report, no flag suggestion | | 4 | Proxy not running | both paths work — no server dependency | +| 5 | Report then reclaim on the same fixture | reported count/bytes equal what reclaim removes | +| 6 | More stale temps than the cleanup budget | report is not truncated by `maxCleanups` | Criterion 4 is the whole point of the layer: assert the code path imports nothing that requires a live server. From 6d89332b61c13ccb80508422f14be331215e232f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:08:05 +0900 Subject: [PATCH 008/121] docs(devlog): lock the reclaim roadmap and record the pid-reuse second cause --- .../000_plan.md | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md index a10cab2830..4eb73c9c19 100644 --- a/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md +++ b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md @@ -28,7 +28,16 @@ reclaim it. The condition that produces the garbage is the same condition that disables the collector, so the file count only ever grows. This is a scheduling defect, not a missing-feature defect. Both layers below move or -add a CALLER; neither changes reclaim semantics. +add a CALLER; neither loosens a reclaim safety gate. + +**Second cause, found in audit round 1 (`001_audit_round1.md`).** Scheduling alone does +not explain the reported files surviving the passes that DID run. `state.ts:582` skips a +temp whose pid is alive, and the 15-minute gate at `:581` is a lower bound that never +expires that skip. After a reboot the original writer's pid is routinely reused, so the +file is skipped forever. That matches the reported symptom — accumulation measured per +reboot — more precisely than scheduling does. Phase 1 therefore ships a boot-time floor +alongside the timer; a reclaim that runs on schedule but still skips every file would be +a phantom fix. ## Scope @@ -46,13 +55,22 @@ lifecycle; if they share the defect it is a separate unit. | # | Phase | Doc | Depends on | |---|-------|-----|------------| -| 1 | Periodic reclaim via the state-store sweeper | `010_phase1_periodic_sweeper.md` | — | +| 1 | Periodic reclaim + boot-time floor | `010_phase1_periodic_sweeper.md` | — | | 2 | Operator reclaim via `ocx doctor` | `020_phase2_doctor_reclaim.md` | phase 1 | Phase 1 makes a RUNNING proxy self-healing. Phase 2 covers the case phase 1 cannot reach — a proxy that will not start — and reuses the reporting shape phase 1 establishes. The dependency runs upward, so the stack lands bottom-up. +Audit round 1 is recorded in `001_audit_round1.md` (research range, per LEXICO-SPLIT-01). + +## Roadmap lock + +This docs-only cycle closes with the map above final and 1:1 with the goalplan's +`wp1`/`wp2`. Both decade docs are written to diff-level precision, so each later cycle's +P begins by re-verifying its pre-written doc against the tree rather than designing then. +Appending a later work-phase stays allowed as a P-phase amendment if one is discovered. + ## Stack plan (DEV-STACK-01) Two layers. Phase 1 is mergeable alone and fixes the reported accumulation for every @@ -66,6 +84,7 @@ codex/tmp-reclaim-1-sweeper → PR #1 (base: dev) ## Terminal criteria - A proxy that never serves a continuation request still reclaims abandoned temps. +- A temp stranded by a reused pid across a reboot is reclaimed rather than skipped forever. - An operator whose proxy will not start can reclaim them with a documented command. - No live temp is ever removed: the age gate and PID-liveness check stay intact. - `bun run typecheck` and `bun run test` green before either PR is review-ready. From f138dac159a29696543d2fbf4d23dc0752ea9c90 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:17:11 +0900 Subject: [PATCH 009/121] docs(devlog): fold audit round 2 into the phase 1 plan --- .../010_phase1_periodic_sweeper.md | 124 ++++++++++++++---- .../011_audit_round2.md | 64 +++++++++ 2 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/011_audit_round2.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md index d0dc0aeb0b..ad7e01b50d 100644 --- a/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md +++ b/devlog/_plan/260819_response_state_temp_reclaim/010_phase1_periodic_sweeper.md @@ -34,24 +34,66 @@ gate at `:581` is a LOWER bound and never expires that skip, so a pid reused aft reboot strands the file forever. A temp whose `mtimeMs` predates system boot cannot belong to any live pid, so the liveness probe is provably vacuous for it. -Add `bootTime: () => number` to `ResponseStateTempRecoveryIO` (default -`() => Date.now() - os.uptime() * 1000`) and reclaim when the file predates boot, in -ADDITION to the existing gates: +Amended by audit round 2 (`011_audit_round2.md`): the original justification — "a temp +predating boot cannot belong to any live pid" — is FALSE under a container sharing the +config dir, suspend-excluding `os.uptime()`, and network mtime skew. The real safety +argument is that the unconditional 15-minute grace at `:581` stays AHEAD of this gate. +The boot floor only retires a liveness probe that has become vacuous. + +Add `bootTime: () => number` to `ResponseStateTempRecoveryIO` (`:499`) AND to the +default literal `responseStateTempRecoveryIO` (`:524`, else typecheck fails): + +```diff + const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = { + now: Date.now, ++ bootTime: () => Date.now() - uptime() * 1_000, +``` + +Hoist the probe ABOVE the loop (one syscall per scan, not per entry) and guard it: + +```ts +const rawBoot = io.bootTime(); +// Not finite or in the future: treat the floor as absent rather than trusting it. +const bootMs = Number.isFinite(rawBoot) ? Math.min(rawBoot, io.now()) : Number.NEGATIVE_INFINITY; +``` + +Then, per entry: ```diff - if (pid === process.pid || io.isProcessAlive(pid)) continue; -+ // A temp written before the current boot cannot belong to any live pid: after a -+ // reboot the original writer's pid is routinely reused, which would otherwise make -+ // the liveness skip permanent (the 15-minute gate is a lower bound, so it never -+ // expires it). Every other guard still applies. -+ const predatesBoot = file.mtimeMs < io.bootTime() - BOOT_FLOOR_SKEW_MS; ++ // After a reboot the original writer's pid is routinely reused, which makes the ++ // liveness skip PERMANENT: the 15-minute gate at :581 is a lower bound and never ++ // expires it. A temp older than this boot cannot be owned by the pid we would be ++ // probing, so the probe is vacuous and we retire it — we do NOT claim the file is ++ // provably dead. The unconditional 15-minute grace above remains the safety floor, ++ // which is what keeps this sound under a shared-volume container, suspend-excluding ++ // uptime, or a network config dir, where the computed boot can land after real boot. ++ const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS; + if (!predatesBoot && (pid === process.pid || io.isProcessAlive(pid))) continue; + if (predatesBoot && pid === process.pid) continue; ``` -`BOOT_FLOOR_SKEW_MS = 60_000` absorbs clock skew and `os.uptime()` granularity. The -`pid === process.pid` guard is kept unconditionally: this process is by definition -younger than boot, and must never unlink its own in-flight temp. +`BOOT_FLOOR_SKEW_MS = 60_000` absorbs granularity only; it is explicitly NOT what makes +the change safe (the named failure modes are hours, not seconds). The +`pid === process.pid` guard is kept unconditionally: this process must never unlink its +own in-flight temp. + +### MODIFY `src/responses/state.ts` — scan deadline (audit blocker 6) + +An entry cap bounds syscalls, not time: 512 synchronous `lstat`s is 2-5 ms on APFS but +5-10 s on an SMB/NFS config dir, which would block the event loop and stall in-flight +SSE streams. Add a wall-clock deadline inside the scan loop, with the entry cap kept as +a backstop: + +```ts +const SCAN_DEADLINE_MS = 25; +// inside the loop, alongside the existing bounds: +if (deadlineMs !== null && io.now() - startedAt > deadlineMs) break; +``` + +`deadlineMs` is an option, null for the startup path (unchanged behavior) and +`SCAN_DEADLINE_MS` for the periodic path. Reclaim is idempotent, so a truncated tick +simply resumes on the next one. ### MODIFY `src/responses/state.ts` — shared directory resolution (audit blocker 3) @@ -100,22 +142,41 @@ const PERIODIC_TEMP_MAX_CLEANUPS = 64; */ export function sweepAbandonedResponseStateTemps(): number { let removed = 0; - for (const dir of responseStateSweepDirectories()) { - try { + // The try encloses responseStateSweepDirectories() deliberately (audit blocker 4): + // recoverStaleResponseStateTemps already swallows its own list/iterator failures, so a + // catch around only that call would be unreachable. snapshotPath()/getConfigDir() can + // genuinely throw, and that is the failure this guard exists for. + try { + for (const dir of responseStateSweepDirectories()) { removed += recoverStaleResponseStateTemps(dir, { maxEntries: PERIODIC_TEMP_MAX_ENTRIES, maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS, + deadlineMs: SCAN_DEADLINE_MS, }).removed; - } catch { - /* best-effort: disk reclaim must never destabilize the sweeper tick */ } + } catch { + /* best-effort: disk reclaim must never destabilize the sweeper tick */ } return removed; } ``` -New import: `uptime` from `node:os` for the boot floor. `dirname`, `resolveWriteTarget`, -and `recoverStaleResponseStateTemps` are already in scope. +New import: `uptime` from `node:os`. `dirname`, `resolveWriteTarget`, and +`recoverStaleResponseStateTemps` are already in scope. + +### MODIFY `tests/responses-state.test.ts` — existing fixtures (audit blocker 1) + +The existing test at `:1522` ages fixtures exactly 60 minutes and keeps `live` via +`isProcessAlive`. On a host booted <60 min ago (a normal CI runner) the boot floor would +bypass that skip and delete `live`, turning `removed: 1` into `removed: 2`. Inject +`bootTime: () => 0` there and at `:1575` to pin the floor out of those cases. + +### MODIFY `tests/state-store-sweeper.test.ts` — home isolation (audit blocker 5) + +Once `sweepLiveness` is registered, the fake-clock test at `:132` invokes the REAL +reclaim, and that describe block sets no `OPENCODEX_HOME` — so the suite would +`opendir` the developer's real `~/.opencodex` and could unlink real temps. Point +`OPENCODEX_HOME` at a temp dir for that block. ### MODIFY `src/lib/state-store-registrations.ts` @@ -149,10 +210,14 @@ driven. IN: the wrapper, the registration, the test. -OUT: any change to `recoverStaleResponseStateTemps` itself — its age gate, PID check, -file-type check, and bounds are already correct and independently tested -(`tests/responses-state.test.ts:1522`, `:1575`). Touching them would widen the blast -radius of a scheduling fix into a safety-critical one. +OUT: loosening any existing gate. The age gate, file-type check, unlink-only removal, +and `pid === process.pid` guard are unchanged; the boot floor is ADDITIVE and sits +behind the 15-minute grace. + +IN (widened by audit round 2): `recoverStaleResponseStateTemps` gains the boot floor and +the scan deadline, and its existing tests at `tests/responses-state.test.ts:1522`/`:1575` +gain `bootTime` injection. The earlier "no changes to this function" boundary was +unachievable once the pid-reuse leak was accepted as in scope. OUT: startup one-shot reclaim. The first tick lands 60 s after start, which is adequate for a defect measured in months of accumulation, and adding a startup call @@ -169,16 +234,23 @@ would put a filesystem scan on the boot path. | 5 | Temp predating boot whose pid is now LIVE (reuse) | reclaimed — the permanent-skip case | | 6 | Temp predating boot owned by THIS process | survives; never unlink our own in-flight temp | | 7 | Symlinked snapshot dir | temp in the resolved real dir is reclaimed | +| 8 | Temp predating boot but YOUNGER than the 15-min grace | survives — the grace outranks the floor | +| 9 | `bootTime` in the future / not finite | floor ignored; live-pid temps still skipped | -Criterion 4 is the activation scenario for the new catch block -(C-ACTIVATION-GROUNDING-01): force `list` to throw and assert the tick still returns. +Criterion 4 is the activation scenario for the wrapper's catch +(C-ACTIVATION-GROUNDING-01): make `responseStateSweepDirectories()` throw — NOT `list`, +which the reclaim already swallows internally, and which would leave the catch +unreachable. Criterion 5 is the activation scenario for the boot floor: without it the file is skipped forever, so the test must fail if the floor is removed. +Criterion 8 proves the ordering that carries the whole safety argument. ## Verification `bun test tests/responses-state.test.ts`, then `bun run typecheck` and `bun run test` before the PR is review-ready (shared runtime + registration table). -Also `bun test tests/state-store-sweeper.test.ts`: its "global fake-clock sweep" -assertion derives from `STATE_STORE_REGISTRATIONS`, so adding a `sweepLiveness` member -changes what that test expects. +Also `bun test tests/state-store-sweeper.test.ts`. Corrected by audit round 2: its +assertions do NOT change, because both the registration-name list (`:100`) and the +fake-clock test (`:132`) derive from `STATE_STORE_REGISTRATIONS` and we EXTEND the +existing `responses-continuation` entry rather than adding a store. That test does begin +invoking the real reclaim, which is why it needs `OPENCODEX_HOME` isolation. diff --git a/devlog/_plan/260819_response_state_temp_reclaim/011_audit_round2.md b/devlog/_plan/260819_response_state_temp_reclaim/011_audit_round2.md new file mode 100644 index 0000000000..8de885e300 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/011_audit_round2.md @@ -0,0 +1,64 @@ +# Audit round 2 — phase 1 implementation plan + +Reviewer: independent `explorer`, read-only, against `6d89332b6`. Verdict: +**GO-WITH-FIXES (blockers=6)**. Main-agent judgment: **near-pass** — all six folded, none +rebutted. The two that would have shipped real damage are 1 and 5. + +## The safety argument was wrong, and the fix is the wording + +Round 1 justified the boot floor as "a temp predating boot cannot belong to any live +pid." That is false under three environments the reviewer named: a container sharing the +config dir by volume mount (uptime is sandbox uptime), suspend-excluding `os.uptime()` +(a 3-hour lid-close shifts computed boot forward by 3 hours), and a network config dir +where `mtimeMs` comes from the server clock. + +What actually keeps this safe is the UNCONDITIONAL 15-minute grace at `state.ts:581`, +which stays ahead of the new gate. The boot floor never bypasses it. So the honest claim +is narrower: **the boot floor retires a liveness probe that has become vacuous, and the +15-minute grace remains the safety floor.** The residual exposure is a writer stalled +>15 minutes mid-write, whose worst case is a lost cache write (and on Windows an +`EACCES` that merely increments `failed`). + +60 s of skew is also the wrong order of magnitude for those three cases — they are +hours — so the constant is not what buys the safety, and the doc must stop implying it. + +## Blockers (all accepted) + +1. **CI flake, `tests/responses-state.test.ts:1522`.** The existing fixture ages files + exactly 60 minutes and keeps `live` via `isProcessAlive: pid => pid === 5252`. On a + host booted <60 min ago — the normal state of a CI runner — the boot floor bypasses + that skip and deletes `live`, so `removed` becomes 2. Fix: inject `bootTime: () => 0` + in that test (and `:1575` for symmetry). The 010 scope boundary widens to include + these tests. +2. **`state.ts:524`.** A required `bootTime` on the IO interface fails typecheck until + the default literal `responseStateTempRecoveryIO` gains it. Name it in the change map. +3. **`state.ts:582`.** Hoist `io.bootTime()` above the loop — as drafted it was one + `os.uptime()` syscall PER directory entry — and guard it: + `const bootMs = Math.min(io.bootTime(), io.now())`, skipping the floor when the value + is not finite. Replace the false comment with the accurate one above. +4. **Accept criterion 4 was vacuous.** `recoverStaleResponseStateTemps` already swallows + `list` failures at `:561` and iterator failures at `:567`, so a throwing `list` can + never reach the wrapper's new `catch` — the test would pass with the `catch` deleted. + Fix: the `try` must enclose `responseStateSweepDirectories()`, whose + `snapshotPath()`/`getConfigDir()` can genuinely throw, and criterion 4 grounds there. +5. **A unit test would touch the developer's real home.** Once `sweepLiveness` is + registered, the fake-clock test at `tests/state-store-sweeper.test.ts:132` invokes the + REAL reclaim, and that describe block sets no `OPENCODEX_HOME` — so + `bun test tests/state-store-sweeper.test.ts` would `opendir` `~/.opencodex` and could + unlink real temps as a side effect. Fix: isolate `OPENCODEX_HOME` in that block. +6. **An entry cap does not bound time.** 512 synchronous `lstat`s is 2-5 ms on APFS but + 5-10 s on an SMB/NFS config dir, blocking the event loop and stalling in-flight SSE + streams. Round 1's answer (a smaller constant) addressed the symptom, not the + mechanism. Fix: add a wall-clock deadline inside the scan loop + (`io.now() - startedAt > SCAN_DEADLINE_MS`), keeping the entry cap as a backstop. + +## Confirmed non-issues + +- Adding a required member to `ResponseStateTempRecoveryIO` does NOT break existing call + sites: `Options` is `Partial & {...}` (`:507`). No export needed for phase 1; + round 1's defect (b) is genuinely phase-2-only. +- The registration-name test (`:100`) and the fake-clock test (`:132`) need no assertion + changes, because we EXTEND the existing `responses-continuation` entry instead of + adding a store. That design choice is load-bearing. The 010 Verification section + claimed otherwise and is corrected. +- Reclaim throughput is fine: ~800 files at 64 cleanups/tick is ~13 minutes. From 89fbd6f896b9d309fbd83af882583a454ecab77c Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 18:20:31 -0700 Subject: [PATCH 010/121] feat(openrouter): enable Fast on the OpenAI-backed slugs, without route pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B2 of the FastWire umbrella (lidge-jun/opencodex#1886), and a documented correction to what that issue proposed. The umbrella specified an atomic route pin for OpenRouter — only inject a tier alongside `provider: { only: [...], allow_fallbacks: false }` — to stop a tier reaching an upstream that would silently bill for it. OpenRouter's own documentation retires that requirement, and shows the proposal would not even have worked: - Tier endpoints are separate suffixed slugs (`openai/priority`), and they are explicitly NOT matched by base slugs. Pinning `only: ["openai"]` would have excluded the very endpoint that serves priority. - Priority tries tier endpoints first and falls back otherwise, and billing always follows the endpoint actually used — so the silent-overbilling risk the pin existed to prevent does not exist. - The response reports the tier actually served. Pinning would therefore have turned a graceful capacity fallback into a hard failure while protecting against nothing. Downgrade safety instead rests on B0's confirmation model, which was built for exactly this contract. What this adds: - The three OpenAI-backed slugs we ship get exact-model capability. The provider stays unclassified, and `anthropic/claude-sonnet-5` is left out because OpenRouter does not list Anthropic among its priority upstreams. - Registry model capability is now guarded by destination. A provider merely named `openrouter` but pointed at someone's own gateway must not inherit evidence gathered about openrouter.ai, and OpenRouter's endpoint is fixed, so the guard reads the operator's configured base URL rather than the routed one. Catalog and runtime both feed it that same configured value, keeping A1's one-resolver invariant intact. - The Chat surface finally reads the upstream's `service_tier` echo, closing the gap B0 left open. Without it every OpenRouter Fast request would have recorded `assumed` even when OpenRouter told us it had fallen back to standard. - A confirmed priority result with no bundled tier price is now billed at the standard rate but flagged a floor rather than silently reported as exact: OpenRouter documents priority as "faster, higher cost", so standard is provably a lower bound. Scoped to canonical priority only — flex is cheaper, so the same argument would be false there. A first attempt scoped capability with the registry's `preserveCustomDestination` flag. It worked, but that flag also decides provider claiming and hosted-tool preference validation, and the full suite caught it changing which configs `openrouter` accepts. The destination guard above replaces it and touches nothing outside FastWire. Full suite: 13361 pass / 10 skip / 1 fail — the pre-existing dev-side key-login-live-update regression, which reproduces on pristine dev. Co-Authored-By: Claude Fable 5 --- .../docs/reference/configuration/providers.md | 29 +++ gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 44 ++-- gui/src/pages/logs-cost-format.ts | 11 + gui/tests/logs-priority-lower-bound.test.ts | 12 + src/adapters/base.ts | 16 +- src/adapters/openai-chat.ts | 33 ++- src/codex/catalog/provider-fetch.ts | 1 + src/config.ts | 5 +- src/providers/derive.ts | 14 +- src/providers/registry.ts | 43 +++- src/providers/service-tier.ts | 41 +++- src/router.ts | 10 +- src/routing/compatibility/behavior.ts | 6 +- src/server/management/shared.ts | 4 +- src/server/responses/core.ts | 17 +- src/usage/cost.ts | 32 ++- tests/fastwire-observability.test.ts | 216 +++++++++++++++++- tests/service-tier-capability.test.ts | 83 ++++++- 27 files changed, 579 insertions(+), 47 deletions(-) create mode 100644 gui/src/pages/logs-cost-format.ts create mode 100644 gui/tests/logs-priority-lower-bound.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2c1d2e3eef..900c29e8c1 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -151,6 +151,35 @@ contract; existing configurations see these migration deltas: Explicit capability `false` and Responses caller-tier forwarding retain their existing contracts. +### OpenRouter Fast + +The canonical `https://openrouter.ai/api/v1` preset advertises Fast only for these exact +OpenAI-backed model slugs: + +- `openai/gpt-5.6-sol` +- `openai/gpt-5.6-terra` +- `openai/gpt-5.6-luna` + +`anthropic/claude-sonnet-5` and undeclared OpenRouter models remain unclassified. A provider-level +`supportsServiceTier` default is intentionally absent, and a user-set `supportsServiceTier: false` +still disables the exact-model declarations. The registry declarations apply only while the +provider still targets the canonical OpenRouter base URL; a same-named custom destination is not +assumed to share OpenRouter's contract. + +Fast sends `service_tier: "priority"`. It does not add or rewrite `provider.only`, +`provider.order`, or `provider.allow_fallbacks`. OpenRouter documents priority endpoints as the +first routing choice, followed by graceful fallback to other endpoints when priority capacity is +unavailable. Billing follows the endpoint actually used, and the response reports the actual +top-level `service_tier`. Pinning tier endpoints and disabling fallback would therefore reduce +availability without improving billing safety. + +Request logs use that response echo as the authority. `priority` confirms Fast as applied; +`default` records a downgrade and uses the standard-price estimate; a missing field leaves the +attempt assumed rather than guessing a downgrade. OpenRouter's priority multiplier varies by +upstream and is not bundled here. When priority is confirmed but no exact priority price is known, +the dashboard keeps the standard-price estimate as a documented lower bound and prefixes it with +`≥`; downgraded attempts have no lower-bound marker. + API-key providers may hold a literal key or an environment reference. OAuth providers use the credential store populated by `ocx login`; subscription-backed Claude Code launch behavior is configured under [`claudeCode.authMode`](/reference/configuration/server/#claude-code). diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5637644402..6405dff15c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -711,6 +711,7 @@ export const de: Record = { "logs.detail.estimate.cache_detail_missing": "Cache-Details fehlen; Eingabe ist als Obergrenze geschätzt.", "logs.detail.estimate.expected_price_overlay": "Ein verifizierter Expected-Listenpreis wurde verwendet.", "logs.detail.estimate.provider_cost_overlay": "Ein vom Anbieter konfiguriertes Preis-Overlay wurde verwendet.", + "logs.detail.estimate.priority_lower_bound": "Der bestätigte OpenRouter-Priority-Preis ist nicht verfügbar; die angezeigte Standardpreisschätzung ist eine bekannte Untergrenze.", "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6256742991..6600769d66 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -744,6 +744,7 @@ export const en = { "logs.detail.estimate.cache_detail_missing": "Cache details were unavailable; input is an upper-bound estimate.", "logs.detail.estimate.expected_price_overlay": "A verified expected list price was used.", "logs.detail.estimate.provider_cost_overlay": "A provider-configured price overlay was used.", + "logs.detail.estimate.priority_lower_bound": "The confirmed OpenRouter priority price is unavailable; the displayed standard-price estimate is a known lower bound.", "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 98b503fd25..71057339fe 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -725,6 +725,7 @@ export const fr: Record = { "logs.detail.estimate.cache_detail_missing": "Les détails du cache n’étaient pas disponibles ; l’entrée est une estimation de la limite supérieure.", "logs.detail.estimate.expected_price_overlay": "Un tarif catalogue attendu et vérifié a été utilisé.", "logs.detail.estimate.provider_cost_overlay": "Un remplacement de tarif configuré pour le fournisseur a été utilisé.", + "logs.detail.estimate.priority_lower_bound": "Le tarif Priority OpenRouter confirmé n’est pas disponible ; l’estimation au tarif standard affichée est une borne inférieure connue.", "logs.col.error": "Erreur", "logs.col.upstreamReason": "Motif en amont", "logs.col.duration": "Durée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 07e011548d..dd4fcdac63 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -687,6 +687,7 @@ export const ja: Record = { "logs.detail.estimate.cache_detail_missing": "キャッシュの詳細が利用できませんでした; 入力は上限の推定です。", "logs.detail.estimate.expected_price_overlay": "検証済みの予想定価が使用されました。", "logs.detail.estimate.provider_cost_overlay": "プロバイダー設定の価格オーバーレイが使用されました。", + "logs.detail.estimate.priority_lower_bound": "確認済みの OpenRouter Priority 価格は取得できないため、表示される標準価格の見積もりは既知の下限です。", "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 93ffebf949..0b35335f48 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -730,6 +730,7 @@ export const ko: Record = { "logs.detail.estimate.cache_detail_missing": "캐시 상세가 없어 입력 전액을 상한으로 추정했습니다.", "logs.detail.estimate.expected_price_overlay": "검증된 expected 정가를 사용했습니다.", "logs.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", + "logs.detail.estimate.priority_lower_bound": "확인된 OpenRouter Priority 가격을 사용할 수 없어 표시된 표준 가격 추정치는 알려진 하한입니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index d92f2ca9c5..04198c5bc9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -728,6 +728,7 @@ export const ru: Record = { "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", + "logs.detail.estimate.priority_lower_bound": "Подтверждённая цена OpenRouter Priority недоступна; показанная оценка по стандартной цене является известной нижней границей.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0db71ce089..db6493be81 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -735,6 +735,7 @@ export const tr: Record = { "logs.detail.estimate.cache_detail_missing": "Önbellek detayları eksik.", "logs.detail.estimate.expected_price_overlay": "Doğrulanmış liste fiyatı kullanıldı.", "logs.detail.estimate.provider_cost_overlay": "Kullanıcı tarafından yapılandırılan bir sağlayıcı fiyat katmanı kullanıldı.", + "logs.detail.estimate.priority_lower_bound": "Doğrulanan OpenRouter Priority fiyatı kullanılamıyor; gösterilen standart fiyat tahmini bilinen bir alt sınırdır.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index dba3c64621..e7d58be32b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1805,6 +1805,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", + "logs.detail.estimate.priority_lower_bound": "無法取得已確認的 OpenRouter Priority 價格;目前顯示的標準價格估算是已知下限。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", "pws.cockpitImportFileLabel": "Cockpit Tools Antigravity JSON 匯出檔", "pws.cockpitImportChooseFile": "選擇 JSON 檔案", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0499b83ff3..e98836763a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -723,6 +723,7 @@ export const zh: Record = { "logs.detail.estimate.cache_detail_missing": "缺少缓存明细;输入费用按上限估算。", "logs.detail.estimate.expected_price_overlay": "使用了已验证的 Expected 标价。", "logs.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", + "logs.detail.estimate.priority_lower_bound": "暂无已确认的 OpenRouter Priority 价格;当前显示的标准价估算是已知下界。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index b7acbb4d02..fef77bc9ac 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -17,6 +17,7 @@ import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; import { modelTitle } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; +import { formatEstimatedUsdValue } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; @@ -53,7 +54,8 @@ type CostEstimateReason = | "usage_estimated" | "cache_detail_missing" | "expected_price_overlay" - | "provider_cost_overlay"; + | "provider_cost_overlay" + | "priority_lower_bound"; type TokPerSecondResult = | { kind: "value"; value: number; estimated: boolean } @@ -75,6 +77,7 @@ type CostResult = estimate: { cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; estimated: boolean; + priorityLowerBound?: boolean; price?: MatchedPriceInfo; attempts?: Array<{ ordinal: number; price: MatchedPriceInfo }>; }; @@ -240,20 +243,12 @@ function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: function formatEstimatedUsd(result: CostResult | undefined, localeTag?: string): string { if (!result || result.kind === "unavailable" || !Number.isFinite(result.estimate.cost.total) || result.estimate.cost.total < 0) return "\u2014"; const totalUsd = result.estimate.cost.total; - return `~$${new Intl.NumberFormat(localeTag, { + return `${result.estimate.priorityLowerBound ? "≥" : ""}~$${new Intl.NumberFormat(localeTag, { minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(totalUsd)}`; } -function formatEstimatedUsdValue(value: number, localeTag?: string): string { - if (!Number.isFinite(value) || value < 0) return "\u2014"; - return `~$${new Intl.NumberFormat(localeTag, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }).format(value)}`; -} - /** Consecutive failed polls before a stale table is called out. Two seconds each, so ~6s. */ const STALE_POLL_FAILURE_LIMIT = 3; @@ -273,6 +268,7 @@ const ESTIMATE_REASON_KEYS = { cache_detail_missing: "logs.detail.estimate.cache_detail_missing", expected_price_overlay: "logs.detail.estimate.expected_price_overlay", provider_cost_overlay: "logs.detail.estimate.provider_cost_overlay", + priority_lower_bound: "logs.detail.estimate.priority_lower_bound", } as const satisfies Record; /** @@ -345,11 +341,13 @@ function summarizeFilteredLogs(entries: LogEntry[]): { requests: number; totalTokens: number; estimatedCostUsd: number; + priorityLowerBound: boolean; unpricedRequests: number; unmeteredRequests: number; } { let totalTokens = 0; let estimatedCostUsd = 0; + let priorityLowerBound = false; let unpricedRequests = 0; let unmeteredRequests = 0; for (const entry of entries) { @@ -363,11 +361,19 @@ function summarizeFilteredLogs(entries: LogEntry[]): { const total = cost?.kind === "value" ? cost.estimate.cost.total : undefined; if (total !== undefined && Number.isFinite(total) && total >= 0) { estimatedCostUsd += total; + priorityLowerBound ||= cost?.kind === "value" && cost.estimate.priorityLowerBound === true; continue; } unpricedRequests += 1; } - return { requests: entries.length, totalTokens, estimatedCostUsd, unpricedRequests, unmeteredRequests }; + return { + requests: entries.length, + totalTokens, + estimatedCostUsd, + priorityLowerBound, + unpricedRequests, + unmeteredRequests, + }; } export default function Logs({ apiBase }: { apiBase: string }) { @@ -612,7 +618,11 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.conversation.totals", { requests: conversationTotals.requests, tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale), - cost: formatEstimatedUsdValue(conversationTotals.estimatedCostUsd, localeTag), + cost: formatEstimatedUsdValue( + conversationTotals.estimatedCostUsd, + localeTag, + conversationTotals.priorityLowerBound, + ), })} {" "} @@ -956,11 +966,11 @@ function LogDetailDialog({ {cost?.kind === "value" ? ( <>
- {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, localeTag)} - {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, localeTag)} - {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, localeTag)} - {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, localeTag)} - {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, localeTag)} + {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, localeTag, cost.estimate.priorityLowerBound)} {cost.estimate.price && ( <> {t("logs.detail.matchedKey")} diff --git a/gui/src/pages/logs-cost-format.ts b/gui/src/pages/logs-cost-format.ts new file mode 100644 index 0000000000..0997737896 --- /dev/null +++ b/gui/src/pages/logs-cost-format.ts @@ -0,0 +1,11 @@ +export function formatEstimatedUsdValue( + value: number, + localeTag?: string, + priorityLowerBound = false, +): string { + if (!Number.isFinite(value) || value < 0) return "\u2014"; + return `${priorityLowerBound ? "≥" : ""}~$${new Intl.NumberFormat(localeTag, { + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }).format(value)}`; +} diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts new file mode 100644 index 0000000000..6117bcfdd3 --- /dev/null +++ b/gui/tests/logs-priority-lower-bound.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { formatEstimatedUsdValue } from "../src/pages/logs-cost-format"; + +describe("Logs priority lower-bound formatting", () => { + test("prefixes confirmed unpriced priority estimates with the lower-bound marker", () => { + expect(formatEstimatedUsdValue(1.6, "en-US", true)).toBe("≥~$1.6000"); + }); + + test("keeps ordinary standard-price estimates unchanged", () => { + expect(formatEstimatedUsdValue(1.6, "en-US", false)).toBe("~$1.6000"); + }); +}); diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 06b5f6f087..6ffc420a05 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -33,8 +33,20 @@ export interface ProviderAdapter { fetchResponse?(request: AdapterRequest, ctx?: AdapterFetchContext): Promise; - parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator; - parseResponse?(response: Response, budget: TranslatorBudget): Promise; + /** + * Parse one upstream response. `tierMetadata` is the same live observer returned on the + * corresponding AdapterRequest; adapters that receive a documented tier echo may update it. + */ + parseStream( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): AsyncGenerator; + parseResponse?( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): Promise; runTurn?( parsed: OcxParsedRequest, incoming: IncomingMeta, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a6d13fa1f4..c26537336a 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -19,6 +19,7 @@ import { import { canonicalFastTierMarker, createAdapterTierMetadata, + type AdapterTierMetadata, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; @@ -1474,7 +1475,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd }; }, - async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator { + async *parseStream( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): AsyncGenerator { if (!response.body) { yield { type: "error", message: "No response body" }; return; @@ -1543,11 +1548,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd try { parsed = JSON.parse(payload); } catch { + tierMetadata?.markResponseUnparseable(); yield { type: "error", message: "malformed upstream SSE data frame" }; return "terminate"; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue"; const chunk = parsed as Record; + if (Object.hasOwn(chunk, "service_tier")) { + tierMetadata?.observeResponseServiceTier(chunk.service_tier); + } if (chunk.error !== undefined && chunk.error !== null) { const event = upstreamErrorEvent(chunk.error, pendingUsage); @@ -1748,8 +1757,26 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } }, - async parseResponse(response: Response, budget: TranslatorBudget): Promise { - const json = await response.json() as Record; + async parseResponse( + response: Response, + budget: TranslatorBudget, + tierMetadata?: AdapterTierMetadata, + ): Promise { + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + tierMetadata?.markResponseUnparseable(); + throw error; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + tierMetadata?.markResponseUnparseable(); + throw new Error("upstream response was not a JSON object"); + } + const json = parsed as Record; + if (Object.hasOwn(json, "service_tier")) { + tierMetadata?.observeResponseServiceTier(json.service_tier); + } const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength; budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 69203c8940..225c1cb558 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -413,6 +413,7 @@ function captureProviderGather( name, provider, registryTransportMatch, + configured, ); const observedAuth = authResolver.kind === "observed" && provider.authMode !== "forward" diff --git a/src/config.ts b/src/config.ts index 4d5547f5fd..a0fa0a3b25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,7 @@ import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault, + registryModelServiceTierCapabilityApplies, } from "./providers/registry"; import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; @@ -2159,7 +2160,9 @@ function inheritedFastWireConflictProviderNames( if (!registry) continue; const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier; const effectiveModelCapabilities = { - ...(registry.modelSupportsServiceTier ?? {}), + ...(registryModelServiceTierCapabilityApplies(registry, provider) + ? registry.modelSupportsServiceTier ?? {} + : {}), ...(provider.modelSupportsServiceTier ?? {}), }; if ( diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c5e08065b3..3db91f73df 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -3,6 +3,7 @@ import { cloneFastWire } from "./fastwire"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination, + registryModelServiceTierCapabilityApplies, type ProviderRegistryEntry, } from "./registry"; import { @@ -377,6 +378,15 @@ function applyServiceTierModelDefaults( }; } +function serviceTierModelDefaultsFor( + entry: ProviderRegistryEntry | undefined, + prov: OcxProviderConfig, +): Readonly> | undefined { + return entry && registryModelServiceTierCapabilityApplies(entry, prov) + ? entry.modelSupportsServiceTier + : undefined; +} + /** * Last-resort enrichment for a provider whose NAME matches no registry id. * @@ -412,7 +422,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // which vendor endpoint is this row talking to — and is already restricted to fixed key // destinations, so a templated or overridable base URL cannot be claimed by it. enrichReasoningSummariesByDestination(prov); - applyServiceTierModelDefaults(prov, registryEntryForProviderDestination(prov)?.modelSupportsServiceTier); + applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(registryEntryForProviderDestination(prov), prov)); return; } const explicitDirectReasoning: DirectReasoningEffortOverrides = { @@ -466,7 +476,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); - applyServiceTierModelDefaults(prov, entry.modelSupportsServiceTier); + applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c81735ba15..364478143d 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -17,6 +17,7 @@ import { cursorModelReasoningEfforts, } from "../adapters/cursor/discovery"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; +import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -217,6 +218,11 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; + /** + * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence + * without changing provider ownership, routing, authentication, or config validation. + */ + modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ @@ -1349,7 +1355,33 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ autoToolChoiceOnlyModels: ["kimi-k2.7-code"], preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, }, - { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } }, + { + id: "openrouter", + label: "OpenRouter", + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authKind: "key", + featured: true, + dashboardUrl: "https://openrouter.ai/keys", + jawcodeBundle: "openrouter", + models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], + modelContextWindows: { + "anthropic/claude-sonnet-5": 1_000_000, + ...OPENROUTER_GPT56_CONTEXT_WINDOWS, + }, + // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the + // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts + // belong only to the canonical destination; a same-named custom gateway is unknown to us. + modelServiceTierCapabilityBaseUrlGuard: isCanonicalOpenRouterTarget, + modelSupportsServiceTier: { + "openai/gpt-5.6-sol": true, + "openai/gpt-5.6-terra": true, + "openai/gpt-5.6-luna": true, + }, + // Deliberately no OpenRouter route pin: it bills the endpoint actually used and reports the + // actual service_tier. B0 confirmation therefore owns downgrade safety. Forcing `only` plus + // `allow_fallbacks:false` would turn a graceful priority-capacity fallback into a hard failure. + }, { // Primary sources checked 2026-08-02: // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly @@ -2555,6 +2587,15 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un return PROVIDER_REGISTRY.find(entry => entry.id === id); } +/** Whether this registry row's per-model service-tier evidence applies to one configured target. */ +export function registryModelServiceTierCapabilityApplies( + entry: Pick, + provider: Pick, +): boolean { + const guard = entry.modelServiceTierCapabilityBaseUrlGuard; + return guard === undefined || guard(provider.baseUrl); +} + function normalizedProviderEndpoint(value: string): string { const trimmed = value.trim(); try { diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index a06c42c7b4..278f89394e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -4,6 +4,7 @@ import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, + registryModelServiceTierCapabilityApplies, type InboundWire, type ModelWireDefault, } from "./registry"; @@ -57,8 +58,14 @@ function buildFastPolicyAuthority( providerName: string, provider: ServiceTierCapabilityProvider, registryTransportMatch: boolean, + capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined; + const registryModelCapabilities = registry + && registryModelServiceTierCapabilityApplies(registry, capabilityProvider) + ? registry.modelSupportsServiceTier + : undefined; + const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier; const authority: FastPolicyAuthority = Object.freeze({ providerAdapter: provider.adapter, fastWireDeclaration: cloneFastWire( @@ -72,8 +79,11 @@ function buildFastPolicyAuthority( provider.apiKeyTransport, ), capability: Object.freeze({ - ...(provider.supportsServiceTier !== undefined ? { provider: provider.supportsServiceTier } : {}), - models: Object.freeze({ ...(provider.modelSupportsServiceTier ?? {}) }), + ...(providerCapability !== undefined ? { provider: providerCapability } : {}), + models: Object.freeze({ + ...(registryModelCapabilities ?? {}), + ...(capabilityProvider.modelSupportsServiceTier ?? {}), + }), ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), @@ -87,8 +97,14 @@ export function captureFastPolicyAuthority( providerName: string, provider: ServiceTierCapabilityProvider, registryTransportMatch: boolean, + capabilityProvider: ServiceTierCapabilityProvider = provider, ): FastPolicyAuthority { - const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch); + const authority = buildFastPolicyAuthority( + providerName, + provider, + registryTransportMatch, + capabilityProvider, + ); if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority); return authority; } @@ -106,12 +122,13 @@ export function captureServiceTierAdapterAuthority( function authorityForProvider( provider: ServiceTierCapabilityProvider, providerName?: string, + capabilityProvider?: ServiceTierCapabilityProvider, ): FastPolicyAuthority { // Preserve the legacy no-name short circuit: serviceTierSupportForModel() used the // provider adapter directly when no provider identity was available, so no configured // override, hard pin, or registry default may participate on this path in A1. if (providerName === undefined) { - const authority = buildFastPolicyAuthority("", provider, false); + const authority = buildFastPolicyAuthority("", provider, false, capabilityProvider ?? provider); return Object.freeze({ ...authority, modelAdapters: Object.freeze({}), @@ -119,12 +136,17 @@ function authorityForProvider( registryWireDefaults: Object.freeze({}), }); } - const captured = Object.isFrozen(provider) + const captured = capabilityProvider === undefined && Object.isFrozen(provider) ? capturedFastPolicyAuthorities.get(provider) : undefined; if (captured) return captured; const registryTransportMatch = providerMatchesRegistryTransport(providerName, provider); - const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch); + const authority = buildFastPolicyAuthority( + providerName, + provider, + registryTransportMatch, + capabilityProvider ?? provider, + ); // Frozen provider snapshots cannot drift, so repeated catalog/runtime projections may safely // reuse the registry lookup and detached declaration maps. Mutable configs still rebuild. if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority); @@ -137,8 +159,13 @@ export function fastPolicyForModel( modelId: string, providerName?: string, inbound: InboundWire = "responses", + capabilityProvider?: ServiceTierCapabilityProvider, ): ResolvedFastPolicy { - return resolveFastPolicy(authorityForProvider(provider, providerName), modelId, inbound); + return resolveFastPolicy( + authorityForProvider(provider, providerName, capabilityProvider), + modelId, + inbound, + ); } /** diff --git a/src/router.ts b/src/router.ts index 723ff8ca84..be06d6c478 100644 --- a/src/router.ts +++ b/src/router.ts @@ -11,7 +11,11 @@ import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; -import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; +import { + PROVIDER_REGISTRY, + providerCodexAccountMode, + registryModelServiceTierCapabilityApplies, +} from "./providers/registry"; import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive"; import { cloneFastWire } from "./providers/fastwire"; import { @@ -296,7 +300,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens); const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens); const modelSupportsServiceTier = mergeRecordFill( - registryEntry.modelSupportsServiceTier, + registryModelServiceTierCapabilityApplies(registryEntry, provider) + ? registryEntry.modelSupportsServiceTier + : undefined, provider.modelSupportsServiceTier, ); const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels); diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 553e531bb1..434aada342 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,6 +1,6 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY } from "../../providers/registry"; -import { fastPolicyForModel, serviceTierSupportForModel } from "../../providers/service-tier"; +import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier"; import { resolveProviderAuthTransport } from "../../providers/fastwire"; import { localFingerprint } from "../../lab/digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types"; @@ -93,7 +93,7 @@ export function resolveProductionBehaviorValues( const project = typeof effective.project === "string" && effective.project ? effective.project : null; const location = typeof effective.location === "string" && effective.location ? effective.location : null; const nativeLocalExec = effective.nativeLocalExec === "on" || effective.unsafeAllowNativeLocalExec === true; - const fastPolicy = fastPolicyForModel(effective, modelId, providerName); + const fastPolicy = fastPolicyForModel(effective, modelId, providerName, "responses", provider); const values: LabBehaviorValues = { "wire.adapter": behaviorRow("provider_config", adapter), @@ -112,7 +112,7 @@ export function resolveProductionBehaviorValues( "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true), "responses.serviceTier": behaviorRow( "provider_config", - serviceTierSupportForModel(effective, modelId, providerName) ?? null, + serviceTierSupportFromPolicy(fastPolicy) ?? null, ), "responses.fastWireKind": behaviorRow( "provider_config", diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 77d1f74264..3ebea685e8 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -86,7 +86,8 @@ export type CostEstimateReason = | "usage_estimated" | "cache_detail_missing" | "expected_price_overlay" - | "provider_cost_overlay"; + | "provider_cost_overlay" + | "priority_lower_bound"; export type CostResult = | { kind: "value"; estimate: NonNullable>; estimateReasons: CostEstimateReason[] } @@ -143,6 +144,7 @@ export function costResult(entry: MetricSource): CostResult { ? "expected_price_overlay" as const : undefined, estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user") ? "provider_cost_overlay" as const : undefined, + estimate.priorityLowerBound ? "priority_lower_bound" as const : undefined, ].filter((reason): reason is CostEstimateReason => reason !== undefined); return { kind: "value", estimate, estimateReasons }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index d0cb1b5b13..2a3bb54564 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1199,6 +1199,7 @@ async function applyFinalRouteRequestNormalization(args: { route.modelId, route.providerName, inboundWire, + config.providers[route.providerName], ); const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy); const callerTier = parsed.options.serviceTier; @@ -4288,9 +4289,9 @@ async function handleResponsesInner( const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal); try { if (nextParsed.stream) { - yield* activeAdapter.parseStream(response, translatorBudget); + yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata); } else if (activeAdapter.parseResponse) { - yield* await activeAdapter.parseResponse(response, translatorBudget); + yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata); } else { yield { type: "error", message: "Provider continuation does not support response parsing" }; } @@ -4320,7 +4321,11 @@ async function handleResponsesInner( }; if (parsed.stream) { - const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget); + const initialEventStream = activeAdapter.parseStream( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); const eventStream = terminalGuardEnabled ? guardTerminalEventStream({ parsed, @@ -4388,7 +4393,11 @@ async function handleResponsesInner( if (activeAdapter.parseResponse) { let events: AdapterEvent[]; try { - const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget); + const initialEvents = await activeAdapter.parseResponse( + upstreamResponse, + translatorBudget, + logCtx.activeTierMetadata, + ); let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { guardedEvents = []; diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 5cf798e144..6a82604722 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -83,6 +83,8 @@ export interface AttemptCostEstimate { estimated: boolean; /** Applied OpenAI priority-tier multiplier (undefined or 1 = standard). */ priorityMultiplier?: number; + /** Standard-price estimate is a known floor for a confirmed, unpriced priority endpoint. */ + priorityLowerBound?: boolean; /** Set when the published long-context rate was applied (#908). */ contextTier?: ContextTierName; } @@ -95,6 +97,8 @@ export interface CostEstimate { price?: MatchedPrice; /** Applied OpenAI priority-tier multiplier (undefined or 1 = standard). */ priorityMultiplier?: number; + /** Standard-price estimate is a known floor for a confirmed, unpriced priority endpoint. */ + priorityLowerBound?: boolean; /** Set when any priced attempt used the published long-context rate (#908). */ contextTier?: ContextTierName; } @@ -350,7 +354,9 @@ export type ServiceTierInput = string | ServiceTierContext; * and long-context exclusivity depends on that distinction. */ export function serviceTierContext(entry: ServiceTierContext): ServiceTierContext { - if (entry.tierOutcome) return serviceTierContextFromOutcome(entry.tierOutcome); + if (entry.tierOutcome) { + return { ...serviceTierContextFromOutcome(entry.tierOutcome), tierOutcome: entry.tierOutcome }; + } return { responseServiceTier: entry.responseServiceTier, requestedServiceTier: entry.requestedServiceTier, @@ -448,6 +454,22 @@ function applyPriorityMultiplier( }, multiplier]; } +/** + * OpenRouter confirms the actual endpoint tier and documents priority as higher cost, but this + * branch does not bundle its provider-specific priority endpoint prices. A confirmed canonical + * priority result can therefore use the standard price only as a provable lower bound. Do not + * extend this to flex (cheaper) or to other providers without the same pricing contract. + */ +function isOpenRouterPriorityLowerBound( + provider: string, + outcome: AttemptTierOutcome | undefined, +): boolean { + return baseProviderLabel(provider) === "openrouter" + && outcome?.canonical === "priority" + && outcome.fastOutcome === "applied" + && outcome.confirmation === "confirmed"; +} + /** * Per-attempt cost estimate: tokens normalized, price resolved (user overlay → * catalogs), priority/long-context tiers applied. Null when usage or price is @@ -476,6 +498,7 @@ export function estimateAttemptCost( const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier); + const priorityLowerBound = isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome); return { ordinal: attempt.ordinal, provider: attempt.provider, @@ -485,6 +508,7 @@ export function estimateAttemptCost( cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(attempt.usage, attempt.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), }; } @@ -527,6 +551,7 @@ export function estimateComboCost( ...(estimates.some(est => est.priorityMultiplier && est.priorityMultiplier !== 1) ? { priorityMultiplier: estimates.find(est => est.priorityMultiplier)?.priorityMultiplier } : {}), + ...(estimates.some(est => est.priorityLowerBound) ? { priorityLowerBound: true } : {}), ...(estimates.some(est => est.contextTier) ? { contextTier: "long" as const } : {}), }; } @@ -554,12 +579,17 @@ export function estimateRequestCost( const [effectiveCost4, multiplier] = contextTier ? [tieredCost4, 1] as const : applyPriorityMultiplier(tieredCost4, input.provider, input.model, input.serviceTier); + const priorityLowerBound = isOpenRouterPriorityLowerBound( + input.provider, + typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined, + ); return { tokens, price, cost: calculateCost(tokens, effectiveCost4), estimated: isEstimated(input.usage, input.usageStatus, price.status), ...(multiplier !== 1 ? { priorityMultiplier: multiplier } : {}), + ...(priorityLowerBound ? { priorityLowerBound: true } : {}), ...(contextTier ? { contextTier } : {}), }; } diff --git a/tests/fastwire-observability.test.ts b/tests/fastwire-observability.test.ts index 058ca6c961..f29332f9e0 100644 --- a/tests/fastwire-observability.test.ts +++ b/tests/fastwire-observability.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { AdapterRequest } from "../src/adapters/base"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { createResponsesPassthroughAdapter } from "../src/adapters/openai-responses"; import { buildBehaviorFingerprintV1 } from "../src/lab/subject/behavior-fingerprint"; import { sanitizeLogMetadataString } from "../src/lib/redact"; @@ -18,12 +19,13 @@ import { type RequestLogContext, type RequestLogEntry, } from "../src/server/request-log"; -import { applyServiceTierGate } from "../src/server/responses/core"; +import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; +import { costResult } from "../src/server/management/shared"; import type { OcxConfig, OcxParsedRequest, TierObservationContext } from "../src/types"; import { estimateComboCost, serviceTierContextFromOutcome } from "../src/usage/cost"; import type { ExpectedPriceOverlay } from "../src/usage/expected-prices"; import { normalizeUsageEntryForTest } from "../src/usage/log"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const SERVICE_WIRE = { kind: "service-tier" as const, @@ -408,6 +410,120 @@ describe("FastWire logging and persistence", () => { expect(normalized.responseServiceTier).toBe(expected); expect(normalized.tierOutcome?.responseServiceTier).toBe(expected); }); + + test.each([ + { wire: "stream", responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed", canonical: "priority" }, + { wire: "stream", responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded", canonical: undefined }, + { wire: "stream", responseTier: undefined, fastOutcome: "applied", confirmation: "assumed", canonical: "priority" }, + { wire: "non-stream", responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed", canonical: "priority" }, + { wire: "non-stream", responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded", canonical: undefined }, + { wire: "non-stream", responseTier: undefined, fastOutcome: "applied", confirmation: "assumed", canonical: "priority" }, + ] as const)( + "OpenAI Chat $wire response tier=$responseTier updates the existing adapter observer", + async ({ wire, responseTier, fastOutcome, confirmation, canonical }) => { + const tracker = createAdapterTierMetadata( + observation(), + { kind: "set", value: "priority" }, + "service-tier", + "priority", + )!; + const adapter = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + }); + const tierField = responseTier === undefined ? {} : { service_tier: responseTier }; + const budget = createTestTranslatorBudget(); + + if (wire === "stream") { + const chunk = { + id: "chatcmpl-tier", + object: "chat.completion.chunk", + ...tierField, + choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }; + for await (const _ of adapter.parseStream(new Response( + `data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, + ), budget, tracker)) { + // Drain the adapter so the final chunk and tier echo are observed. + } + } else { + await adapter.parseResponse(new Response(JSON.stringify({ + id: "chatcmpl-tier", + object: "chat.completion", + ...tierField, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + })), budget, tracker); + } + + expect(tracker.outcome).toMatchObject({ fastOutcome, confirmation }); + if (canonical === undefined) expect(tracker.outcome).not.toHaveProperty("canonical"); + else expect(tracker.outcome.canonical).toBe(canonical); + if (responseTier === undefined) expect(tracker.outcome).not.toHaveProperty("responseServiceTier"); + else expect(tracker.outcome.responseServiceTier).toBe(responseTier); + }, + ); + + test.each([ + { stream: true, responseTier: "priority", fastOutcome: "applied", confirmation: "confirmed" }, + { stream: false, responseTier: "default", fastOutcome: "downgraded", confirmation: "downgraded" }, + ] as const)( + "the Responses bridge passes the live observer into Chat parsing (stream=$stream)", + async ({ stream, responseTier, fastOutcome, confirmation }) => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + const payload = stream + ? `data: ${JSON.stringify({ + service_tier: responseTier, + choices: [{ index: 0, delta: { content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + })}\n\ndata: [DONE]\n\n` + : JSON.stringify({ + service_tier: responseTier, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + return new Response(payload, { + status: 200, + headers: { "content-type": stream ? "text/event-stream" : "application/json" }, + }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + try { + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", input: "ping", stream }), + }), + { + port: 10100, + defaultProvider: "fixture", + fastMode: true, + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://fixture.example.test/v1", + apiKey: "sk-test", + supportsServiceTier: true, + }, + }, + }, + logCtx, + {}, + ); + await response.text(); + } finally { + globalThis.fetch = originalFetch; + } + expect(logCtx.activeAttempt?.tierOutcome).toMatchObject({ + fastOutcome, + confirmation, + responseServiceTier: responseTier, + }); + }, + ); }); describe("FastWire per-attempt cost", () => { @@ -498,6 +614,102 @@ describe("FastWire per-attempt cost", () => { expect(estimate.cost.total).toBeCloseTo(6.4, 9); expect(estimate.attempts?.every(attempt => attempt.priorityMultiplier === 2)).toBe(true); }); + + test("confirmed OpenRouter priority is a standard-price lower bound, while downgrade and OpenAI stay unchanged", () => { + const openRouterOverlays: ExpectedPriceOverlay[] = [{ + provider: "openrouter", + modelId: "openai/gpt-5.6-sol", + cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + source: "test", + verifiedAt: "2026-08-18", + status: "verified", + }]; + const confirmedPriority = { + canonical: "priority" as const, + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "applied" as const, + confirmation: "confirmed" as const, + responseServiceTier: "priority", + }; + const downgraded = { + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "downgraded" as const, + fastDowngradeReason: "response-declined" as const, + confirmation: "downgraded" as const, + responseServiceTier: "default", + }; + + const priority = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: confirmedPriority, + }], openRouterOverlays)!; + expect(priority.cost.total).toBeCloseTo(1.6, 9); + expect(priority.priorityMultiplier).toBeUndefined(); + expect(priority.priorityLowerBound).toBe(true); + expect(priority.attempts?.[0]?.priorityLowerBound).toBe(true); + + const standard = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: downgraded, + }], openRouterOverlays)!; + expect(standard.cost.total).toBeCloseTo(1.6, 9); + expect(standard.priorityLowerBound).toBeUndefined(); + + const flex = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: { ...downgraded, responseServiceTier: "flex" }, + }], openRouterOverlays)!; + expect(flex.cost.total).toBeCloseTo(1.6, 9); + expect(flex.priorityLowerBound).toBeUndefined(); + + const openAi = estimateComboCost([{ + ordinal: 1, + provider: "openai", + model: "gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: confirmedPriority, + }], overlays)!; + expect(openAi.cost.total).toBeCloseTo(3.2, 9); + expect(openAi.priorityMultiplier).toBe(2); + expect(openAi.priorityLowerBound).toBeUndefined(); + }); + + test("management cost metadata exposes the aligned priority_lower_bound reason", () => { + const result = costResult({ + provider: "openrouter", + model: "openai/gpt-5.6-sol", + durationMs: 1, + usageStatus: "reported", + usage, + tierOutcome: { + canonical: "priority", + wireKind: "service-tier", + wireValue: "priority", + fastOutcome: "applied", + confirmation: "confirmed", + responseServiceTier: "priority", + }, + }); + expect(result.kind).toBe("value"); + if (result.kind !== "value") throw new Error("expected a priced OpenRouter result"); + expect(result.estimate.priorityLowerBound).toBe(true); + expect(result.estimateReasons).toContain("priority_lower_bound"); + }); }); describe("FastWire gate and compatibility fingerprint", () => { diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index 2e397be86b..49c051b8dc 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -7,6 +7,9 @@ * ever receiving an injection (PR #860 family). */ import { afterEach, describe, expect, test } from "bun:test"; +import { applyProviderConfigHints } from "../src/codex/catalog"; +import { applyCatalogModelMetadata } from "../src/codex/catalog/effort"; +import type { RawEntry } from "../src/codex/catalog/parsing"; import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; @@ -45,6 +48,30 @@ describe("registry capability reaches saved configs without overriding them", () enrichProviderFromRegistry("deepseek", optedIn); expect(optedIn.supportsServiceTier).toBe(true); }); + + test("OpenRouter stays provider-unclassified and declares only its three OpenAI-backed slugs", () => { + const entry = getProviderRegistryEntry("openrouter")!; + expect(entry.supportsServiceTier).toBeUndefined(); + expect(entry.chatServiceTier).toBeUndefined(); + expect(entry.modelSupportsServiceTier).toEqual({ + "openai/gpt-5.6-sol": true, + "openai/gpt-5.6-terra": true, + "openai/gpt-5.6-luna": true, + }); + expect(entry.modelSupportsServiceTier).not.toHaveProperty("anthropic/claude-sonnet-5"); + expect(providerConfigSeed(entry).modelSupportsServiceTier).toBeUndefined(); + }); + + test("OpenRouter registry capability is not inherited by a same-named noncanonical destination", () => { + const provider: OcxProviderConfig = { + ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), + baseUrl: "https://openrouter-proxy.example.test/v1", + apiKey: "sk-test", + }; + enrichProviderFromRegistry("openrouter", provider); + expect(provider.modelSupportsServiceTier).toBeUndefined(); + expect(supportsServiceTierForModel(provider, "openai/gpt-5.6-sol")).toBeUndefined(); + }); }); describe("service-tier capability is exact-model and provider-scoped", () => { @@ -237,6 +264,14 @@ describe("the gate fires on the live handleResponses path", () => { ({ ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }); const openAiKeyProvider = (): OcxProviderConfig => ({ ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), apiKey: "sk-test" }); + const openRouterProvider = (overrides: Partial = {}): OcxProviderConfig => { + const provider: OcxProviderConfig = { + ...providerConfigSeed(getProviderRegistryEntry("openrouter")!), + apiKey: "sk-test", + ...overrides, + }; + return provider; + }; test("DeepSeek never receives service_tier, even with fastMode on", async () => { const body = await drive("deepseek", deepseekProvider(), "deepseek-v4-flash", {}, true); @@ -310,6 +345,53 @@ describe("the gate fires on the live handleResponses path", () => { const undeclared = await drive("custom-chat", custom(), "undeclared-model", { service_tier: "priority" }); expect(undeclared).not.toHaveProperty("service_tier"); }); + + test.each([ + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", + ])("OpenRouter %s publishes and injects canonical Fast without route pins", async modelId => { + const provider = openRouterProvider(); + const model = applyProviderConfigHints("openrouter", provider, { id: modelId, provider: "openrouter" }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry.service_tiers).toEqual([expect.objectContaining({ id: "priority", name: "Fast" })]); + + const body = await drive("openrouter", provider, modelId, {}, true); + expect(body.service_tier).toBe("priority"); + expect(body).not.toHaveProperty("provider"); + }); + + test("OpenRouter Anthropic and undeclared slugs stay unclassified and receive no Fast injection", async () => { + const provider = openRouterProvider(); + for (const modelId of ["anthropic/claude-sonnet-5", "google/gemini-unknown"]) { + const model = applyProviderConfigHints("openrouter", provider, { id: modelId, provider: "openrouter" }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry).not.toHaveProperty("service_tiers"); + expect(await drive("openrouter", provider, modelId, {}, true)).not.toHaveProperty("service_tier"); + } + }); + + test("an explicit OpenRouter provider-level false remains fail-closed", async () => { + const provider = openRouterProvider({ supportsServiceTier: false }); + expect(supportsServiceTierForModel(provider, "openai/gpt-5.6-sol")).toBe(false); + const body = await drive("openrouter", provider, "openai/gpt-5.6-sol", {}, true); + expect(body).not.toHaveProperty("service_tier"); + }); + + test("a same-named noncanonical OpenRouter destination neither publishes nor injects registry Fast", async () => { + const provider = openRouterProvider({ baseUrl: "https://openrouter-proxy.example.test/v1" }); + const model = applyProviderConfigHints("openrouter", provider, { + id: "openai/gpt-5.6-sol", + provider: "openrouter", + }); + const catalogEntry: RawEntry = {}; + applyCatalogModelMetadata(catalogEntry, model); + expect(catalogEntry).not.toHaveProperty("service_tiers"); + const body = await drive("openrouter", provider, "openai/gpt-5.6-sol", {}, true); + expect(body).not.toHaveProperty("service_tier"); + }); }); describe("unclassified chat-wire tier projection (release-audit fix)", () => { @@ -328,4 +410,3 @@ describe("unclassified chat-wire tier projection (release-audit fix)", () => { expect(serviceTierSupportForModel(provider, "some-model")).toBeUndefined(); }); }); - From 48b0c2a702bd1aa7171170582af65036360231a2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 10:21:23 +0900 Subject: [PATCH 011/121] fix(responses): reclaim abandoned state temps on a timer, not only at load --- .../000_plan.md | 23 +++- .../002_audit_round1_late.md | 72 ++++++++++ src/lib/state-store-registrations.ts | 10 +- src/responses/state.ts | 113 +++++++++++++++- tests/responses-state.test.ts | 127 ++++++++++++++++++ tests/state-store-sweeper.test.ts | 28 +++- 6 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/002_audit_round1_late.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md index 4eb73c9c19..dece4c6cc2 100644 --- a/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md +++ b/devlog/_plan/260819_response_state_temp_reclaim/000_plan.md @@ -22,10 +22,25 @@ an operator a way to reclaim them when the proxy will not start at all. ## Root cause -The reclaim is attached to the request path. A proxy that crashes before serving a -continuation request leaves its temp behind and never reaches the code that would -reclaim it. The condition that produces the garbage is the same condition that -disables the collector, so the file count only ever grows. +**Corrected after the late round-1 audit (`002_audit_round1_late.md`); the original +narrative here was falsified.** It claimed a crashing proxy never reaches the reclaim. +That is wrong: a temp only exists if a snapshot write ran, and every `schedulePersist` +site (`:897`, `:929`, `:956`, `:971`, `:1214`) is downstream of `ensureLoaded`, so a +process that produced a temp had ALREADY run the reclaim. + +The reclaim runs **once per process, at load, before that process writes anything**. +Three properties then combine: + +1. **One-shot per process.** `ensureLoaded` sets `loaded = true` and never sweeps again, + so any temp a process abandons after startup is invisible to it forever. +2. **The 15-minute grace excludes the predecessor.** `:581` skips files younger than 15 + minutes, so a proxy restarting promptly after a crash cannot reclaim the temp that + crash just produced — and by (1) it never looks again. +3. **`maxCleanups = 512` caps a single pass** below the ~816 files implied by + 19.6 GB ÷ 24 MiB, so even a well-timed sweep cannot drain the backlog in one pass. + +A restart loop therefore accumulates monotonically: each process sweeps once, too early +to see its predecessor's fresh temp, then adds one of its own. This is a scheduling defect, not a missing-feature defect. Both layers below move or add a CALLER; neither loosens a reclaim safety gate. diff --git a/devlog/_plan/260819_response_state_temp_reclaim/002_audit_round1_late.md b/devlog/_plan/260819_response_state_temp_reclaim/002_audit_round1_late.md new file mode 100644 index 0000000000..5c418dc545 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/002_audit_round1_late.md @@ -0,0 +1,72 @@ +# Audit round 1 (late) — roadmap review, VERDICT: FAIL + +The first reviewer, retired as a failed dispatch after ~11 minutes of silence, returned +afterwards against the ORIGINAL roadmap at `d75a2402f`. Its verdict is **FAIL**. Several +findings were independently fixed by round 2 in the meantime; the rest are folded here. + +**The headline finding is correct and I verified it myself.** + +## Falsified: the original root-cause narrative + +`000_plan.md` claimed a crashing proxy "never reaches the code that would reclaim." +That is wrong. A temp only exists if `atomicWriteFileAsync` ran, which requires +`writeBoundedSnapshot` ← `persistNow` ← `schedulePersist`. Every `schedulePersist` site +is downstream of a populated store: `:1214` follows `ensureLoaded()` at `:1185`; +`:956`/`:971` sit under `expandPreviousResponseInput` → `ensureLoaded`; `:897` and +`:929` are no-ops on an empty store (`:897` fires only when `removed > 0`). + +Verified directly: `grep -n 'schedulePersist()' src/responses/state.ts` returns exactly +`:897, :929, :956, :971, :1214`, and `:890-898` confirms the `removed > 0` guard. So +**a process that produced a temp had already run the reclaim.** + +## The corrected cause (three parts, all still fixed by this unit) + +The reclaim runs **once per process, at load, before that process writes anything**: + +1. **One-shot per process.** `ensureLoaded` sets `loaded = true` and never sweeps again, + so every temp a process abandons after startup is invisible to that process forever. +2. **The 15-minute grace excludes the predecessor.** `:581` skips anything younger than + 15 minutes, so a successor starting promptly after a crash cannot reclaim the temp + that crash just produced — and it never looks again (part 1). +3. **`maxCleanups = 512` caps one pass** below the ~816 files implied by 19.6 GB ÷ 24 MiB, + so even a well-timed startup sweep cannot finish the backlog in one go. + +A periodic sweep fixes all three: it repeats, so the grace expires into a later tick and +the per-pass cap becomes a per-tick rate. **The fix is unchanged; the justification is +corrected.** That distinction matters — the original story would have made the periodic +tick look optional. + +## Blockers folded + +- **B1 root cause** — restated in `000_plan.md` as the three-part cause above. +- **B5 the stack's dependency edge did not typecheck.** Phase 1 defined only + `sweepAbandonedResponseStateTemps(): number`, but phase 2 consumed + `removed`/`failed`/`bytesRemoved` from a `reclaimAbandonedResponseStateTemps()` that + phase 1 never defined. Fix: phase 1 exports a result-returning core and the sweeper + adapter narrows it to `number`. +- **B8 concurrent proxies produce false failures.** Two processes ticking over one config + dir race; the loser's `unlink` raises ENOENT and lands in `failed`, which phase 2 would + surface as "in use or locked". Fix: treat a missing path as removed, mirroring + `isMissingPathError` (`config.ts:132`). +- **B2 `matched` overstates.** It increments at `:574` BEFORE the age and PID gates, so + doctor would report live-PID temps, young temps, and directories as "abandoned". Fix + (phase 2): count eligibility after the gates. +- **B10 `formatBytes` does not exist in `src/`** — only `gui/src/format-bytes.ts`, which + needs a `Locale`. Phase 2 must name a CLI-side helper. +- **B9 sibling producers, recorded as residuals.** The same `.ocx...tmp` + template is minted by `config.ts:214`, `config.ts:453`, `catalog-writer.ts`, and + `prompt-journal.ts`. None are matched by `RESPONSE_STATE_TEMP_NAME` (`:34`) and none + are reclaimed anywhere. This unit deliberately does not widen the regex — reclaiming + another subsystem's files under a response-state name would be worse — but they are now + named as a follow-up unit rather than silently ignored. + +## Rejected + +- **B6 (unbudgeted tick)** and **B4 (vacuous criterion 4)** were already fixed by round 2 + (scan deadline; catch moved to enclose `responseStateSweepDirectories()`). +- **B3** misreads the dry-run hazard in the opposite direction from my own note; round 2 + supersedes both by replacing injected-IO trickery with an explicit `dryRun` mode. +- **"The stack is too small to justify splitting."** Rejected with reason: phase 1 is a + correctness fix that every user needs and is mergeable alone; phase 2 adds a CLI surface + plus docs and carries its own review risk. Landing the correctness fix without waiting + on CLI review is the point. diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 55bb4f292a..61aad5f7b9 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -35,7 +35,7 @@ import { listLiveOAuthAccountKeys, reconcileOAuthReauthState } from "../oauth/st import { reconcileGuardianBackoff } from "../oauth/token-guardian"; import { sweepExpiredApiKeyCooldowns } from "../providers/key-failover"; import { reconcileProviderRequestPacing } from "../providers/request-pacing"; -import { sweepExpiredResponseStates } from "../responses/state"; +import { sweepAbandonedResponseStateTemps, sweepExpiredResponseStates } from "../responses/state"; import { sweepExpiredAntigravityReplay } from "../adapters/google-antigravity-replay"; import { reconcileProviderAccountQuotaRows } from "../providers/quota"; import { reconcileRouterWarningMemos } from "../router"; @@ -84,7 +84,13 @@ export const STATE_STORE_REGISTRATIONS = [ }, { name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth }, { name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts }, - { name: "responses-continuation", sweepExpired: sweepExpiredResponseStates }, + { + name: "responses-continuation", + sweepExpired: sweepExpiredResponseStates, + // Disk reclaim rides the liveness tick, not the TTL tick: sweepExpiredOnWrite puts + // sweepExpired on hot write paths, where a directory scan does not belong. + sweepLiveness: sweepAbandonedResponseStateTemps, + }, { name: "antigravity-replay", sweepExpired: sweepExpiredAntigravityReplay }, { name: "config-warning-memos", reconcileGeneration: (context: GenerationContext) => reconcileConfigWarningMemos(context.generation) }, { name: "catalog-warning-memos", reconcileGeneration: (context: GenerationContext) => reconcileCatalogWarningMemos(context.generation) }, diff --git a/src/responses/state.ts b/src/responses/state.ts index a08ed1012e..a31da4c67d 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1,4 +1,5 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs"; +import { uptime } from "node:os"; import { dirname, join } from "node:path"; import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config"; import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory"; @@ -31,6 +32,17 @@ const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024; const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000; const STALE_TEMP_MAX_ENTRIES = 4_096; const STALE_TEMP_MAX_CLEANUPS = 512; +/** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin: + * the unconditional 15-minute grace above is (see the boot floor in the scan loop). */ +const BOOT_FLOOR_SKEW_MS = 60 * 1_000; +/** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the + * periodic pass runs synchronously on the serving process's event loop every 60 s. */ +const PERIODIC_TEMP_MAX_ENTRIES = 512; +const PERIODIC_TEMP_MAX_CLEANUPS = 64; +/** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a + * network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight + * streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */ +const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25; const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/; const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4; @@ -498,6 +510,8 @@ export interface ResponseStateTempRecoveryResult { interface ResponseStateTempRecoveryIO { now: () => number; + /** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */ + bootTime: () => number; list: (dir: string) => Iterable; inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number }; isProcessAlive: (pid: number) => boolean; @@ -507,6 +521,8 @@ interface ResponseStateTempRecoveryIO { type ResponseStateTempRecoveryOptions = Partial & { maxEntries?: number; maxCleanups?: number; + /** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */ + deadlineMs?: number | null; }; function processIsAlive(pid: number): boolean { @@ -523,6 +539,7 @@ function processIsAlive(pid: number): boolean { const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = { now: Date.now, + bootTime: () => Date.now() - uptime() * 1_000, list: function* list(dir) { const handle = opendirSync(dir); try { @@ -549,7 +566,12 @@ export function recoverStaleResponseStateTemps( dir = getConfigDir(), options: ResponseStateTempRecoveryOptions = {}, ): ResponseStateTempRecoveryResult { - const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options; + const { + maxEntries = STALE_TEMP_MAX_ENTRIES, + maxCleanups = STALE_TEMP_MAX_CLEANUPS, + deadlineMs = null, + ...overrides + } = options; const io = { ...responseStateTempRecoveryIO, ...overrides }; const result: ResponseStateTempRecoveryResult = { matched: 0, @@ -557,6 +579,13 @@ export function recoverStaleResponseStateTemps( failed: 0, bytesRemoved: 0, }; + const startedAt = io.now(); + // One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and + // clamping it to "now" would be the WORST response: the floor would then retire the liveness + // probe for every file older than the skew, which is every file past the grace. Disable it + // instead -- an absent floor only costs a missed reclaim, never a wrong one. + const rawBoot = io.bootTime(); + const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY; let names: Iterable; try { names = io.list(dir); } catch { return result; } let iterator: Iterator; @@ -569,6 +598,7 @@ export function recoverStaleResponseStateTemps( const name = next.value; scanned += 1; if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break; + if (deadlineMs !== null && io.now() - startedAt > deadlineMs) break; const match = RESPONSE_STATE_TEMP_NAME.exec(name); if (!match) continue; result.matched += 1; @@ -579,13 +609,30 @@ export function recoverStaleResponseStateTemps( let file: ReturnType; try { file = io.inspect(path); } catch { continue; } if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue; - if (pid === process.pid || io.isProcessAlive(pid)) continue; + // Boot floor. After a reboot the original writer's pid is routinely reused, which makes + // the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never + // expires it, so the file is skipped on every future pass forever. A temp older than + // this boot cannot be owned by the pid we would probe, so the probe is vacuous and we + // retire it. This does NOT claim the file is provably dead: under a shared-volume + // container, suspend-excluding uptime, or a network config dir the computed boot can + // land after the real one. The unconditional 15-minute grace above remains the safety + // floor, and this process's own temps are never touched. + const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS; + if (pid === process.pid) continue; + if (!predatesBoot && io.isProcessAlive(pid)) continue; try { io.unlink(path); result.removed += 1; result.bytesRemoved += file.size; - } catch { + } catch (error) { + // Another proxy sharing this config dir may have won the race. A file that is already + // gone is reclaimed, not a failure -- reporting it as one would surface "in use or + // locked" to an operator for a file nobody holds. + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + result.removed += 1; + continue; + } // Locked files remain for a later startup. Do not truncate by path: a same-user // replacement could turn that fallback into an arbitrary symlink-target write. result.failed += 1; @@ -594,6 +641,23 @@ export function recoverStaleResponseStateTemps( return result; } +/** + * Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside + * the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in + * the link's real directory where a scan of the literal dir would never see them. The two + * collapse to one when nothing is symlinked. + */ +function responseStateSweepDirectories(): Set { + const path = snapshotPath(); + let resolvedDir = dirname(path); + try { + resolvedDir = dirname(resolveWriteTarget(path)); + } catch { + /* unresolvable link: sweep the literal dir only */ + } + return new Set([dirname(path), resolvedDir]); +} + /** * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next @@ -898,6 +962,49 @@ export function sweepExpiredResponseStates(at = now()): number { return removed; } +/** + * Periodic disk reclaim for abandoned atomic-write temps. + * + * `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything: + * every `schedulePersist` site is downstream of it. So a process that abandons a temp has + * already had its only look, the 15-minute grace hides the temp its predecessor's crash + * just produced, and `maxCleanups` caps a single pass below a large backlog. A restart + * loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all + * three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate. + * + * Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts + * `sweepExpired` on hot write paths, and a directory scan does not belong there. + */ +export function reclaimAbandonedResponseStateTemps( + options: ResponseStateTempRecoveryOptions = {}, +): ResponseStateTempRecoveryResult { + const total: ResponseStateTempRecoveryResult = { matched: 0, removed: 0, failed: 0, bytesRemoved: 0 }; + // The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps + // already swallows its own enumeration failures, so a catch around only that call would be + // unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw. + try { + for (const dir of responseStateSweepDirectories()) { + const result = recoverStaleResponseStateTemps(dir, options); + total.matched += result.matched; + total.removed += result.removed; + total.failed += result.failed; + total.bytesRemoved += result.bytesRemoved; + } + } catch { + /* best-effort: disk reclaim must never destabilize the caller */ + } + return total; +} + +/** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */ +export function sweepAbandonedResponseStateTemps(): number { + return reclaimAbandonedResponseStateTemps({ + maxEntries: PERIODIC_TEMP_MAX_ENTRIES, + maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS, + deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS, + }).removed; +} + export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot { return { count: states.size, diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 3084c44858..95a88fb935 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -36,6 +36,7 @@ import { previousResponseScopeMismatch, recoverStaleResponseStateTemps, rememberResponseState, + sweepAbandonedResponseStateTemps, responseAdmissionCountersForTests, responseStateMetrics, responseStatePersistPendingForTests, @@ -1521,6 +1522,10 @@ describe("Responses previous_response_id state", () => { const result = recoverStaleResponseStateTemps(home, { isProcessAlive: pid => pid === 5252, + // Pin the boot floor out of this case: it ages fixtures by exactly 60 minutes, so on a + // host booted more recently (a normal CI runner) the floor would retire the liveness + // probe and reclaim `live` too. The floor has its own tests below. + bootTime: () => 0, }); expect(result).toMatchObject({ matched: 5, removed: 1, failed: 0 }); @@ -1575,6 +1580,7 @@ describe("Responses previous_response_id state", () => { const result = recoverStaleResponseStateTemps(home, { isProcessAlive: () => false, unlink: () => { throw new Error("locked"); }, + bootTime: () => 0, }); expect(result).toMatchObject({ matched: 1, removed: 0, failed: 1, bytesRemoved: 0 }); @@ -1620,6 +1626,127 @@ describe("Responses previous_response_id state", () => { expect(result).toEqual({ matched: 0, removed: 0, failed: 0, bytesRemoved: 0 }); }); + test("periodic reclaim frees abandoned temps without any continuation access", () => { + // The defect this fixes: the reclaim ran only from ensureLoaded, which every + // schedulePersist site sits downstream of, so a process had its only look BEFORE it + // wrote anything. Here nothing touches the continuation store at all. + const old = new Date(Date.now() - 60 * 60 * 1_000); + const deadPid = process.pid === 4242 ? 4243 : 4242; + const stale = join(home, `responses-state.json.ocx.${deadPid}.1.tmp`); + const young = join(home, "responses-state.json.ocx.6262.4.tmp"); + for (const path of [stale, young]) writeFileSync(path, "private state"); + utimesSync(stale, old, old); + + const removed = sweepAbandonedResponseStateTemps(); + + expect(removed).toBe(1); + expect(existsSync(stale)).toBe(false); + expect(existsSync(young)).toBe(true); + }); + + test("boot floor reclaims a pre-boot temp whose pid has been reused", () => { + // Without the floor this file is immortal: the liveness probe matches a recycled pid + // and the 15-minute grace is a lower bound that never expires the skip. + const old = new Date(Date.now() - 60 * 60 * 1_000); + const path = join(home, "responses-state.json.ocx.9101.1.tmp"); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + + const result = recoverStaleResponseStateTemps(home, { + isProcessAlive: () => true, + bootTime: () => Date.now() - 30 * 60 * 1_000, + }); + + expect(result).toMatchObject({ matched: 1, removed: 1, failed: 0 }); + expect(existsSync(path)).toBe(false); + }); + + test("the 15-minute grace outranks the boot floor", () => { + // A temp written after boot but younger than the grace must survive even though the + // floor would otherwise retire its liveness probe. This ordering is the safety argument. + const path = join(home, "responses-state.json.ocx.9102.1.tmp"); + writeFileSync(path, "private state"); + + const result = recoverStaleResponseStateTemps(home, { + isProcessAlive: () => true, + bootTime: () => Date.now() - 24 * 60 * 60 * 1_000, + }); + + expect(result).toMatchObject({ matched: 1, removed: 0, failed: 0 }); + expect(existsSync(path)).toBe(true); + }); + + test("this process's own temps are never reclaimed, even before boot", () => { + const old = new Date(Date.now() - 60 * 60 * 1_000); + const path = join(home, `responses-state.json.ocx.${process.pid}.1.tmp`); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + + const result = recoverStaleResponseStateTemps(home, { + isProcessAlive: () => false, + bootTime: () => Date.now(), + }); + + expect(result).toMatchObject({ matched: 1, removed: 0, failed: 0 }); + expect(existsSync(path)).toBe(true); + }); + + test("a future or non-finite boot time disables the floor instead of trusting it", () => { + const old = new Date(Date.now() - 60 * 60 * 1_000); + const path = join(home, "responses-state.json.ocx.9103.1.tmp"); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + + for (const bootTime of [() => Date.now() + 60 * 60 * 1_000, () => Number.NaN]) { + const result = recoverStaleResponseStateTemps(home, { isProcessAlive: () => true, bootTime }); + expect(result).toMatchObject({ matched: 1, removed: 0, failed: 0 }); + expect(existsSync(path)).toBe(true); + } + }); + + test("a temp another process already removed counts as reclaimed, not failed", () => { + // Two proxies sharing one config dir race every tick. Reporting the loser's ENOENT as a + // failure would tell an operator a file is "in use or locked" when nobody holds it. + const old = new Date(Date.now() - 60 * 60 * 1_000); + const path = join(home, "responses-state.json.ocx.9104.1.tmp"); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + + const result = recoverStaleResponseStateTemps(home, { + isProcessAlive: () => false, + bootTime: () => 0, + unlink: () => { + const error = new Error("gone") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }, + }); + + expect(result).toMatchObject({ matched: 1, removed: 1, failed: 0 }); + }); + + test("the periodic scan stops at its wall-clock deadline", () => { + const old = new Date(Date.now() - 60 * 60 * 1_000); + const names = ["responses-state.json.ocx.9201.1.tmp", "responses-state.json.ocx.9202.2.tmp"]; + for (const name of names) { + const path = join(home, name); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + } + // Clock jumps past the deadline on the first in-loop read. + let ticks = 0; + const result = recoverStaleResponseStateTemps(home, { + list: () => names, + isProcessAlive: () => false, + bootTime: () => 0, + now: () => (ticks++ === 0 ? 0 : 10_000), + deadlineMs: 25, + }); + + expect(result.removed).toBe(0); + for (const name of names) expect(existsSync(join(home, name))).toBe(true); + }); + test("v1 Cursor snapshot migrates to versioned provider state", () => { mkdirSync(home, { recursive: true }); writeFileSync(join(home, "responses-state.json"), JSON.stringify({ diff --git a/tests/state-store-sweeper.test.ts b/tests/state-store-sweeper.test.ts index 8b9ac2f10d..ea2d473b27 100644 --- a/tests/state-store-sweeper.test.ts +++ b/tests/state-store-sweeper.test.ts @@ -64,7 +64,17 @@ function context( }; } +// The responses-continuation store now reclaims abandoned atomic-write temps on the liveness +// tick, so any test that drives a real tick performs filesystem work under OPENCODEX_HOME. +// Without this isolation the suite would scan (and could unlink inside) a developer's real +// ~/.opencodex as a side effect of a unit test. +let sweeperHome: string; +let previousSweeperHome: string | undefined; + beforeEach(() => { + previousSweeperHome = process.env.OPENCODEX_HOME; + sweeperHome = mkdtempSync(join(tmpdir(), "ocx-sweeper-home-")); + process.env.OPENCODEX_HOME = sweeperHome; resetStateStoreSweeperForTests(); resetAppOwnedMemoryForTests(); clearResponseStateMemoryForTests(); @@ -77,6 +87,9 @@ afterEach(() => { __resetAntigravityReplayCache(); setOcxStartProcessCacheForTests([]); setOcxStartProcessProbeForTests(null); + if (previousSweeperHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousSweeperHome; + rmSync(sweeperHome, { recursive: true, force: true }); }); describe("state-store sweeper", () => { @@ -151,10 +164,17 @@ describe("state-store sweeper", () => { sweepExpired(123); sweepLiveness(); - expect(visits).toEqual(STATE_STORE_REGISTRATIONS.flatMap(registration => [ - ...(registration.sweepExpired ? [`${registration.name}:ttl:123`] : []), - ...(registration.sweepLiveness ? [`${registration.name}:liveness`] : []), - ])); + // Two separate passes over the table, not one interleaved pass: sweepExpired visits every + // TTL owner, then sweepLiveness visits every liveness owner. The previous per-registration + // flatMap only matched because the single liveness owner happened to sit last in the table. + expect(visits).toEqual([ + ...STATE_STORE_REGISTRATIONS.flatMap(registration => ( + registration.sweepExpired ? [`${registration.name}:ttl:123`] : [] + )), + ...STATE_STORE_REGISTRATIONS.flatMap(registration => ( + registration.sweepLiveness ? [`${registration.name}:liveness`] : [] + )), + ]); }); test("expiry boundary removes expired rows and preserves live rows", () => { From 5682c62ee427fb9d12be6ca5d2b88f81b3d55700 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 18:39:35 -0700 Subject: [PATCH 012/121] docs(devlog): capture the OpenRouter Fast lower-bound rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot evidence for lidge-jun/opencodex#2080: the Logs table showing a confirmed-priority OpenRouter request rendered as a floor (≥$) next to a response-declined downgrade and a standard request (~$). Taking this screenshot is what surfaced two defects the test suites missed: the lower-bound marker disagreed with the parallel xAI unit's rendering, and the fix for it initially reached only the detail panel because the table cell had its own inline formatter. Both paths now share one implementation. Co-Authored-By: Claude Fable 5 --- ...0_logs_openrouter_priority_lower_bound.png | Bin 0 -> 164931 bytes .../evidence/README.md | 16 ++++++++++++++++ gui/src/pages/Logs.tsx | 11 +---------- gui/src/pages/logs-cost-format.ts | 17 ++++++++++++++++- gui/tests/logs-priority-lower-bound.test.ts | 18 ++++++++++++++++-- 5 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png create mode 100644 devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md diff --git a/devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/010_logs_openrouter_priority_lower_bound.png new file mode 100644 index 0000000000000000000000000000000000000000..5ee905fea5fd3718c8bf6b6fac93241cc936e90a GIT binary patch literal 164931 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

yi@puXP{<+%cnKY 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 literal 0 HcmV?d00001 diff --git a/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md new file mode 100644 index 0000000000..a713c0f453 --- /dev/null +++ b/devlog/_plan/260818_fastwire_b2_openrouter/evidence/README.md @@ -0,0 +1,16 @@ +# Evidence — FastWire B2 (OpenRouter) + +`010_logs_openrouter_priority_lower_bound.png` — Logs table with three seeded OpenRouter +requests on `openai/gpt-5.6-sol`, captured against a local proxy with a temporary +`OPENCODEX_HOME`: + +| Row | Attempt outcome | Rendering | +| --- | --- | --- | +| `or-priority-confirmed` | upstream echoed `service_tier: "priority"` | `≥$0.1105` — priced at the standard rate but marked a floor, because OpenRouter publishes no bundled tier price and documents priority as higher cost | +| `or-priority-declined` | upstream echoed `service_tier: "default"` | `~$0.1105` — a real downgrade, so no floor marker | +| `or-standard` | no tier requested | `~$0.1105` | + +All three totals match on purpose: without a bundled priority price every row is computed at +the standard rate, so the only thing that differs is whether the cost is presented as an +estimate (`~$`) or as a known lower bound (`≥$`). The marker matches the convention used by +the parallel xAI unit (#2072). diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index fef77bc9ac..62490437eb 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -17,7 +17,7 @@ import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; import { modelTitle } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; -import { formatEstimatedUsdValue } from "./logs-cost-format"; +import { formatEstimatedUsd, formatEstimatedUsdValue } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; @@ -240,15 +240,6 @@ function formatTokPerSecond(result: TokPerSecondResult | undefined, localeTag?: return `${result.estimated ? "~" : ""}${value}`; } -function formatEstimatedUsd(result: CostResult | undefined, localeTag?: string): string { - if (!result || result.kind === "unavailable" || !Number.isFinite(result.estimate.cost.total) || result.estimate.cost.total < 0) return "\u2014"; - const totalUsd = result.estimate.cost.total; - return `${result.estimate.priorityLowerBound ? "≥" : ""}~$${new Intl.NumberFormat(localeTag, { - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }).format(totalUsd)}`; -} - /** Consecutive failed polls before a stale table is called out. Two seconds each, so ~6s. */ const STALE_POLL_FAILURE_LIMIT = 3; diff --git a/gui/src/pages/logs-cost-format.ts b/gui/src/pages/logs-cost-format.ts index 0997737896..ef09e5741b 100644 --- a/gui/src/pages/logs-cost-format.ts +++ b/gui/src/pages/logs-cost-format.ts @@ -4,8 +4,23 @@ export function formatEstimatedUsdValue( priorityLowerBound = false, ): string { if (!Number.isFinite(value) || value < 0) return "\u2014"; - return `${priorityLowerBound ? "≥" : ""}~$${new Intl.NumberFormat(localeTag, { + return `${priorityLowerBound ? "≥$" : "~$"}${new Intl.NumberFormat(localeTag, { minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(value)}`; } + +export function formatEstimatedUsd( + result: { + kind: "value"; + estimate: { cost: { total: number }; priorityLowerBound?: boolean }; + } | { kind: "unavailable" } | undefined, + localeTag?: string, +): string { + if (!result || result.kind === "unavailable") return "\u2014"; + return formatEstimatedUsdValue( + result.estimate.cost.total, + localeTag, + result.estimate.priorityLowerBound, + ); +} diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts index 6117bcfdd3..a22d23f16e 100644 --- a/gui/tests/logs-priority-lower-bound.test.ts +++ b/gui/tests/logs-priority-lower-bound.test.ts @@ -1,12 +1,26 @@ import { describe, expect, test } from "bun:test"; -import { formatEstimatedUsdValue } from "../src/pages/logs-cost-format"; +import { formatEstimatedUsd, formatEstimatedUsdValue } from "../src/pages/logs-cost-format"; describe("Logs priority lower-bound formatting", () => { test("prefixes confirmed unpriced priority estimates with the lower-bound marker", () => { - expect(formatEstimatedUsdValue(1.6, "en-US", true)).toBe("≥~$1.6000"); + expect(formatEstimatedUsdValue(1.6, "en-US", true)).toBe("≥$1.6000"); }); test("keeps ordinary standard-price estimates unchanged", () => { expect(formatEstimatedUsdValue(1.6, "en-US", false)).toBe("~$1.6000"); }); }); + +describe("Logs table cost formatting", () => { + test("uses the shared value formatter for lower bounds, ordinary estimates, and unavailable costs", () => { + expect(formatEstimatedUsd({ + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, "en-US")).toBe("≥$1.6000"); + expect(formatEstimatedUsd({ + kind: "value", + estimate: { cost: { total: 1.6 } }, + }, "en-US")).toBe("~$1.6000"); + expect(formatEstimatedUsd({ kind: "unavailable" }, "en-US")).toBe("—"); + }); +}); From 816024c954fae882fec4133bbcc70812c6e54382 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 11:06:39 +0900 Subject: [PATCH 013/121] docs(devlog): record phase 1 verification evidence --- .../012_phase1_verification.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/012_phase1_verification.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/012_phase1_verification.md b/devlog/_plan/260819_response_state_temp_reclaim/012_phase1_verification.md new file mode 100644 index 0000000000..c7ec1d8383 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/012_phase1_verification.md @@ -0,0 +1,47 @@ +# Phase 1 verification + +Full suite executed on `macmini-cf` (CPU-heavy work does not run on the workstation), in a +dedicated worktree at `/tmp/ocx-reclaim` checked out to `48b0c2a70`. + +## Focused suites — green + +`bun test tests/responses-state.test.ts tests/state-store-sweeper.test.ts` +→ **125 pass, 0 fail**, 349 assertions. + +## Full suite — 13296 pass, 8 fail, all pre-existing + +`bun run test` → `Ran 13316 tests across 850 files [478.53s]`, 13296 pass / 8 fail. + +The 8 failures are two environmental classes, neither touched by this change: + +1. **`update-npm-cache-preflight` (1).** `runNpmCachePreflight` returns + `npm_config_failed` instead of `cache_accessible`. Proven pre-existing by checking the + worktree out to the UNMODIFIED base `59964ad77` and re-running that file: **10 pass, + 1 fail** — identical. It depends on a working `npm config` on the host. +2. **GUI module loads (7).** `Cannot find package 'react'` / + `Cannot find module 'react/jsx-dev-runtime'` from `gui/src/...`. That box has no + `gui/node_modules`; only the root workspace was installed. + +## Local checks + +- `bun run typecheck` (`bun x tsc --noEmit`) — clean. +- `bun test tests/repo-hygiene.test.ts` — 11 pass. +- `bun run privacy:scan` — passed. +- `bun test tests/core-lab-boundary.test.ts` — 13 pass (registration touches a + Lab-protected import path, so this was re-verified rather than assumed). + +## Defect found by the new tests + +The first draft clamped an anomalous boot time with `Math.min(rawBoot, now)`. The +future/non-finite test failed immediately: clamping to "now" makes the floor MAXIMALLY +aggressive — every file past the 15-minute grace would have its liveness probe retired. +Corrected to disable the floor outright when the value is not finite or is in the future. +An absent floor costs a missed reclaim; a wrong floor costs a live file. + +## Correction to audit round 2 + +Round 2 predicted the "global fake-clock sweep" assertion in +`tests/state-store-sweeper.test.ts` would NOT change. It did. Its per-registration +`flatMap` interleaved `:ttl` and `:liveness` per store, which only matched observed order +while the single liveness owner sat last in the table. `sweepExpired()` and +`sweepLiveness()` are two separate passes, so the expectation is now built as two passes. From eceaf0b6e0f3597bbe10643a50a67734c5fe5e22 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Wed, 19 Aug 2026 09:11:53 +0700 Subject: [PATCH 014/121] fix(admission): resolve the per-model window the way the catalog does resolveInputCeiling read modelContextWindows and modelMaxInputTokens with a bare lookup, while the catalog resolves the same two maps through modelRecordValue, which also accepts a family entry for a tagged id. With contextWindow 8_000 and modelContextWindows {"gpt-oss": 131_072}: catalog advertises 131_072 provider-fetch.ts:612 admission ceiling 8_000 before this change So the gate refused turns the model can plainly hold, using a window that belongs to a different model. That is the opposite of what this module documents about itself -- "every uncertainty resolves toward admitting". modelMaxInputTokens had the mirror of it: a family cap never applied to the tagged sibling it was written for. Three tests, all red without the src change and green with it. The first asserts the catalog's value first so the two can never drift apart again. No behavior changes for ids that already resolved exactly. --- src/server/responses/input-admission.ts | 9 ++++-- tests/input-admission.test.ts | 37 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index 219c3b6f89..a2a06e5b73 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -13,6 +13,7 @@ import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata"; import { estimateTokens } from "../../lib/token-estimate"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { modelRecordValue } from "../../reasoning-effort"; import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types"; /** @@ -133,7 +134,11 @@ export function resolveInputCeiling( // config here so this stays pure: no filesystem, no catalog, no registry scan. nativeContextCap?: NativeContextLimitsInput, ): number | null { - const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow); + // `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that + // way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall + // back to the provider-wide window and refuse turns the model can plainly hold. + const configured = positive(modelRecordValue(provider.modelContextWindows, modelId)) + ?? positive(provider.contextWindow); // The canonical `openai` registry entry declares no context fields, so without this the // gate would be inert on the default Codex route. All three clauses are load-bearing: a @@ -156,7 +161,7 @@ export function resolveInputCeiling( const window = canonicalNativeBare ? native : configured; // modelMaxInputTokens is an input-only cap, so it can only tighten the window. - const configuredMaxInput = positive(provider.modelMaxInputTokens?.[modelId]); + const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId)); const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null); return limits.length === 0 ? null : Math.min(...limits); } diff --git a/tests/input-admission.test.ts b/tests/input-admission.test.ts index 44422d502c..d97e0fe82e 100644 --- a/tests/input-admission.test.ts +++ b/tests/input-admission.test.ts @@ -5,6 +5,7 @@ import { estimateInputTokens, resolveInputCeiling, } from "../src/server/responses/input-admission"; +import { modelRecordValue } from "../src/reasoning-effort"; import type { OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../src/types"; const CANONICAL_NATIVE: OcxProviderConfig = { @@ -91,6 +92,42 @@ describe("resolveInputCeiling", () => { const pinned: OcxProviderConfig = { ...CANONICAL_NATIVE, modelContextWindows: { "gpt-5.6-sol": 50_000 } }; expect(resolveInputCeiling(pinned, "openai", "gpt-5.6-sol")).toBe(50_000); }); + + test("a family entry covers its tagged siblings, like the catalog", () => { + // The catalog resolves modelContextWindows through modelRecordValue, so it advertises + // 131_072 for gpt-oss:120b off this config. A bare lookup here resolved nothing and + // fell back to contextWindow, leaving the gate refusing turns the model can hold. + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + contextWindow: 8_000, + modelContextWindows: { "gpt-oss": 131_072 }, + }; + expect(modelRecordValue(provider.modelContextWindows, "gpt-oss:120b")).toBe(131_072); + expect(resolveInputCeiling(provider, "custom", "gpt-oss:120b")).toBe(131_072); + // An id with no tag and no entry still falls back to the provider-wide window. + expect(resolveInputCeiling(provider, "custom", "other")).toBe(8_000); + }); + + test("an exact entry still beats the family entry", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelContextWindows: { "gpt-oss": 131_072, "gpt-oss:20b": 32_000 }, + }; + expect(resolveInputCeiling(provider, "custom", "gpt-oss:20b")).toBe(32_000); + expect(resolveInputCeiling(provider, "custom", "gpt-oss:120b")).toBe(131_072); + }); + + test("a family modelMaxInputTokens tightens its tagged siblings", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelContextWindows: { "gpt-oss": 131_072 }, + modelMaxInputTokens: { "gpt-oss": 40_000 }, + }; + expect(resolveInputCeiling(provider, "custom", "gpt-oss:120b")).toBe(40_000); + }); }); describe("estimateInputTokens", () => { From 71ed29de91ccf034804781bc648df7cbc1162f4a Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Wed, 19 Aug 2026 09:16:19 +0700 Subject: [PATCH 015/121] fix(cli): report model capabilities the way the runtime resolves them `ocx models` classified each row with bare lookups while the proxy resolves the same four fields through modelInList / modelRecordValue, which accept a family entry for a tagged id. With models ["gpt-oss:120b"], noVisionModels ["gpt-oss"], modelContextWindows {"gpt-oss": 131072} and modelReasoningEfforts {"gpt-oss": ["low","high"]}: runtime isModelTextOnly = true, window 131072, efforts [low, high] ocx models {"contextWindow":null,"inputModalities":null, "reasoningEfforts":null} Every field came back unclassified, so a text-only model reads as image-capable and a configured window reads as unset -- for a config the proxy honours in full. Two tests. The first asserts isModelTextOnly first, so the command is pinned to the runtime's answer rather than to a copy of it; it is red without the src change. The second pins exact-over-family precedence and passes either way -- it guards the fix from over-reaching, it is not evidence of the bug. 237 tests green across cli-models, vision-eligibility, codex-catalog and input-admission. tsc --noEmit clean. --- src/cli/models.ts | 16 ++++++---- tests/cli-models.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index db4f20742d..59632e8e07 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; -import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort"; +import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec"; import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; -import type { OcxConfig, OcxCustomModel } from "../types"; +import { modelInList, type OcxConfig, type OcxCustomModel } from "../types"; const ADD_USAGE = "Usage: ocx models add [--display-name ] [--context-window ] [--modalities text,image,audio] [--reasoning-efforts ] [--default-reasoning-effort ]"; const REMOVE_USAGE = "Usage: ocx models remove [--yes]"; @@ -98,15 +98,19 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] if (seen.has(model)) return; seen.add(model); - const noVision = prov.noVisionModels?.includes(model); - const modalities = inputModalities[model] ?? (noVision ? ["text"] : null); - const efforts = reasoningEfforts[model] ?? prov.reasoningEfforts ?? null; + // Resolve exactly as the runtime does, or this command reports capabilities the + // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList + // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers + // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field. + const noVision = modelInList(prov.noVisionModels, model); + const modalities = modelRecordValue(inputModalities, model) ?? (noVision ? ["text"] : null); + const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null; entries.push({ provider: provName, model, isDefault, - contextWindow: contextWindows[model] ?? globalContext, + contextWindow: modelRecordValue(contextWindows, model) ?? globalContext, inputModalities: modalities, reasoningEfforts: efforts, }); diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 2c62326e71..0ad1a18719 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "./helpers/test-budget"; +import { isModelTextOnly } from "../src/vision"; +import type { OcxProviderConfig } from "../src/types"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -165,6 +167,67 @@ describe("ocx models richer metadata", () => { } }); + test("a family entry classifies its tagged siblings, as the runtime does", () => { + // isModelTextOnly matches noVisionModels with modelInList and reads + // modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers + // `gpt-oss:120b`. This command must not report a different answer. + const dir = mkdtempSync(join(tmpdir(), "ocx-models-family-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:120b", + models: ["gpt-oss:120b"], + modelContextWindows: { "gpt-oss": 131000 }, + noVisionModels: ["gpt-oss"], + modelReasoningEfforts: { "gpt-oss": ["low", "high"] }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10121, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + // Ground truth first: what the proxy itself will do with this config. + expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true); + + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:120b"); + expect(row.inputModalities).toEqual(["text"]); + expect(row.contextWindow).toBe(131000); + expect(row.reasoningEfforts).toEqual(["low", "high"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an exact entry still wins over the family entry", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-models-exact-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:20b", + models: ["gpt-oss:20b"], + modelContextWindows: { "gpt-oss": 131000, "gpt-oss:20b": 32000 }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10122, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:20b"); + expect(row.contextWindow).toBe(32000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("models rejects unknown flags", () => { const { dir } = freshConfig(); try { From 63bfd149dd04ffbe448f43004a17e371a514eb38 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 19 Aug 2026 12:39:23 +0900 Subject: [PATCH 016/121] docs(governance): move @Wibias to former maintainers (#2098) @Wibias stepped down from developing opencodex, and repository permission was reduced to read access. Move him out of the current-maintainers table into a new Former maintainers section, drop him from the CODEOWNERS default-reviewer line and the four high-impact runtime paths, and record the change with the 2026-07-27 addition entry it closes. Nothing he authored is unwound: commits, merged pull requests, release-note attributions, and the code comments citing his reviews stay as they are. --- .github/CODEOWNERS | 10 +++++----- MAINTAINERS.md | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 71f14c14a9..ae2c27bce7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1,11 @@ # Default reviewers -* @lidge-jun @Ingwannu @Wibias +* @lidge-jun @Ingwannu # High-impact runtime behavior -/src/adapters/ @lidge-jun @Ingwannu @Wibias -/src/providers/ @lidge-jun @Ingwannu @Wibias -/src/codex/ @lidge-jun @Ingwannu @Wibias -/src/server/ @lidge-jun @Ingwannu @Wibias +/src/adapters/ @lidge-jun @Ingwannu +/src/providers/ @lidge-jun @Ingwannu +/src/codex/ @lidge-jun @Ingwannu +/src/server/ @lidge-jun @Ingwannu # Repository automation and release security /.github/ @lidge-jun @Ingwannu diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 3214adfdf1..43377c093d 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -9,7 +9,6 @@ review and merge policy. | --- | --- | --- | | [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | | [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | -| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` integration, and provider/CI maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. @@ -17,6 +16,16 @@ through GitHub repository settings. `dev` is the only integration line. The former `dev2-go` carry duty is retired; see [The retired `dev2-go` line](#the-retired-dev2-go-line). +## Former maintainers + +| GitHub account | Project role | Period | +| --- | --- | --- | +| [@Wibias](https://github.com/Wibias) | Maintainer | 2026-07-27 – 2026-08-19 | + +Former maintainers keep contributor standing and are welcome to open issues and pull requests like +anyone else. Authorship credit in git history, release notes, and code comments is not rewritten +when a maintainer steps down. + ## Review and merge policy - Pull requests target `dev`. It is the only integration line, and promotion to @@ -98,6 +107,24 @@ Adding or removing a maintainer requires: ### Change log +- 2026-08-19 — [@Wibias](https://github.com/Wibias) stepped down as a maintainer + and is now a contributor. This follows his own decision to stop developing + opencodex; it is not a disciplinary action, and it was made with the owner's + agreement (requirement 1). Requirement 2 does not apply to a maintainer's own + resignation, which needs no second maintainer to ratify it. Requirement 3 is + met by this file and `.github/CODEOWNERS`, where the default-reviewer line + and the four runtime paths that listed him (`/src/adapters/`, + `/src/providers/`, `/src/codex/`, `/src/server/`) drop back to the two + remaining maintainers. Repository permission was reduced to read access at + the same time, so the roster and the GitHub settings agree again. + + Nothing he authored is being unwound. His commits, the pull requests he + merged, the release-note attributions, and the code comments citing his + reviews stay exactly as they are, and the trust-lane gate derived from his + work in `.github/scripts/pr-sponsored-surface.cjs` keeps its attribution. + Returning to the maintainer table later would go through the same three + requirements that govern every addition. + - 2026-07-27 — [@Wibias](https://github.com/Wibias) added as a maintainer. Requirement 1 (agreement from the project owner) is met: the owner requested the addition. **Requirement 2 (review by another current maintainer) was @@ -105,10 +132,10 @@ Adding or removing a maintainer requires: carried the addition (`a2693c02`, `dc3a4ade`, `02bbd47a`) landed on `dev` as direct owner pushes with no associated pull request, so no second maintainer reviewed them. Requirement 3 is met by this file and `.github/CODEOWNERS`. - The addition is in effect regardless: @Wibias holds write access on the - repository and has been merging pull requests since 2026-07-26. This entry - records the gap rather than papering over it — a later maintainer change - should go through a reviewed pull request. + The addition took effect regardless: @Wibias held write access on the + repository and merged pull requests from 2026-07-26 until he stepped down on + 2026-08-19. This entry records the gap rather than papering over it — a later + maintainer change should go through a reviewed pull request. Scope covers issue and pull-request triage, `dev` integration, and provider/CI maintenance. (This entry originally also described carrying From d49538cbb1e73bfde4b6f6fa0b6f23541a23c78e Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 22:56:56 -0700 Subject: [PATCH 017/121] fix(gui): localize estimated cost totals --- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/Logs.tsx | 40 +++------ gui/src/pages/logs-cost-format.ts | 67 ++++++++++++-- gui/tests/logs-priority-lower-bound.test.ts | 97 +++++++++++++++++++-- 12 files changed, 188 insertions(+), 43 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c5f7cce3db..6b3483938b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -645,6 +645,9 @@ export const de: Record = { "logs.conversation.totals": "{requests} Anfragen · {tokens} Tokens · {cost}", "logs.conversation.scope": "Summen gelten nur für den aktuell geladenen Logs-Ring.", "logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Konversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a9ecab5e82..997fcdc806 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -678,6 +678,9 @@ export const en = { "logs.conversation.totals": "{requests} requests · {tokens} tokens · {cost}", "logs.conversation.scope": "Totals cover the currently loaded Logs ring only.", "logs.conversation.excluded": "({unpriced} unpriced, {unmetered} unmetered excluded from ~$)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index cf573fb9df..c9a3bb024c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -659,6 +659,9 @@ export const fr: Record = { "logs.conversation.totals": "{requests} requêtes · {tokens} jetons · {cost}", "logs.conversation.scope": "Les totaux couvrent uniquement le tampon circulaire des journaux actuellement chargé.", "logs.conversation.excluded": "({unpriced} sans tarif, {unmetered} sans mesure exclus du total en ~$)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ce63420d0c..572e8d94f9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -621,6 +621,9 @@ export const ja: Record = { "logs.conversation.totals": "{requests} 件 · {tokens} トークン · {cost}", "logs.conversation.scope": "合計は現在読み込まれている Logs リングのみです。", "logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "会話", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9e98124c77..9accfcf624 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -664,6 +664,9 @@ export const ko: Record = { "logs.conversation.totals": "{requests}건 요청 · {tokens} 토큰 · {cost}", "logs.conversation.scope": "합계는 현재 로드된 Logs 링만 포함합니다.", "logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "대화", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index cbeef58a72..e7f358db2d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -662,6 +662,9 @@ export const ru: Record = { "logs.conversation.totals": "{requests} запросов · {tokens} токенов · {cost}", "logs.conversation.scope": "Итоги только по загруженному кольцу Logs.", "logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Диалог", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7028838668..1accd38130 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -669,6 +669,9 @@ export const tr: Record = { "logs.conversation.totals": "{requests} istek · {tokens} jeton · {cost}", "logs.conversation.scope": "Toplamlar yalnızca yüklü günlükleri kapsar.", "logs.conversation.excluded": "({unpriced} fiyatlandırılmamış, {unmetered} ölçülmemiş hariç)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "Sohbet", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 838e134410..9eb218183a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -516,6 +516,9 @@ export const zhTW: Record = { "logs.conversation.totals": "{requests} 次請求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合計僅涵蓋目前已載入的 Logs 環形緩衝。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 筆無定價、{unmetered} 筆無計量)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "對話", "logs.badge.claude": "Claude", "logs.col.time": "時間", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5bd1cc024f..44b65d897a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -657,6 +657,9 @@ export const zh: Record = { "logs.conversation.totals": "{requests} 次请求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合计仅覆盖当前已加载的 Logs 环形缓冲。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)", + "logs.cost.approximate": "~{amount}", + "logs.cost.lowerBound": "≥{amount}", + "logs.cost.unavailable": "—", "logs.detail.conversation": "会话", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 62490437eb..55a7ef634d 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -17,7 +17,7 @@ import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; import { modelTitle } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; -import { formatEstimatedUsd, formatEstimatedUsdValue } from "./logs-cost-format"; +import { formatEstimatedUsd, formatEstimatedUsdValue, summarizeEstimatedCosts } from "./logs-cost-format"; import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; @@ -337,33 +337,14 @@ function summarizeFilteredLogs(entries: LogEntry[]): { unmeteredRequests: number; } { let totalTokens = 0; - let estimatedCostUsd = 0; - let priorityLowerBound = false; - let unpricedRequests = 0; - let unmeteredRequests = 0; for (const entry of entries) { const tokens = displayTokenTotal(entry); if (tokens !== undefined) totalTokens += tokens; - if (entry.usageStatus === "unsupported") { - unmeteredRequests += 1; - continue; - } - const cost = entry.displayMetrics?.cost; - const total = cost?.kind === "value" ? cost.estimate.cost.total : undefined; - if (total !== undefined && Number.isFinite(total) && total >= 0) { - estimatedCostUsd += total; - priorityLowerBound ||= cost?.kind === "value" && cost.estimate.priorityLowerBound === true; - continue; - } - unpricedRequests += 1; } return { requests: entries.length, totalTokens, - estimatedCostUsd, - priorityLowerBound, - unpricedRequests, - unmeteredRequests, + ...summarizeEstimatedCosts(entries), }; } @@ -611,6 +592,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { tokens: formatTokens(conversationTotals.totalTokens, localeTag ?? locale), cost: formatEstimatedUsdValue( conversationTotals.estimatedCostUsd, + t, localeTag, conversationTotals.priorityLowerBound, ), @@ -732,7 +714,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {formatTokPerSecond(log.displayMetrics?.tokPerSecond, localeTag)} - {formatEstimatedUsd(log.displayMetrics?.cost, localeTag)} + {formatEstimatedUsd(log.displayMetrics?.cost, t, localeTag)} @@ -957,11 +939,11 @@ function LogDetailDialog({ {cost?.kind === "value" ? ( <>

- {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, localeTag, cost.estimate.priorityLowerBound)} - {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.detail.costTotal")}{formatEstimatedUsdValue(cost.estimate.cost.total, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.input")}{formatEstimatedUsdValue(cost.estimate.cost.input, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheRead")}{formatEstimatedUsdValue(cost.estimate.cost.cacheRead, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.cacheWrite")}{formatEstimatedUsdValue(cost.estimate.cost.cacheWrite, t, localeTag, cost.estimate.priorityLowerBound)} + {t("logs.tokens.output")}{formatEstimatedUsdValue(cost.estimate.cost.output, t, localeTag, cost.estimate.priorityLowerBound)} {cost.estimate.price && ( <> {t("logs.detail.matchedKey")} @@ -979,7 +961,7 @@ function LogDetailDialog({ ) : (
- {t("logs.detail.costTotal")}{"\u2014"} + {t("logs.detail.costTotal")}{t("logs.cost.unavailable")} {t("logs.detail.unavailableReason")} {cost?.kind === "unavailable" ? t(metricReasonKey(cost.reason)) : t("logs.detail.reason.usage_missing")}
@@ -1034,7 +1016,7 @@ function LogDetailDialog({ {attempt.durationMs}ms {formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} - {formatEstimatedUsd(attemptCost, localeTag)} + {formatEstimatedUsd(attemptCost, t, localeTag)} {reason} ); diff --git a/gui/src/pages/logs-cost-format.ts b/gui/src/pages/logs-cost-format.ts index ef09e5741b..48a0814097 100644 --- a/gui/src/pages/logs-cost-format.ts +++ b/gui/src/pages/logs-cost-format.ts @@ -1,26 +1,77 @@ +import type { TFn } from "../i18n/shared"; + +type EstimatedCostResult = { + kind: "value"; + estimate: { cost: { total: number }; priorityLowerBound?: boolean }; +} | { kind: "unavailable" }; + export function formatEstimatedUsdValue( value: number, + t: TFn, localeTag?: string, priorityLowerBound = false, ): string { - if (!Number.isFinite(value) || value < 0) return "\u2014"; - return `${priorityLowerBound ? "≥$" : "~$"}${new Intl.NumberFormat(localeTag, { + if (!Number.isFinite(value) || value < 0) return t("logs.cost.unavailable"); + const amount = new Intl.NumberFormat(localeTag, { + style: "currency", + currency: "USD", minimumFractionDigits: 4, maximumFractionDigits: 4, - }).format(value)}`; + }).format(value); + return t(priorityLowerBound ? "logs.cost.lowerBound" : "logs.cost.approximate", { amount }); } export function formatEstimatedUsd( - result: { - kind: "value"; - estimate: { cost: { total: number }; priorityLowerBound?: boolean }; - } | { kind: "unavailable" } | undefined, + result: EstimatedCostResult | undefined, + t: TFn, localeTag?: string, ): string { - if (!result || result.kind === "unavailable") return "\u2014"; + if (!result || result.kind === "unavailable") return t("logs.cost.unavailable"); return formatEstimatedUsdValue( result.estimate.cost.total, + t, localeTag, result.estimate.priorityLowerBound, ); } + +interface CostSummaryEntry { + usageStatus?: string; + displayMetrics?: { cost: EstimatedCostResult }; +} + +export function summarizeEstimatedCosts(entries: readonly CostSummaryEntry[]): { + estimatedCostUsd: number; + priorityLowerBound: boolean; + unpricedRequests: number; + unmeteredRequests: number; +} { + let estimatedCostUsd = 0; + let everyPricedEstimateIsLowerBound = true; + let pricedEstimates = 0; + let unpricedRequests = 0; + let unmeteredRequests = 0; + for (const entry of entries) { + if (entry.usageStatus === "unsupported") { + unmeteredRequests += 1; + continue; + } + const cost = entry.displayMetrics?.cost; + if (cost?.kind === "value") { + const total = cost.estimate.cost.total; + if (Number.isFinite(total) && total >= 0) { + estimatedCostUsd += total; + pricedEstimates += 1; + everyPricedEstimateIsLowerBound &&= cost.estimate.priorityLowerBound === true; + continue; + } + } + unpricedRequests += 1; + } + return { + estimatedCostUsd, + priorityLowerBound: pricedEstimates > 0 && everyPricedEstimateIsLowerBound, + unpricedRequests, + unmeteredRequests, + }; +} diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts index a22d23f16e..bfd68bae3b 100644 --- a/gui/tests/logs-priority-lower-bound.test.ts +++ b/gui/tests/logs-priority-lower-bound.test.ts @@ -1,13 +1,30 @@ import { describe, expect, test } from "bun:test"; -import { formatEstimatedUsd, formatEstimatedUsdValue } from "../src/pages/logs-cost-format"; +import { DICTS } from "../src/i18n/catalogs"; +import { interpolate, type TFn } from "../src/i18n/shared"; +import { + formatEstimatedUsd, + formatEstimatedUsdValue, + summarizeEstimatedCosts, +} from "../src/pages/logs-cost-format"; + +function translator(locale: keyof typeof DICTS): TFn { + return (key, vars) => interpolate(DICTS[locale][key], vars); +} + +const en = translator("en"); +const de = translator("de"); describe("Logs priority lower-bound formatting", () => { test("prefixes confirmed unpriced priority estimates with the lower-bound marker", () => { - expect(formatEstimatedUsdValue(1.6, "en-US", true)).toBe("≥$1.6000"); + expect(formatEstimatedUsdValue(1.6, en, "en-US", true)).toBe("≥$1.6000"); }); test("keeps ordinary standard-price estimates unchanged", () => { - expect(formatEstimatedUsdValue(1.6, "en-US", false)).toBe("~$1.6000"); + expect(formatEstimatedUsdValue(1.6, en, "en-US", false)).toBe("~$1.6000"); + }); + + test("uses locale-aware USD placement and separators", () => { + expect(formatEstimatedUsdValue(1.6, de, "de-DE", false)).toBe("~1,6000\u00a0$"); }); }); @@ -16,11 +33,79 @@ describe("Logs table cost formatting", () => { expect(formatEstimatedUsd({ kind: "value", estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, - }, "en-US")).toBe("≥$1.6000"); + }, en, "en-US")).toBe("≥$1.6000"); expect(formatEstimatedUsd({ kind: "value", estimate: { cost: { total: 1.6 } }, - }, "en-US")).toBe("~$1.6000"); - expect(formatEstimatedUsd({ kind: "unavailable" }, "en-US")).toBe("—"); + }, en, "en-US")).toBe("~$1.6000"); + expect(formatEstimatedUsd({ kind: "unavailable" }, en, "en-US")).toBe("—"); + expect(formatEstimatedUsdValue(Number.NaN, en, "en-US")).toBe("—"); + }); +}); + +describe("Logs conversation cost aggregation", () => { + test("does not mark a mixed ordinary and lower-bound total as a lower bound", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, + }, + }, + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 0.4 } }, + }, + }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(2); + expect(summary.priorityLowerBound).toBe(false); + }); + + test("marks a total as a lower bound only when every priced estimate is one", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 1.6 }, priorityLowerBound: true }, + }, + }, + }, + { + usageStatus: "reported", + displayMetrics: { + cost: { + kind: "value", + estimate: { cost: { total: 0.4 }, priorityLowerBound: true }, + }, + }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(2); + expect(summary.priorityLowerBound).toBe(true); + }); + + test("does not mark an unpriced-only total as a lower bound", () => { + const summary = summarizeEstimatedCosts([ + { + usageStatus: "reported", + displayMetrics: { cost: { kind: "unavailable" } }, + }, + ]); + + expect(summary.estimatedCostUsd).toBe(0); + expect(summary.priorityLowerBound).toBe(false); + expect(summary.unpricedRequests).toBe(1); }); }); From 9cd3c1c084e4521fcb46717a1cc7c9d5bf189381 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 23:32:44 -0700 Subject: [PATCH 018/121] fix(gui): translate estimated cost labels --- gui/src/i18n/de.ts | 6 +++--- gui/src/i18n/fr.ts | 6 +++--- gui/src/i18n/ja.ts | 6 +++--- gui/src/i18n/ko.ts | 6 +++--- gui/src/i18n/ru.ts | 6 +++--- gui/src/i18n/tr.ts | 6 +++--- gui/src/i18n/zh-TW.ts | 6 +++--- gui/src/i18n/zh.ts | 6 +++--- gui/tests/logs-priority-lower-bound.test.ts | 2 +- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6b3483938b..113fc419d2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -645,9 +645,9 @@ export const de: Record = { "logs.conversation.totals": "{requests} Anfragen · {tokens} Tokens · {cost}", "logs.conversation.scope": "Summen gelten nur für den aktuell geladenen Logs-Ring.", "logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "ca. {amount}", + "logs.cost.lowerBound": "mind. {amount}", + "logs.cost.unavailable": "nicht verfügbar", "logs.detail.conversation": "Konversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c9a3bb024c..112fd7ad2a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -659,9 +659,9 @@ export const fr: Record = { "logs.conversation.totals": "{requests} requêtes · {tokens} jetons · {cost}", "logs.conversation.scope": "Les totaux couvrent uniquement le tampon circulaire des journaux actuellement chargé.", "logs.conversation.excluded": "({unpriced} sans tarif, {unmetered} sans mesure exclus du total en ~$)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "env. {amount}", + "logs.cost.lowerBound": "au moins {amount}", + "logs.cost.unavailable": "indisponible", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 572e8d94f9..69fdac1c93 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -621,9 +621,9 @@ export const ja: Record = { "logs.conversation.totals": "{requests} 件 · {tokens} トークン · {cost}", "logs.conversation.scope": "合計は現在読み込まれている Logs リングのみです。", "logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "約{amount}", + "logs.cost.lowerBound": "最低{amount}", + "logs.cost.unavailable": "利用不可", "logs.detail.conversation": "会話", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9accfcf624..9f491a1efc 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -664,9 +664,9 @@ export const ko: Record = { "logs.conversation.totals": "{requests}건 요청 · {tokens} 토큰 · {cost}", "logs.conversation.scope": "합계는 현재 로드된 Logs 링만 포함합니다.", "logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "약 {amount}", + "logs.cost.lowerBound": "최소 {amount}", + "logs.cost.unavailable": "사용 불가", "logs.detail.conversation": "대화", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e7f358db2d..00aa9cb06b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -662,9 +662,9 @@ export const ru: Record = { "logs.conversation.totals": "{requests} запросов · {tokens} токенов · {cost}", "logs.conversation.scope": "Итоги только по загруженному кольцу Logs.", "logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "около {amount}", + "logs.cost.lowerBound": "не менее {amount}", + "logs.cost.unavailable": "недоступно", "logs.detail.conversation": "Диалог", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1accd38130..743be2af77 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -669,9 +669,9 @@ export const tr: Record = { "logs.conversation.totals": "{requests} istek · {tokens} jeton · {cost}", "logs.conversation.scope": "Toplamlar yalnızca yüklü günlükleri kapsar.", "logs.conversation.excluded": "({unpriced} fiyatlandırılmamış, {unmetered} ölçülmemiş hariç)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "yaklaşık {amount}", + "logs.cost.lowerBound": "en az {amount}", + "logs.cost.unavailable": "kullanılamıyor", "logs.detail.conversation": "Sohbet", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 9eb218183a..c195aead79 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -516,9 +516,9 @@ export const zhTW: Record = { "logs.conversation.totals": "{requests} 次請求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合計僅涵蓋目前已載入的 Logs 環形緩衝。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 筆無定價、{unmetered} 筆無計量)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "約 {amount}", + "logs.cost.lowerBound": "至少 {amount}", + "logs.cost.unavailable": "無法估算", "logs.detail.conversation": "對話", "logs.badge.claude": "Claude", "logs.col.time": "時間", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 44b65d897a..7dd4beb860 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -657,9 +657,9 @@ export const zh: Record = { "logs.conversation.totals": "{requests} 次请求 · {tokens} tokens · {cost}", "logs.conversation.scope": "合计仅覆盖当前已加载的 Logs 环形缓冲。", "logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)", - "logs.cost.approximate": "~{amount}", - "logs.cost.lowerBound": "≥{amount}", - "logs.cost.unavailable": "—", + "logs.cost.approximate": "约 {amount}", + "logs.cost.lowerBound": "至少 {amount}", + "logs.cost.unavailable": "无法估算", "logs.detail.conversation": "会话", "logs.badge.claude": "Claude", "logs.badge.grok": "Grok", diff --git a/gui/tests/logs-priority-lower-bound.test.ts b/gui/tests/logs-priority-lower-bound.test.ts index bfd68bae3b..877252c07f 100644 --- a/gui/tests/logs-priority-lower-bound.test.ts +++ b/gui/tests/logs-priority-lower-bound.test.ts @@ -24,7 +24,7 @@ describe("Logs priority lower-bound formatting", () => { }); test("uses locale-aware USD placement and separators", () => { - expect(formatEstimatedUsdValue(1.6, de, "de-DE", false)).toBe("~1,6000\u00a0$"); + expect(formatEstimatedUsdValue(1.6, de, "de-DE", false)).toBe("ca. 1,6000\u00a0$"); }); }); From 929a41f617bd9ea50f1f1529c8081b7ec1133d1b Mon Sep 17 00:00:00 2001 From: hyohyeon08 Date: Wed, 19 Aug 2026 16:29:33 +0900 Subject: [PATCH 019/121] fix(xai): preserve Claude Code tools with root $schema --- src/adapters/openai-chat.ts | 9 ++- tests/xai-tool-schema.test.ts | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/xai-tool-schema.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b93222d6ac..1e3b8985ee 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1156,7 +1156,11 @@ function normalizeXaiToolParameters(parameters: unknown): Record key !== "oneOf" && key !== "anyOf" && key !== "type")); + const metadata = Object.fromEntries(Object.entries(normalizedRoot).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type")); delete metadata.properties; delete metadata.required; delete metadata.additionalProperties; @@ -1180,6 +1184,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record [name, mergeXaiPropertySchemas(values)]), ); diff --git a/tests/xai-tool-schema.test.ts b/tests/xai-tool-schema.test.ts new file mode 100644 index 0000000000..1cba7477b5 --- /dev/null +++ b/tests/xai-tool-schema.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxTool } from "../src/types"; + +function parsedRequest(tool: OcxTool): Parameters["buildRequest"]>[0] { + return { + modelId: "grok-4.6", + context: { + messages: [ + { + role: "user", + content: "run the command", + timestamp: 0, + }, + ], + tools: [tool], + }, + stream: true, + options: {}, + } as never; +} + +function xaiAdapter() { + return createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://cli-chat-proxy.grok.com/v1", + apiKey: "k", + }); +} + +describe("xAI Grok CLI tool schema normalization", () => { + test("keeps Claude Code tools with a root $schema", () => { + const tool: OcxTool = { + name: "Bash", + description: "Execute a shell command", + parameters: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + command: { + type: "string", + }, + }, + required: ["command"], + additionalProperties: false, + }, + }; + + const body = JSON.parse( + xaiAdapter().buildRequest(parsedRequest(tool)).body, + ) as { + tools?: Array<{ + type: string; + function: { + name: string; + parameters: Record; + }; + }>; + }; + + expect(body.tools).toHaveLength(1); + expect(body.tools?.[0]?.function.name).toBe("Bash"); + expect(body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + command: { + type: "string", + }, + }, + required: ["command"], + additionalProperties: false, + }); + expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("$schema"); + }); + + test("does not reintroduce $schema when flattening a root union", () => { + const tool: OcxTool = { + name: "Bash", + parameters: { + $schema: "https://json-schema.org/draft/2020-12/schema", + oneOf: [ + { + type: "object", + properties: { + command: { + type: "string", + }, + }, + required: ["command"], + additionalProperties: false, + }, + { + type: "object", + properties: { + command: { + type: "string", + minLength: 1, + }, + }, + required: ["command"], + additionalProperties: false, + }, + ], + }, + }; + + const body = JSON.parse( + xaiAdapter().buildRequest(parsedRequest(tool)).body, + ) as { + tools?: Array<{ + function: { + parameters: Record; + }; + }>; + }; + + expect(body.tools).toHaveLength(1); + expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("$schema"); + expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("oneOf"); + }); +}); \ No newline at end of file From 78b8f909f91599559f51e587d02b68e9191c2965 Mon Sep 17 00:00:00 2001 From: hyohyeon08 Date: Wed, 19 Aug 2026 16:45:28 +0900 Subject: [PATCH 020/121] fix(xai): strip root $schema from tool parameters --- tests/xai-tool-schema.test.ts | 56 +++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/tests/xai-tool-schema.test.ts b/tests/xai-tool-schema.test.ts index 1cba7477b5..6590f5f145 100644 --- a/tests/xai-tool-schema.test.ts +++ b/tests/xai-tool-schema.test.ts @@ -1,8 +1,23 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; -import type { OcxParsedRequest, OcxTool } from "../src/types"; +import { + createOpenAIChatAdapter as createOpenAIChatAdapterProduction, +} from "../src/adapters/openai-chat"; +import type { + OcxParsedRequest, + OcxTool, +} from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; -function parsedRequest(tool: OcxTool): Parameters["buildRequest"]>[0] { +const createOpenAIChatAdapter = ( + ...args: Parameters +) => + withTestTranslatorBudget( + createOpenAIChatAdapterProduction(...args), + ); + +function parsedRequest( + tool: OcxTool, +): OcxParsedRequest { return { modelId: "grok-4.6", context: { @@ -17,7 +32,7 @@ function parsedRequest(tool: OcxTool): Parameters { - test("keeps Claude Code tools with a root $schema", () => { + test("keeps Claude Code tools with a root $schema", async () => { const tool: OcxTool = { name: "Bash", description: "Execute a shell command", @@ -46,9 +61,11 @@ describe("xAI Grok CLI tool schema normalization", () => { }, }; - const body = JSON.parse( - xaiAdapter().buildRequest(parsedRequest(tool)).body, - ) as { + const request = await xaiAdapter().buildRequest( + parsedRequest(tool), + ); + + const body = JSON.parse(request.body) as { tools?: Array<{ type: string; function: { @@ -70,12 +87,15 @@ describe("xAI Grok CLI tool schema normalization", () => { required: ["command"], additionalProperties: false, }); - expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("$schema"); + expect( + body.tools?.[0]?.function.parameters, + ).not.toHaveProperty("$schema"); }); - test("does not reintroduce $schema when flattening a root union", () => { + test("does not reintroduce $schema when flattening a root union", async () => { const tool: OcxTool = { name: "Bash", + description: "Execute a shell command", parameters: { $schema: "https://json-schema.org/draft/2020-12/schema", oneOf: [ @@ -104,9 +124,11 @@ describe("xAI Grok CLI tool schema normalization", () => { }, }; - const body = JSON.parse( - xaiAdapter().buildRequest(parsedRequest(tool)).body, - ) as { + const request = await xaiAdapter().buildRequest( + parsedRequest(tool), + ); + + const body = JSON.parse(request.body) as { tools?: Array<{ function: { parameters: Record; @@ -115,7 +137,11 @@ describe("xAI Grok CLI tool schema normalization", () => { }; expect(body.tools).toHaveLength(1); - expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("$schema"); - expect(body.tools?.[0]?.function.parameters).not.toHaveProperty("oneOf"); + expect( + body.tools?.[0]?.function.parameters, + ).not.toHaveProperty("$schema"); + expect( + body.tools?.[0]?.function.parameters, + ).not.toHaveProperty("oneOf"); }); }); \ No newline at end of file From 8ef4f4feae03d9703bab87cd6e3dfd71871effdf Mon Sep 17 00:00:00 2001 From: hyohyeon08 Date: Wed, 19 Aug 2026 16:56:22 +0900 Subject: [PATCH 021/121] test(xai): strengthen tool schema regression coverage --- tests/xai-tool-schema.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/xai-tool-schema.test.ts b/tests/xai-tool-schema.test.ts index 6590f5f145..26bf63e56e 100644 --- a/tests/xai-tool-schema.test.ts +++ b/tests/xai-tool-schema.test.ts @@ -137,11 +137,18 @@ describe("xAI Grok CLI tool schema normalization", () => { }; expect(body.tools).toHaveLength(1); - expect( - body.tools?.[0]?.function.parameters, - ).not.toHaveProperty("$schema"); - expect( - body.tools?.[0]?.function.parameters, - ).not.toHaveProperty("oneOf"); + expect(body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + command: { + anyOf: [ + { type: "string" }, + { type: "string", minLength: 1 }, + ], + }, + }, + required: ["command"], + additionalProperties: false, + }); }); }); \ No newline at end of file From f408914101fcb0d9c0385c0fdf0187c58979f1ee Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Wed, 19 Aug 2026 16:13:43 +0700 Subject: [PATCH 022/121] fix(cli): give noVisionModels precedence over an exact modality entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isModelTextOnly returns true on the noVisionModels match before it ever reads modelInputModalities, so a `gpt-oss` noVision entry beats an exact `gpt-oss:120b` entry that lists "image". Resolving the exact entry first made `ocx models` advertise image support the proxy then rejects — the same class of drift this PR set out to remove. Add the conflicting-config regression case, which asserts the runtime's answer via isModelTextOnly before comparing the CLI's. Thanks @coderabbitai for catching it. --- src/cli/models.ts | 5 ++++- tests/cli-models.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/cli/models.ts b/src/cli/models.ts index 59632e8e07..a20ba18472 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -102,8 +102,11 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] // proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList // and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers // `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field. + // noVisionModels is checked first because `isModelTextOnly` returns true on that + // match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats + // an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image. const noVision = modelInList(prov.noVisionModels, model); - const modalities = modelRecordValue(inputModalities, model) ?? (noVision ? ["text"] : null); + const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null); const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null; entries.push({ diff --git a/tests/cli-models.test.ts b/tests/cli-models.test.ts index 0ad1a18719..0827f61ae6 100644 --- a/tests/cli-models.test.ts +++ b/tests/cli-models.test.ts @@ -203,6 +203,39 @@ describe("ocx models richer metadata", () => { } }); + test("a noVision family entry beats an exact modality entry, as the runtime does", () => { + // isModelTextOnly returns true on the noVisionModels match before it ever reads + // modelInputModalities, so an exact entry listing "image" does not grant vision. + // Reporting ["text", "image"] here would advertise support the proxy then rejects. + const dir = mkdtempSync(join(tmpdir(), "ocx-models-novision-")); + const provider = { + adapter: "openai-chat", + baseUrl: "http://localhost:8080/v1", + allowPrivateNetwork: true, + defaultModel: "gpt-oss:120b", + models: ["gpt-oss:120b"], + noVisionModels: ["gpt-oss"], + modelInputModalities: { "gpt-oss:120b": ["text", "image"] }, + }; + writeFileSync( + join(dir, "config.json"), + JSON.stringify({ port: 10123, providers: { test: provider }, defaultProvider: "test" }), + "utf8", + ); + try { + // Ground truth first: the proxy treats this model as text-only. + expect(isModelTextOnly(provider as unknown as OcxProviderConfig, "gpt-oss:120b")).toBe(true); + + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const row = JSON.parse(result.stdout).models + .find((m: { model: string }) => m.model === "gpt-oss:120b"); + expect(row.inputModalities).toEqual(["text"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("an exact entry still wins over the family entry", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-models-exact-")); const provider = { From 1fbac66f813cb83accf87fa03d86b96a18bdecc7 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 18:57:39 +0900 Subject: [PATCH 023/121] fix(responses): close the directory iterator when a reclaim scan truncates An early break abandons the enumeration generator instead of resuming it, so the finally that closes the directory handle never runs. The periodic reclaim truncates by design -- entry cap, cleanup cap, wall-clock deadline -- which turned that into one leaked handle per truncated tick. Route every early exit through a stopScan() helper that calls iterator.return() before returning, and add a regression that fails when the fix is reverted. Also repairs the deadline test's oracle. Its fake clock started at 0 while the fixtures carried real epoch mtimes, making every computed age negative, so the files survived the 15-minute grace whether or not a deadline check existed -- the test passed against its own ablation. Anchor the clock to real time and add an explicit unbounded-run assertion so the deadline is the only reason nothing is removed. --- src/responses/state.ts | 14 ++++++++-- tests/responses-state.test.ts | 52 +++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/responses/state.ts b/src/responses/state.ts index a31da4c67d..d269c03f4b 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -591,14 +591,24 @@ export function recoverStaleResponseStateTemps( let iterator: Iterator; try { iterator = names[Symbol.iterator](); } catch { return result; } let scanned = 0; + // Every early exit runs through this. The production `list` is a generator that closes its + // directory handle in a `finally`, and a `finally` does NOT run when the consumer simply + // stops calling `next()` -- only `return()` resumes the generator to completion. Breaking + // out of the loop directly therefore leaked one directory handle per truncated scan, and the + // periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow + // filesystem that is a leak per tick, forever. + const stopScan = (): ResponseStateTempRecoveryResult => { + try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ } + return result; + }; for (;;) { let next: IteratorResult; try { next = iterator.next(); } catch { return result; } if (next.done) break; const name = next.value; scanned += 1; - if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break; - if (deadlineMs !== null && io.now() - startedAt > deadlineMs) break; + if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) return stopScan(); + if (deadlineMs !== null && io.now() - startedAt > deadlineMs) return stopScan(); const match = RESPONSE_STATE_TEMP_NAME.exec(name); if (!match) continue; result.matched += 1; diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 95a88fb935..3b530ab088 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -1733,18 +1733,66 @@ describe("Responses previous_response_id state", () => { writeFileSync(path, "private state"); utimesSync(path, old, old); } - // Clock jumps past the deadline on the first in-loop read. + // The fake clock must stay ANCHORED to real time, or this test proves nothing: an + // `io.now()` of 10_000 against real epoch mtimes makes every age negative, so the files + // survive the 15-minute grace whether or not a deadline check exists. Anchoring instead + // means the only reason a file survives is the deadline itself. + const base = Date.now(); let ticks = 0; const result = recoverStaleResponseStateTemps(home, { list: () => names, isProcessAlive: () => false, bootTime: () => 0, - now: () => (ticks++ === 0 ? 0 : 10_000), + // First read is startedAt; every later read is past the 25 ms budget. + now: () => (ticks++ === 0 ? base : base + 10_000), deadlineMs: 25, }); expect(result.removed).toBe(0); for (const name of names) expect(existsSync(join(home, name))).toBe(true); + + // Ablation guard: the SAME inputs without a deadline must remove both files. If this + // half ever fails, the assertions above stopped depending on the deadline. + ticks = 0; + const unbounded = recoverStaleResponseStateTemps(home, { + list: () => names, + isProcessAlive: () => false, + bootTime: () => 0, + now: () => (ticks++ === 0 ? base : base + 10_000), + }); + expect(unbounded.removed).toBe(2); + }); + + test("a truncated scan closes the directory iterator", () => { + const old = new Date(Date.now() - 60 * 60 * 1_000); + const names = ["responses-state.json.ocx.9301.1.tmp", "responses-state.json.ocx.9302.2.tmp"]; + for (const name of names) { + const path = join(home, name); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + } + + // Production enumerates with a generator that closes its directory handle in a finally. + // A finally only runs if the consumer calls return() -- abandoning the iterator leaks the + // handle, once per truncated scan, and the periodic reclaim truncates by design. + let closed = false; + const list = function* list(): Generator { + try { + for (const name of names) yield name; + } finally { + closed = true; + } + }; + + const result = recoverStaleResponseStateTemps(home, { + list, + isProcessAlive: () => false, + bootTime: () => 0, + maxEntries: 1, + }); + + expect(result.removed).toBe(1); + expect(closed).toBe(true); }); test("v1 Cursor snapshot migrates to versioned provider state", () => { From 3f63e7946278e7b09283d16e243a7f13f834048b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 11:13:36 +0900 Subject: [PATCH 024/121] feat(doctor): report and reclaim abandoned response-state temps --- docs-site/astro.config.mjs | 1 + .../troubleshooting/disk-usage-temp-files.md | 74 +++++++++++++++++++ src/cli/doctor.ts | 45 +++++++++++ src/responses/state.ts | 48 +++++++++++- tests/doctor.test.ts | 42 +++++++++++ tests/responses-state.test.ts | 52 ++++++++++++- 6 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index d71631d1cf..fb790ff743 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -155,6 +155,7 @@ export default defineConfig({ collapsed: true, items: [ { label: "Windows Memory Growth", translations: { fr: "Augmentation de la mémoire sous Windows", ko: "Windows 메모리 증가", "zh-CN": "Windows 内存增长", "zh-TW": "Windows 記憶體增長", ru: "Рост памяти в Windows", ja: "Windows メモリ増加", tr: "Windows Bellek Artışı" }, slug: "troubleshooting/windows-memory" }, + { label: "Disk Usage from Temp Files", translations: { fr: "Espace disque et fichiers temporaires", ko: "임시 파일 디스크 사용량", "zh-CN": "临时文件磁盘占用", "zh-TW": "暫存檔磁碟用量", ru: "Использование диска временными файлами", ja: "一時ファイルのディスク使用量", tr: "Geçici Dosya Disk Kullanımı" }, slug: "troubleshooting/disk-usage-temp-files" }, ], }, { label: "Contributing", translations: { fr: "Contribuer", ko: "기여하기", "zh-CN": "贡献", "zh-TW": "貢獻", ru: "Как внести вклад", ja: "コントリビュート", tr: "Katkıda Bulunma" }, slug: "contributing" }, diff --git a/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md new file mode 100644 index 0000000000..68a4d78f01 --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md @@ -0,0 +1,74 @@ +--- +title: Disk Usage from Temp Files +description: What responses-state.json.ocx.*.tmp files are, why they could accumulate, and how to reclaim them. +--- + +Some users found many gigabytes of files named like +`responses-state.json.ocx...tmp` in their opencodex home +(`~/.opencodex` by default), growing after every reboot. + +## What these files are + +opencodex keeps a continuation cache so `previous_response_id` chains survive a +proxy restart. It writes that snapshot atomically: content goes to a temp file +first, then replaces the real file in one step. That is what stops a crash +mid-write from leaving a half-written snapshot. + +The temp is normally removed the instant the swap completes. If the process dies +between the two steps, the temp survives. + +Each file can be up to 24 MB because the snapshot is rewritten whole, not +appended to. A few hundred abandoned files therefore add up quickly. + +**They are cache, not durable state.** Deleting them costs nothing except that +in-flight conversation chains may re-send context once. No configuration, +credentials, or history live in these files. + +## Why they could accumulate + +A cleanup already existed, but it ran at one moment only: when a proxy loaded +the continuation cache for the first time, which happens *before* that process +writes anything. Two consequences followed. + +A proxy that crashed and restarted swept too early to see the temp its +predecessor had just left — there is a 15-minute grace period so a file being +written right now is never touched — and it never looked again for the rest of +its life. Each restart then added one more file. + +Worse, the cleanup skipped any file whose owning process ID was still alive. +After a reboot the operating system routinely reissues the same process IDs, so +an old file could be permanently mistaken for one belonging to a running +process. That is why the growth tracked reboots. + +## What opencodex does now + +The cleanup repeats on the proxy's normal background timer instead of running +once at startup, so a running proxy reclaims abandoned files on its own. It also +ignores the process-ID check for files older than the current boot, since no +running process can own those. + +The safety rules are unchanged: a file younger than 15 minutes is never removed, +and the proxy never removes a file it is writing itself. + +## Reclaiming files that already accumulated + +If the proxy runs, this happens automatically within a minute or two. + +If the proxy will **not** start — the case where the pile grows fastest — check +and reclaim from the command line: + +```bash +ocx doctor +``` + +The "Response-state temp files" section reports how many files are reclaimable +and how much space they hold. It only reports; it changes nothing. + +To actually remove them: + +```bash +ocx doctor --reclaim-response-temps +``` + +Both commands work without a running proxy. Files currently locked by another +process are reported rather than forced, and are retried later. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 7962c06672..7400d157c9 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -25,6 +25,11 @@ import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHome import { scanCodexAgentRolesWithTomlModelFallback } from "../codex/subagent-model-fallback"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; import { countPendingOpencodexHistory } from "../codex/history-provider"; +import { + inspectAbandonedResponseStateTemps, + reclaimAbandonedResponseStateTemps, + type ResponseStateTempRecoveryResult, +} from "../responses/state"; import { CodexUserIdentityRefusal, probeCodexCoordinatorNamespace, @@ -678,6 +683,37 @@ export async function fetchServiceMemory( const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`; +/** + * Render the abandoned-temp section (testable without console capture). + * + * Report is the DEFAULT and reclaim is opt-in: `doctor` is a diagnostic an operator runs + * to understand a machine, so deleting files as a side effect of asking a question is the + * wrong default even for cache files. + * + * Counts come from `eligible`/`eligibleBytes`, never `matched`: `matched` is incremented + * before the file-type, age, boot-floor, and liveness gates, so reporting it would tell an + * operator that live-pid temps and young temps are "abandoned". + */ +export function formatResponseTempLines( + result: ResponseStateTempRecoveryResult, + reclaimed: boolean, +): string[] { + if (reclaimed) { + if (result.removed === 0 && result.failed === 0) return [" ok No abandoned response-state temp files."]; + const lines = [` ok Reclaimed ${result.removed} abandoned response-state temp file(s), ${mb(result.bytesRemoved)} freed.`]; + if (result.failed > 0) { + lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). They are retried automatically.`); + } + return lines; + } + if (result.eligible === 0) return [" ok No abandoned response-state temp files."]; + return [ + ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`, + " These are interrupted snapshot writes (continuation cache only) and are safe to remove.", + " Reclaim them with: ocx doctor --reclaim-response-temps", + ]; +} + /** Render the doctor "Memory / runtime" section lines (testable without console capture). */ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] { const lines: string[] = []; @@ -805,6 +841,15 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); } + // Runs without the proxy on purpose: the worst accumulation happens when the proxy will + // not start, which is exactly when the in-process periodic reclaim never ticks. + const reclaimTemps = args.includes("--reclaim-response-temps"); + console.log("\nResponse-state temp files"); + for (const line of formatResponseTempLines( + reclaimTemps ? reclaimAbandonedResponseStateTemps() : inspectAbandonedResponseStateTemps(), + reclaimTemps, + )) console.log(line); + const orcaHome = collectOrcaCodexHomeDiagnostic(); console.log("\nCodex app home targeting"); console.log(` ${orcaHome.mismatch ? "!! " : "ok "} Effective Codex home: ${orcaHome.effectiveCodexHome}`); diff --git a/src/responses/state.ts b/src/responses/state.ts index d269c03f4b..39c83b7022 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -506,6 +506,18 @@ export interface ResponseStateTempRecoveryResult { removed: number; failed: number; bytesRemoved: number; + /** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is + * unlinked, so this is the only honest count to show an operator: `matched` is + * incremented before the file-type, age, boot-floor, and liveness gates. */ + eligible: number; + /** Total size of the `eligible` entries. */ + eligibleBytes: number; + /** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching + * the end of the directory, so the counts below describe a prefix of the backlog and not + * the backlog. `eligible > removed + failed` cannot express this: outside a dry run every + * eligible entry is unlinked or failed on the same iteration, so the two are always equal + * and a comparison between them is dead code. */ + truncated: boolean; } interface ResponseStateTempRecoveryIO { @@ -518,11 +530,13 @@ interface ResponseStateTempRecoveryIO { unlink: (path: string) => void; } -type ResponseStateTempRecoveryOptions = Partial & { +export type ResponseStateTempRecoveryOptions = Partial & { maxEntries?: number; maxCleanups?: number; /** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */ deadlineMs?: number | null; + /** Report only: apply every gate, count what would be reclaimed, unlink nothing. */ + dryRun?: boolean; }; function processIsAlive(pid: number): boolean { @@ -570,6 +584,7 @@ export function recoverStaleResponseStateTemps( maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, deadlineMs = null, + dryRun = false, ...overrides } = options; const io = { ...responseStateTempRecoveryIO, ...overrides }; @@ -578,6 +593,9 @@ export function recoverStaleResponseStateTemps( removed: 0, failed: 0, bytesRemoved: 0, + eligible: 0, + eligibleBytes: 0, + truncated: false, }; const startedAt = io.now(); // One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and @@ -607,8 +625,11 @@ export function recoverStaleResponseStateTemps( if (next.done) break; const name = next.value; scanned += 1; - if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) return stopScan(); - if (deadlineMs !== null && io.now() - startedAt > deadlineMs) return stopScan(); + // A dry run performs no cleanups, so bounding it by the cleanup budget would truncate + // the very report an operator uses to size the problem. + if (scanned > maxEntries) { result.truncated = true; return stopScan(); } + if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); } + if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); } const match = RESPONSE_STATE_TEMP_NAME.exec(name); if (!match) continue; result.matched += 1; @@ -631,6 +652,10 @@ export function recoverStaleResponseStateTemps( if (pid === process.pid) continue; if (!predatesBoot && io.isProcessAlive(pid)) continue; + result.eligible += 1; + result.eligibleBytes += file.size; + if (dryRun) continue; + try { io.unlink(path); result.removed += 1; @@ -988,7 +1013,9 @@ export function sweepExpiredResponseStates(at = now()): number { export function reclaimAbandonedResponseStateTemps( options: ResponseStateTempRecoveryOptions = {}, ): ResponseStateTempRecoveryResult { - const total: ResponseStateTempRecoveryResult = { matched: 0, removed: 0, failed: 0, bytesRemoved: 0 }; + const total: ResponseStateTempRecoveryResult = { + matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false, + }; // The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps // already swallows its own enumeration failures, so a catch around only that call would be // unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw. @@ -999,6 +1026,10 @@ export function reclaimAbandonedResponseStateTemps( total.removed += result.removed; total.failed += result.failed; total.bytesRemoved += result.bytesRemoved; + total.eligible += result.eligible; + total.eligibleBytes += result.eligibleBytes; + // Truncation anywhere makes the whole total a prefix. + total.truncated ||= result.truncated; } } catch { /* best-effort: disk reclaim must never destabilize the caller */ @@ -1006,6 +1037,15 @@ export function reclaimAbandonedResponseStateTemps( return total; } +/** + * Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks + * nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent + * removal cannot disagree about which files are reclaimable. + */ +export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult { + return reclaimAbandonedResponseStateTemps({ dryRun: true }); +} + /** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */ export function sweepAbandonedResponseStateTemps(): number { return reclaimAbandonedResponseStateTemps({ diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index d7b00691fa..0f042ed342 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -10,6 +10,7 @@ import { collectRunningProxyEnv, collectWslDualInstall, fetchServiceMemory, + formatResponseTempLines, formatServiceMemoryLines, parseProcessEnvBlock, probeWham, @@ -623,3 +624,44 @@ describe("service memory section (#314 WP4)", () => { expect(conflict).toContain("ocx service install"); }); }); + +describe("doctor abandoned response-state temps", () => { + const result = (over: Partial[0]> = {}) => ({ + matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, ...over, + }); + + test("reports reclaimable files without removing them, and names the opt-in flag", () => { + // Report is the default: doctor is a diagnostic, so it must not delete as a side effect + // of being asked a question. + const lines = formatResponseTempLines(result({ matched: 9, eligible: 3, eligibleBytes: 72 * 1024 * 1024 }), false); + expect(lines[0]).toContain("3 abandoned response-state temp file(s)"); + expect(lines[0]).toContain("72MB"); + expect(lines.join("\n")).toContain("ocx doctor --reclaim-response-temps"); + }); + + test("reports eligible, never matched", () => { + // matched counts name-matching entries BEFORE the age/liveness/file-type gates, so + // reporting it would call live-pid and young temps abandoned. + const lines = formatResponseTempLines(result({ matched: 12, eligible: 0 }), false); + expect(lines).toEqual([" ok No abandoned response-state temp files."]); + expect(lines.join("\n")).not.toContain("12"); + }); + + test("reclaim mode reports what was freed", () => { + const lines = formatResponseTempLines(result({ matched: 4, removed: 2, bytesRemoved: 48 * 1024 * 1024 }), true); + expect(lines[0]).toContain("Reclaimed 2"); + expect(lines[0]).toContain("48MB"); + expect(lines.join("\n")).not.toContain("--reclaim-response-temps"); + }); + + test("locked files are surfaced honestly and described as retried", () => { + const lines = formatResponseTempLines(result({ matched: 3, removed: 1, failed: 2, bytesRemoved: 24 * 1024 * 1024 }), true); + expect(lines.join("\n")).toContain("2 file(s) could not be removed"); + expect(lines.join("\n")).toContain("retried automatically"); + }); + + test("a clean machine says so in both modes", () => { + expect(formatResponseTempLines(result(), false)).toEqual([" ok No abandoned response-state temp files."]); + expect(formatResponseTempLines(result(), true)).toEqual([" ok No abandoned response-state temp files."]); + }); +}); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 3b530ab088..a468fa75f4 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -1623,7 +1623,7 @@ describe("Responses previous_response_id state", () => { }, }); - expect(result).toEqual({ matched: 0, removed: 0, failed: 0, bytesRemoved: 0 }); + expect(result).toEqual({ matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0 }); }); test("periodic reclaim frees abandoned temps without any continuation access", () => { @@ -1725,6 +1725,56 @@ describe("Responses previous_response_id state", () => { expect(result).toMatchObject({ matched: 1, removed: 1, failed: 0 }); }); + test("a dry run reports exactly what a reclaim then removes", () => { + // Report and reclaim must share one predicate. If they drift, doctor tells an operator + // to reclaim files it will then refuse to touch (or vice versa). + const old = new Date(Date.now() - 60 * 60 * 1_000); + const deadPid = process.pid === 4242 ? 4243 : 4242; + const stale = join(home, `responses-state.json.ocx.${deadPid}.1.tmp`); + const live = join(home, "responses-state.json.ocx.5252.2.tmp"); + const young = join(home, "responses-state.json.ocx.6262.3.tmp"); + for (const path of [stale, live, young]) writeFileSync(path, "private state"); + for (const path of [stale, live]) utimesSync(path, old, old); + + const io = { isProcessAlive: (pid: number) => pid === 5252, bootTime: () => 0 }; + const report = recoverStaleResponseStateTemps(home, { ...io, dryRun: true }); + + // matched counts every name-matching entry, including the live and young ones; only + // eligible survives every gate. Reporting matched would overstate by 2 here. + expect(report).toMatchObject({ matched: 3, eligible: 1, removed: 0, failed: 0 }); + expect(report.eligibleBytes).toBe("private state".length); + for (const path of [stale, live, young]) expect(existsSync(path)).toBe(true); + + const reclaim = recoverStaleResponseStateTemps(home, io); + expect(reclaim.removed).toBe(report.eligible); + expect(reclaim.bytesRemoved).toBe(report.eligibleBytes); + expect(existsSync(stale)).toBe(false); + for (const path of [live, young]) expect(existsSync(path)).toBe(true); + }); + + test("a dry run is not truncated by the cleanup budget", () => { + // maxCleanups counts removals. A report removes nothing, so bounding it by that budget + // would under-report precisely the large backlog an operator needs to see. + const old = new Date(Date.now() - 60 * 60 * 1_000); + const names = [7301, 7302, 7303].map(pid => `responses-state.json.ocx.${pid}.1.tmp`); + for (const name of names) { + const path = join(home, name); + writeFileSync(path, "private state"); + utimesSync(path, old, old); + } + + const report = recoverStaleResponseStateTemps(home, { + list: () => names, + isProcessAlive: () => false, + bootTime: () => 0, + maxCleanups: 1, + dryRun: true, + }); + + expect(report.eligible).toBe(3); + for (const name of names) expect(existsSync(join(home, name))).toBe(true); + }); + test("the periodic scan stops at its wall-clock deadline", () => { const old = new Date(Date.now() - 60 * 60 * 1_000); const names = ["responses-state.json.ocx.9201.1.tmp", "responses-state.json.ocx.9202.2.tmp"]; From 3d4a3fb531fc6b0069447fd9c8c6a638f56b4c66 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 11:21:17 +0900 Subject: [PATCH 025/121] fix(doctor): close audit round 3 blockers on the reclaim surface --- .../021_audit_round3.md | 60 +++++++++++++++ .../troubleshooting/disk-usage-temp-files.md | 10 ++- src/cli/doctor.ts | 32 ++++++-- src/cli/help.ts | 2 + tests/doctor.test.ts | 77 ++++++++++++++++++- 5 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/021_audit_round3.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/021_audit_round3.md b/devlog/_plan/260819_response_state_temp_reclaim/021_audit_round3.md new file mode 100644 index 0000000000..228fc02923 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/021_audit_round3.md @@ -0,0 +1,60 @@ +# Audit round 3 — phase 2 implementation + +Reviewer: independent `explorer`, read-only, against `24a901d5c`. Verdict: +**GO-WITH-FIXES (blockers=5)**. Main-agent judgment: **near-pass** — all five folded, +none rebutted. + +## Confirmed + +- **The dry run shares one predicate.** The `dryRun` branch sits AFTER every gate + (basename, pid/seq sanity, inspect failure, isFile + grace, boot floor, self-pid, + liveness), so `eligible` is by construction the exact set that would reach `unlink`. + The drift risk the plan named is closed. +- **The default path deletes nothing** and needs no running server: the only syscalls are + `readdir`/`lstat`/`realpath`, and `getConfigDir()` is pure string resolution. +- **The layer stands alone** at its own tip. + +## Blocker 1 (accepted) — report and reclaim disagreed in MAGNITUDE + +The predicate agreed; the budget did not. The report was bounded by `maxEntries` (4096) +while the reclaim used the default `maxCleanups` (512). On the reported ~816-file backlog +doctor would say "816 reclaimable", then free 512 and print that, leaving 304 with no hint +that another run was needed. + +Fixed twice over: the doctor reclaim now passes a matching budget, AND a partial pass +prints how many remain with an instruction to run again. The second half matters because +any budget can still be exceeded. + +## Blocker 4 (accepted, the most serious) — the safety property had no test + +`formatResponseTempLines` tests feed literal objects to a pure formatter, so none of them +can observe deletion. Nothing covered the call site: **inverting the report/reclaim +ternary would have left the whole suite green.** The flagship property — "doctor does not +delete by default" — was claimed by three accept criteria and demonstrated by none. + +Fixed with an end-to-end `describe` that seeds a stale temp in an isolated +`OPENCODEX_HOME`, runs `runDoctor([])`, asserts the file SURVIVES, then runs the flag and +asserts it is gone. That test fails if the default is ever inverted. + +## Blocker 5 (accepted) — the CLI told a lie to its own target reader + +Both the CLI string and the docs promised locked files "are retried automatically". True +only while a proxy runs and ticks — but this command exists for the operator whose proxy +will NOT start. Reworded to "retried on the next reclaim — automatically while the proxy +runs, otherwise re-run this command", in the CLI and the docs, with a regression test +asserting the phrase "retried automatically" never appears. + +## Blockers 2 and 3 (accepted) — discoverability + +The flag had no help text, and a typo (`--reclaim-response-temp`) silently degraded into a +report, so an operator would read "nothing to reclaim" as an answer to a question they +never asked. Added to `ocx help`, and any unrecognized `--reclaim*` argument now warns. + +## Non-blocking, recorded + +- `bytesRemoved` under-counts against `eligibleBytes` when another process wins an ENOENT + race. Defensible — we did not free those bytes — and left as-is. +- The "none abandoned" line now names that it covers response-state temps specifically, + since the sibling producers (B9 in `002`) remain unreclaimed by design. +- i18n: only the English page was added, matching the existing convention for + `windows-memory.md`. Locale readers fall back to English; no contradiction is introduced. diff --git a/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md index 68a4d78f01..5dc636b856 100644 --- a/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md +++ b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md @@ -71,4 +71,12 @@ ocx doctor --reclaim-response-temps ``` Both commands work without a running proxy. Files currently locked by another -process are reported rather than forced, and are retried later. +process are reported rather than forced. They are retried on the next reclaim — +automatically while the proxy is running, otherwise the next time you run this +command. + +If a very large backlog exceeds one pass, the command says how many files remain +so you can run it again. + +This covers response-state snapshot temps specifically. Other components write +their own temp files with a similar name, and those are not touched here. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 7400d157c9..59ac797ec1 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -683,6 +683,12 @@ export async function fetchServiceMemory( const mb = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))}MB`; +export const RECLAIM_RESPONSE_TEMPS_FLAG = "--reclaim-response-temps"; +/** Matches the dry run's entry bound so report and reclaim agree on a large backlog. */ +const RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS = 4_096; +/** Names the subsystem: other components mint temps with the same shape and are not covered. */ +const CLEAN_RESPONSE_TEMP_LINE = " ok No abandoned response-state temp files."; + /** * Render the abandoned-temp section (testable without console capture). * @@ -699,14 +705,19 @@ export function formatResponseTempLines( reclaimed: boolean, ): string[] { if (reclaimed) { - if (result.removed === 0 && result.failed === 0) return [" ok No abandoned response-state temp files."]; + if (result.removed === 0 && result.failed === 0) return [CLEAN_RESPONSE_TEMP_LINE]; const lines = [` ok Reclaimed ${result.removed} abandoned response-state temp file(s), ${mb(result.bytesRemoved)} freed.`]; if (result.failed > 0) { - lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). They are retried automatically.`); + // Never "retried automatically": this command exists for the operator whose proxy will + // NOT start, and in that state nothing retries anything. + lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). Retried on the next reclaim — automatically while the proxy runs, otherwise re-run this command.`); + } + if (result.eligible > result.removed + result.failed) { + lines.push(` !! Cleanup budget reached; ${result.eligible - result.removed - result.failed} file(s) remain. Run the command again to continue.`); } return lines; } - if (result.eligible === 0) return [" ok No abandoned response-state temp files."]; + if (result.eligible === 0) return [CLEAN_RESPONSE_TEMP_LINE]; return [ ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`, " These are interrupted snapshot writes (continuation cache only) and are safe to remove.", @@ -843,10 +854,21 @@ export async function runDoctor(args: string[] = []): Promise { // Runs without the proxy on purpose: the worst accumulation happens when the proxy will // not start, which is exactly when the in-process periodic reclaim never ticks. - const reclaimTemps = args.includes("--reclaim-response-temps"); + const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG); console.log("\nResponse-state temp files"); + // A typo must not silently degrade into "nothing to reclaim" — the operator would read the + // report as an answer to a question they never actually asked. + for (const arg of args) { + if (arg !== RECLAIM_RESPONSE_TEMPS_FLAG && /^--reclaim/.test(arg)) { + console.log(` !! Unrecognized flag ${arg}; did you mean ${RECLAIM_RESPONSE_TEMPS_FLAG}? Reporting only.`); + } + } for (const line of formatResponseTempLines( - reclaimTemps ? reclaimAbandonedResponseStateTemps() : inspectAbandonedResponseStateTemps(), + // The reclaim budget matches the report budget: a report bounded by entries and a removal + // bounded by a smaller cleanup cap would tell an operator 816 and then silently free 512. + reclaimTemps + ? reclaimAbandonedResponseStateTemps({ maxCleanups: RESPONSE_TEMP_RECLAIM_MAX_CLEANUPS }) + : inspectAbandonedResponseStateTemps(), reclaimTemps, )) console.log(line); diff --git a/src/cli/help.ts b/src/cli/help.ts index 19843c2e01..c335c347b5 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -36,6 +36,8 @@ Usage: Refresh Codex's model cache from the active catalog ocx status Check proxy server status ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability) + ocx doctor --reclaim-response-temps + Reclaim abandoned response-state temp files (works without a running proxy) ocx debug provider/usage/injection/claude on|off|status|reset ocx login OAuth or API-key provider login ocx logout Remove a stored OAuth login diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 0f042ed342..45dedeb485 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { collectPaths, detectFsType, @@ -16,6 +16,7 @@ import { probeWham, proxyDownRestartHint, resolveCodexHomeDir, + runDoctor, type ServiceMemoryData, } from "../src/cli/doctor"; import { collectOrcaCodexHomeDiagnostic } from "../src/codex/home"; @@ -654,14 +655,82 @@ describe("doctor abandoned response-state temps", () => { expect(lines.join("\n")).not.toContain("--reclaim-response-temps"); }); - test("locked files are surfaced honestly and described as retried", () => { + test("locked files are surfaced honestly", () => { const lines = formatResponseTempLines(result({ matched: 3, removed: 1, failed: 2, bytesRemoved: 24 * 1024 * 1024 }), true); expect(lines.join("\n")).toContain("2 file(s) could not be removed"); - expect(lines.join("\n")).toContain("retried automatically"); + expect(lines.join("\n")).toContain("in use or locked"); }); test("a clean machine says so in both modes", () => { expect(formatResponseTempLines(result(), false)).toEqual([" ok No abandoned response-state temp files."]); expect(formatResponseTempLines(result(), true)).toEqual([" ok No abandoned response-state temp files."]); }); + + test("a partial reclaim tells the operator to run again instead of silently stopping", () => { + const lines = formatResponseTempLines(result({ eligible: 816, removed: 512, bytesRemoved: 512 * 24 * 1024 * 1024 }), true); + expect(lines.join("\n")).toContain("304 file(s) remain"); + expect(lines.join("\n")).toContain("Run the command again"); + }); + + test("locked files are never described as retried automatically", () => { + // This command exists for the operator whose proxy will not start; in that state nothing + // retries anything, so promising automatic retry would be a lie to its target reader. + const lines = formatResponseTempLines(result({ removed: 1, failed: 2 }), true).join("\n"); + expect(lines).not.toContain("retried automatically"); + expect(lines).toContain("re-run this command"); + }); +}); + +describe("doctor reclaim wiring (end to end)", () => { + // The formatter tests above cannot observe deletion. This covers the call site itself: + // inverting the report/reclaim ternary in runDoctor must fail a test. + let tempHome: string; + let previousHome: string | undefined; + let logged: string[]; + const realLog = console.log; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + tempHome = join(tmpdir(), `ocx-doctor-temps-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(tempHome, { recursive: true }); + process.env.OPENCODEX_HOME = tempHome; + logged = []; + console.log = (...parts: unknown[]) => { logged.push(parts.join(" ")); }; + }); + afterEach(() => { + console.log = realLog; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(tempHome, { recursive: true, force: true }); + }); + + const seedStaleTemp = (): string => { + const deadPid = process.pid === 4242 ? 4243 : 4242; + const path = join(tempHome, `responses-state.json.ocx.${deadPid}.1.tmp`); + writeFileSync(path, "abandoned snapshot"); + const old = new Date(Date.now() - 48 * 60 * 60 * 1_000); + utimesSync(path, old, old); + return path; + }; + + test("the default run reports the file and leaves it on disk", async () => { + const path = seedStaleTemp(); + await runDoctor([]); + expect(existsSync(path)).toBe(true); + expect(logged.join("\n")).toContain("reclaimable"); + }); + + test("the opt-in flag removes it", async () => { + const path = seedStaleTemp(); + await runDoctor(["--reclaim-response-temps"]); + expect(existsSync(path)).toBe(false); + expect(logged.join("\n")).toContain("Reclaimed 1"); + }); + + test("a mistyped flag warns instead of silently reporting", async () => { + const path = seedStaleTemp(); + await runDoctor(["--reclaim-response-temp"]); + expect(existsSync(path)).toBe(true); + expect(logged.join("\n")).toContain("Unrecognized flag"); + }); }); From 990077e85c7c8f728a3c1033f10ac8ed10e7b0be Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 11:31:10 +0900 Subject: [PATCH 026/121] docs(devlog): record phase 2 verification evidence --- .../022_phase2_verification.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 devlog/_plan/260819_response_state_temp_reclaim/022_phase2_verification.md diff --git a/devlog/_plan/260819_response_state_temp_reclaim/022_phase2_verification.md b/devlog/_plan/260819_response_state_temp_reclaim/022_phase2_verification.md new file mode 100644 index 0000000000..c98200f744 --- /dev/null +++ b/devlog/_plan/260819_response_state_temp_reclaim/022_phase2_verification.md @@ -0,0 +1,36 @@ +# Phase 2 verification + +Full suite on `macmini-cf` at `a2cec13db` (worktree `/tmp/ocx-reclaim`). + +## Full suite — 13397 pass, 1 fail + +`bun run test` → `Ran 13410 tests across 850 files [474.20s]`. + +The single failure is `update-npm-cache-preflight > runs the real worker protocol against +npm's configured cache path`, already proven pre-existing in `012` by running that file at +the unmodified base `59964ad77` (10 pass / 1 fail, identical). It depends on a working +`npm config` on the host. + +The 7 GUI `react` module-load errors seen in the phase-1 run are absent here — that run +had an incomplete `gui/node_modules`, confirming they were environmental as recorded. + +## Focused — 171 pass, 0 fail + +`bun test tests/doctor.test.ts tests/responses-state.test.ts tests/state-store-sweeper.test.ts` +→ 171 pass, 506 assertions, on both the workstation and `macmini-cf`. + +`bun run typecheck` clean; `bun run privacy:scan` passed. + +## What the new end-to-end tests actually pin + +Audit round 3's sharpest finding was that inverting the report/reclaim ternary in +`runDoctor` would have left the entire suite green. The added +`doctor reclaim wiring (end to end)` block seeds a real stale temp in an isolated +`OPENCODEX_HOME` and asserts: + +- `runDoctor([])` leaves the file ON DISK and prints "reclaimable"; +- `runDoctor(["--reclaim-response-temps"])` removes it and prints "Reclaimed 1"; +- `runDoctor(["--reclaim-response-temp"])` (typo) warns and removes nothing. + +The first of those fails if the default is ever inverted, which is the property three +accept criteria claimed and none previously demonstrated. From e298cf8eafffd0da39af76694431d973c08f2cde Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:02:01 +0900 Subject: [PATCH 027/121] fix(doctor): report a truncated reclaim from a signal that can actually fire The budget warning keyed on eligible > removed + failed, which is unreachable outside a dry run: an entry is counted eligible and then unlinked or failed on the same iteration, so the two are always equal. An operator whose backlog exceeded the cleanup budget was told the reclaim had finished. Carry an explicit truncated flag on the scan result instead, set wherever the loop stops on a budget rather than on the end of the directory, and OR it across the swept directories. The dry-run report is bounded by the entry cap too, so a truncated report now says the count is a floor. The partial-reclaim test asserted a state production cannot reach; it now uses a reachable one and is paired with an ablation guard that fails if the warning stops depending on the flag. --- src/cli/doctor.ts | 15 ++++++++++++--- tests/doctor.test.ts | 33 ++++++++++++++++++++++++++++++--- tests/responses-state.test.ts | 7 ++++++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 59ac797ec1..8af24a2693 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -712,17 +712,26 @@ export function formatResponseTempLines( // NOT start, and in that state nothing retries anything. lines.push(` !! ${result.failed} file(s) could not be removed (in use or locked). Retried on the next reclaim — automatically while the proxy runs, otherwise re-run this command.`); } - if (result.eligible > result.removed + result.failed) { - lines.push(` !! Cleanup budget reached; ${result.eligible - result.removed - result.failed} file(s) remain. Run the command again to continue.`); + // `truncated`, not `eligible > removed + failed`: outside a dry run every eligible entry + // is unlinked or failed on the same iteration it is counted, so those two are always + // equal and the comparison never fired. An operator with a backlog past the budget was + // told the reclaim had finished. + if (result.truncated) { + lines.push(" !! Cleanup budget reached; files remain. Run the command again to continue."); } return lines; } if (result.eligible === 0) return [CLEAN_RESPONSE_TEMP_LINE]; - return [ + const lines = [ ` !! ${result.eligible} abandoned response-state temp file(s), ${mb(result.eligibleBytes)} reclaimable.`, " These are interrupted snapshot writes (continuation cache only) and are safe to remove.", " Reclaim them with: ocx doctor --reclaim-response-temps", ]; + // The dry run skips the cleanup budget but is still bounded by the entry cap, so a large + // enough backlog makes this a floor rather than a total. Say so instead of letting an + // operator size the problem from a truncated count. + if (result.truncated) lines.push(" Scan stopped at its entry budget; the real total is higher."); + return lines; } /** Render the doctor "Memory / runtime" section lines (testable without console capture). */ diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 45dedeb485..b308f7d0b3 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -628,7 +628,7 @@ describe("service memory section (#314 WP4)", () => { describe("doctor abandoned response-state temps", () => { const result = (over: Partial[0]> = {}) => ({ - matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, ...over, + matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false, ...over, }); test("reports reclaimable files without removing them, and names the opt-in flag", () => { @@ -667,11 +667,38 @@ describe("doctor abandoned response-state temps", () => { }); test("a partial reclaim tells the operator to run again instead of silently stopping", () => { - const lines = formatResponseTempLines(result({ eligible: 816, removed: 512, bytesRemoved: 512 * 24 * 1024 * 1024 }), true); - expect(lines.join("\n")).toContain("304 file(s) remain"); + // The shape here is one the scanner can actually produce. It cannot produce + // eligible > removed + failed outside a dry run: an entry is counted eligible and then + // unlinked or failed on the same iteration, so those are always equal, and the earlier + // version of this warning keyed on a comparison between them and therefore never fired. + const lines = formatResponseTempLines( + result({ eligible: 512, removed: 512, bytesRemoved: 512 * 24 * 1024 * 1024, truncated: true }), + true, + ); + expect(lines.join("\n")).toContain("Cleanup budget reached"); expect(lines.join("\n")).toContain("Run the command again"); }); + test("a reclaim that finished does NOT claim files remain", () => { + // Ablation guard for the test above: same counts, truncated false. If the warning ever + // stops depending on `truncated`, this fails. + const lines = formatResponseTempLines( + result({ eligible: 512, removed: 512, bytesRemoved: 512 * 24 * 1024 * 1024 }), + true, + ).join("\n"); + expect(lines).not.toContain("Cleanup budget reached"); + expect(lines).not.toContain("Run the command again"); + }); + + test("a truncated report says the total is a floor, not the backlog", () => { + const lines = formatResponseTempLines( + result({ matched: 4096, eligible: 4096, eligibleBytes: 96 * 1024 * 1024, truncated: true }), + false, + ).join("\n"); + expect(lines).toContain("4096 abandoned response-state temp file(s)"); + expect(lines).toContain("the real total is higher"); + }); + test("locked files are never described as retried automatically", () => { // This command exists for the operator whose proxy will not start; in that state nothing // retries anything, so promising automatic retry would be a lie to its target reader. diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index a468fa75f4..07d23a57a9 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -1623,7 +1623,12 @@ describe("Responses previous_response_id state", () => { }, }); - expect(result).toEqual({ matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0 }); + expect(result).toEqual({ + matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, + // A read failure is not a budget stop: the caller must not be told the backlog was + // merely truncated when enumeration actually broke. + truncated: false, + }); }); test("periodic reclaim frees abandoned temps without any continuation access", () => { From a77d5f972e77a6d07c4c925daf2402dcd21d8e92 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:31:56 +0800 Subject: [PATCH 028/121] fix(catalog): allow per-provider/model opt-out of code_mode_only tool mode (#2106) --- src/codex/catalog/aggregation.ts | 3 + src/codex/catalog/parsing.ts | 23 ++++- src/codex/catalog/provider-fetch.ts | 3 + src/codex/catalog/sync.ts | 4 +- src/types.ts | 12 +++ tests/codex-tool-mode.test.ts | 140 ++++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/codex-tool-mode.test.ts diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index a4736ab4b0..a605534227 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -183,6 +183,9 @@ export function deriveComboCatalogModel( ? { supportsServiceTier: false } : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), + ...(members.every(member => member.codexToolMode === "shell") + ? { codexToolMode: "shell" as const } + : {}), }; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f42049150e..0d1b2c2aaa 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -126,6 +126,12 @@ export interface CatalogModel { /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ supportsServiceTier?: boolean; supportsReasoningSummaries?: boolean; + /** + * Codex tool calling mode for this routed model. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */ @@ -423,7 +429,14 @@ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean { export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; -export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { +export function applyRoutedCodexToolMode( + entry: RawEntry, + toolMode?: "code_mode_only" | "shell" | string, +): RawEntry { + if (toolMode === "shell") { + delete entry.tool_mode; + return entry; + } entry.tool_mode = ROUTED_CODEX_TOOL_MODE; return entry; } @@ -490,10 +503,14 @@ export function applyMultiAgentMode( return entries; } -export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry { +export function normalizeRoutedCatalogEntry( + entry: RawEntry, + parallelToolCalls = false, + toolMode?: "code_mode_only" | "shell" | string, +): RawEntry { delete entry.model_messages; delete entry.tool_mode; - applyRoutedCodexToolMode(entry); + applyRoutedCodexToolMode(entry, toolMode); delete entry.multi_agent_version; delete entry.use_responses_lite; delete entry.supports_websockets; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 93d36ac8ae..a578524bda 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -676,6 +676,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) ? { parallelToolCalls: true } : {}), + ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); if (providerCap !== undefined && capped !== hinted.contextWindow) { @@ -1881,6 +1882,7 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, @@ -1905,6 +1907,7 @@ async function gatherRoutedModelsUncached( ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), + ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), } : base; // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5d833e2c17..16309f0d9f 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -331,7 +331,7 @@ export function deriveEntry( // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. if (!codexForwardNativeCapabilityAlias) { - normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); + normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); } if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); @@ -373,7 +373,7 @@ export function deriveEntry( : {}), }; if (isRouted) { - applyRoutedCodexToolMode(entry); + applyRoutedCodexToolMode(entry, model?.codexToolMode); applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); } else { diff --git a/src/types.ts b/src/types.ts index 77c88be200..beec8c051c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -637,6 +637,12 @@ export interface OcxCustomModel { reasoningEfforts?: string[]; /** Default effort label when `reasoningEfforts` is non-empty. */ defaultReasoningEffort?: string; + /** + * Codex tool calling mode override for this custom model. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** 추가 시각 (ISO 8601) */ addedAt?: string; } @@ -1376,6 +1382,12 @@ export type TierDecision = */ export interface OcxProviderConfig { adapter: string; + /** + * Codex tool calling mode for routed models. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only" (unified exec helper tool). + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** Optional outbound request-start pacing shared by this provider and its model overrides. */ requestPacing?: ProviderRequestPacingConfig; /** Cursor MCP compatibility bounds; positive integers when configured. */ diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts new file mode 100644 index 0000000000..cd8b3352d5 --- /dev/null +++ b/tests/codex-tool-mode.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { + applyRoutedCodexToolMode, + normalizeRoutedCatalogEntry, + ROUTED_CODEX_TOOL_MODE, + type RawEntry, +} from "../src/codex/catalog/parsing"; +import { buildCatalogEntries } from "../src/codex/catalog"; +import { + collectDeclaredWireToolNames, + undeclaredToolCallName, +} from "../src/server/responses-undeclared-tool-guard"; + +describe("Codex tool mode configuration (#2106)", () => { + test("applyRoutedCodexToolMode defaults to code_mode_only", () => { + const entry: RawEntry = { slug: "deepseek/deepseek-v4-flash" }; + applyRoutedCodexToolMode(entry); + expect(entry.tool_mode).toBe(ROUTED_CODEX_TOOL_MODE); + + const explicitCodeMode: RawEntry = { slug: "deepseek/deepseek-v4-flash" }; + applyRoutedCodexToolMode(explicitCodeMode, "code_mode_only"); + expect(explicitCodeMode.tool_mode).toBe(ROUTED_CODEX_TOOL_MODE); + }); + + test("applyRoutedCodexToolMode deletes tool_mode when toolMode is shell", () => { + const entry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + tool_mode: "code_mode_only", + }; + applyRoutedCodexToolMode(entry, "shell"); + expect(entry.tool_mode).toBeUndefined(); + expect(Object.hasOwn(entry, "tool_mode")).toBe(false); + }); + + test("normalizeRoutedCatalogEntry respects toolMode", () => { + const defaultEntry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + model_messages: { input: [] }, + }; + normalizeRoutedCatalogEntry(defaultEntry); + expect(defaultEntry.tool_mode).toBe("code_mode_only"); + + const shellEntry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + model_messages: { input: [] }, + }; + normalizeRoutedCatalogEntry(shellEntry, false, "shell"); + expect(shellEntry.tool_mode).toBeUndefined(); + expect(Object.hasOwn(shellEntry, "tool_mode")).toBe(false); + }); + + test("buildCatalogEntries preserves tool_mode = code_mode_only by default", () => { + const entries = buildCatalogEntries(null, [], [ + { id: "deepseek-v4-flash", provider: "deepseek" }, + ]); + const deepseekEntry = entries.find(e => e.slug === "deepseek/deepseek-v4-flash"); + expect(deepseekEntry).toBeDefined(); + expect(deepseekEntry?.tool_mode).toBe("code_mode_only"); + expect(deepseekEntry?.shell_type).toBe("shell_command"); + }); + + test("buildCatalogEntries leaves tool_mode unset when codexToolMode is shell", () => { + const entries = buildCatalogEntries(null, [], [ + { + id: "deepseek-v4-flash", + provider: "deepseek", + codexToolMode: "shell", + }, + ]); + const deepseekEntry = entries.find(e => e.slug === "deepseek/deepseek-v4-flash"); + expect(deepseekEntry).toBeDefined(); + expect(deepseekEntry?.tool_mode).toBeUndefined(); + expect(deepseekEntry?.shell_type).toBe("shell_command"); + }); + + test("under shell mode with declared exec_command, undeclared-tool-guard allows exec_command", () => { + // When Codex operates under flat shell mode (tool_mode omitted), it declares exec_command on the wire: + const wireBody = { + tools: [ + { + type: "function", + name: "exec_command", + description: "Execute a shell command", + }, + ], + }; + const declaredTools = collectDeclaredWireToolNames(wireBody); + expect(declaredTools.has("exec_command")).toBe(true); + + const sseEvent = { + type: "response.output_item.added", + item: { + type: "function_call", + name: "exec_command", + call_id: "call_abc", + }, + }; + const undeclared = undeclaredToolCallName(sseEvent, declaredTools); + expect(undeclared).toBeUndefined(); + }); + + test("catalogHintsFromProviderConfig propagates codexToolMode", () => { + const { catalogHintsFromProviderConfig } = require("../src/codex/catalog/provider-fetch"); + const hints = catalogHintsFromProviderConfig( + "deepseek", + { + adapter: "openai-responses", + baseUrl: "https://api.deepseek.com", + codexToolMode: "shell", + }, + "deepseek-v4-flash", + ); + expect(hints.codexToolMode).toBe("shell"); + }); + + test("deriveComboCatalogModel sets codexToolMode = shell when all members specify shell", () => { + const { deriveComboCatalogModel } = require("../src/codex/catalog/aggregation"); + const combo = { + name: "all-shell-combo", + targets: [ + { provider: "deepseek", model: "v4" }, + { provider: "qwen", model: "max" }, + ], + }; + const members = [ + { id: "v4", provider: "deepseek", contextWindow: 128000, codexToolMode: "shell" as const }, + { id: "max", provider: "qwen", contextWindow: 128000, codexToolMode: "shell" as const }, + ]; + const derived = deriveComboCatalogModel("all-shell-combo", combo, members); + expect(derived?.codexToolMode).toBe("shell"); + + const mixedMembers = [ + { id: "v4", provider: "deepseek", contextWindow: 128000, codexToolMode: "shell" as const }, + { id: "max", provider: "qwen", contextWindow: 128000 }, + ]; + const mixedDerived = deriveComboCatalogModel("all-shell-combo", combo, mixedMembers); + expect(mixedDerived?.codexToolMode).toBeUndefined(); + }); +}); + From 02e4011dbdbadc7aa3081088533d54acc9c78131 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:36:45 +0800 Subject: [PATCH 029/121] fix(catalog): inherit provider codexToolMode for custom models --- src/codex/catalog/provider-fetch.ts | 6 +++- tests/codex-tool-mode.test.ts | 52 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a578524bda..9857f3870e 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1882,7 +1882,11 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), - ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : {}), + ...(cm.codexToolMode !== undefined + ? { codexToolMode: cm.codexToolMode } + : effectiveProvider?.codexToolMode !== undefined + ? { codexToolMode: effectiveProvider.codexToolMode } + : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index cd8b3352d5..5cdd80a6b8 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -136,5 +136,57 @@ describe("Codex tool mode configuration (#2106)", () => { const mixedDerived = deriveComboCatalogModel("all-shell-combo", combo, mixedMembers); expect(mixedDerived?.codexToolMode).toBeUndefined(); }); + + test("gatherRoutedModels custom model inherits provider codexToolMode when undiscovered", async () => { + const { gatherRoutedModels } = require("../src/codex/catalog"); + const { withStubbedProviderFetch } = require("./helpers/catalog-provider-fetch"); + const config = { + providers: { + customprov: { + adapter: "openai-responses", + baseUrl: "https://api.custom.com", + codexToolMode: "shell", + liveModels: false, + }, + }, + customModels: [ + { + provider: "customprov", + modelId: "undiscovered-model", + }, + ], + }; + const models = await gatherRoutedModels(withStubbedProviderFetch(config as any)); + const model = models.find((m: any) => m.id === "undiscovered-model"); + expect(model).toBeDefined(); + expect(model.codexToolMode).toBe("shell"); + }); + + test("gatherRoutedModels custom model explicit codexToolMode overrides provider setting", async () => { + const { gatherRoutedModels } = require("../src/codex/catalog"); + const { withStubbedProviderFetch } = require("./helpers/catalog-provider-fetch"); + const config = { + providers: { + customprov: { + adapter: "openai-responses", + baseUrl: "https://api.custom.com", + codexToolMode: "shell", + liveModels: false, + }, + }, + customModels: [ + { + provider: "customprov", + modelId: "override-model", + codexToolMode: "code_mode_only", + }, + ], + }; + const models = await gatherRoutedModels(withStubbedProviderFetch(config as any)); + const model = models.find((m: any) => m.id === "override-model"); + expect(model).toBeDefined(); + expect(model.codexToolMode).toBe("code_mode_only"); + }); }); + From 73dfa7cdcfa55f9b02441103db88e8f1a613ed35 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:41:25 +0800 Subject: [PATCH 030/121] fix(sync): apply explicit codexToolMode for native capability alias --- src/codex/catalog/sync.ts | 2 ++ tests/codex-tool-mode.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 16309f0d9f..e83056afc3 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -332,6 +332,8 @@ export function deriveEntry( // native tool/search/responses-lite contract while preserving the routed slug and wire id. if (!codexForwardNativeCapabilityAlias) { normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); + } else if (model?.codexToolMode !== undefined) { + applyRoutedCodexToolMode(e, model.codexToolMode); } if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index 5cdd80a6b8..e9efcdf3db 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -187,6 +187,24 @@ describe("Codex tool mode configuration (#2106)", () => { expect(model).toBeDefined(); expect(model.codexToolMode).toBe("code_mode_only"); }); + + test("buildCatalogEntries with codexForwardNativeCapabilityAlias applies codexToolMode = shell", () => { + const { buildCatalogEntries, NATIVE_DAYBREAK_BLUE_MODEL, upstreamNativeEntry } = require("../src/codex/catalog"); + const { CODEX_CUSTOM_MODEL_CATALOG_KIND, findNativeTemplate } = require("../src/codex/catalog/parsing"); + const nativeTemplate = () => findNativeTemplate(upstreamNativeEntry("gpt-5.6-sol")!); + const models = [{ + id: NATIVE_DAYBREAK_BLUE_MODEL, + provider: "openai", + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + codexForwardNativeCapabilityAlias: true, + codexToolMode: "shell" as const, + }]; + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const daybreak = entries.find((entry: any) => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); + expect(daybreak).toBeDefined(); + expect(daybreak?.tool_mode).toBeUndefined(); + expect(daybreak?.use_responses_lite).toBe(true); + }); }); From 457a3b175ed5d7be168ca89b5953b16bf7a92d65 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:46:26 +0800 Subject: [PATCH 031/121] chore(sync): refine fallback comment and template fixture in test --- src/codex/catalog/sync.ts | 4 ++-- tests/codex-tool-mode.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index e83056afc3..1167bacc27 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -360,8 +360,8 @@ export function deriveEntry( }); } // Fallback when no template is available (best-effort; strict parser may need more). - // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog - // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). + // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell"); + // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar. const isCursorFallback = isRouted && model?.provider === "cursor"; const entry: RawEntry = { diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index e9efcdf3db..9fa11092f4 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -191,7 +191,7 @@ describe("Codex tool mode configuration (#2106)", () => { test("buildCatalogEntries with codexForwardNativeCapabilityAlias applies codexToolMode = shell", () => { const { buildCatalogEntries, NATIVE_DAYBREAK_BLUE_MODEL, upstreamNativeEntry } = require("../src/codex/catalog"); const { CODEX_CUSTOM_MODEL_CATALOG_KIND, findNativeTemplate } = require("../src/codex/catalog/parsing"); - const nativeTemplate = () => findNativeTemplate(upstreamNativeEntry("gpt-5.6-sol")!); + const nativeTemplate = () => findNativeTemplate({ models: [upstreamNativeEntry("gpt-5.6-sol")!] }); const models = [{ id: NATIVE_DAYBREAK_BLUE_MODEL, provider: "openai", From d86a2faedb66fa2cac769a456cb28a8fa7625c05 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 12:41:18 +0000 Subject: [PATCH 032/121] fix(mimo): route Pro images through vision sidecar --- src/providers/registry.ts | 4 +++ structure/03_catalog-and-subagents.md | 15 +++++++++++ .../catalog-vision-sidecar-modalities.test.ts | 26 +++++++++++++++++++ tests/provider-registry-parity.test.ts | 2 ++ 4 files changed, 47 insertions(+) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c81735ba15..ce82dc512e 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2470,6 +2470,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // The gateway validates the ladder strictly and rejects anything above `high`. reasoningEfforts: ["low", "medium", "high"], reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, + // Live token-plan verification (#1927): the Pro route rejects image input while + // mimo-v2.5 accepts it natively. Keep this provider-scoped so a hand-rolled + // provider with the same id but another destination does not inherit the claim. + noVisionModels: ["mimo-v2.5-pro"], // A user may already have hand-rolled a provider under this id against a different host; // without this, routedProviderConfig() would canonicalize their base URL onto ours and send // their key somewhere they did not choose. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index fe347602d4..2c0a8345e5 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -255,6 +255,21 @@ the request, and they never raise it. `preserveCustomDestination`으로 같은 이름의 다른 host/key를 보호한다. 대신 새 preset 표면을 문서와 registry parity에서 함께 유지해야 한다. +[Decision Log] +- 목적과 의도: Xiaomi token-plan에서 image input을 거부하는 `mimo-v2.5-pro`만 vision + sidecar로 우회하고, 실제 image input을 받는 `mimo-v2.5`는 native vision 경로에 남긴다. +- 기존 구현 및 제약 조건: upstream `/v1/models`는 input modality를 제공하지 않으며, + `noVisionModels`는 text-only 모델을 sidecar로 보내면서 Codex catalog에는 image input을 + 광고하는 provider-scoped 계약이다. +- 검토한 주요 대안: MiMo 전체를 text-only로 분류하기, live discovery에서 modality를 + 추측하기, `mimo-v2.5-pro` 하나만 registry에 고정 분류하기. +- 선택한 방식: canonical `mimo` preset의 `noVisionModels`에 `mimo-v2.5-pro`만 추가한다. +- 다른 대안 대신 이 방식을 선택한 이유: live endpoint 검증으로 확인된 최소 범위만 + 적용하며, 정상 동작하는 `mimo-v2.5`의 native image 경로를 훼손하지 않는다. +- 장점, 단점 및 영향: Pro image 요청의 404를 sidecar 설명 경로로 바꾸고 base 모델은 + 그대로 유지한다. `preserveCustomDestination` guard 때문에 같은 provider id를 다른 host에 + 연결한 사용자 설정에는 이 capability 분류가 전파되지 않는다. + [Decision Log] - 목적과 의도: bare `defaultModel` selectors that route into third-party providers must keep their adapter-owned effort ladder; only true ChatGPT-native requests should receive the mock-max repair. diff --git a/tests/catalog-vision-sidecar-modalities.test.ts b/tests/catalog-vision-sidecar-modalities.test.ts index c49347025e..a9e4f1cd85 100644 --- a/tests/catalog-vision-sidecar-modalities.test.ts +++ b/tests/catalog-vision-sidecar-modalities.test.ts @@ -45,6 +45,32 @@ describe("vision-sidecar catalog modalities", () => { const hinted = applyProviderConfigHints("opencode-go", prov, { id: "glm-5.2", provider: "opencode-go" }); expect(hinted.inputModalities).toEqual(["text", "image"]); }); + + test("MiMo token-plan sends only the Pro model through the sidecar (#1927)", () => { + const canonical: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + authMode: "key", + }; + enrichProviderFromRegistry("mimo", canonical); + expect(canonical.noVisionModels).toEqual(["mimo-v2.5-pro"]); + expect(applyProviderConfigHints("mimo", canonical, { + id: "mimo-v2.5-pro", + provider: "mimo", + }).inputModalities).toEqual(["text", "image"]); + expect(applyProviderConfigHints("mimo", canonical, { + id: "mimo-v2.5", + provider: "mimo", + }).inputModalities).toBeUndefined(); + + const customDestination: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://mimo-compatible.example/v1", + authMode: "key", + }; + enrichProviderFromRegistry("mimo", customDestination); + expect(customDestination.noVisionModels).toBeUndefined(); + }); }); describe("vision-sidecar custom-model override (#349/#344)", () => { diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 850409d8df..f06a8953c3 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -67,6 +67,8 @@ describe("provider registry parity", () => { "qwen3.7-max", ]); expect(KEY_LOGIN_PROVIDERS["opencode-go"].noVisionModels).not.toContain("kimi-k2.7-code"); + expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).toEqual(["mimo-v2.5-pro"]); + expect(KEY_LOGIN_PROVIDERS.mimo.noVisionModels).not.toContain("mimo-v2.5"); expect(KEY_LOGIN_PROVIDERS["opencode-go"]).toMatchObject({ modelContextWindows: { "kimi-k3": 262_144 }, modelInputModalities: { "kimi-k3": ["text", "image"] }, From 159d2ab183c69db98a2955660c2f8344f83c8e4d Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 17 Aug 2026 00:20:07 +0000 Subject: [PATCH 033/121] fix(windows): keep catalog discovery off request event loop --- .../content/docs/guides/sub-agent-surface.md | 6 + src/codex/app-server-processes.ts | 283 +++++++++++++++--- src/server/responses/collaboration.ts | 7 +- structure/03_catalog-and-subagents.md | 10 + tests/codex-app-server-processes.test.ts | 87 ++++++ 5 files changed, 354 insertions(+), 39 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 6d09880fc6..8fb0e4d49e 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -259,6 +259,12 @@ timestamp, an unreadable process start time, or a failed process enumeration — separately by `ocx doctor`. `stale` clears only after every detected Codex app-server starts after the final catalog write; it does not necessarily clear `unknown`. +On Windows, this advisory check uses asynchronous PowerShell/CIM discovery on the v2 request path. +Concurrent cold checks share one in-flight discovery and successful results are cached briefly. A +slow or failing CIM query can delay or suppress only OpenCodex-authored model guidance; it does not +block the Bun event loop, `/healthz`, or unrelated proxy traffic. Explicit CLI/service lifecycle +operations retain the synchronous, fail-closed process collector because they may signal processes. + Only a real change counts. A sync whose result is byte-identical to the catalog already on disk leaves the file untouched, so restarting the proxy or re-syncing an unchanged model set does not make a running Codex look stale. diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index c70c3af5c5..8d64b829d2 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -7,7 +7,7 @@ * Never match broad `*codex*` patterns that hit unrelated tools such as * `hermes-codex-bridge-mcp`. */ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync, type ExecFileException } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { isProcessAlive, waitForExit } from "../lib/process-control"; import { @@ -98,9 +98,30 @@ export interface CodexAppServerProcessIo { waitExit?: (pid: number, timeoutMs: number) => boolean; now?: () => number; readStartMs?: (pid: number) => number | null; + /** Async process-list seam used by the request-path Windows collector. */ + listSnapshotsAsync?: () => Promise; + /** Async batch start-time seam used by the request-path Windows collector. */ + readStartMsBatchAsync?: (pids: readonly number[]) => Promise>; catalogMtimeMs?: () => number | null; } +function execFileTextAsync( + file: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + execFile(file, [...args], { + encoding: "utf-8", + timeout: timeoutMs, + windowsHide: true, + }, (error: ExecFileException | null, stdout: string) => { + if (error) reject(error); + else resolve(stdout); + }); + }); +} + /** Split a process command line into argv-like tokens (handles simple quotes). */ export function tokenizeCommandLine(commandLine: string): string[] { const tokens: string[] = []; @@ -375,7 +396,7 @@ export function parseWindowsSnapshotOutput(output: string): ProcessSnapshot[] { return out; } -export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] { +function windowsSnapshotPowerShellCommand(): string { // Newlines keep -Command as a real script (space-joined statements need ';'). // Double-quoted format string so `t expands to a real tab. // Codex candidates only: basename token codex / codex.exe / codex.cmd / @@ -384,7 +405,7 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri // path with "opencodex". const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source); const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source); - const psCommand = [ + return [ "$ErrorActionPreference='SilentlyContinue'", "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name", // -ErrorAction Stop plus the outer try is what makes a TOP-LEVEL query failure @@ -412,9 +433,13 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri "}", "} catch { \"__OCX_ENUM_INCOMPLETE__\" }", ].join("\n"); +} + +export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] { // Top-level exec failure propagates (see listDarwinSnapshots note). The // executable resolves from the trusted System32 directory (never PATH), and // windowsHide keeps the enumeration console-less on desktop sessions (#1278). + const psCommand = windowsSnapshotPowerShellCommand(); const output = runPowerShell ? runPowerShell(psCommand) : execFileSync(resolveTrustedWindowsPowerShellExe(), [ @@ -425,6 +450,15 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri return parseWindowsSnapshotOutput(output); } +async function listWindowsSnapshotsAsync(): Promise { + const output = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", "-NoLogo", "-NonInteractive", + "-Command", + windowsSnapshotPowerShellCommand(), + ], 8_000); + return parseWindowsSnapshotOutput(output); +} + function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] { if (platform === "win32") return listWindowsSnapshots(); if (platform === "darwin") return listDarwinSnapshots(getuid()); @@ -533,6 +567,41 @@ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = proc return readLinuxProcStartMs(pid); } +function windowsProcessStartPowerShellCommand(pids: readonly number[]): string { + const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR "); + return `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`; +} + +function parseWindowsProcessStartTimes( + stdout: string, + pids: readonly number[], +): Map { + const byPid = new Map(); + for (const line of stdout.split(/\r?\n/)) { + const tab = line.indexOf("\t"); + if (tab <= 0) continue; + const pid = Number(line.slice(0, tab)); + const parsed = Date.parse(line.slice(tab + 1).trim()); + if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed); + } + return new Map(pids.map(pid => [pid, byPid.get(pid) ?? null])); +} + +async function readWindowsProcessStartMsBatchAsync( + pids: readonly number[], +): Promise> { + try { + const stdout = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", "-NoLogo", "-NonInteractive", + "-Command", + windowsProcessStartPowerShellCommand(pids), + ], 5_000); + return parseWindowsProcessStartTimes(stdout, pids); + } catch { + return new Map(pids.map(pid => [pid, null])); + } +} + /** * Start times for many pids in ONE platform call where possible, so the * staleness check does not serialize per-process ps/PowerShell invocations @@ -568,22 +637,12 @@ export function readProcessStartMsBatch( } if (platform === "win32") { try { - const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR "); const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", - `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`, + windowsProcessStartPowerShellCommand(pids), ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }); - const byPid = new Map(); - for (const line of stdout.split(/\r?\n/)) { - const tab = line.indexOf("\t"); - if (tab <= 0) continue; - const pid = Number(line.slice(0, tab)); - const parsed = Date.parse(line.slice(tab + 1).trim()); - if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed); - } - for (const pid of pids) out.set(pid, byPid.get(pid) ?? null); - return out; + return parseWindowsProcessStartTimes(stdout, pids); } catch { for (const pid of pids) out.set(pid, null); return out; @@ -610,9 +669,65 @@ function defaultCatalogMtimeMs(): number | null { } } +function codexAppServerProcessesFromSnapshots( + snapshots: readonly ProcessSnapshot[], +): CodexAppServerProcess[] { + const processes: CodexAppServerProcess[] = []; + const seen = new Set(); + for (const snapshot of snapshots) { + if (seen.has(snapshot.pid)) continue; + if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; + seen.add(snapshot.pid); + processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); + } + return processes; +} + +function catalogStatusFromProcesses( + processes: readonly CodexAppServerProcess[], + catalogMtimeMs: number | null, + starts: ReadonlyMap, +): CodexAppServerCatalogStatus { + const withStarts = processes.map(proc => ({ + pid: proc.pid, + startedAtMs: starts.get(proc.pid) ?? null, + })); + if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) { + return { state: "unknown", processes: withStarts, catalogMtimeMs }; + } + // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can + // report equal values when the catalog actually changed after startup. + const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs); + return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; +} + // Short TTL: process listing + stat run once per window even under per-turn // guidance calls (#857). let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null; +interface RequestCatalogStateIdentity { + platform: NodeJS.Platform; + listSnapshots?: CodexAppServerProcessIo["listSnapshots"]; + listSnapshotsAsync?: CodexAppServerProcessIo["listSnapshotsAsync"]; + readStartMs?: CodexAppServerProcessIo["readStartMs"]; + readStartMsBatchAsync?: CodexAppServerProcessIo["readStartMsBatchAsync"]; + catalogMtimeMs?: CodexAppServerProcessIo["catalogMtimeMs"]; + now?: CodexAppServerProcessIo["now"]; +} + +interface RequestCatalogStateFlight { + generation: number; + identity: RequestCatalogStateIdentity; + promise: Promise; +} + +let requestCatalogStateGeneration = 0; +let requestCatalogStateCache: { + generation: number; + identity: RequestCatalogStateIdentity; + atMs: number; + status: CodexAppServerCatalogStatus; +} | null = null; +let requestCatalogStateFlight: RequestCatalogStateFlight | null = null; const CATALOG_STATE_TTL_MS = 5_000; /** * `unknown` is a failure to observe, not an observation, so it gets a much shorter @@ -627,6 +742,19 @@ export function catalogStateTtlMs(state: CodexAppServerCatalogState): number { return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS; } +function sameRequestCatalogStateIdentity( + left: RequestCatalogStateIdentity, + right: RequestCatalogStateIdentity, +): boolean { + return left.platform === right.platform + && left.listSnapshots === right.listSnapshots + && left.listSnapshotsAsync === right.listSnapshotsAsync + && left.readStartMs === right.readStartMs + && left.readStartMsBatchAsync === right.readStartMsBatchAsync + && left.catalogMtimeMs === right.catalogMtimeMs + && left.now === right.now; +} + /** * Compare the on-disk catalog mtime against the start time of running Codex * app-servers (#857): a server that started before the catalog changed keeps @@ -676,33 +804,17 @@ export function collectCodexAppServerCatalogState( snapshots = []; enumerationFailed = true; } - const processes: CodexAppServerProcess[] = []; - const seen = new Set(); - for (const snapshot of snapshots) { - if (seen.has(snapshot.pid)) continue; - if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue; - seen.add(snapshot.pid); - processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine }); - } + const processes = codexAppServerProcessesFromSnapshots(snapshots); if (processes.length === 0) { return enumerationFailed ? { state: "unknown", processes: [], catalogMtimeMs: null } : { state: "not_running", processes: [], catalogMtimeMs: null }; } const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); - const withStarts = io.readStartMs - ? processes.map(proc => ({ pid: proc.pid, startedAtMs: io.readStartMs!(proc.pid) })) - : (() => { - const batch = readProcessStartMsBatch(processes.map(proc => proc.pid), platform); - return processes.map(proc => ({ pid: proc.pid, startedAtMs: batch.get(proc.pid) ?? null })); - })(); - if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) { - return { state: "unknown", processes: withStarts, catalogMtimeMs }; - } - // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can - // report equal values when the catalog actually changed after startup. - const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs); - return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; + const starts = io.readStartMs + ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) + : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); + return catalogStatusFromProcesses(processes, catalogMtimeMs, starts); }; const status = compute(); if (fullyDefault) { @@ -711,9 +823,108 @@ export function collectCodexAppServerCatalogState( return status; } +/** + * Request-path catalog state collector. + * + * [Decision Log] + * - 목적과 의도: keep Windows CIM discovery from blocking Bun's event loop while v2 guidance is built. + * - 기존 구현 및 제약 조건: CLI/service operations still need the synchronous, fail-closed collector; the request path needs only advisory state. + * - 검토한 주요 대안: remove stale-catalog guidance, move all process work to workers, or add a Windows-only async boundary. + * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests. + * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract. + * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe. + */ +export async function collectCodexAppServerCatalogStateForRequest( + io: CodexAppServerProcessIo = {}, +): Promise { + const platform = io.platform ?? process.platform; + if (platform !== "win32") return collectCodexAppServerCatalogState(io); + + const now = (io.now ?? Date.now)(); + const generation = requestCatalogStateGeneration; + const identity: RequestCatalogStateIdentity = { + platform, + listSnapshots: io.listSnapshots, + listSnapshotsAsync: io.listSnapshotsAsync, + readStartMs: io.readStartMs, + readStartMsBatchAsync: io.readStartMsBatchAsync, + catalogMtimeMs: io.catalogMtimeMs, + now: io.now, + }; + if (requestCatalogStateCache + && requestCatalogStateCache.generation === generation + && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity) + && now - requestCatalogStateCache.atMs < CATALOG_STATE_TTL_MS) { + return requestCatalogStateCache.status; + } + if (requestCatalogStateFlight + && requestCatalogStateFlight.generation === generation + && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) { + return requestCatalogStateFlight.promise; + } + + const refresh = async (): Promise => { + let snapshots: ProcessSnapshot[]; + try { + snapshots = io.listSnapshotsAsync + ? await io.listSnapshotsAsync() + : io.listSnapshots + ? io.listSnapshots() + : await listWindowsSnapshotsAsync(); + } catch { + return { state: "unknown", processes: [], catalogMtimeMs: null }; + } + const processes = codexAppServerProcessesFromSnapshots(snapshots); + if (processes.length === 0) { + return { state: "not_running", processes: [], catalogMtimeMs: null }; + } + let catalogMtimeMs: number | null; + try { + catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); + } catch { + catalogMtimeMs = null; + } + const pids = processes.map(proc => proc.pid); + const starts = io.readStartMsBatchAsync + ? await io.readStartMsBatchAsync(pids) + : io.readStartMs + ? new Map(pids.map(pid => [pid, io.readStartMs!(pid)] as const)) + : await readWindowsProcessStartMsBatchAsync(pids); + return catalogStatusFromProcesses(processes, catalogMtimeMs, starts); + }; + + const pending = refresh().catch(() => ({ + state: "unknown" as const, + processes: [], + catalogMtimeMs: null, + })); + let flight: RequestCatalogStateFlight; + const promise = pending.then(status => { + // A catalog write can invalidate while slow CIM is still running. Never + // let that pre-write result repopulate the post-write cache. + if (requestCatalogStateGeneration === generation && requestCatalogStateFlight === flight) { + requestCatalogStateCache = { + generation, + identity, + atMs: (io.now ?? Date.now)(), + status, + }; + } + return status; + }).finally(() => { + if (requestCatalogStateFlight === flight) requestCatalogStateFlight = null; + }); + flight = { generation, identity, promise }; + requestCatalogStateFlight = flight; + return flight.promise; +} + /** Test hook: drop the memoized catalog state. */ export function resetCodexAppServerCatalogStateCache(): void { catalogStateCache = null; + requestCatalogStateGeneration += 1; + requestCatalogStateCache = null; + requestCatalogStateFlight = null; } export interface RestartCodexAppServersResult { diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 37fca4777f..7ab1037d13 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -197,7 +197,8 @@ export interface MultiAgentGuidanceDeps { configuredModels: readonly string[], surface: SpawnAgentSurface, ) => EffectiveSubagentRoster | Promise; - collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" }; + collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" } + | Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }>; } async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }> { @@ -207,8 +208,8 @@ async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale" if (override === "fresh" || override === "stale" || override === "not_running" || override === "unknown") { return { state: override }; } - const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes"); - return collectCodexAppServerCatalogState(); + const { collectCodexAppServerCatalogStateForRequest } = await import("../../codex/app-server-processes"); + return collectCodexAppServerCatalogStateForRequest(); } diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 2c0a8345e5..9f4b6cad33 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -59,6 +59,16 @@ deleting, or editing a provider's shape clears that per-provider cache; a disabl deliberately does not, because a disabled provider is already excluded from the catalog gather instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +### Windows request-path catalog-state discovery + +[Decision Log] +- 목적과 의도: Prevent Windows PowerShell/CIM process discovery from blocking Bun's event loop while v2 sub-agent guidance is assembled. +- 기존 구현 및 제약 조건: The stale-catalog check is advisory on the request path, but CLI/service lifecycle operations use the same process evidence before warning or terminating narrowly matched app-servers. +- 검토한 주요 대안: Remove stale-catalog guidance, move every platform collector into workers, or isolate only the Windows request path behind asynchronous child processes. +- 선택한 방식: Keep the synchronous fail-closed collector for explicit lifecycle operations; v2 requests use asynchronous trusted-System32 PowerShell, one identity-scoped in-flight refresh, and the existing short cache. Cache invalidation advances a generation so a pre-write CIM result cannot repopulate post-write state. +- 다른 대안 대신 이 방식을 선택한 이유: This preserves process ownership and matching invariants while preventing a slow CIM query from starving `/healthz` and unrelated proxy traffic. +- 장점, 단점 및 영향: Concurrent v2 turns do not multiply CIM walks and the event loop remains responsive. A cold request can still await the bounded advisory check, and collection failure suppresses OpenCodex-authored model guidance as `unknown`. + ## Startup readiness Each `startServer` invocation owns a private, one-shot readiness gate created before the listener diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index c3cd7dad56..cda73d4204 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -8,6 +8,7 @@ import { attachStaleAppServerHint, catalogStateTtlMs, collectCodexAppServerCatalogState, + collectCodexAppServerCatalogStateForRequest, formatStaleCodexAppServerWarning, isCodexAppServerCommandLine, isWindowsCodexCandidateCommandLine, @@ -74,6 +75,92 @@ describe("collectCodexAppServerCatalogState (#857)", () => { expect(noCatalog.state).toBe("unknown"); }); + test("Windows request collection yields to the event loop while CIM enumeration is slow (#1852)", async () => { + let releaseSnapshots: ((snapshots: Array<{ pid: number; commandLine: string }>) => void) | undefined; + const snapshots = new Promise>(resolve => { + releaseSnapshots = resolve; + }); + const collection = collectCodexAppServerCatalogStateForRequest({ + platform: "win32", + listSnapshotsAsync: () => snapshots, + readStartMsBatchAsync: async pids => new Map(pids.map(pid => [pid, 2_000])), + catalogMtimeMs: () => 1_000, + }); + + const first = await Promise.race([ + collection.then(() => "collection"), + new Promise<"timer">(resolve => setTimeout(() => resolve("timer"), 10)), + ]); + expect(first).toBe("timer"); + + releaseSnapshots?.([{ pid: 42, commandLine: APP_SERVER_CMD }]); + await expect(collection).resolves.toMatchObject({ state: "fresh" }); + }); + + test("Windows request collection shares one in-flight refresh and its short cache (#1852)", async () => { + resetCodexAppServerCatalogStateCache(); + let calls = 0; + let now = 1_000; + const io = { + platform: "win32" as const, + now: () => now, + listSnapshotsAsync: async () => { + calls += 1; + await Bun.sleep(10); + return [{ pid: 42, commandLine: APP_SERVER_CMD }]; + }, + readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 2_000])), + catalogMtimeMs: () => 1_000, + }; + + const [first, joined] = await Promise.all([ + collectCodexAppServerCatalogStateForRequest(io), + collectCodexAppServerCatalogStateForRequest(io), + ]); + expect(first.state).toBe("fresh"); + expect(joined).toBe(first); + expect(calls).toBe(1); + + now += 4_999; + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("fresh"); + expect(calls).toBe(1); + + now += 2; + expect((await collectCodexAppServerCatalogStateForRequest(io)).state).toBe("fresh"); + expect(calls).toBe(2); + resetCodexAppServerCatalogStateCache(); + }); + + test("cache invalidation cannot be undone by an older in-flight Windows refresh (#1852)", async () => { + resetCodexAppServerCatalogStateCache(); + let calls = 0; + let releaseFirst: ((snapshots: Array<{ pid: number; commandLine: string }>) => void) | undefined; + const firstSnapshots = new Promise>(resolve => { + releaseFirst = resolve; + }); + const io = { + platform: "win32" as const, + listSnapshotsAsync: async () => { + calls += 1; + if (calls === 1) return firstSnapshots; + return []; + }, + readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 2_000])), + catalogMtimeMs: () => 1_000, + }; + + const staleFlight = collectCodexAppServerCatalogStateForRequest(io); + resetCodexAppServerCatalogStateCache(); + releaseFirst?.([{ pid: 42, commandLine: APP_SERVER_CMD }]); + await expect(staleFlight).resolves.toMatchObject({ state: "fresh" }); + + await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ + state: "not_running", + }); + expect(calls).toBe(2); + resetCodexAppServerCatalogStateCache(); + }); + test("unrelated processes never enter the comparison", () => { const status = collectCodexAppServerCatalogState({ listSnapshots: () => [ From a71d81adbb0ea466076db8a2c624cfd699f2b5fc Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 17 Aug 2026 00:38:50 +0000 Subject: [PATCH 034/121] test(windows): pin request catalog cache boundaries --- .../content/docs/guides/sub-agent-surface.md | 2 +- src/codex/app-server-processes.ts | 7 ++++- tests/codex-app-server-processes.test.ts | 27 +++++++++++++++++++ tests/multi-agent-compat.test.ts | 2 +- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 8fb0e4d49e..e5773a4f4d 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -260,7 +260,7 @@ separately by `ocx doctor`. `stale` clears only after every detected Codex app-s the final catalog write; it does not necessarily clear `unknown`. On Windows, this advisory check uses asynchronous PowerShell/CIM discovery on the v2 request path. -Concurrent cold checks share one in-flight discovery and successful results are cached briefly. A +Concurrent cold checks share one in-flight discovery and results are cached briefly. A slow or failing CIM query can delay or suppress only OpenCodex-authored model guidance; it does not block the Bun event loop, `/healthz`, or unrelated proxy traffic. Explicit CLI/service lifecycle operations retain the synchronous, fail-closed process collector because they may signal processes. diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 8d64b829d2..086c1d95ff 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -919,7 +919,12 @@ export async function collectCodexAppServerCatalogStateForRequest( return flight.promise; } -/** Test hook: drop the memoized catalog state. */ +/** + * Drop memoized catalog state after a relevant catalog/cache write and before + * the post-write state read. Advancing the generation prevents an older + * in-flight Windows CIM refresh from publishing its pre-write result after the + * write has completed. + */ export function resetCodexAppServerCatalogStateCache(): void { catalogStateCache = null; requestCatalogStateGeneration += 1; diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index cda73d4204..b725949801 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -161,6 +161,33 @@ describe("collectCodexAppServerCatalogState (#857)", () => { resetCodexAppServerCatalogStateCache(); }); + test("Windows request collection briefly caches failed CIM enumeration (#1852)", async () => { + resetCodexAppServerCatalogStateCache(); + let calls = 0; + let now = 1_000; + const io = { + platform: "win32" as const, + now: () => now, + listSnapshotsAsync: async () => { + calls += 1; + throw new Error("windows_enum_incomplete"); + }, + catalogMtimeMs: () => 1_000, + }; + + await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ + state: "unknown", + }); + now += 10; + await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ + state: "unknown", + }); + // Failure is advisory and fail-closed, but caching it briefly prevents a + // broken CIM provider from spawning one PowerShell process per request. + expect(calls).toBe(1); + resetCodexAppServerCatalogStateCache(); + }); + test("unrelated processes never enter the comparison", () => { const status = collectCodexAppServerCatalogState({ listSnapshots: () => [ diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 360727a00a..afbddab3d9 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -129,7 +129,7 @@ describe("multiAgentGuidanceText", () => { for (const state of ["stale", "unknown"] as const) { const text = await multiAgentGuidanceText(parsed, options, { - collectCatalogState: () => ({ state }), + collectCatalogState: async () => ({ state }), }); // #1395: withhold OpenCodex's disk-derived claims, but do not prohibit // options the active spawn_agent tool advertises — the global catalog From 53ffc9eb9a3180b545243eb88f18f6c7a782ce6b Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 12:35:38 +0000 Subject: [PATCH 035/121] fix(windows): preserve short unknown catalog retries --- docs-site/src/content/docs/guides/sub-agent-surface.md | 3 ++- src/codex/app-server-processes.ts | 2 +- tests/codex-app-server-processes.test.ts | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index e5773a4f4d..4a56c810bd 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -260,7 +260,8 @@ separately by `ocx doctor`. `stale` clears only after every detected Codex app-s the final catalog write; it does not necessarily clear `unknown`. On Windows, this advisory check uses asynchronous PowerShell/CIM discovery on the v2 request path. -Concurrent cold checks share one in-flight discovery and results are cached briefly. A +Concurrent cold checks share one in-flight discovery. Observed states are cached for five seconds; +an `unknown` failure is cached for only 250 milliseconds so a transient CIM error retries quickly. A slow or failing CIM query can delay or suppress only OpenCodex-authored model guidance; it does not block the Bun event loop, `/healthz`, or unrelated proxy traffic. Explicit CLI/service lifecycle operations retain the synchronous, fail-closed process collector because they may signal processes. diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 086c1d95ff..d10f26a2ae 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -854,7 +854,7 @@ export async function collectCodexAppServerCatalogStateForRequest( if (requestCatalogStateCache && requestCatalogStateCache.generation === generation && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity) - && now - requestCatalogStateCache.atMs < CATALOG_STATE_TTL_MS) { + && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) { return requestCatalogStateCache.status; } if (requestCatalogStateFlight diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index b725949801..e7eb23f42d 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -185,6 +185,12 @@ describe("collectCodexAppServerCatalogState (#857)", () => { // Failure is advisory and fail-closed, but caching it briefly prevents a // broken CIM provider from spawning one PowerShell process per request. expect(calls).toBe(1); + + now += 241; + await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ + state: "unknown", + }); + expect(calls).toBe(2); resetCodexAppServerCatalogStateCache(); }); From 61a1edace63929346b9c8d1552449cf2860415db Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:23:11 +0900 Subject: [PATCH 036/121] fix(windows): degrade an invalidated in-flight catalog observation to unknown The generation check suppressed the cache write but still returned the pre-write status to the request already awaiting it. That request then saw `fresh`, which is the one state authorizing positive model guidance (collaboration.ts returns null for stale/unknown), so it would advertise the newly written disk catalog to an app-server whose in-memory copy the write had just made stale. That trades the hang this change exists to fix for a wrong answer, which is the worse failure. Return `unknown` when the generation moved: it is what an invalidated observation actually knows, and the guidance path already treats it as say-nothing. The existing regression asserted the old `fresh` result, so it pinned the defect; it now asserts `unknown`, and a companion test proves the next post-write observation still reports `fresh` rather than being poisoned. Both fail when the fix is reverted. --- src/codex/app-server-processes.ts | 22 +++++++++++-- tests/codex-app-server-processes.test.ts | 40 +++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index d10f26a2ae..8d51d8efa9 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -900,9 +900,25 @@ export async function collectCodexAppServerCatalogStateForRequest( })); let flight: RequestCatalogStateFlight; const promise = pending.then(status => { - // A catalog write can invalidate while slow CIM is still running. Never - // let that pre-write result repopulate the post-write cache. - if (requestCatalogStateGeneration === generation && requestCatalogStateFlight === flight) { + // A catalog write can invalidate while slow CIM is still running. The result + // describes the PRE-write world, so it must neither repopulate the post-write + // cache nor reach the caller. + // + // Suppressing only the cache write is not enough. The awaiting request still + // received `fresh`, and `fresh` is the one state that authorizes positive + // guidance (`src/server/responses/collaboration.ts:279-280` returns null for + // `stale`/`unknown` but describes the catalog for `fresh`). So the request + // would advertise the newly written disk catalog to an app-server whose + // in-memory copy the write just made stale — a wrong answer, which is worse + // than the slow answer this whole change exists to fix. + // + // Degrade to `unknown` instead: it is the honest description of what an + // invalidated observation knows, and the guidance path already treats it as + // "say nothing positive". + if (requestCatalogStateGeneration !== generation) { + return { state: "unknown" as const, processes: [], catalogMtimeMs: null }; + } + if (requestCatalogStateFlight === flight) { requestCatalogStateCache = { generation, identity, diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index e7eb23f42d..cb310388e0 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -152,7 +152,12 @@ describe("collectCodexAppServerCatalogState (#857)", () => { const staleFlight = collectCodexAppServerCatalogStateForRequest(io); resetCodexAppServerCatalogStateCache(); releaseFirst?.([{ pid: 42, commandLine: APP_SERVER_CMD }]); - await expect(staleFlight).resolves.toMatchObject({ state: "fresh" }); + // The awaiting request must NOT receive the pre-write `fresh`. It was true + // before the invalidating write and is false after it, and `fresh` is the one + // state that authorizes positive model guidance — so handing it back trades the + // original hang for a wrong answer. `unknown` is what an invalidated + // observation actually knows, and the guidance path already stays silent on it. + await expect(staleFlight).resolves.toMatchObject({ state: "unknown" }); await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ state: "not_running", @@ -161,6 +166,39 @@ describe("collectCodexAppServerCatalogState (#857)", () => { resetCodexAppServerCatalogStateCache(); }); + test("an invalidated in-flight refresh does not poison the next request (#1852)", async () => { + // Companion to the case above: degrading the obsolete result to `unknown` must + // not also suppress the NEXT observation, which is made after the write and is + // therefore the one the caller should trust. + resetCodexAppServerCatalogStateCache(); + let calls = 0; + let releaseFirst: ((snapshots: Array<{ pid: number; commandLine: string }>) => void) | undefined; + const firstSnapshots = new Promise>(resolve => { + releaseFirst = resolve; + }); + const io = { + platform: "win32" as const, + listSnapshotsAsync: async () => { + calls += 1; + if (calls === 1) return firstSnapshots; + return [{ pid: 43, commandLine: APP_SERVER_CMD }]; + }, + readStartMsBatchAsync: async (pids: readonly number[]) => new Map(pids.map(pid => [pid, 2_000])), + catalogMtimeMs: () => 1_000, + }; + + const obsolete = collectCodexAppServerCatalogStateForRequest(io); + resetCodexAppServerCatalogStateCache(); + releaseFirst?.([{ pid: 42, commandLine: APP_SERVER_CMD }]); + await expect(obsolete).resolves.toMatchObject({ state: "unknown" }); + + await expect(collectCodexAppServerCatalogStateForRequest(io)).resolves.toMatchObject({ + state: "fresh", + }); + expect(calls).toBe(2); + resetCodexAppServerCatalogStateCache(); + }); + test("Windows request collection briefly caches failed CIM enumeration (#1852)", async () => { resetCodexAppServerCatalogStateCache(); let calls = 0; From 4ff8456e4a43a01232ca679048d9c60d31d6e1ee Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:25:46 +0900 Subject: [PATCH 037/121] test(windows): give the async catalog enumeration a real oracle The existing #1852 test injects an already-async seam, so it stays green when the production default is reverted to execFileSync. A pre-merge review restored the synchronous enumeration and the whole suite still reported 93 pass 0 fail -- including the test named 'yields to the event loop'. It described the design without guarding it. Off Windows the default path was untestable at all: resolveTrustedWindowsPowerShellExe fails immediately, so sync and async are indistinguishable. Add an exec seam to listWindowsSnapshotsAsync so a test can drive the DEFAULT wiring, then assert the property that actually matters -- a timer keeps ticking while enumeration is in flight. Driven red: reverting the request path to listWindowsSnapshots fails this test and nothing else. --- src/codex/app-server-processes.ts | 28 ++++++++++++++++--- tests/codex-app-server-processes.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 8d51d8efa9..6b312168a7 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -450,12 +450,32 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri return parseWindowsSnapshotOutput(output); } -async function listWindowsSnapshotsAsync(): Promise { - const output = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ +/** + * Exec seam for the async enumeration. Without it the default request path is + * untestable off Windows: `resolveTrustedWindowsPowerShellExe()` fails immediately, so a + * synchronous and an asynchronous implementation are indistinguishable from a test — which + * is exactly how a test asserting "yields to the event loop" stayed green after the async + * call was reverted to `execFileSync`. Overriding this lets a test drive the real default + * wiring and observe whether the event loop keeps running during enumeration. + */ +let windowsSnapshotExecAsync: (command: string, timeoutMs: number) => Promise = + (command, timeoutMs) => execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", - windowsSnapshotPowerShellCommand(), - ], 8_000); + command, + ], timeoutMs); + +/** Test-only: swap the async enumeration exec. Returns a restore function. */ +export function setWindowsSnapshotExecAsyncForTest( + exec: (command: string, timeoutMs: number) => Promise, +): () => void { + const previous = windowsSnapshotExecAsync; + windowsSnapshotExecAsync = exec; + return () => { windowsSnapshotExecAsync = previous; }; +} + +async function listWindowsSnapshotsAsync(): Promise { + const output = await windowsSnapshotExecAsync(windowsSnapshotPowerShellCommand(), 8_000); return parseWindowsSnapshotOutput(output); } diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index cb310388e0..c0c6d302f9 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -17,6 +17,7 @@ import { parseWindowsSnapshotOutput, resetCodexAppServerCatalogStateCache, restartCodexAppServers, + setWindowsSnapshotExecAsyncForTest, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -97,6 +98,39 @@ describe("collectCodexAppServerCatalogState (#857)", () => { await expect(collection).resolves.toMatchObject({ state: "fresh" }); }); + // The test above injects an ALREADY-async seam, so it stays green whether or not the + // production default is async: reverting the default to `execFileSync` leaves every + // assertion in it passing. It describes the intended design without guarding it. + // + // This one drives the DEFAULT wiring — no `listSnapshotsAsync` override — through the + // exec seam, so a synchronous enumeration is observable as what it actually is: a + // blocked event loop. That is the defect #1852 reported. + test("the default Windows request enumeration does not block the event loop (#1852)", async () => { + resetCodexAppServerCatalogStateCache(); + const restore = setWindowsSnapshotExecAsyncForTest(async () => { + // Stand in for a slow CIM walk. A synchronous implementation spends this time + // inside execFileSync with the loop parked; an async one leaves it running. + await new Promise(resolve => setTimeout(resolve, 30)); + return `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`; + }); + try { + let ticks = 0; + const timer = setInterval(() => { ticks += 1; }, 5); + const status = await collectCodexAppServerCatalogStateForRequest({ + platform: "win32", + readStartMsBatchAsync: async pids => new Map(pids.map(pid => [pid, 2_000])), + catalogMtimeMs: () => 1_000, + }); + clearInterval(timer); + expect(status.state).toBe("fresh"); + // The whole point of the fix: other work ran while enumeration was in flight. + expect(ticks).toBeGreaterThan(0); + } finally { + restore(); + resetCodexAppServerCatalogStateCache(); + } + }); + test("Windows request collection shares one in-flight refresh and its short cache (#1852)", async () => { resetCodexAppServerCatalogStateCache(); let calls = 0; From d55bc920db427e3ea491f7f088a278557f031222 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:37:05 +0900 Subject: [PATCH 038/121] test(windows): guard all three async wirings behind #1852 Two pre-merge review rounds showed the existing tests describe the fix rather than guard it. Round one: reverting the request path to a synchronous enumeration left 93 pass 0 fail, including the test named 'yields to the event loop'. Round two defeated my first repair as well -- it replaced the very exec the test injected, so the seam proved only that a fake async function is async. The tests could not reach the default path at all off Windows, because resolveTrustedWindowsPowerShellExe() fails immediately and makes sync and async indistinguishable. Use the executable override the repo already has (setTrustedWindowsElevationExecutablesForTests) to point PowerShell at a stalling shell script, then assert the property #1852 is actually about: other work keeps running while enumeration is in flight. Thresholds are measured, not guessed. Request path: async admits ~42 ticks against a 10ms timer, sync ~19 (Bun's execFileSync is not a total block). Collaboration path: async ~23, sync 0. Driven red against all three mutations the reviewer used: 1. async enumeration -> execFileSync 2. async start-time discovery -> readProcessStartMsBatch 3. v2 default wiring -> collectCodexAppServerCatalogState Each fails the new tests and nothing else. Restored: 96 pass, 0 fail, tsc 0. --- src/codex/app-server-processes.ts | 28 ++--------- tests/codex-app-server-processes.test.ts | 62 ++++++++++++++++-------- tests/multi-agent-compat.test.ts | 55 ++++++++++++++++++++- 3 files changed, 98 insertions(+), 47 deletions(-) diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 6b312168a7..8d51d8efa9 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -450,32 +450,12 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri return parseWindowsSnapshotOutput(output); } -/** - * Exec seam for the async enumeration. Without it the default request path is - * untestable off Windows: `resolveTrustedWindowsPowerShellExe()` fails immediately, so a - * synchronous and an asynchronous implementation are indistinguishable from a test — which - * is exactly how a test asserting "yields to the event loop" stayed green after the async - * call was reverted to `execFileSync`. Overriding this lets a test drive the real default - * wiring and observe whether the event loop keeps running during enumeration. - */ -let windowsSnapshotExecAsync: (command: string, timeoutMs: number) => Promise = - (command, timeoutMs) => execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ +async function listWindowsSnapshotsAsync(): Promise { + const output = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", - command, - ], timeoutMs); - -/** Test-only: swap the async enumeration exec. Returns a restore function. */ -export function setWindowsSnapshotExecAsyncForTest( - exec: (command: string, timeoutMs: number) => Promise, -): () => void { - const previous = windowsSnapshotExecAsync; - windowsSnapshotExecAsync = exec; - return () => { windowsSnapshotExecAsync = previous; }; -} - -async function listWindowsSnapshotsAsync(): Promise { - const output = await windowsSnapshotExecAsync(windowsSnapshotPowerShellCommand(), 8_000); + windowsSnapshotPowerShellCommand(), + ], 8_000); return parseWindowsSnapshotOutput(output); } diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index c0c6d302f9..7196b7c222 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; import { @@ -17,7 +18,6 @@ import { parseWindowsSnapshotOutput, resetCodexAppServerCatalogStateCache, restartCodexAppServers, - setWindowsSnapshotExecAsyncForTest, STALE_CODEX_APP_SERVER_HINT, warnIfStaleCodexAppServersAfterStartupWrite, WINDOWS_CODEX_BASENAME_CANDIDATE_RE, @@ -102,33 +102,53 @@ describe("collectCodexAppServerCatalogState (#857)", () => { // production default is async: reverting the default to `execFileSync` leaves every // assertion in it passing. It describes the intended design without guarding it. // - // This one drives the DEFAULT wiring — no `listSnapshotsAsync` override — through the - // exec seam, so a synchronous enumeration is observable as what it actually is: a - // blocked event loop. That is the defect #1852 reported. - test("the default Windows request enumeration does not block the event loop (#1852)", async () => { + // The one below guards it. It replaces the *executable* rather than the code under + // test, so BOTH default PowerShell calls — process enumeration and start-time + // discovery — run for real through their production wiring. A synchronous + // implementation parks the event loop for the script's whole duration; an + // asynchronous one does not. That difference is the entire content of #1852. + test("the default Windows request path keeps the event loop alive through both PowerShell calls (#1852)", async () => { resetCodexAppServerCatalogStateCache(); - const restore = setWindowsSnapshotExecAsyncForTest(async () => { - // Stand in for a slow CIM walk. A synchronous implementation spends this time - // inside execFileSync with the loop parked; an async one leaves it running. - await new Promise(resolve => setTimeout(resolve, 30)); - return `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`; - }); + const dir = mkdtempSync(join(tmpdir(), "ocx-ps-fake-")); + const fake = join(dir, "powershell.sh"); + // Ignores its arguments and stalls, then prints one enumeration row. Both the + // snapshot call and the start-time call land here; each sleeps, so a synchronous + // runner blocks twice. + writeFileSync(fake, [ + "#!/bin/sh", + "sleep 0.2", + `printf '%s\\t%s\\t%s\\n' 42 '${APP_SERVER_CMD}' 'CONTOSO\\\\jun'`, + ].join("\n")); + chmodSync(fake, 0o755); + setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + + let ticks = 0; + let status: Awaited>; + const timer = setInterval(() => { ticks += 1; }, 10); try { - let ticks = 0; - const timer = setInterval(() => { ticks += 1; }, 5); - const status = await collectCodexAppServerCatalogStateForRequest({ + status = await collectCodexAppServerCatalogStateForRequest({ platform: "win32", - readStartMsBatchAsync: async pids => new Map(pids.map(pid => [pid, 2_000])), catalogMtimeMs: () => 1_000, }); - clearInterval(timer); - expect(status.state).toBe("fresh"); - // The whole point of the fix: other work ran while enumeration was in flight. - expect(ticks).toBeGreaterThan(0); } finally { - restore(); + clearInterval(timer); + setTrustedWindowsElevationExecutablesForTests(null); + rmSync(dir, { recursive: true, force: true }); resetCodexAppServerCatalogStateCache(); } + + // Assert the fake was actually parsed. Without this the test passes on + // "not_running" — which is what a failed exec also produces — so a broken + // enumeration would look identical to a fast one. + expect(status.processes.map(proc => proc.pid)).toEqual([42]); + + // The fake stalls ~200ms per call against a 10ms timer, and the request path makes + // TWO calls (enumeration, then start-time discovery). Measured on this repo: + // async default ~42 ticks, synchronous default ~19. The gap is real but not total — + // Bun's execFileSync still lets a few timers through — so the threshold sits between + // the two measurements rather than at zero. Isolated probe for the same runtime: + // execFileSync("sleep 0.3") admits 1 tick against a 10ms timer. + expect(ticks).toBeGreaterThan(28); }); test("Windows request collection shares one in-flight refresh and its short cache (#1852)", async () => { diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index afbddab3d9..b5de902bd9 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -4,14 +4,15 @@ * the Proactive delegation prompt when they arrive with the synthetic top tier. */ import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { parseRequest } from "../src/responses/parser"; import type { OcxParsedRequest } from "../src/types"; import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../src/codex/catalog"; -import { collectCodexAppServerCatalogState } from "../src/codex/app-server-processes"; +import { collectCodexAppServerCatalogState, resetCodexAppServerCatalogStateCache } from "../src/codex/app-server-processes"; +import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; import { clearDebugSettings, setDebugSettings } from "../src/lib/debug-settings"; import { getInjectionDebugLogEntries, @@ -118,6 +119,56 @@ describe("multiAgentGuidanceText", () => { expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "max", tools: [{ name: "shell" }] }))).toBeNull(); }); + // Every catalog-state test in this file injects `collectCatalogState`, which means none + // of them observes which collector the DEFAULT path picks. Rewiring the v2 boundary back + // to the synchronous collector left this whole suite green — the regression #1852 exists + // to prevent would have shipped unnoticed. This pins the default wiring itself. + test("the v2 default catalog path uses the request collector, not the synchronous one (#1852)", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [{ + slug: "anthropic/claude-sonnet-5", + efforts: ["low", "medium", "high", "xhigh"], + }]); + const parsed = parsedFixture({ reasoning: "medium", tools: [{ name: "spawn_agent" }] }); + + // Force the default dependency by passing NO collectCatalogState, and make the + // underlying process enumeration observable through the trusted-executable seam: + // a stalling fake stands in for a slow CIM walk. The async request collector leaves + // the loop free; the synchronous collector parks it. + const fakeDir = mkdtempSync(join(tmpdir(), "ocx-collab-ps-")); + const fake = join(fakeDir, "powershell.sh"); + writeFileSync(fake, ["#!/bin/sh", "sleep 0.2", "printf ''"].join("\n")); + chmodSync(fake, 0o755); + setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + const realPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + resetCodexAppServerCatalogStateCache(); + // This suite sets a hermetic state override at module load so the host's real + // app-server cannot leak in. That override short-circuits before any collector runs, + // so it has to come off for exactly this test — which is the one test that needs the + // real default path. + delete process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE; + + let ticks = 0; + const timer = setInterval(() => { ticks += 1; }, 10); + try { + await multiAgentGuidanceText(parsed, { injectionModel: "anthropic/claude-sonnet-5" }); + } finally { + clearInterval(timer); + Object.defineProperty(process, "platform", realPlatform); + setTrustedWindowsElevationExecutablesForTests(null); + rmSync(fakeDir, { recursive: true, force: true }); + resetCodexAppServerCatalogStateCache(); + process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = "fresh"; + } + + // Measured on this repo against the stalling fake: the async request collector admits + // ~23 ticks, the synchronous collector admits 0 — it parks the loop for the whole + // enumeration. Any positive count proves the async wiring; the margin to 8 is + // generous enough that a loaded CI box cannot flake it. + expect(ticks).toBeGreaterThan(8); + }); + test("v2 guidance suppresses positive model claims while the app-server catalog is stale or unknown (#857)", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ From ca7923a59bc4fb7fbd92d5a8be3e6d16e2dd8c48 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:44:20 +0900 Subject: [PATCH 039/121] test(windows): make the #1852 oracles portable and timing-free Review round 3 accepted the oracles but found two defects in how they were built, both real: 1. The fixture was a POSIX .sh script. execFile launches its target with no shell, so that is not executable on Windows -- the platform this fix exists for, and one whose CI shard runs this suite. Emit a .cmd on win32 and a shell script elsewhere. 2. The assertions counted setInterval callbacks and compared against a midpoint between measured sync and async values (19 vs 42). setInterval makes no catch-up guarantee, so a loaded runner could push a correct implementation under the threshold. Replaced with a phase signal: did any event-loop work run while the child was alive? A synchronous exec parks the loop, so the flag cannot flip regardless of machine speed. Splitting the request-path test to isolate enumeration also lost the start-time oracle, so that second PowerShell call -- up to five seconds of blocking -- now has its own test. Driven red against all three mutations, each separately: enumeration to execFileSync, start-time to readProcessStartMsBatch, and v2 wiring to the synchronous collector. 96 pass, 0 fail, tsc 0. --- tests/codex-app-server-processes.test.ts | 92 +++++++++++++++++------- tests/multi-agent-compat.test.ts | 29 +++++--- 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/tests/codex-app-server-processes.test.ts b/tests/codex-app-server-processes.test.ts index 7196b7c222..10426505d8 100644 --- a/tests/codex-app-server-processes.test.ts +++ b/tests/codex-app-server-processes.test.ts @@ -24,7 +24,32 @@ import { } from "../src/codex/app-server-processes"; describe("collectCodexAppServerCatalogState (#857)", () => { - const APP_SERVER_CMD = "/usr/local/bin/codex app-server"; +const APP_SERVER_CMD = "/usr/local/bin/codex app-server"; + +/** + * A stand-in for powershell.exe that stalls, prints `line`, and exits. + * + * Platform-shaped on purpose: `execFile` launches its target directly with no shell, so a + * POSIX `.sh` script is not an executable on Windows — and Windows is the platform this + * whole fix exists for, with its own CI shard running this suite. On Windows the fake is a + * `.cmd` invoked through `cmd.exe`; elsewhere it is a shell script. + */ +function writeStallingFakePowerShell(dir: string, line: string): string { + if (process.platform === "win32") { + const cmd = join(dir, "fake-powershell.cmd"); + writeFileSync(cmd, [ + "@echo off", + // ~200ms without depending on timeout.exe, which refuses a redirected stdin. + "ping -n 1 -w 200 192.0.2.1 >nul 2>&1", + `echo ${line.replace(/\t/g, "\t")}`, + ].join("\r\n")); + return cmd; + } + const sh = join(dir, "fake-powershell.sh"); + writeFileSync(sh, ["#!/bin/sh", "sleep 0.2", `printf '%s\\n' '${line}'`].join("\n")); + chmodSync(sh, 0o755); + return sh; +} test("not_running when no app-server process exists", () => { const status = collectCodexAppServerCatalogState({ @@ -110,45 +135,64 @@ describe("collectCodexAppServerCatalogState (#857)", () => { test("the default Windows request path keeps the event loop alive through both PowerShell calls (#1852)", async () => { resetCodexAppServerCatalogStateCache(); const dir = mkdtempSync(join(tmpdir(), "ocx-ps-fake-")); - const fake = join(dir, "powershell.sh"); - // Ignores its arguments and stalls, then prints one enumeration row. Both the - // snapshot call and the start-time call land here; each sleeps, so a synchronous - // runner blocks twice. - writeFileSync(fake, [ - "#!/bin/sh", - "sleep 0.2", - `printf '%s\\t%s\\t%s\\n' 42 '${APP_SERVER_CMD}' 'CONTOSO\\\\jun'`, - ].join("\n")); - chmodSync(fake, 0o755); + const fake = writeStallingFakePowerShell(dir, `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`); setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); - let ticks = 0; + // Phase signal instead of a timer count. A callback tally has to pick a threshold + // between "sync" and "async" observations, and `setInterval` makes no catch-up + // guarantee — on a loaded runner a correct implementation can dip under any midpoint. + // This asks a binary question instead: did event-loop work make progress WHILE the + // child was running? A synchronous exec parks the loop, so the flag stays false no + // matter how slow or fast the machine is. + let loopRanDuringExec = false; + const beat = setInterval(() => { loopRanDuringExec = true; }, 5); let status: Awaited>; - const timer = setInterval(() => { ticks += 1; }, 10); try { status = await collectCodexAppServerCatalogStateForRequest({ platform: "win32", catalogMtimeMs: () => 1_000, + // Only the enumeration is exercised here; the start-time half has its own test. + readStartMsBatchAsync: async pids => new Map(pids.map(pid => [pid, 2_000])), }); } finally { - clearInterval(timer); + clearInterval(beat); setTrustedWindowsElevationExecutablesForTests(null); rmSync(dir, { recursive: true, force: true }); resetCodexAppServerCatalogStateCache(); } - // Assert the fake was actually parsed. Without this the test passes on - // "not_running" — which is what a failed exec also produces — so a broken - // enumeration would look identical to a fast one. + // Without this a failed exec ("not_running") would look identical to a fast one. expect(status.processes.map(proc => proc.pid)).toEqual([42]); + expect(loopRanDuringExec).toBe(true); + }); + + // The request path makes TWO PowerShell calls. The test above injects + // `readStartMsBatchAsync` so it isolates the first one — which means reverting the + // SECOND to a synchronous read slips past it. That second call is up to five seconds of + // blocking when an app-server exists, so it needs its own oracle. + test("the default Windows start-time discovery keeps the event loop alive (#1852)", async () => { + resetCodexAppServerCatalogStateCache(); + const dir = mkdtempSync(join(tmpdir(), "ocx-ps-start-")); + const fake = writeStallingFakePowerShell(dir, `42\t${APP_SERVER_CMD}\tCONTOSO\\jun`); + setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); + + let loopRanDuringExec = false; + const beat = setInterval(() => { loopRanDuringExec = true; }, 5); + try { + // No readStartMsBatchAsync override: the default start-time path must run for real. + await collectCodexAppServerCatalogStateForRequest({ + platform: "win32", + listSnapshotsAsync: async () => [{ pid: 42, commandLine: APP_SERVER_CMD }], + catalogMtimeMs: () => 1_000, + }); + } finally { + clearInterval(beat); + setTrustedWindowsElevationExecutablesForTests(null); + rmSync(dir, { recursive: true, force: true }); + resetCodexAppServerCatalogStateCache(); + } - // The fake stalls ~200ms per call against a 10ms timer, and the request path makes - // TWO calls (enumeration, then start-time discovery). Measured on this repo: - // async default ~42 ticks, synchronous default ~19. The gap is real but not total — - // Bun's execFileSync still lets a few timers through — so the threshold sits between - // the two measurements rather than at zero. Isolated probe for the same runtime: - // execFileSync("sleep 0.3") admits 1 tick against a 10ms timer. - expect(ticks).toBeGreaterThan(28); + expect(loopRanDuringExec).toBe(true); }); test("Windows request collection shares one in-flight refresh and its short cache (#1852)", async () => { diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index b5de902bd9..5cb4217185 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -136,9 +136,15 @@ describe("multiAgentGuidanceText", () => { // a stalling fake stands in for a slow CIM walk. The async request collector leaves // the loop free; the synchronous collector parks it. const fakeDir = mkdtempSync(join(tmpdir(), "ocx-collab-ps-")); - const fake = join(fakeDir, "powershell.sh"); - writeFileSync(fake, ["#!/bin/sh", "sleep 0.2", "printf ''"].join("\n")); - chmodSync(fake, 0o755); + // Platform-shaped: execFile takes no shell, so a POSIX script is not executable on + // Windows — the platform this fix targets, whose CI shard runs this suite. + const fake = join(fakeDir, process.platform === "win32" ? "fake-powershell.cmd" : "fake-powershell.sh"); + if (process.platform === "win32") { + writeFileSync(fake, ["@echo off", "ping -n 1 -w 200 192.0.2.1 >nul 2>&1"].join("\r\n")); + } else { + writeFileSync(fake, ["#!/bin/sh", "sleep 0.2", "printf ''"].join("\n")); + chmodSync(fake, 0o755); + } setTrustedWindowsElevationExecutablesForTests({ powershell: fake }); const realPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); @@ -149,12 +155,17 @@ describe("multiAgentGuidanceText", () => { // real default path. delete process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE; - let ticks = 0; - const timer = setInterval(() => { ticks += 1; }, 10); + // Phase signal rather than a tick count: a threshold between "sync" and "async" + // observations has to guess how many timer callbacks a loaded runner will deliver, + // and setInterval promises no catch-up. This asks the binary question instead — did + // any event-loop work run WHILE the child was alive? A synchronous exec parks the + // loop, so the flag cannot flip regardless of machine speed. + let loopRanDuringExec = false; + const beat = setInterval(() => { loopRanDuringExec = true; }, 5); try { await multiAgentGuidanceText(parsed, { injectionModel: "anthropic/claude-sonnet-5" }); } finally { - clearInterval(timer); + clearInterval(beat); Object.defineProperty(process, "platform", realPlatform); setTrustedWindowsElevationExecutablesForTests(null); rmSync(fakeDir, { recursive: true, force: true }); @@ -162,11 +173,7 @@ describe("multiAgentGuidanceText", () => { process.env.OPENCODEX_APP_SERVER_CATALOG_STATE_OVERRIDE = "fresh"; } - // Measured on this repo against the stalling fake: the async request collector admits - // ~23 ticks, the synchronous collector admits 0 — it parks the loop for the whole - // enumeration. Any positive count proves the async wiring; the margin to 8 is - // generous enough that a loaded CI box cannot flake it. - expect(ticks).toBeGreaterThan(8); + expect(loopRanDuringExec).toBe(true); }); test("v2 guidance suppresses positive model claims while the app-server catalog is stale or unknown (#857)", async () => { From 135872d25b5718701e7bcfc624932f79835d5121 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:16:03 +0900 Subject: [PATCH 040/121] fix(tools): recover the namespace when a custom_tool_call is replayed The alias mapping was one-way. The bridge emits a client-facing custom call carrying only the bare name -- {"type":"custom_tool_call","name":"exec"} even for a tool declared as mcp__functions__exec -- and the parser copied that name without reconstructing the namespace. Adapters then replay tool history through namespacedToolName(namespace, name), so the replayed call targeted a bare exec the provider may not expose. Rebuild the namespace from the request's own tool catalog at parse time. function_call items are unaffected: they carry namespace on the wire. The reserved functions namespace stays flattened, matching buildTools -- reconstructing one there would invent a namespace the request never advertised. Both directions are pinned by a regression; removing the reconstruction fails the first and nothing else. --- src/responses/parser.ts | 37 ++++++++++++++++++++++++ tests/responses-parser.test.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index f7f6326d3d..ca9f425f29 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -328,6 +328,35 @@ function attachPendingReasoningToCallOwner( const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); +/** + * Namespace a custom tool was declared under, by its bare name. + * + * A `custom_tool_call` echoed back by the client carries only the bare name — the bridge + * emits `{"type":"custom_tool_call","name":"exec"}` even when the tool was declared as + * `mcp__functions__exec`. Without this lookup the namespace is lost on the return trip, + * and the adapters replay history through `namespacedToolName(namespace, name)`, which + * then produces a bare `exec` the provider may not have. Ordinary `function_call` items + * do not need this: they carry `namespace` on the wire. + */ +function customToolNamespaces(tools: unknown): Map { + const out = new Map(); + if (!Array.isArray(tools)) return out; + for (const spec of tools) { + if (!isObj(spec) || spec.type !== "namespace" || !Array.isArray(spec.tools)) continue; + const namespace = typeof spec.name === "string" ? spec.name : undefined; + // Codex 0.147 groups ordinary client tools under the reserved `functions` namespace and + // buildTools deliberately flattens those without a namespace. Mirror that here, or the + // reconstruction would invent a namespace the request never advertised. + if (!namespace || namespace === "functions") continue; + for (const inner of spec.tools) { + if (!isObj(inner) || inner.type !== "custom" || typeof inner.name !== "string") continue; + // Ambiguous bare names are already rejected upstream, so first declaration wins. + if (!out.has(inner.name)) out.set(inner.name, namespace); + } + } + return out; +} + export function parseRequest( body: unknown, parseOptions?: { replayCacheScope?: OcxReasoningReplayScopeRef }, @@ -341,6 +370,9 @@ export function parseRequest( const data = parsed.data; const now = Date.now(); const messages: OcxMessage[] = []; + // Built before the item loop: a custom_tool_call echoed back in `input` needs the + // namespace from the request's own tool catalog to survive the round trip. + const customToolNamespacesByName = customToolNamespaces(data.tools); const systemPrompt: string[] = []; // Responses reasoning siblings belong to the following assistant, including across call items. // Keep them off the message list until that assistant arrives; turn boundaries clear the array. @@ -574,10 +606,15 @@ export function parseRequest( if (effectiveType === "custom_tool_call") { const call = item as { id?: string; call_id: string; name: string; input: string }; const remembered = typeof call.call_id === "string" ? replayThoughtSignatureMetadata(call.call_id, replayCacheScope) : undefined; + // Reconstruct the namespace the request declared this tool under. The wire item + // carries only the bare name, so without this the round trip loses it and adapters + // replay the call as an unnamespaced tool the provider may not expose. + const customNamespace = customToolNamespacesByName.get(call.name); const toolCall: OcxToolCall = { type: "toolCall", id: call.call_id, name: call.name, arguments: { input: call.input ?? "" }, customWireName: call.name, + ...(customNamespace ? { namespace: customNamespace } : {}), ...(remembered ? { providerMetadata: remembered } : {}), }; assistantHolderWithReasoning().content.push(toolCall); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 98443126f2..1f5f991e14 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -606,4 +606,57 @@ describe("codex-rs compat surface (260707)", () => { expect(parseRequest({ model: "p/m", input: "hi", reasoning: { effort: "banana" } }).options.reasoning).toBeUndefined(); expect(() => parseRequest({ model: "p/m", input: "hi", reasoning: { effort: null } })).toThrow(); }); + + test("a replayed custom_tool_call recovers the namespace it was declared under", () => { + // The round trip loses it otherwise. The bridge emits a client-facing custom call with + // only the BARE name — `{"type":"custom_tool_call","name":"exec"}` even for a tool + // declared as `mcp__functions__exec`. On the next request the adapters replay tool + // history through `namespacedToolName(namespace, name)`, so a missing namespace makes + // the replayed call target a bare `exec` the provider may not expose. + // + // `function_call` items do not need this: they carry `namespace` on the wire. + const parsed = parseRequest({ + model: "p/m", + tools: [{ + type: "namespace", + name: "mcp__tools", + tools: [{ type: "custom", name: "exec", description: "run", format: { type: "text" } }], + }], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "custom_tool_call", call_id: "call_1", name: "exec", input: "ls" }, + ], + }); + + const assistant = parsed.context.messages.find(msg => msg.role === "assistant"); + const call = (assistant?.content as Array<{ type: string; name?: string; namespace?: string }> | undefined) + ?.find(part => part.type === "toolCall"); + expect(call?.name).toBe("exec"); + expect(call?.namespace).toBe("mcp__tools"); + }); + + test("a replayed custom_tool_call under the reserved functions namespace stays bare", () => { + // Companion guard. Codex 0.147 groups ordinary client tools under `functions`, and + // buildTools deliberately flattens those WITHOUT a namespace. Reconstructing one here + // would invent a namespace the request never advertised and break the reverse mapping + // in the other direction. + const parsed = parseRequest({ + model: "p/m", + tools: [{ + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "apply_patch", description: "patch", format: { type: "text" } }], + }], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "custom_tool_call", call_id: "call_2", name: "apply_patch", input: "*** Begin Patch" }, + ], + }); + + const assistant = parsed.context.messages.find(msg => msg.role === "assistant"); + const call = (assistant?.content as Array<{ type: string; name?: string; namespace?: string }> | undefined) + ?.find(part => part.type === "toolCall"); + expect(call?.name).toBe("apply_patch"); + expect(call?.namespace).toBeUndefined(); + }); }); From e1ef7942b11a9a5370895f17f881947c889decc2 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:18:16 +0900 Subject: [PATCH 041/121] fix(usage): mark an assumed OpenRouter priority cost as a lower bound An assumed Fast attempt reported the standard total with no uncertainty marker, so the UI received a definite price for a request whose served tier the provider never echoed. OpenRouter bills by the tier actually served and documents priority as more expensive, so an unmarked standard total can understate the real charge. The confirmed case was already a lower bound because the premium endpoint price is not bundled here. The assumed case needs the same marker for a stronger reason: the outcome itself was never observed. Same treatment, different justification. Driven red: restricting the predicate back to confirmed-only fails the new regression and nothing else. --- src/usage/cost.ts | 8 +++++- tests/fastwire-observability.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 6a82604722..608dea31ac 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -459,6 +459,12 @@ function applyPriorityMultiplier( * branch does not bundle its provider-specific priority endpoint prices. A confirmed canonical * priority result can therefore use the standard price only as a provable lower bound. Do not * extend this to flex (cheaper) or to other providers without the same pricing contract. + * + * An ASSUMED priority attempt needs the same marker for a different reason. There the provider + * never echoed a tier at all, so the standard price is not merely a lower bound on a known + * premium — it is a floor under an outcome we did not observe. Returning it unmarked reports a + * definite standard cost for a request that may well have been billed as priority, which is the + * one thing a cost estimate must never do. */ function isOpenRouterPriorityLowerBound( provider: string, @@ -467,7 +473,7 @@ function isOpenRouterPriorityLowerBound( return baseProviderLabel(provider) === "openrouter" && outcome?.canonical === "priority" && outcome.fastOutcome === "applied" - && outcome.confirmation === "confirmed"; + && (outcome.confirmation === "confirmed" || outcome.confirmation === "assumed"); } /** diff --git a/tests/fastwire-observability.test.ts b/tests/fastwire-observability.test.ts index f29332f9e0..d712a038ad 100644 --- a/tests/fastwire-observability.test.ts +++ b/tests/fastwire-observability.test.ts @@ -689,6 +689,44 @@ describe("FastWire per-attempt cost", () => { expect(openAi.priorityLowerBound).toBeUndefined(); }); + test("an ASSUMED OpenRouter priority attempt is a lower bound, not a definite standard price", () => { + // The provider never echoed a tier, so we do not know which price was billed. Reporting + // the standard total unmarked would tell the UI "this cost 1.6" for a request that may + // have been served -- and billed -- as priority. The confirmed case is a lower bound + // because the premium price is not bundled; the assumed case is a lower bound because + // the OUTCOME itself was never observed. Both must carry the marker. + const usage = { inputTokens: 1_000_000, outputTokens: 0, cachedInputTokens: 0 }; + const assumedPriority = { + canonical: "priority" as const, + wireKind: "service-tier" as const, + wireValue: "priority", + fastOutcome: "applied" as const, + confirmation: "assumed" as const, + }; + const openRouterOverlays: ExpectedPriceOverlay[] = [{ + provider: "openrouter", + modelId: "openai/gpt-5.6-sol", + cost4: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + source: "test", + verifiedAt: "2026-08-18", + status: "verified", + }]; + + const assumed = estimateComboCost([{ + ordinal: 1, + provider: "openrouter", + model: "openai/gpt-5.6-sol", + usageStatus: "reported", + usage, + tierOutcome: assumedPriority, + }], openRouterOverlays)!; + + expect(assumed.priorityLowerBound).toBe(true); + expect(assumed.attempts?.[0]?.priorityLowerBound).toBe(true); + // No premium multiplier is applied — the standard rate IS the floor being reported. + expect(assumed.priorityMultiplier).toBeUndefined(); + }); + test("management cost metadata exposes the aligned priority_lower_bound reason", () => { const result = costResult({ provider: "openrouter", From 47e752a7c1bb79a97b9d993a83d22a4afb5df8cd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 19:48:38 +0900 Subject: [PATCH 042/121] refactor(types): move value clusters to types/tools and types/wire leaves --- src/types.ts | 234 +++++---------------------------------------- src/types/tools.ts | 131 +++++++++++++++++++++++++ src/types/wire.ts | 80 ++++++++++++++++ 3 files changed, 234 insertions(+), 211 deletions(-) create mode 100644 src/types/tools.ts create mode 100644 src/types/wire.ts diff --git a/src/types.ts b/src/types.ts index c3e4a4eaca..e341ea6366 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ import type { KiroOAuthMetadata } from "./oauth/types"; +import type { OcxTool, OcxToolChoice } from "./types/tools"; +import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; /** Exact provider/credential namespace for process-local reasoning replay. */ export interface OcxReasoningReplayIdentity { @@ -214,137 +216,17 @@ export interface OcxProviderOpaqueToolCallMetadata { export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall; -export interface OcxTool { - name: string; - description: string; - parameters: Record; - strict?: boolean; - /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ - namespace?: string; - /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ - freeform?: boolean; - /** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */ - toolSearch?: boolean; - /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */ - loadedFromToolSearch?: boolean; - /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */ - cursorStructuredEdit?: true; - /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ - webSearch?: boolean; - /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ - imageGeneration?: boolean; - /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ - videoGeneration?: boolean; -} - -/** - * Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to - * "__" so they survive the chat-completions function-tool format; - * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP - * calls by an explicit `namespace` field, not by parsing the name). - */ -export function namespacedToolName(namespace: string | undefined, name: string): string { - return namespace ? `${namespace}__${name}` : name; -} - -export function toolChoiceAliases(tool: Pick): string[] { - const wireName = namespacedToolName(tool.namespace, tool.name); - return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; -} - -function sameToolIdentity( - left: Pick, - right: Pick, -): boolean { - return left.namespace === right.namespace && left.name === right.name; -} - -/** - * All tools that could be selected by one client-facing name. Bare logical names are included - * here because they are a compatibility selector for namespaced tools, while wire and dotted - * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. - */ -export function toolChoiceCandidates( - tools: readonly Pick[] | undefined, - name: string, -): Pick[] { - if (!tools) return []; - const candidates: Pick[] = []; - for (const tool of tools) { - if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; - if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); - } - return candidates; -} - -/** - * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that - * shorthand only when the request contains one tool with the logical name, so an ambiguous name - * cannot authorize a tool from an unintended namespace. - */ -export function toolAllowedByChoice( - tool: Pick, - allowedTools: ReadonlySet, - tools?: readonly Pick[], -): boolean { - if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); - for (const name of [...toolChoiceAliases(tool), tool.name]) { - if (!allowedTools.has(name)) continue; - const candidates = toolChoiceCandidates(tools, name); - if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; - } - return false; -} - -export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { - const candidates = toolChoiceCandidates(tools, name); - if (candidates.length === 1) { - const match = candidates[0]; - return namespacedToolName(match.namespace, match.name); - } - // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The - // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. - return name; -} - -/** - * Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full - * id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers - * `gpt-oss:120b`/`gpt-oss:20b`. Colon-less ids (e.g. `grok-build-0.1`) still match exactly only. - */ -export function modelInList(list: string[] | undefined, modelId: string): boolean { - if (!list || list.length === 0) return false; - if (list.includes(modelId)) return true; - const colon = modelId.indexOf(":"); - return colon > 0 && list.includes(modelId.slice(0, colon)); -} - -export type OcxToolChoice = - | "auto" - | "none" - | "required" - | { name: string } - | { allowedTools: string[]; mode: "auto" | "required" }; - -export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } { - return typeof value === "object" && value !== null && "allowedTools" in value; -} - -/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ -export function toolChoiceToolPredicate( - choice: OcxToolChoice | undefined, - tools?: readonly Pick[], -): (tool: Pick) => boolean { - if (!choice || choice === "auto" || choice === "required") return () => true; - if (choice === "none") return () => false; - if (isAllowedToolChoice(choice)) { - const allowed = new Set(choice.allowedTools); - return tool => toolAllowedByChoice(tool, allowed, tools); - } - if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); - const candidates = toolChoiceCandidates(tools, choice.name); - return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); -} +export type { OcxTool, OcxToolChoice } from "./types/tools"; +export { + namespacedToolName, + toolChoiceAliases, + toolChoiceCandidates, + toolAllowedByChoice, + resolveToolChoiceWireName, + modelInList, + isAllowedToolChoice, + toolChoiceToolPredicate, +} from "./types/tools"; export interface OcxRequestOptions { maxOutputTokens?: number; @@ -1825,86 +1707,16 @@ export interface OcxProviderConfig { nativeLocalExec?: "off" | "codex-sandbox" | "on"; } -/** - * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the - * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so - * a value that one boundary accepts can never be rejected by another. - */ -export const UPSTREAM_HTTP_VERSION_VALUES = [ - "auto", - "http1.1", - "h1", - "http2", - "h2", -] as const; - -export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number]; - -export const REASONING_SUMMARY_DELIVERY_VALUES = [ - "sequential", - "sequential_cutoff", - "concurrent", - "concurrent_cutoff", -] as const; - -export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; - -/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ -export type CodexAccountMode = "direct" | "pool"; - -export const OPENAI_PROVIDER_TIER_VERSION = 2 as const; - -/** - * Wires that a per-model `modelAdapters` override may select. - * - * Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry - * their own credential and base-URL semantics, so exposing them here would widen the - * auth boundary rather than pick a wire. Widening this set needs a per-adapter - * credential threat model first (#404). - */ -export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ - "openai-chat", - "openai-responses", -]); - -/** - * Providers whose listed model ids must be driven over the Anthropic wire even when - * the provider's configured adapter says otherwise — the upstream only speaks - * Anthropic for these models. - */ -const ANTHROPIC_WIRE_MODELS: Record> = { - "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]), -}; - -function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { - return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) - ? ANTHROPIC_WIRE_MODELS[providerName] - : undefined; -} - -/** Detached provider-local hard-pin table for pure wire-policy resolution. */ -export function captureWireAdapterHardPins(providerName: string): Readonly> { - const models = anthropicWireModelsForProvider(providerName); - if (!models) return Object.freeze({}); - return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); -} - -/** - * True when the upstream speaks exactly one wire for this model, so a configured - * override must not apply. - * - * Deliberately independent of the provider's current adapter: the wire resolver runs - * more than once per request, and a check phrased as "pin differs from the current - * adapter" would pass on the first pass and then let the override win on the second. - */ -export function isWirePinnedModel(providerName: string, modelId: string): boolean { - return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false; -} - -/** The wire a pinned model must use, or undefined when the model is not pinned. */ -export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined { - return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined; -} +export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; +export { + UPSTREAM_HTTP_VERSION_VALUES, + REASONING_SUMMARY_DELIVERY_VALUES, + OPENAI_PROVIDER_TIER_VERSION, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + captureWireAdapterHardPins, + isWirePinnedModel, + pinnedWireAdapter, +} from "./types/wire"; export interface CodexAccount { id: string; diff --git a/src/types/tools.ts b/src/types/tools.ts new file mode 100644 index 0000000000..9e3dc37fd0 --- /dev/null +++ b/src/types/tools.ts @@ -0,0 +1,131 @@ +export interface OcxTool { + name: string; + description: string; + parameters: Record; + strict?: boolean; + /** MCP namespace (e.g. "mcp__context7") for tools flattened out of a Responses "namespace" tool. */ + namespace?: string; + /** Freeform/custom tool (e.g. apply_patch): the model's call must be relayed as a custom_tool_call. */ + freeform?: boolean; + /** Client-executed tool discovery (tool_search): the model's call must be relayed as a tool_search_call. */ + toolSearch?: boolean; + /** Tool definition restored from a prior tool_search output; transports may prioritize it when catalogs are bounded. */ + loadedFromToolSearch?: boolean; + /** Cursor-only synthetic exact-match edit tool; never inferred from the wire name. */ + cursorStructuredEdit?: true; + /** Synthetic web_search tool: the model's call is executed by the gpt-5.4-mini sidecar, not relayed to Codex. */ + webSearch?: boolean; + /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */ + imageGeneration?: boolean; + /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */ + videoGeneration?: boolean; +} + +/** + * Wire name a chat model sees for a tool. Namespaced (MCP) tools are flattened to + * "__" so they survive the chat-completions function-tool format; + * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP + * calls by an explicit `namespace` field, not by parsing the name). + */ +export function namespacedToolName(namespace: string | undefined, name: string): string { + return namespace ? `${namespace}__${name}` : name; +} + +export function toolChoiceAliases(tool: Pick): string[] { + const wireName = namespacedToolName(tool.namespace, tool.name); + return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; +} + +function sameToolIdentity( + left: Pick, + right: Pick, +): boolean { + return left.namespace === right.namespace && left.name === right.name; +} + +/** + * All tools that could be selected by one client-facing name. Bare logical names are included + * here because they are a compatibility selector for namespaced tools, while wire and dotted + * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. + */ +export function toolChoiceCandidates( + tools: readonly Pick[] | undefined, + name: string, +): Pick[] { + if (!tools) return []; + const candidates: Pick[] = []; + for (const tool of tools) { + if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; + if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); + } + return candidates; +} + +/** + * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that + * shorthand only when the request contains one tool with the logical name, so an ambiguous name + * cannot authorize a tool from an unintended namespace. + */ +export function toolAllowedByChoice( + tool: Pick, + allowedTools: ReadonlySet, + tools?: readonly Pick[], +): boolean { + if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); + for (const name of [...toolChoiceAliases(tool), tool.name]) { + if (!allowedTools.has(name)) continue; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; + } + return false; +} + +export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1) { + const match = candidates[0]; + return namespacedToolName(match.namespace, match.name); + } + // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The + // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. + return name; +} + +/** + * Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full + * id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers + * `gpt-oss:120b`/`gpt-oss:20b`. Colon-less ids (e.g. `grok-build-0.1`) still match exactly only. + */ +export function modelInList(list: string[] | undefined, modelId: string): boolean { + if (!list || list.length === 0) return false; + if (list.includes(modelId)) return true; + const colon = modelId.indexOf(":"); + return colon > 0 && list.includes(modelId.slice(0, colon)); +} + +export type OcxToolChoice = + | "auto" + | "none" + | "required" + | { name: string } + | { allowedTools: string[]; mode: "auto" | "required" }; + +export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } { + return typeof value === "object" && value !== null && "allowedTools" in value; +} + +/** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ +export function toolChoiceToolPredicate( + choice: OcxToolChoice | undefined, + tools?: readonly Pick[], +): (tool: Pick) => boolean { + if (!choice || choice === "auto" || choice === "required") return () => true; + if (choice === "none") return () => false; + if (isAllowedToolChoice(choice)) { + const allowed = new Set(choice.allowedTools); + return tool => toolAllowedByChoice(tool, allowed, tools); + } + if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); + const candidates = toolChoiceCandidates(tools, choice.name); + return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); +} diff --git a/src/types/wire.ts b/src/types/wire.ts new file mode 100644 index 0000000000..0800bca428 --- /dev/null +++ b/src/types/wire.ts @@ -0,0 +1,80 @@ +/** + * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the + * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so + * a value that one boundary accepts can never be rejected by another. + */ +export const UPSTREAM_HTTP_VERSION_VALUES = [ + "auto", + "http1.1", + "h1", + "http2", + "h2", +] as const; + +export type UpstreamHttpVersion = (typeof UPSTREAM_HTTP_VERSION_VALUES)[number]; + +export const REASONING_SUMMARY_DELIVERY_VALUES = [ + "sequential", + "sequential_cutoff", + "concurrent", + "concurrent_cutoff", +] as const; + +export type ReasoningSummaryDelivery = typeof REASONING_SUMMARY_DELIVERY_VALUES[number]; + +/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */ +export type CodexAccountMode = "direct" | "pool"; + +export const OPENAI_PROVIDER_TIER_VERSION = 2 as const; + +/** + * Wires that a per-model `modelAdapters` override may select. + * + * Deliberately narrow: provider-specific adapters (cursor, kiro, google, ...) carry + * their own credential and base-URL semantics, so exposing them here would widen the + * auth boundary rather than pick a wire. Widening this set needs a per-adapter + * credential threat model first (#404). + */ +export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ + "openai-chat", + "openai-responses", +]); + +/** + * Providers whose listed model ids must be driven over the Anthropic wire even when + * the provider's configured adapter says otherwise — the upstream only speaks + * Anthropic for these models. + */ +const ANTHROPIC_WIRE_MODELS: Record> = { + "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]), +}; + +function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { + return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) + ? ANTHROPIC_WIRE_MODELS[providerName] + : undefined; +} + +/** Detached provider-local hard-pin table for pure wire-policy resolution. */ +export function captureWireAdapterHardPins(providerName: string): Readonly> { + const models = anthropicWireModelsForProvider(providerName); + if (!models) return Object.freeze({}); + return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); +} + +/** + * True when the upstream speaks exactly one wire for this model, so a configured + * override must not apply. + * + * Deliberately independent of the provider's current adapter: the wire resolver runs + * more than once per request, and a check phrased as "pin differs from the current + * adapter" would pass on the first pass and then let the override win on the second. + */ +export function isWirePinnedModel(providerName: string, modelId: string): boolean { + return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false; +} + +/** The wire a pinned model must use, or undefined when the model is not pinned. */ +export function pinnedWireAdapter(providerName: string, modelId: string): string | undefined { + return isWirePinnedModel(providerName, modelId) ? "anthropic" : undefined; +} From 3ddae409598f2db58b1bf4b2b8731e3fb6592adf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 19:48:38 +0900 Subject: [PATCH 043/121] docs(devlog): WP1 plan + split-program risk assessment --- .../010_wp1_types_value_leaves.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md diff --git a/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md b/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md new file mode 100644 index 0000000000..653f95d991 --- /dev/null +++ b/devlog/_plan/260818_megafile_split_program/010_wp1_types_value_leaves.md @@ -0,0 +1,104 @@ +# WP1 — types.ts value-leaf extraction (stacked PR 1 of the split program) + +Unit: devlog/_plan/260818_megafile_split_program. Risk basis: 000_risk_assessment.md. +Branch: codex/split-wp1-types on dev @ b04cd26e7 (post FastWire B0/B1 merge). +Class: C2 (mechanical move, shared-runtime file, full-suite gate). + +## Loop spec + +- Archetype: pure-move refactor, zero behavior change. +- Trigger: split program WP1, lowest-risk opener. +- Goal: src/types.ts stops carrying runtime value code; values live in leaves; + every existing import keeps working via re-export. +- Non-goals: NO type-cluster split yet (OcxConfig/OcxProviderConfig stay), + NO consumer retargeting to leaf paths, NO behavior or signature change. +- Verifier: bun run typecheck && bun run test (full — shared runtime file). +- Stop: both green + core-lab-boundary green; PR opened against dev. +- Memory artifact: this doc + ledger attests. + +## Scope (IN) + +Extract the two VALUE clusters from src/types.ts (1867 lines) into leaves: + +1. src/types/tools.ts — lines ~236-292: + namespacedToolName, toolChoiceAliases, toolAllowedByChoice, + resolveToolChoiceWireName, modelInList, OcxToolChoice (type), + isAllowedToolChoice, toolChoiceToolPredicate. + Needs `import type { OcxTool } from "../types"` — type-only, erased at + runtime, so the types.ts -> tools.ts re-export is NOT a runtime cycle. +2. src/types/wire.ts — lines ~1760-1839: + UPSTREAM_HTTP_VERSION_VALUES, UpstreamHttpVersion, + REASONING_SUMMARY_DELIVERY_VALUES, ReasoningSummaryDelivery, + CodexAccountMode, OPENAI_PROVIDER_TIER_VERSION, + MODEL_ADAPTER_OVERRIDE_ALLOWED, ANTHROPIC_WIRE_MODELS (internal), + anthropicWireModelsForProvider (internal), captureWireAdapterHardPins, + isWirePinnedModel, pinnedWireAdapter. Self-contained, no imports. + +src/types.ts keeps every current export via `export ... from "./types/..."`; +type-only names re-exported with `export type`. + +## Scope (OUT) + +- All interface/type clusters stay in types.ts this PR. +- No import-path changes anywhere else in src/ or tests/. +- No lab imports anywhere new (types is on the protected graph as a value + import from responses/core.ts: modelInList, namespacedToolName). + +## File change map + +- ADD src/types/tools.ts (~60 lines incl. docs) +- ADD src/types/wire.ts (~85 lines incl. docs) +- EDIT src/types.ts: delete moved bodies, add two re-export blocks at the + same positions; net -120 lines. + +## Accept criteria + +1. bun run typecheck exit 0. +2. bun run test full suite: same pass count as base (13k+), 0 fail. +3. tests/core-lab-boundary.test.ts green (covers the new static edges + types.ts -> types/tools.ts, types/wire.ts on the protected walk). +4. rg confirms no consumer file changed: git diff --stat touches exactly 3 + files. +5. Value identity preserved: MODEL_ADAPTER_OVERRIDE_ALLOWED still a single + ReadonlySet instance (only one declaration site, re-export not re-create). + +Activation grounding: criterion 3's scenario is the existing boundary test +run; criterion 5's scenario is the full suite (service-tier tests compare +set membership through both import paths). + +## Verifier reality (PLAN-VERIFIER-REAL-01) + +- bun run typecheck: exists in package.json, reads src/ via tsconfig + include ["src"] — observes both new files. To be run in C. +- bun run test: tests/ suite imports ../src/types in 400 files — observes + the barrel; core-lab-boundary walks the import graph from the three + protected roots which reach types.ts — observes the new edges. + +## Stacked-PR plan (DEV-STACK-01) + +PR 1 (this): value leaves + barrel. Target: dev. +PR 2 (next cycle): type-cluster split (request/config/provider/accounts) +stacked on PR 1's head branch. +Later cycles per 000_risk_assessment.md order (config leaves, registry, ...). + +## Audit amendments (A-phase, 2 auditors: grok-4.6 NEAR-PASS / gpt-5.6-sol FAIL->fixed) + +1. CYCLE FIX (sol blocker): OcxTool (lines 211-232) moves INTO types/tools.ts. + tools.ts imports NOTHING from ../types — dependency is strictly one-way + (types.ts -> types/tools.ts). types.ts re-exports OcxTool as a type. +2. RECIPE FIX (grok finding 7): `export type { X } from` does not BIND X in + the barrel. types.ts still uses OcxTool (line 106), OcxToolChoice (299), + UpstreamHttpVersion (1455), CodexAccountMode (1470), + ReasoningSummaryDelivery (1574) — so the barrel adds a local + `import type { OcxTool, OcxToolChoice } from "./types/tools"` and + `import type { UpstreamHttpVersion, ReasoningSummaryDelivery, + CodexAccountMode } from "./types/wire"` next to the Kiro import. +3. OcxToolChoice + its guards travel with tools.ts (they are one cluster). +4. Extensionless specifiers only (lab walker resolves `${base}.ts`). +5. AC4 corrected: scope proof = `git diff --stat ..HEAD -- src tests` + showing exactly 3 src files; devlog/plan files are committed separately. +6. AC5 proof corrected: identity is preserved by ESM re-export semantics + (single declaration site); drop the false 'both import paths' claim. +7. Protected-roots note corrected: PROTECTED has 4 files; only + responses/core.ts puts types.ts on the runtime graph (core.ts:63). + From a0f8c0135b26118dcb55b4c700ffb7c7da013a84 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:11:36 +0900 Subject: [PATCH 044/121] refactor(types): move type clusters to leaves; types.ts becomes a pure barrel Recut onto the rebased WP1 parent rather than rebased onto it. A rebase conflicted across the whole file for a mechanical reason: this commit rewrites types.ts into a 103-line barrel, so any dev commit that adds a declaration to the old 1884-line file collides with the rewrite everywhere. Three declarations landed on dev after the fork point and are re-homed into the leaf that owns their cluster rather than dropped: - OcxReasoningReplayIdentity.credentialDurableIdentity (#1926) -> types/request - CodexAccount.planSource and .planCredentialGeneration -> types/accounts Verified: tsc --noEmit clean, 150 tests pass across the two suites that read these types most directly. --- .../020_wp1b_type_clusters.md | 94 + src/types.ts | 1811 +---------------- src/types/accounts.ts | 37 + src/types/config.ts | 818 ++++++++ src/types/provider.ts | 521 +++++ src/types/request.ts | 358 ++++ 6 files changed, 1907 insertions(+), 1732 deletions(-) create mode 100644 devlog/_plan/260818_megafile_split_program/020_wp1b_type_clusters.md create mode 100644 src/types/accounts.ts create mode 100644 src/types/config.ts create mode 100644 src/types/provider.ts create mode 100644 src/types/request.ts diff --git a/devlog/_plan/260818_megafile_split_program/020_wp1b_type_clusters.md b/devlog/_plan/260818_megafile_split_program/020_wp1b_type_clusters.md new file mode 100644 index 0000000000..fe187b6528 --- /dev/null +++ b/devlog/_plan/260818_megafile_split_program/020_wp1b_type_clusters.md @@ -0,0 +1,94 @@ +# WP1b — types.ts type-cluster split (stacked PR 2, layer 2 of the stack) + +Stack (DEV-STACK-01/03): layer 1 = #2019 (codex/split-wp1-types, value leaves). +This layer: codex/split-wp1b-type-clusters, base = codex/split-wp1-types. +Thesis: src/types.ts becomes a pure barrel; all type clusters move to leaves. +Class: C2 pure-move, type-only (zero runtime code moves in this layer). + +## Loop spec + +- Archetype: pure-move refactor, zero behavior change (type-only). +- Verifier: bun run typecheck + full bun run test on lidge (remote contract). +- Stop: green + PR opened with base codex/split-wp1-types + stack map in body. + +## Measured dependency structure (one-way, no cycles) + +- request cluster (lines 6-368): needs KiroOAuthMetadata (oauth/types), + OcxTool + OcxToolChoice (types/tools). Nothing else external. +- config cluster (370-1180): needs OcxProviderConfig only (provider cluster). +- provider cluster (1183-1698): needs UpstreamHttpVersion x2, + ReasoningSummaryDelivery x3, CodexAccountMode x2 (types/wire). +- accounts cluster (1700-1729): self-contained. + +## File change map + +- ADD src/types/request.ts <- lines 6-368 + import type {KiroOAuthMetadata} + from ../oauth/types, import type {OcxTool, OcxToolChoice} from ./tools +- ADD src/types/config.ts <- lines 370-1180 + import type + {OcxProviderConfig} from ./provider +- ADD src/types/provider.ts <- lines 1183-1698 + import type {...} from ./wire +- ADD src/types/accounts.ts <- lines 1700-1729, no imports +- EDIT src/types.ts -> pure barrel (~30 lines): export type blocks for the 4 + new leaves + existing tools/wire re-exports (values stay `export {}`, + types stay `export type {}`). KiroOAuthMetadata import dropped from barrel. + +## Accept criteria + +1. typecheck exit 0. 2. lidge full suite 0 fail (>= 13201 pass baseline). +3. core-lab-boundary green (barrel value re-exports still walked; type-only + leaves are erased so runtime graph SHRINKS, never grows). +4. Source diff: exactly 5 files under src/ (4 adds + barrel). +5. Public surface byte-compatible: src/index.ts exports (OcxConfig, OcxContext, + OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxRequestOptions, OcxTool, + AdapterEvent) all still resolve from ./types. + +## Risks + +- `export type ... from` binds nothing locally (WP1 lesson) — but the new + barrel needs NO local bindings once all interfaces leave; only the 4 + import-type lines vanish too. Residual: none expected. +- interface merging/declaration duplication: each name must exist in exactly + one leaf; grep-verify no name appears in two files. +- Tests importing `import * as types from ../src/types` (namespace): type-only + namespaces erased; runtime namespace keeps the same value exports via + tools/wire re-exports. No test currently reads a VALUE that moves (nothing + moves at runtime this layer). + + +## Audit amendments round 2 (grok-4.6 NEAR-PASS / sol FAIL -> both fixed) + +CORRECTED extract ranges (file is 1727 lines): + +- request.ts: lines 5-211 (incl. leading JSDoc) + 224-364 + + import type { KiroOAuthMetadata } from ../oauth/types + + import type { OcxTool, OcxToolChoice } from ./tools + + import type { TierDecision, TierObservationContext } from ./provider + (OcxRequestOptions.tierDecision:235 / tierObservation:237 — missed edge) +- config.ts: lines 366-1181 (incl. closing brace 1181) MINUS the + RefreshPolicy block (1074-1080, moves to provider — see below) + + import type { OcxProviderConfig, RefreshPolicy } is WRONG — instead: + + import type { OcxProviderConfig } from ./provider (604) + + import type { CodexAccount } from ./accounts (874 — missed edge) +- provider.ts: lines 1183-1687 + RefreshPolicy block (1074-1080; sole + consumer is OcxProviderConfig.refreshPolicy:1484 — relocation keeps the + graph one-way, avoids the config<->provider cycle) + + import type { UpstreamHttpVersion, ReasoningSummaryDelivery, + CodexAccountMode } from ./wire + + REWRITE 2 inline type-query paths (1659, 1665): + import("./adapters/cursor/...") -> import("../adapters/cursor/...") +- accounts.ts: lines 1700-1727, no imports +- BARREL KEEPS lines 213-222 (tools value re-exports) and 1689-1698 (wire + value re-exports): RUNTIME blocks, must NOT enter type-only leaves. + Final barrel = 2 value blocks + 4 export type blocks, named re-exports + only, NO export * (would duplicate runtime names). +- The 3 import type lines at 1-3 vanish with their consumers. +- Barrel needs RefreshPolicy re-exported from ./provider (was ./config). + +Corrected one-way graph: request -> {oauth, tools, provider}; +config -> {provider, accounts}; provider -> wire; accounts -> none. + +Both auditors confirmed: no namespace imports, no runtime dynamic import of +types.ts (all import("...types").X hits are erased type queries), no textual +test pins, lab walker unaffected while value blocks stay in barrel, +src/index.ts keeps resolving. AC4 corrected: 5 files under src. + diff --git a/src/types.ts b/src/types.ts index e341ea6366..f474695ca7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,220 +1,5 @@ -import type { KiroOAuthMetadata } from "./oauth/types"; -import type { OcxTool, OcxToolChoice } from "./types/tools"; -import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; - -/** Exact provider/credential namespace for process-local reasoning replay. */ -export interface OcxReasoningReplayIdentity { - providerName: string; - /** Opaque process-local digest of the exact upstream destination. */ - providerDestinationIdentity: string; - /** - * The same destination, digested WITHOUT the process-local random key, so it can key a - * durable store. Absent when no base URL was resolvable. - */ - providerDestinationDurableIdentity?: string; - adapterName: string; - modelId: string; - /** Opaque process-local credential identity; never a raw token or API key. */ - credentialIdentity: string; - /** - * Salted-HMAC credential identity that survives restarts, for the durable - * thought-signature store (#1926). Absent when no durable identity could be - * derived — the durable store then refuses to key the entry (fail closed). - */ - credentialDurableIdentity?: string; -} - -/** - * Stable holder shared by parsed-request copies and already-created bridges. - * Credential/provider rotation replaces `current` atomically without replacing - * the holder, so late tool-call cache writes see the active physical identity. - */ -export interface OcxReasoningReplayScopeRef { - readonly clientThreadId: string; - current?: Readonly; -} - -export interface OcxParsedRequest { - modelId: string; - /** Client-facing model selector retained for Anthropic routes after wire-model normalization. */ - _responseModelId?: string; - /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ - _openAiVirtualSelectedModelId?: string; - previousResponseId?: string; - context: OcxContext; - stream: boolean; - options: OcxRequestOptions; - _rawBody?: unknown; - /** - * Boundary between replayed history and this turn's newly appended input. Usually the - * items the proxy restored from local previous_response_id state; also set when the - * CLIENT already carried that history verbatim and the proxy skipped the prepend. - */ - _replayPrefixLen?: number; - /** Parsed-message index before the first conversational item in a continuation's current delta. */ - _continuationConversationMessageIndex?: number; - /** - * True when the full history for a previous_response_id request is present in the input — - * whether the proxy expanded it or the client already sent it. Consumers read this as - * "this request is self-contained", never as "the proxy mutated it". - */ - _previousResponseInputExpanded?: boolean; - /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */ - _cursorConversationId?: string; - /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ - _clientThreadId?: string; - /** Provider/account/model-bound namespace for process-local raw-reasoning replay. */ - _reasoningReplayScope?: OcxReasoningReplayScopeRef; - /** - * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. - * When absent (single-operator local proxy), derivation stays local-scoped. - */ - _cursorIdentityScope?: string; - /** - * True for helper/shadow/compaction turns that must not append into the main Cursor conversation - * derived from the parent thread id. - */ - _cursorIsolateConversation?: boolean; - /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */ - _kiroAuthContext?: Pick; - /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ - _providerContinuation?: OcxProviderContinuationState; - /** - * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed - * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and - * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested. - */ - _webSearch?: Record; - /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */ - _imageGeneration?: { toolNames: Set; originalTool?: Record }; - /** - * True when Codex requested structured output (`text.format` = json_schema/json_object). The - * web-search tool_result is then rendered as compact JSON instead of markdown prose, so its - * answer/"Sources:" text can't bleed into and corrupt the model's schema-constrained output. - */ - _structuredOutput?: boolean; - /** - * True when the input carried `{type:"compaction_trigger"}` — Codex remote compaction v2 asking - * this turn to produce a `{type:"compaction"}` output item. Routed adapters can't natively; - * the server runs the model as a summarizer and the bridge emits a synthetic compaction item - * (see src/responses/compaction.ts). - */ - _compactionRequest?: boolean; - /** - * True when the current request newly introduced a stored compaction summary/marker. Historical - * markers restored by previous_response_id expansion were already acknowledged and do not reset - * provider-private continuation caches again on every later turn. - */ - _contextCompactionBoundary?: boolean; -} - -export interface OcxContext { - systemPrompt?: string[]; - messages: OcxMessage[]; - tools?: OcxTool[]; -} - -export type OcxMessage = - | OcxUserMessage - | OcxAssistantMessage - | OcxDeveloperMessage - | OcxToolResultMessage; - -export interface OcxUserMessage { - role: "user"; - content: string | OcxContentPart[]; - timestamp: number; -} - -export interface OcxAssistantMessage { - role: "assistant"; - content: OcxAssistantContentPart[]; - /** Responses message phase, preserved when replaying translated provider output. */ - phase?: OcxMessagePhase; - model?: string; - timestamp: number; - /** - * Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob - * Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so - * it rides the message rather than a content part: any other adapter simply ignores it. - */ - kiroRedactedReasoning?: string; -} - -export interface OcxDeveloperMessage { - role: "developer"; - content: string | OcxContentPart[]; - timestamp: number; -} - -export interface OcxToolResultMessage { - role: "toolResult"; - toolCallId: string; - toolName: string; - /** MCP namespace from the originating tool call, if any. */ - toolNamespace?: string; - /** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */ - content: string | OcxContentPart[]; - /** True when the Responses result contained opaque encrypted output Kiro cannot translate. */ - containsEncryptedContent?: boolean; - isError: boolean; - timestamp: number; -} - -export interface OcxTextContent { - type: "text"; - text: string; -} - -export interface OcxImageContent { - type: "image"; - /** A `data:` URL (base64) or a remote https URL — passed through from Codex verbatim, NEVER inlined as text. */ - imageUrl: string; - /** Fidelity hint from Codex: "low" | "high" | "auto". */ - detail?: string; -} - -/** A user/developer message content part: text or an image (vision). */ -export type OcxContentPart = OcxTextContent | OcxImageContent; - -export interface OcxThinkingContent { - type: "thinking"; - thinking: string; - signature?: string; - itemId?: string; - /** Raw Anthropic redacted_thinking block payloads to replay verbatim (order preserved). */ - redacted?: string[]; -} - -export interface OcxToolCall { - type: "toolCall"; - id: string; - name: string; - arguments: Record; - customWireName?: string; - thoughtSignature?: string; - /** - * Provider-issued opaque metadata that must survive the whole round trip unchanged - * (issue #1735). A signed Gemini part is only valid when its signature comes back on the - * SAME part it was issued for, so this travels with the individual tool call rather than - * being matched by name/arguments after the fact. - */ - providerMetadata?: OcxProviderOpaqueToolCallMetadata; - /** MCP namespace (e.g. "mcp__context7") when this call targets a namespaced tool. */ - namespace?: string; -} - -/** - * Opaque, provider-scoped tool-call metadata. Values are never parsed, merged, re-encoded, or - * synthesized — they are carried verbatim or not at all. - */ -export interface OcxProviderOpaqueToolCallMetadata { - google?: { - thoughtSignature?: string; - }; -} - -export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall; +// AUTO-SPLIT barrel: src/types.ts re-exports every historical name; bodies live in src/types/*. +// Values (runtime): tools + wire. Types (erased): request + config + provider + accounts. export type { OcxTool, OcxToolChoice } from "./types/tools"; export { @@ -228,1485 +13,6 @@ export { toolChoiceToolPredicate, } from "./types/tools"; -export interface OcxRequestOptions { - maxOutputTokens?: number; - temperature?: number; - topP?: number; - stopSequences?: string[]; - toolChoice?: OcxToolChoice; - parallelToolCalls?: boolean; - reasoning?: string; - hideThinkingSummary?: boolean; - serviceTier?: string; - /** Final outbound tier action, resolved after the provider/model wire is settled. */ - tierDecision?: TierDecision; - /** Internal B0 observation inputs; adapters combine these with the wire they actually serialize. */ - tierObservation?: TierObservationContext; - presencePenalty?: number; - frequencyPenalty?: number; - /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */ - promptCacheKey?: string; - /** - * Responses `text.format` (json_schema / json_object), preserved for adapters whose - * upstream wire has an equivalent. The openai-chat adapter re-nests it as chat - * `response_format`, the exact inverse of responseFormatToText in src/chat/inbound.ts. - * The native passthrough ignores it (it forwards `_rawBody.text` verbatim) and Kiro - * keeps rejecting structured output via `_structuredOutput`. - */ - textFormat?: { - type: "json_schema" | "json_object"; - name?: string; - description?: string; - schema?: Record; - strict?: boolean; - }; -} - -export type OcxMessagePhase = "commentary" | "final_answer"; - -/** - * Provider-private state that must follow a locally expanded `previous_response_id` chain. - * Kept out of public Responses output and persisted only in the bounded local continuation cache. - */ -export interface OcxProviderContinuationState { - cursor?: { - conversationId?: string; - checkpointUsable?: boolean; - }; - kiro?: { - conversationId?: string; - }; - [provider: string]: Record | undefined; -} - -export type AdapterEvent = - | { type: "heartbeat" } - | { type: "text_delta"; text: string; phase?: OcxMessagePhase } - | { type: "thinking_delta"; thinking: string } - // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and - // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400. - | { type: "thinking_signature"; signature: string } - | { type: "redacted_thinking"; data: string } - // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn. - // Never rendered — it only rides the reasoning item's envelope so the next request can replay it. - | { type: "kiro_redacted_reasoning"; data: string } - | { type: "reasoning_raw_delta"; text: string } - | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } - | { type: "tool_call_delta"; arguments: string } - | { type: "tool_call_end" } - /** Internal boundary between a guarded first pass and its one-shot continuation. */ - | { type: "assistant_boundary" } - // Native web-search activity surfaced by the web-search sidecar so Codex renders a "Searched the - // web" cell. Emitted as a lifecycle PAIR at real wall-clock moments by src/web-search/loop.ts - // (routed adapters never emit these): `begin` right before the sidecar runs so Codex shows the - // "Searching the web" spinner, then `end` once it resolves. The bridge maps begin → an - // output_item.added(in_progress) and end → the matching output_item.done(completed|failed) under - // the SAME output index, so the activity animates instead of flashing completed instantly. - | { type: "web_search_call_begin"; id: string } - | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[] } - | { - type: "done"; - usage?: OcxUsage; - stopReason?: string; - endTurn?: boolean; - providerState?: OcxProviderContinuationState; - } - | { - type: "incomplete"; - reason: string; - message?: string; - usage?: OcxUsage; - retryable?: boolean; - endTurn?: boolean; - providerState?: OcxProviderContinuationState; - } - // `usage` carries best-effort partial consumption when a turn dies before a clean done - // (e.g. cursor upstream 502 mid-stream), so failed requests can log real token counts. - | { - type: "error"; - message: string; - usage?: OcxUsage; - /** Authoritative upstream/proxy status when known; avoids message-based classification. */ - status?: number; - /** Responses error type and code when the adapter has a structured provider failure. */ - errorType?: string; - code?: string; - retryable?: boolean; - }; - -/** - * A web source backing a search answer. Surfaced on the search-end event and rendered by the bridge - * as a `url_citation` annotation on the following assistant message (the desktop app's Sources chip - * reads these; the TUI ignores annotations, so this is additive). - */ -export interface OcxUrlCitation { - url: string; - title?: string; -} - -/** - * Canonical usage convention (devlog/260711_claude_inbound/070): - * - `inputTokens` is the TOTAL prompt size, INCLUDING cache reads and cache writes - * (OpenAI Responses convention). Anthropic parse sites normalize into this shape. - * - `cachedInputTokens` is cache READ tokens only (a subset of `inputTokens`). - * - `cacheReadInputTokens`/`cacheCreationInputTokens` carry the read/write split when - * the provider reports both; reads mirror `cachedInputTokens`. - * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top. - */ -export interface OcxUsage { - inputTokens: number; - outputTokens: number; - /** - * Absolute active-context size after the response. Stateful providers can expose this separately - * from their per-attempt usage. Responses serialization derives the input side from - * `contextTotalTokens - outputTokens` so output is never added to an absolute checkpoint twice. - */ - contextTotalTokens?: number; - totalTokens?: number; - cachedInputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - reasoningOutputTokens?: number; - estimated?: boolean; -} - -/** - * Claude Code inbound settings (devlog/260711_claude_inbound). Consumed by the - * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. - */ -export interface OcxClaudeCodeConfig { - /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ - enabled?: boolean; - /** - * Verbatim passthrough of unmapped claude/anthropic models to api.anthropic.com with the - * caller's own sk-ant-* credential (Claude Code subscription OAuth). Default: enabled. - */ - nativePassthrough?: boolean; - /** Upstream for the native passthrough (tests/enterprise gateways). Default: https://api.anthropic.com */ - anthropicBaseUrl?: string; - /** - * Native passthrough body inactivity budget in SECONDS — raw upstream-byte silence - * while a read is pending, NOT total duration (slow-but-alive streams never trip it; - * devlog 260716_passthrough_followups/010). Default 90. Min 1. Exactly 0 disables; - * negative/non-finite values fall back to the default. - */ - bodyStallSec?: number; - /** - * Native passthrough cumulative body byte cap (streamed SSE and buffered non-stream - * alike) — an OOM/occupancy guard, not a correctness limit. Default 67108864 (64 MiB). - * Exactly 0 disables; negative/non-finite values fall back to the default. - */ - bodyMaxBytes?: number; - /** Default model slot injected as ANTHROPIC_MODEL by `ocx claude`. */ - model?: string; - /** Haiku/small-fast slot injected as ANTHROPIC_DEFAULT_HAIKU_MODEL (+ legacy SMALL_FAST). */ - smallFastModel?: string; - /** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */ - modelMap?: Record; - /** - * Explicit classifier model for Claude Code Auto Mode safety checks (e.g. "RelayA/claude-opus-5"). - * When unset, bare classifier requests check modelMap, then same-provider affinity from - * `claudeCode.model`, then compatible Anthropic-adapter providers, and finally fallbacks. - */ - classifierModel?: string; - /** - * Ordered fallback candidates for Claude Code Auto Mode classifier routing when the primary - * classifier route is not available. - */ - classifierFallbacks?: string[]; - /** - * Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv` - * so plain `claude` commands route through the proxy without `ocx claude`. Reverted - * on stop/shutdown. Default: false (opt-in). macOS only. - */ - systemEnv?: boolean; - /** - * Auth mode for Claude Code inbound requests — a THREE-state intent. - * - * "proxy": inject the dummy ANTHROPIC_AUTH_TOKEN so Claude Code routes through the - * proxy without a real Anthropic key. "subscription": never inject it. UNSET means - * AUTO: the mode is resolved from detected Claude auth on every launch and every - * status read (src/claude/auth-mode.ts), so registering a Claude login switches the - * behaviour with no migration and no stored state. - * - * An explicit value always wins over detection and is never rewritten by the auto - * logic — that is what makes a manual choice stick (devlog 260726_claude_auth_auto). - */ - authMode?: "proxy" | "subscription"; - /** - * ISO timestamp of the one-time authMode migration. Before auto existed, choosing - * "Subscription" DELETED the key, so a pre-upgrade config cannot distinguish an - * explicit subscription choice from "never chose". Its ABSENCE identifies a - * pre-upgrade block; the migration writes it once and never re-runs, so a user who - * later picks Auto (which deletes authMode) is not silently converted back. - */ - authModeMigratedAt?: string; - /** - * Context-window override for Claude Code/Desktop clients (devlog 136 B6): - * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official - * env pair — recognized claude-shaped ids need both). WARNING: DISABLE_COMPACT - * turns off auto-compaction. Unset = client defaults. - */ - maxContextTokens?: number; - /** - * Opt-in CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 injection. Default OFF: opus-shaped - * aliases already carry output_config.effort on the wire (devlog 136 실측), and - * forcing effort on every request can leak reasoning params to non-reasoning routes. - */ - alwaysEnableEffort?: boolean; - /** - * Subagent tier slots (devlog 260712 B2): injected as ANTHROPIC_DEFAULT_*_MODEL so - * Claude Code's Agent-tool aliases (opus/sonnet/haiku/fable + parent-inherit) route - * to proxy models. haiku falls back to smallFastModel (one effective value feeds - * both ANTHROPIC_DEFAULT_HAIKU_MODEL and legacy ANTHROPIC_SMALL_FAST_MODEL). - */ - tierModels?: { opus?: string; sonnet?: string; haiku?: string; fable?: string }; - /** - * Auto-context (devlog 260712 020): when not false, routed/native models whose - * authoritative window is > 200k AND >= the compact window get the [1m] marker - * (Claude Code then accounts 1M) and CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected - * so compaction fires at the real budget. 2.1.207 semantics (binary-verified): - * effective compact window = min(believed window, env) — one global env behaves - * like a per-model floor. Default: enabled. Inert while maxContextTokens is set - * (the legacy DISABLE_COMPACT pair takes rule-1 precedence in the CLI). - */ - autoContext?: boolean; - /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ - autoCompactWindow?: number; - /** - * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712 - * 060): Skill-tool results whose skill name matches an entry here are replaced - * with a short stub in the anthropic->responses translation. Third-party models - * are not trained on these Anthropic doc bundles, and claude-api alone injects - * ~136k tokens (GitHub anthropics/claude-code#74473). Native Anthropic - * passthrough never goes through the translation, so Claude models keep the - * full content. Default: ["claude-api"]. Empty array = explicitly off. - */ - blockedSkills?: string[]; - /** - * Sync the featured subagent roster (config.subagentModels + main model) into - * ~/.claude/agents/ocx-*.md custom agent definitions at launch (devlog 260712 - * 070) so any routed model is dispatchable as a subagent_type — the Agent - * tool's model argument is a hard 4-alias enum, but definition frontmatter is - * free. Only ocx-*.md files are owned/pruned. Default: enabled. - */ - injectAgents?: boolean; - /** - * Optional Claude Code effort pinned in every generated ocx-* subagent - * definition. Unset inherits the parent session effort. - */ - subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max"; - /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */ - webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string }; - /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */ - visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; - /** Persisted Claude Desktop four-family routing profile. */ - desktopProfile?: OcxClaudeDesktopProfile; - /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ - desktopAutoApply?: boolean; - /** - * When false, omit `native/*` rows from Claude Desktop show/export/apply. Default: enabled. - * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. - */ - desktopNativeModels?: boolean; -} - -export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; - -export interface OcxClaudeDesktopAssignment { - family: OcxClaudeDesktopFamily; - alias: string; -} - -export interface OcxClaudeDesktopProfile { - version: 1; - assignments: Record; - defaults: Record; - /** SHA-256 fingerprint of the last successfully applied 3P config content. */ - appliedFingerprint?: string; - /** ISO timestamp of the last successful apply. */ - appliedAt?: string; -} - -/** - * Opt-in archived-session auto-cleanup policy (issue #42 Phase 3). - * Persisted under `OcxConfig.storageCleanupPolicy`. Default `enabled: false`. - */ -export interface StorageCleanupPolicy { - /** When false/unset, the engine never mutates. Default false. */ - enabled: boolean; - /** Run when archived session bytes exceed this threshold. */ - trigger: { archivedBytesOver: number }; - /** Either shrink archives toward a byte floor, or remove the oldest N%. */ - target: { reduceToBytes?: number } | { removeOldestPercent?: number }; - schedule: "startup" | "daily" | "weekly" | "manual"; - /** Default quarantine. Permanent only when explicitly set. */ - mode: "quarantine" | "permanent"; - lastRun?: { at: number; freedBytes: number; removed: number }; - /** Epoch ms when the next scheduled evaluation is due. */ - nextRun?: number; -} - -/** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ -export interface OcxCustomModel { - /** 고유 ID (crypto.randomUUID()) */ - id: string; - /** 프로바이더 키 (기존 providers[name]) */ - provider: string; - /** Native provider model id; slashes are allowed and encoded for Codex as provider/. */ - modelId: string; - /** 인간 가독 표시명 (선택, 슬래시 불가) */ - displayName?: string; - /** 컨텍스트 윈도우 (토큰) */ - contextWindow?: number; - /** 입력 모달리티 (선택, 기본 ["text"]) */ - inputModalities?: string[]; - /** - * Reasoning ladder (Codex labels) this custom row explicitly advertises. An empty array - * hides the effort control; an omitted key leaves the provider-derived ladder in charge. - */ - reasoningEfforts?: string[]; - /** Default effort label when `reasoningEfforts` is non-empty. */ - defaultReasoningEffort?: string; - /** - * Codex tool calling mode override for this custom model. - * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". - * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). - */ - codexToolMode?: "code_mode_only" | "shell"; - /** 추가 시각 (ISO 8601) */ - addedAt?: string; -} - -/** - * A generated `ocx_` data-plane key. `key` is the secret itself and never leaves - * the server except in the one-time POST /api/keys response; every other surface - * sees only the masked prefix. - */ -export interface OcxApiKeyEntry { - id: string; - name: string; - key: string; - createdAt: string; -} - -/** - * Durable per-client intent. One key today, deliberately. - * - * A top-level `codexEnabled` would force every later client to invent an - * unrelated name and its own helpers; a ten-key union recreated the coupling - * that failed two audits, because every phase then had to touch every client's - * write path. A one-key object keeps the extension point without letting this - * phase claim ownership over a client it does not implement. - */ -export interface OcxClientIntegrationsConfig { - /** Durable desired state for native Codex. MISSING MEANS ON. */ - codex?: boolean; - /** Durable desired state for Grok Build. MISSING MEANS ON. */ - grok?: boolean; - /** Durable desired state for Claude Desktop. MISSING MEANS ON. */ - "claude-desktop"?: boolean; -} - -export interface OcxConfig { - port: number; - /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ - emptyCompletionRetry?: boolean; - /** Maximum usage-log bytes read for one management snapshot. */ - managementUsageMaxReadBytes?: number; - providers: Record; - defaultProvider: string; - /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ - openaiProviderTierVersion?: 1 | 2; - /** One-time migration marker for Antigravity's static-catalog defaults. */ - googleAntigravityStaticCatalogVersion?: 1 | 2; - /** Claude Code inbound + launcher settings. */ - claudeCode?: OcxClaudeCodeConfig; - /** - * Per-client durable intent. This phase owns only `codex`; later phases extend - * one key at a time rather than widening a shared union. - */ - clientIntegrations?: OcxClientIntegrationsConfig; - /** - * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, - * exact account-qualified "/" ids, or routed - * "/" ids. With account selectors, one bare native choice can expand - * into a selector-qualified group; Codex still advertises only the first 5 visible rows. - */ - subagentModels?: string[]; - /** - * Optional full picker ordering for the Codex model catalog, independent of the - * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of - * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort - * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog - * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and - * account-qualified native rows are not reordered (order native rows via `subagentModels`). - * Listed routed rows appear in array order; rows not listed keep their normal display order. - * `subagentModels`-featured rows keep their top position. When unset or empty, catalog - * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent - * candidate set is derived from each row's natural priority and is provably unaffected, even - * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). - */ - modelPickerOrder?: string[]; - /** - * Priority-ordered fallback models for spawned sub-agents. When the requested - * model is quota-exhausted or recently failed, opencodex rewrites the child - * turn to the next available entry before routing. - */ - subagentModelFallback?: string[]; - /** - * Per-primary-model fallback chains for spawned sub-agents, keyed by the - * requested primary model id (bare native or "provider/model"). Entries for - * the matching key are consulted after the requested model and before the - * global `subagentModelFallback` list. - * - * This is the supported home for per-role fallback metadata: storing it as - * `model_fallback` inside `$CODEX_HOME/agents/*.toml` makes Codex >= 0.146 - * reject the whole role file as an unknown field (#1190). - */ - subagentModelFallbackByModel?: Record; - /** - * TTL (ms) for cached sub-agent model availability probes. Default 60_000. - */ - subagentModelFallbackPollMs?: number; - injectionModel?: string; - /** - * Opt in to synchronizing the selected injection model into Codex's native - * sub-agent defaults. Only meaningful while `injectionModel` is set. - */ - syncCodexSubagentDefaults?: boolean; - /** - * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls - * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against - * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary. - */ - injectionEffort?: string; - /** - * Explicit sideband websocket base for realtime/live joins, mirroring upstream's - * `experimental_realtime_ws_base_url`. The value is a ROOT (or a recognized - * `/realtime`, `/realtime/calls/`, `/live/` endpoint form, which is - * stripped back to the root); `/v1` is appended during normalization. Intended - * for local development against a fake realtime server — plaintext `http`/`ws` - * is accepted only for loopback hosts, and URL userinfo is rejected; both - * failures close to the canonical `https://api.openai.com/v1`. Configured by - * editing this file; there is deliberately no management-API or GUI surface. - */ - experimentalRealtimeWsBaseUrl?: string; - /** - * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty - * means "everything visible", which is the historical behaviour — so an existing - * config keeps the fence it already had. - * - * Exclusion list rather than an inclusion list on purpose: a newly added provider - * model should appear in Grok by default, exactly as it does today. An inclusion list - * would silently hide every future model behind a switch nobody knew to flip. - */ - grokExcludedModels?: string[]; - /** - * When true, OpenAI-routed requests include `service_tier: "priority"` (fast inference). - * When false, service_tier is stripped so requests use default speed. - * Undefined = passthrough (don't modify what the client sends). - */ - fastMode?: boolean; - /** - * Windows/macOS SSE passthrough stream shape (#314 mitigation). - * On Windows, "auto" (default) selects eager relay only on a runtime proven - * to carry the Bun#32111 fix. On macOS, "auto" always stays on legacy tee and - * eager relay is explicit-only. "eager-relay" opts into the new relay (and - * accepts #32111 crash risk on Bun 1.3.14); "legacy-tee" pins the tee path. - * Persisted in config.json so service users can select the stream shape. - * See src/lib/bun-stream-caps.ts. - */ - streamMode?: "auto" | "legacy-tee" | "eager-relay"; - /** - * Custom override for the injected v2 multi-agent guidance body (the text inside - * the tags). After guidance is enabled and the v2 surface and - * catalog-state gates pass, a configured injectionModel is sufficient to render it; - * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the - * effective preferred model for the request (a bare native model is account-qualified - * only when the request targets an explicit account selector; unresolved or ambiguous - * bare values become "", while unresolved explicit routed or account-qualified values - * remain unchanged), - * `{{effort}}` -> injectionEffort, `{{roster}}` -> the resolved sub-agent roster - * block ("" when nothing resolves), `{{fallback}}` -> the configured subagent - * model fallback guidance block ("" when unset). - */ - injectionPrompt?: string; - /** - * Proxy-authored multi-agent developer guidance. Undefined/true = enabled for - * backward compatibility; false suppresses both v1 and v2 guidance injection. - */ - multiAgentGuidanceEnabled?: boolean; - /** - * Global hard ceiling for the reasoning effort of EVERY proxied turn (main agent AND - * sub-agents). Ladder value "low".."max"; incoming efforts ranking above it are rewritten - * in both request shapes before any adapter or clamp. Unset = no cap. codex-rs converts - * ultra -> max client-side, so e.g. a "high" cap sends ultra/max-tier turns as high. - */ - effortCap?: string; - /** - * Hard ceiling applied ONLY to sub-agent turns — requests carrying codex-rs's spawned-child - * markers (`x-openai-subagent` header, or `subagent_kind` inside `x-codex-turn-metadata`). - * Lets the main agent keep its tier while delegated children are capped. When both caps are - * set, the lower one wins for sub-agents. See src/server/effort-policy.ts. - */ - subagentEffortCap?: string; - /** - * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids - * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only - * their generated selector row and are omitted from raw /v1/models. BARE native GPT ids hide - * the bare row plus every generated selector row and omit that model family from raw discovery. - */ - disabledModels?: string[]; - /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */ - customModels?: OcxCustomModel[]; - /** - * Internal, versioned evidence for reconciling custom-model deletions with - * pre-marker Codex catalog rows. Consumers must parse this defensively so a - * future state written by a newer binary survives older whole-config saves. - */ - customModelCatalogMigration?: unknown; - /** - * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation, - * commit messages, skill orchestration) to a user-chosen model. Default intercepted - * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+). - * Opt-in; disabled by default. Matching maintenance/helper requests are forced to low. - * All requests for configured shadow source models are intercepted unconditionally. - */ - shadowCallIntercept?: { - /** When true, requests for known shadow/helper source models are rewritten to the configured model. */ - enabled?: boolean; - /** Replacement model id (e.g. "gpt-5.5"). */ - model?: string; - /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */ - sourceModels?: string[]; - }; - /** - * 3-state multi-agent surface override: - * - "v1": force ALL models to v1 surface (override upstream pins) - * - "default" | undefined: respect upstream model pins (sol/terra=v2, luna=v1, rest=codex flag) - * - "v2": force ALL models to v2 surface (override upstream pins) - */ - multiAgentMode?: "v1" | "default" | "v2"; - /** - * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1. - * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). - */ - keepNativeChatGptOnV1?: boolean; - /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */ - agentTaskRecovery?: { - enabled?: boolean; - /** ChatGPT model used by the recovery request. Default: gpt-5.6-sol. */ - model?: string; - /** Recovery request timeout in milliseconds. Default: 45000. */ - timeoutMs?: number; - /** Maximum in-memory ciphertext-to-assignment entries. Default: 200. */ - cacheEntries?: number; - }; - /** Provider-level Codex-visible context caps. Values only lower known model context windows. */ - providerContextCaps?: Record; - /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ - contextCapValue?: number; - /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ - hostname?: string; - /** - * Optional second listener bound to 127.0.0.1 that admits data-plane requests without a - * credential (issue #1102). - * - * Why a separate listener rather than an exemption on the main one: when `hostname` is a - * wildcard, every caller needs `x-opencodex-api-key`, but a `codex app-server` spawned - * directly from the resolved entrypoint never goes through the generated shim and so never - * inherits the token. Exempting "loopback-looking peers" on the public listener would be - * unsound — `requestIP()` only proves the last transport hop, and Docker Desktop port - * forwarding, host-network containers, WSL mirrored networking and tunnels all terminate - * remote connections locally. Binding a second socket to 127.0.0.1 makes the kernel refuse - * remote connections outright, so there is no address to judge. - * - * The public listener's admission policy is unchanged. This adds an explicit local trust - * surface: every process on the machine can reach it, spend account quota, and consume paid - * provider credentials. Off by default; not for multi-tenant hosts. - * - * The port is required when enabled and must differ from the proxy port. An OS-assigned port - * would change across restarts, which would break already-running app-servers holding the - * previous `base_url` — the exact symptom #1102 reported and we disproved for token rotation. - */ - unauthenticatedLoopbackListener?: - | { enabled: false } - | { enabled: true; port: number }; - /** - * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or - * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when - * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded. - */ - proxy?: string; - /** - * Upstream stall timeout (seconds). After this many seconds of no upstream data, emits - * response.incomplete. Default 300. Min 1. - */ - stallTimeoutSec?: number; - /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 200000. */ - connectTimeoutMs?: number; - /** Graceful shutdown drain timeout (ms). Active turns are aborted after this deadline. Default 5000. */ - shutdownTimeoutMs?: number; - /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */ - websockets?: boolean; - /** - * Opt-in auto-cleanup policy for archived Codex sessions (issue #42 Phase 3). - * Default OFF (`enabled` false / unset). Never enabled implicitly. - * See `src/storage/policy.ts`. - */ - storageCleanupPolicy?: StorageCleanupPolicy; - /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ - apiKeys?: OcxApiKeyEntry[]; - /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ - codexAutoStart?: boolean; - /** Restore an installed shim after a stable external Codex update replaces it. Default true. */ - codexShimAutoRestore?: boolean; - /** - * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active - * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default - * interactive-source/provider filters. Default true; originals are backed up and restored by - * `ocx stop` / `ocx restore`. Set false to opt out of history remapping. - */ - syncResumeHistory?: boolean; - /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */ - modelCacheTtlMs?: number; - /** Evictable retained app-state budget in MiB. Default 256; valid 64..4096. */ - appOwnedMemoryBudgetMb?: number; - /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */ - cacheRetention?: "none" | "short" | "long"; - /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */ - webSearchSidecar?: OcxWebSearchSidecarConfig; - /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */ - visionSidecar?: OcxVisionSidecarConfig; - /** /v1/images relay for codex's built-in image_gen tool. */ - images?: OcxImagesConfig; - /** /v1/alpha/search relay for codex's built-in web search client. */ - search?: OcxSearchConfig; - /** Codex multi-account pool. */ - codexAccounts?: CodexAccount[]; - /** Account ids administratively excluded from future pool selection until resumed. */ - pausedCodexAccountIds?: string[]; - /** - * Selection order per account id, higher used earlier; absent = 0. Keyed by id - * rather than stored on `codexAccounts` rows so the Desktop login (`__main__`), - * which has no row, can be ordered too. Range -100..100. - */ - codexAccountPriorities?: Record; - /** - * Account id the operator last selected by hand. Suppresses upward priority - * preemption until that account crosses the auto-switch threshold. Stores the - * id (not a flag) so a stale pin cannot outlive the selection it described. - */ - activeCodexAccountPinned?: string; - /** - * Public model-selector namespaces bound to one Codex account. Values are stored account ids; - * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases - * are intentionally separate from these selectors. - */ - codexAccountNamespaces?: Record; - /** - * Picker visibility override for account-qualified native models. When omitted, a non-empty - * selector map remains visible for compatibility with hand-written configurations. - */ - codexAccountPickerEnabled?: boolean; - /** Active pool account id for next session. undefined = main (passthrough as-is). */ - activeCodexAccountId?: string; - /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ - autoSwitchThreshold?: number; - /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ - accountPoolStrategy?: OcxAccountPoolRotationStrategy; - /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ - accountPoolStickyLimit?: number; - /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ - upstreamFailoverThreshold?: number; - /** - * Opt-in provider-origin circuit threshold for proven pre-connection reachability failures. - * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses. - */ - upstreamHostCircuitThreshold?: number; - /** - * Opt-in Anthropic OAuth account pool (#294). Default OFF. - * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage. - * Experimental — see docs and GUI warning before enabling. - */ - anthropicAccountPool?: { - enabled?: boolean; - /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ - autoSwitchThreshold?: number; - /** New-session rotation strategy. Default quota (today's behaviour). */ - strategy?: OcxAccountPoolRotationStrategy; - /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ - stickyLimit?: number; - }; - /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ - combos?: Record; - /** - * Routing policy profiles (Router Intelligence, RI-04+): explicitly requested - * `policy/` (or configured alias) models select among an explicit - * candidate allowlist using hard capability requirements and deterministic - * scoring. Existing model ids are never routed through profiles implicitly. - */ - routingProfiles?: Record; - /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ - tokenGuardian?: OcxTokenGuardianConfig; - /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://). Loopback origins are always allowed. */ - corsAllowOrigins?: string[]; -} - -export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; - -export type OcxComboStrategy = "failover" | "round-robin"; -export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; - -export interface OcxComboTarget { - provider: string; - model: string; - /** Relative SWRR batch weight. Default 1; valid range 1..10000. */ - weight?: number; -} - -export interface OcxComboConfig { - targets: OcxComboTarget[]; - /** Ordered failover (default) or deterministic smooth weighted round-robin. */ - strategy?: OcxComboStrategy; - /** Successful requests retained on one RR selection batch. Default 1; range 1..100. */ - stickyLimit?: number; - /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */ - defaultEffort?: OcxComboDefaultEffort | null; - /** - * Disable image input even when every target supports it. - * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when - * the target intersection includes image). - */ - imageInput?: "auto" | "disabled"; - /** - * Optional public model name replacing the default `combo/` slug. Bare names - * without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a - * mandated model id; exact-match requests route here before any provider resolution. - */ - alias?: string; - /** - * Explicitly allow a bare OpenAI-native alias (for example `gpt-5.6-sol`) to - * be represented by this routed combo. Never inferred from `alias`. - */ - nativeAlias?: boolean; - /** Display-only label for the public catalog row. Required for native aliases. */ - displayName?: string; -} - -export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude"; - -export interface OcxRoutingProfileCandidate { - provider: string; - model: string; -} - -export interface OcxRoutingProfileRequirements { - /** Minimum model context window in tokens. */ - minContextWindow?: number; - /** Minimum remaining quota headroom fraction (0..1). */ - minQuotaHeadroom?: number; - tools?: boolean; - imageInput?: boolean; - structuredOutput?: boolean; - reasoningEffort?: string; - serviceTier?: string; - localOnly?: boolean; - remoteAllowed?: boolean; - /** Special encrypted Codex task readability (ChatGPT forward pool). */ - encryptedCodexTasks?: boolean; -} - -export interface OcxRoutingProfileOptimize { - latency?: number; - health?: number; - cost?: number; - quota?: number; -} - -/** - * Policy for the hard cost ceiling when a candidate has no finite cost - * estimate. `"allow"` (default) preserves the documented dry-run contract: - * the cap only excludes evidence known to exceed it, and the candidate's - * `cost.capOutcome` is `"unknown-allowed"`. `"exclude"` makes the ceiling - * fail-closed (`cost-limit-unknown` + `capOutcome: "unknown-excluded"`). - */ -export type OcxRoutingUnknownCostCapMode = "allow" | "exclude"; - -export interface OcxRoutingProfileLimits { - /** Hard per-request estimated-cost ceiling in USD. */ - maxEstimatedCostUsd?: number; - /** - * How `maxEstimatedCostUsd` behaves when the estimate is unknown. - * Defaults to `"allow"` (eligible + `cost.capOutcome: "unknown-allowed"`); - * opt in to `"exclude"` for a true hard ceiling. - */ - onUnknownCost?: OcxRoutingUnknownCostCapMode; -} - -export interface OcxRoutingProfileUnknownEvidence { - capability?: OcxRoutingUnknownEvidenceMode; - health?: OcxRoutingUnknownEvidenceMode; - quota?: OcxRoutingUnknownEvidenceMode; - cost?: OcxRoutingUnknownEvidenceMode; -} - -export interface OcxRoutingProfileCompatibilitySuite { - suiteId: string; - evidenceLayer: "protocol_conformance" | "live_route_compatibility"; -} - -export interface OcxRoutingProfileCompatibility { - requiredSuites?: OcxRoutingProfileCompatibilitySuite[]; - minStatus?: "PROBED" | "VERIFIED"; - maxEvidenceAgeMs?: number; - unknownEvidence?: OcxRoutingUnknownEvidenceMode; - degradedEvidence?: OcxRoutingUnknownEvidenceMode; -} - -export interface OcxRoutingProfileConfig { - /** - * Explicit candidate allowlist (`provider/model` refs). No implicit - * expansion in v1. - */ - candidates: OcxRoutingProfileCandidate[]; - /** Optional public model name replacing the default `policy/` slug. */ - alias?: string; - /** Hard requirements evaluated before scoring. */ - require?: OcxRoutingProfileRequirements; - /** Optimization weights; normalized deterministically. */ - optimize?: OcxRoutingProfileOptimize; - limits?: OcxRoutingProfileLimits; - /** How unknown evidence is handled per dimension. */ - unknownEvidence?: OcxRoutingProfileUnknownEvidence; - /** Optional Compatibility Lab policy (CL-06). */ - compatibility?: OcxRoutingProfileCompatibility; -} - -/** - * Per-provider proactive-refresh policy. The guardian only ever touches a provider whose EFFECTIVE - * policy is "proactive"; "lazy-only" keeps today's on-demand refresh, "disabled" forbids the - * guardian entirely (used for providers whose ToS actively enforces against non-official-client - * token traffic, e.g. Anthropic subscription OAuth). See devlog 260703_oauth-multi-account-refresh-and-tos. - */ -export type RefreshPolicy = "proactive" | "lazy-only" | "disabled"; - -export interface OcxTokenGuardianConfig { - /** Global kill-switch. Default false — the guardian does nothing unless explicitly enabled. */ - enabled?: boolean; - /** Seconds between refresh sweeps. Default 21600 (6h). Min 60. */ - tickSeconds?: number; - /** Random 0..jitterSeconds added before each sweep to de-synchronize. Default 300. */ - jitterSeconds?: number; - /** Max concurrent refreshes per sweep. Default 3. Min 1. */ - concurrency?: number; - /** Extra lead (seconds) beyond one tick when deciding a token is "expiring soon". Default 900. */ - leadSeconds?: number; - /** First backoff (seconds) after a permanent refresh failure. Default 300. */ - failureBackoffBaseSeconds?: number; - /** Backoff ceiling (seconds). Default 3600. */ - failureBackoffMaxSeconds?: number; - /** Optional Codex pool session warmup sweep. Default false to avoid background synthetic traffic. */ - codexWarmupEnabled?: boolean; - /** Max age before a Codex pool account is revalidated via `/codex/responses`. Default 691200 (8d). */ - codexWarmupMaxAgeSeconds?: number; - /** Model used for optional Codex pool warmup. Default gpt-5.4-mini. */ - codexWarmupModel?: string; -} - -export interface OcxImagesConfig { - /** Optional custom API-key provider for /v1/images relays. Built-in OpenAI tiers remain automatic. */ - provider?: string; - /** Upstream timeout (ms) for one image generation/edit call (bridge xAI + /v1/images relay). Default 60000 for the bridge; relay may use a higher default (300000). */ - timeoutMs?: number; - /** Master switch for the image bridge. Default false — set true to enable paid xAI Grok Imagine generation. */ - bridgeEnabled?: boolean; - /** xAI image model id. Default "grok-imagine-image-quality" (see DEFAULT_MODEL in images/plan.ts). */ - bridgeModel?: string; - /** Max image-generation loop iterations before forced-final. Default 3; clamped to [0, 10]. */ - maxRounds?: number; - /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */ - artifactsKeepCount?: number; - /** Master switch for the video bridge. Default false — must be explicitly opted in. */ - videoBridgeEnabled?: boolean; - /** Model for xAI video generation. Default "grok-imagine-video". */ - videoBridgeModel?: string; - /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */ - videoMaxRounds?: number; - /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */ - videoTimeoutMs?: number; -} - -export interface OcxSearchConfig { - /** - * Total upstream deadline (ms) for one /v1/alpha/search relay. Default 200000. The endpoint - * is non-streaming JSON (headers arrive only when the search completes), so this is a whole- - * request budget — deliberately NOT connectTimeoutMs, which is a header-arrival budget. - */ - timeoutMs?: number; -} - -export interface OcxVisionSidecarConfig { - /** Master switch. Default: enabled when the selected backend has a usable credential. */ - enabled?: boolean; - /** Description backend. Unset prefers a usable stored Anthropic OAuth credential, else OpenAI. */ - backend?: "openai" | "anthropic"; - /** Vision model that describes images. */ - model?: string; - /** Max description cache misses admitted in one main-model turn. Zero disables description calls. */ - maxDescriptionsPerTurn?: number; - /** Sidecar fetch timeout (ms). */ - timeoutMs?: number; -} - -export interface OcxWebSearchSidecarConfig { - /** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */ - enabled?: boolean; - /** - * Which backend actually runs the server-side search. "openai" replays the hosted web_search via - * the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude - * model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a - * usable anthropic OAuth credential exists, else "openai". - */ - backend?: "openai" | "anthropic"; - /** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */ - model?: string; - /** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */ - reasoning?: string; - /** Max searches executed per main-model turn (loop guard). */ - maxSearchesPerTurn?: number; - /** Sidecar fetch timeout (ms). */ - timeoutMs?: number; - /** - * Config-file-only deadline (ms) for continuous routed-model response-body raw-byte inactivity - * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647. - */ - routedModelStallTimeoutMs?: number; - /** - * Stream the routed model's leading output (text/thinking deltas) live instead of buffering the - * whole iteration. Live delivery stops at the first tool-call boundary so web_search interception - * stays atomic. Tradeoff: text the model emits BEFORE deciding to search — which buffered mode - * silently drops — becomes visible to the client and may partially repeat in the post-search - * answer. Default: false (buffered, previous behavior). - */ - streamRoutedModelOutput?: boolean; -} - -export interface OpenRouterProviderRouting { - /** OpenRouter provider slugs to try first, in priority order. */ - order?: string[]; - /** Restrict routing to these OpenRouter provider slugs. */ - only?: string[]; - /** Whether OpenRouter may use providers outside `order`. Defaults to OpenRouter's policy. */ - allowFallbacks?: boolean; -} - -export interface ResponsesItemIdRepairConfig { - /** Exact `message` item ids that the proxy should rewrite to request-local canonical ids. */ - message?: string[]; - /** Exact `reasoning` item ids that the proxy should rewrite to request-local canonical ids. */ - reasoning?: string[]; - /** Backfill missing `output_item.done` / terminal snapshot ids from the matching output_index. */ - repairMissingTerminalIds?: boolean; - /** - * Treat existing message/reasoning ids without the canonical `msg_`/`rs_` prefix (e.g. bare - * UUIDs from DeepSeek's Responses route) as invalid and mint canonical replacements (#938). - * function_call ids and call_id pairing are never rewritten. - */ - repairInvalidIds?: boolean; -} - -/** - * Same-target 429 wait-and-retry policy (`providers..retryOn429`). When present and not - * explicitly disabled, the proxy waits and replays the identical request on the same key before - * any key failover. All fields optional; the runtime applies defaults (attempts=3, - * intervalMs=5000, maxIntervalMs=60000, respectRetryAfter=true, enabled=true). - */ -export interface RateLimitRetryPolicy { - /** Master switch. The presence of the object also enables the policy (default true). */ - enabled?: boolean; - /** Extra replay attempts after the first 429 (1..20, default 3). */ - attempts?: number; - /** Fixed wait between attempts when the upstream sends no usable Retry-After (default 5000). */ - intervalMs?: number; - /** Cap for any single wait, including an upstream Retry-After (default 60000). */ - maxIntervalMs?: number; - /** Prefer the upstream Retry-After header when present and parseable (default true). */ - respectRetryAfter?: boolean; -} - -/** - * User-configured display price for one model (USD per 1M tokens). - * Mirrors the `Cost4` shape used by the usage cost estimator; structurally - * compatible so config rows can be lifted directly into price overlays. - */ -export interface ProviderCostOverlay { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; -} - -export interface RequestPacingRule { - /** Evenly spread request starts to this many requests per minute. */ - requestsPerMinute?: number; - /** Minimum delay between request starts. The slower configured value wins. */ - minIntervalMs?: number; -} - -export interface ProviderRequestPacingConfig extends RequestPacingRule { - /** False preserves legacy behavior with no client-side waiting. */ - enabled: boolean; - /** Exact upstream model-id overrides; other models inherit the provider rule. */ - models?: Record; -} - -export interface FastWire { - kind: "service-tier" | "anthropic-speed"; - /** Canonical tier name to upstream wire spelling. */ - canonicalToWire: Readonly>; - /** Policy for non-canonical caller-provided tier values. */ - foreignCallerTiers: "verbatim" | "drop"; - /** Anthropic speed headers/betas reserved for the later wire implementation. */ - betas?: readonly string[]; -} - -/** Durable per-attempt service-tier fact produced at the adapter serialization boundary. */ -export interface AttemptTierOutcome { - canonical?: "priority"; - wireKind?: FastWire["kind"] | null; - wireValue?: string | null; - fastOutcome: "not-requested" | "applied" | "downgraded" | "unknown"; - fastDowngradeReason?: "route-unsupported" | "wire-unavailable" | "response-declined"; - callerTierDropped?: boolean; - callerFastSuppressedByConfig?: boolean; - confirmation: "confirmed" | "assumed" | "downgraded" | "unknown"; - responseServiceTier?: string; -} - -/** - * Request-local observation inputs captured before the final tier action mutates the parsed view. - * This is not persisted; the final adapter turns it into AttemptTierOutcome after serialization. - */ -export interface TierObservationContext { - capability: boolean | undefined; - eligibility: - | "eligible" - | "capability-unsupported" - | "unclassified" - | "wire-unavailable" - | "pin-unavailable"; - fastWire: FastWire | null; - demandDecision: "force-fast" | "force-default" | "inherit"; - callerTier?: string; -} - -export type TierDecision = - | { readonly kind: "forward-caller" } - | { readonly kind: "drop" } - | { readonly kind: "set"; readonly value: string }; - -/** - * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 - * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. - */ -export interface OcxProviderConfig { - adapter: string; - /** - * Codex tool calling mode for routed models. - * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only" (unified exec helper tool). - * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). - */ - codexToolMode?: "code_mode_only" | "shell"; - /** Optional outbound request-start pacing shared by this provider and its model overrides. */ - requestPacing?: ProviderRequestPacingConfig; - /** Cursor MCP compatibility bounds; positive integers when configured. */ - mcpMaxTools?: number; - mcpMaxSchemaBytes?: number; - mcpMaxResultBytes?: number; - /** - * Per-model wire override, keyed by the upstream native model id (after namespace - * and combo resolution). A single gateway can front models that speak different - * wires — Grok needs the Responses API for hosted web_search while a sibling model - * is fine on chat completions (#404). - * - * Only OpenAI-shaped wires may be selected; see MODEL_ADAPTER_OVERRIDE_ALLOWED. - * Absent or empty means the provider-wide `adapter` applies to everything, exactly - * as before. - */ - modelAdapters?: Record; - /** - * Fast-wire declaration. `null` explicitly disables adapter-derived defaults; - * absence derives from the final model adapter. - */ - fastWire?: FastWire | null; - baseUrl: string; - /** - * Optional relative resource path for key-auth openai-responses requests. Must start with `/` - * and must not include a URL scheme, query string, or fragment. When omitted, the adapter keeps - * the legacy `/v1/responses` construction. - */ - responsesPath?: string; - /** - * Command Code protocol version sent as `x-command-code-version` on /alpha/generate requests. - * The internal endpoint's schema drifts with the CLI version; operators can pin a known-good - * version here instead of waiting for a code change. Absent uses the adapter's current default. - */ - commandCodeVersion?: string; - /** - * Responses upstream that stores nothing server-side (DeepSeek documents "the API - * is stateless"). Stateful request parameters are dropped, `store` is pinned false, - * and orphaned tool results left by a replay miss are repaired rather than - * forwarded to an upstream that cannot resolve their pair. - */ - statelessResponses?: boolean; - /** - * Responses upstream whose parser requires an unambiguous call batch and its matched - * result batch to remain contiguous. Hook-injected context that splits the batch is - * preserved after it, and parallel calls stay together with the reasoning turn that produced them. - */ - requiresAdjacentResponsesToolResults?: boolean; - /** - * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire. - * This pure tri-state feeds catalog publication, routing eligibility, compatibility - * fingerprints, and proxy-owned canonical Fast injection on both Responses and Chat routes. - * Tri-state: `true` lets fast mode inject/remove the canonical field; `false` strips it and - * never injects, because an upstream documented as not supporting the parameter - * must not receive it; absent (`undefined`) leaves the provider unclassified — fast mode never - * injects or translates, and caller values pass only under the final wire's forwarding permission. - * On Chat, that CallerTierForward permission is `chatServiceTier`; Responses retains passthrough. - * An explicit config value always wins over the registry default. - */ - supportsServiceTier?: boolean; - /** Exact upstream model ids that override the provider-level service-tier capability. */ - modelSupportsServiceTier?: Record; - /** - * Responses upstream whose native contract accepts plaintext reasoning replay - * (DeepSeek documents reasoning items with plaintext content). When set, the - * passthrough serializer keeps `reasoning_text` content on replayed reasoning - * items instead of blanking it the way the ChatGPT backend requires; proxy-minted - * `ocxr1` envelopes are still stripped because no upstream can decrypt them. - */ - preserveResponsesReasoningContent?: boolean; - /** - * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918, - * link-local, or unique-local upstreams. Metadata endpoints remain blocked. - */ - allowPrivateNetwork?: boolean; - /** - * Pin the HTTP version used for upstream provider requests. Bun's fetch negotiates - * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on - * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1, - * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation - * (current behavior unchanged). Explicit pins require an https: target and fail locally when the - * pin cannot be honored. Cursor additionally maps an HTTP/1.1 pin onto its RunSSE + BidiAppend - * compatibility transport. - */ - upstreamHttpVersion?: UpstreamHttpVersion; - /** - * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids - * unchanged to the wire instead of applying the `-tiered` suffix (`gemini-3.7-flash` - * -> `gemini-3.7-flash-tiered`). Set this to `false` when the configured upstream still - * serves the bare ids. Absent (default) keeps the rename. - */ - directGeminiWireRenames?: boolean; - /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ - disabled?: boolean; - /** - * Codex account-selection mode. Valid ONLY on the canonical built-in `openai` forward provider. - * "pool" (default) rotates main + added Codex accounts through the affinity/quota/cooldown/ - * failover engine; "direct" pins the caller's main Codex login and never touches pool state. - */ - codexAccountMode?: CodexAccountMode; - apiKey?: string; - /** - * Key-auth header style for Anthropic-compatible providers. - * Defaults to the native Anthropic `x-api-key`; gateways may require - * `Authorization: Bearer ` instead. - */ - apiKeyTransport?: "x-api-key" | "bearer"; - /** - * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE - * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare - * `apiKey` seeds a one-entry pool on first management touch. - */ - apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>; - defaultModel?: string; - models?: string[]; - /** - * Fetch the provider's live `/models` endpoint. Defaults to true. - * Set false when `models` is an intentional allowlist or a provider's live catalog is too large - * or too flaky for startup/catalog sync. - */ - liveModels?: boolean; - /** - * Per-provider catalog allowlist. When non-empty, ONLY these model ids are emitted to Codex's - * catalog and `/v1/models` — live discovery still runs, this just narrows what ships (so a proxy - * exposing thousands of models, or an aggregator like OpenRouter, doesn't bloat the catalog). - * Empty/undefined = expose all. The admin `/api/models` list is unaffected (it always shows the - * full set so the user can pick). See devlog issue_052_provider-model-allowlist. - */ - selectedModels?: string[]; - /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ - contextWindow?: number; - /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ - modelContextWindows?: Record; - /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ - modelInputModalities?: Record; - /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ - modelMaxInputTokens?: Record; - /** - * Provider-wide fallback for chat-completions `max_tokens` when the caller omits - * Responses `max_output_tokens`. Adapters still let an explicit request win. - */ - defaultMaxOutputTokens?: number; - /** Model-specific fallback output token budgets. Exact/model-pattern entries beat the provider default. */ - modelMaxOutputTokens?: Record; - /** - * Per-model display prices (USD per 1M tokens) keyed by exact model id — - * opencode-style per-model pricing in ocx's flat `modelXxx` convention: - * `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. - * User-configured prices win over the built-in jawcode/expected catalogs in - * the Logs `~$` estimate. Display-time estimation only; never billing. An - * all-zero entry means "not billable here" and falls through to the catalogs. - */ - modelCosts?: Record; - headers?: Record; - /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */ - openRouterRouting?: OpenRouterProviderRouting; - /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */ - modelOpenRouterRouting?: Record; - /** - * "key" (default): authenticate upstream with `apiKey`. - * "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only). - * "oauth": resolve a stored OAuth access token (auto-refreshed) and use it as the Bearer key. - * Only the openai-responses adapter implements "forward"; openai-chat uses its own key/token. - * "local": local runtime (Ollama etc.) — no remote key required. Valid only for - * providers whose registry entry declares authKind "local" (management API enforces). - */ - authMode?: "key" | "forward" | "oauth" | "local"; - /** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */ - keyOptional?: boolean; - /** - * Free-tier pricing flag for UI/catalog (Free badge, Free filter). Not the same as - * `keyOptional` — free tiers may still require an API key (e.g. NVIDIA NIM free credits). - */ - freeTier?: boolean; - /** Optional human note shown in the providers UI (not used for routing). */ - note?: string; - /** Strip one trailing bracketed suffix from model ids before sending them upstream. */ - modelSuffixBracketStrip?: boolean; - /** - * Override the guardian's proactive-refresh policy for this provider. When unset, the provider's - * built-in risk-tiered default applies (see OAUTH_PROVIDERS in src/oauth/index.ts). Set "proactive" - * to opt this provider into background refresh; "disabled"/"lazy-only" to forbid/limit it. - */ - refreshPolicy?: RefreshPolicy; - /** - * Provider-wide Codex-visible reasoning tiers for routed models. Use only Codex-supported labels - * here (`low`, `medium`, `high`, `xhigh`, `max`); translate provider aliases with - * `reasoningEffortMap` / `modelReasoningEffortMap` below. - */ - reasoningEfforts?: string[]; - /** Model-specific Codex-visible reasoning tiers. An empty array means “do not expose effort”. */ - modelReasoningEfforts?: Record; - /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */ - modelDefaultReasoningEfforts?: Record; - /** - * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible - * Responses backend rejects Codex summary-delivery fields for that model. - */ - modelSupportsReasoningSummaries?: Record; - /** - * Per-model wire value for Responses `stream_options.reasoning_summary_delivery`. - * Presence also advertises reasoning-summary support for that routed model. - */ - modelReasoningSummaryDelivery?: Record; - /** - * Exact-model hosted tools that win collisions with Codex client tool declarations. - * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side. - */ - modelPreferHostedTools?: Record; - /** - * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical - * fields or closing events (#893). Disabled by default and applied only to client-facing - * SSE/JSON; raw inspection state remains authoritative. - */ - responsesSnapshotRepair?: boolean; - /** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */ - reasoningEffortMap?: Record; - /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */ - modelReasoningEffortMap?: Record>; - /** OpenAI-compatible gateway reasoning wire shape. Default sends `reasoning_effort`. */ - reasoningWireFormat?: "gateway-object"; - /** - * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops - * reasoning_effort for these even when Codex selects a reasoning level (e.g. xAI grok-build-0.1). - */ - noReasoningModels?: string[]; - /** Model ids that reject caller-specified temperature. */ - noTemperatureModels?: string[]; - /** Model ids that reject caller-specified top_p. */ - noTopPModels?: string[]; - /** Model ids that reject caller-specified presence/frequency penalty values. */ - noPenaltyModels?: string[]; - /** - * Model ids whose Chat Completions endpoint rejects `response_format`. - * Structured-output translation remains enabled by default; this is a narrow - * per-model compatibility escape hatch for mixed-capability gateways. - */ - noStructuredOutputModels?: string[]; - /** - * Allow multiple tool calls per completion. DEFAULT-ON for openai-chat providers (the - * buffered stream parser assembles interleaved/fragmented multi-call turns safely); - * set `false` to force `parallel_tool_calls:false` upstream and drop the catalog's - * `supports_parallel_tool_calls` bit for that provider. Non-chat adapters advertise - * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls. - */ - parallelToolCalls?: boolean; - /** - * Opt-in: when `parallelToolCalls` is `false`, actually send `parallel_tool_calls: false` - * on the `/chat/completions` wire for this provider. By default an opted-out provider only - * OMITS the field (strict OpenAI-compatible hosts reject unknown knobs), and the NVIDIA NIM - * baseUrl is the sole built-in exception that pins the wire bit. Some self-hosted gateways - * (Kimi/GLM-family, vLLM, etc.) do honor `parallel_tool_calls` and keep emitting concurrent - * tool calls unless it is present; enable this to pin the bit without hardcoding their URL. - * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters. - */ - pinParallelToolCallsFalse?: boolean; - /** - * Opt-in: extend the no-tool-call terminal continuation guard to this provider's - * `openai-chat` routed turns. The guard (originally Anthropic-only, see - * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a - * model announces work but ends the turn without emitting a tool call. Self-hosted - * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion - * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on - * Anthropic turns, so it stays OFF by default for the many registry providers that share - * the `openai-chat` adapter. Enable only for a provider whose models are known to stop - * mid-work; non-`openai-chat` adapters ignore this flag. - */ - terminalContinuationGuard?: boolean; - /** - * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. - * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown - * fields. Default off; only enable for providers that document this parameter. - */ - promptCacheKey?: boolean; - /** - * Opt-in: forward caller `service_tier` values to the upstream `/chat/completions` body. - * On a classified route it governs foreign values (for example `flex`), not proxy-owned - * canonical Fast after capability validation. On an unclassified route it governs every caller - * value, including canonical spellings, because no Fast capability has been validated. - * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends - * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a - * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s. - * Exact-model `true` enables canonical Fast capability but does not grant foreign-tier - * forwarding; provider-level `supportsServiceTier: false` remains a global denial. Default off; - * only enable for providers that document this parameter on the chat wire. - */ - chatServiceTier?: boolean; - /** - * Provider-local passthrough SSE repair for broken openai-responses gateways that reuse exact - * placeholder message/reasoning ids or omit the terminal id after a stable added event. - * Disabled by default; function_call ids and call_id pairing are never rewritten. - */ - responsesItemIdRepair?: ResponsesItemIdRepairConfig; - /** Model ids whose tool_choice only accepts `auto` or `none`; forced/named choices are downgraded. */ - autoToolChoiceOnlyModels?: string[]; - /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */ - preserveReasoningContentModels?: string[]; - /** - * Model ids whose upstream hard-rejects a tool_call continuation missing - * `reasoning_content` (DeepSeek thinking mode: HTTP 400). When the replay - * cache misses, the adapter injects a minimal placeholder for these models. - * Defaults to `preserveReasoningContentModels` when unset; set `[]` to opt - * out explicitly (e.g. MiniMax, where low effort disables thinking). - */ - requiresReasoningPlaceholderModels?: string[]; - /** - * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, - * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays - * the identical request on the same key before any failover. Pre-stream only: a 429 arrives - * before any response bytes are relayed, so the replay is lossless. - */ - retryOn429?: RateLimitRetryPolicy; - /** - * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns - * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content. - */ - reasoningSplitModels?: string[]; - /** - * Model ids whose reasoning is a vendor `thinking: {type}` toggle on the - * chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder. - * The openai-chat adapter translates the mapped effort into the thinking toggle for these. - */ - thinkingToggleModels?: string[]; - /** - * Model ids whose reasoning is a `thinking_budget` integer on the chat-completions wire - * (Qwen3.x style), NOT an OpenAI `reasoning_effort` ladder. The openai-chat adapter maps the - * Codex effort to a budget fraction. - */ - thinkingBudgetModels?: string[]; - /** Anthropic-compatible gateways that need custom tool names escaped on the wire. */ - escapeBuiltinToolNames?: boolean; - /** - * Anthropic-compatible gateways (e.g. AgentRouter) that may close the stream before - * `message_stop`. With this enabled the adapter completes an otherwise-clean EOF only when - * visible text was received or an open tool call has complete JSON-object arguments; all - * other EOFs remain truncation errors. Absent = strict default behavior. - */ - anthropicEofTolerance?: boolean; - /** - * Model ids that do NOT accept image inputs. The proxy gives them "eyes" via the vision sidecar: - * attached images are described by a gpt vision model and replaced with text before the call. - */ - noVisionModels?: string[]; - /** - * Google adapter mode. "ai-studio" (default) = Generative Language API + x-goog-api-key. - * "vertex" = Vertex AI project/location endpoints with GCP ADC (or x-goog-api-key). - * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope. - */ - googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; - /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */ - project?: string; - /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ - location?: string; - /** - * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent - * as callable tools. Each entry is spawned (stdio `command`) or connected (`url`) lazily per - * stream; their tools are advertised to the Cursor server and executed against the live server. - */ - mcpServers?: Record; - /** - * Cursor adapter only: opt-in external executor for computer-use / record-screen. opencodex is - * headless and cannot control a screen itself; provide commands here only when running on a host - * that can. With no executor, these tools honestly report "not supported". - */ - desktopExecutor?: import("./adapters/cursor/native-exec-desktop").DesktopExecutorConfig; - /** - * Cursor adapter only: unsafe opt-in escape hatch for Cursor server-driven built-in local - * read/write/delete/ls/grep/shell/fetch execution. Prefer `nativeLocalExec: "on"` for new - * configs; this legacy boolean remains a server-local explicit opt-in for existing operators. - * Defaults to false so remote Cursor messages cannot bypass Codex approval/sandbox semantics. - * Explicit MCP and desktop executors remain controlled by their own opt-in config. - */ - unsafeAllowNativeLocalExec?: boolean; - /** - * Cursor adapter only: native local exec policy mode (exec-policy.ts). - * "off" (default) rejects server-driven local exec; "on" always allows it for this - * provider and should be used only for a trusted local experiment on a host where every - * data-plane caller is trusted. "codex-sandbox" is accepted for backwards compatibility - * but is fail-closed like "off": Responses instructions/system/developer text is - * caller-controlled prose, and opencodex has no trustworthy per-request attestation that it - * reflects a real Codex sandbox state. The default loopback bind admits ANY local process - * without auth (including other local users on multi-user machines), and - * isAllowedRequestOrigin blocks non-loopback browser origins by default but not - * loopback-origin or origin-less callers. - */ - nativeLocalExec?: "off" | "codex-sandbox" | "on"; -} - export type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./types/wire"; export { UPSTREAM_HTTP_VERSION_VALUES, @@ -1718,40 +24,81 @@ export { pinnedWireAdapter, } from "./types/wire"; -export interface CodexAccount { - id: string; - email: string; - /** User-owned display label; never participates in routing or identity checks. */ - alias?: string; - plan?: string; - /** - * Provenance of `plan`. WHAM (live quota API) is authoritative; the JWT - * `chatgpt_plan_type` claim is a fallback that may lag a plan change. A JWT write - * must never overwrite a WHAM-sourced plan observed for the same credential - * generation — only a newer generation (token refresh after the WHAM read) may. - */ - planSource?: "jwt" | "wham"; - /** Credential generation at which `plan`/`planSource` was recorded. */ - planCredentialGeneration?: number; - chatgptAccountId?: string; - logLabel?: string; - isMain: boolean; -} - -export interface CodexAccountCredentials { - accessToken: string; - refreshToken: string; - expiresAt: number; - chatgptAccountId: string; -} +export type { + OcxReasoningReplayIdentity, + OcxReasoningReplayScopeRef, + OcxParsedRequest, + OcxContext, + OcxMessage, + OcxUserMessage, + OcxAssistantMessage, + OcxDeveloperMessage, + OcxToolResultMessage, + OcxTextContent, + OcxImageContent, + OcxContentPart, + OcxThinkingContent, + OcxToolCall, + OcxProviderOpaqueToolCallMetadata, + OcxAssistantContentPart, + OcxRequestOptions, + OcxMessagePhase, + OcxProviderContinuationState, + AdapterEvent, + OcxUrlCitation, + OcxUsage, +} from "./types/request"; + +export type { + OcxClaudeCodeConfig, + OcxClaudeDesktopFamily, + OcxClaudeDesktopAssignment, + OcxClaudeDesktopProfile, + StorageCleanupPolicy, + OcxCustomModel, + OcxApiKeyEntry, + OcxClientIntegrationsConfig, + OcxConfig, + OcxAccountPoolRotationStrategy, + OcxComboStrategy, + OcxComboDefaultEffort, + OcxComboTarget, + OcxComboConfig, + OcxRoutingUnknownEvidenceMode, + OcxRoutingProfileCandidate, + OcxRoutingProfileRequirements, + OcxRoutingProfileOptimize, + OcxRoutingUnknownCostCapMode, + OcxRoutingProfileLimits, + OcxRoutingProfileUnknownEvidence, + OcxRoutingProfileCompatibilitySuite, + OcxRoutingProfileCompatibility, + OcxRoutingProfileConfig, + OcxTokenGuardianConfig, + OcxImagesConfig, + OcxSearchConfig, + OcxVisionSidecarConfig, + OcxWebSearchSidecarConfig, +} from "./types/config"; + +export type { + RefreshPolicy, + OpenRouterProviderRouting, + ResponsesItemIdRepairConfig, + RateLimitRetryPolicy, + ProviderCostOverlay, + RequestPacingRule, + ProviderRequestPacingConfig, + FastWire, + AttemptTierOutcome, + TierObservationContext, + TierDecision, + OcxProviderConfig, +} from "./types/provider"; + +export type { + CodexAccount, + CodexAccountCredentials, + CodexAccountCredentialRecord, +} from "./types/accounts"; -export interface CodexAccountCredentialRecord { - credential?: CodexAccountCredentials; - generation: number; - refreshGrantFingerprint?: string; - deletedAt?: number; - replacedAt?: number; - lastCodexValidatedAt?: number; - lastCodexValidationStatus?: "ok" | "failed"; - lastCodexValidationError?: string; -} diff --git a/src/types/accounts.ts b/src/types/accounts.ts new file mode 100644 index 0000000000..5ff48945c7 --- /dev/null +++ b/src/types/accounts.ts @@ -0,0 +1,37 @@ +export interface CodexAccount { + id: string; + email: string; + /** User-owned display label; never participates in routing or identity checks. */ + alias?: string; + plan?: string; + /** + * Provenance of `plan`. WHAM (live quota API) is authoritative; the JWT + * `chatgpt_plan_type` claim is a fallback that may lag a plan change. A JWT write + * must never overwrite a WHAM-sourced plan observed for the same credential + * generation — only a newer generation (token refresh after the WHAM read) may. + */ + planSource?: "jwt" | "wham"; + /** Credential generation at which `plan`/`planSource` was recorded. */ + planCredentialGeneration?: number; + chatgptAccountId?: string; + logLabel?: string; + isMain: boolean; +} + +export interface CodexAccountCredentials { + accessToken: string; + refreshToken: string; + expiresAt: number; + chatgptAccountId: string; +} + +export interface CodexAccountCredentialRecord { + credential?: CodexAccountCredentials; + generation: number; + refreshGrantFingerprint?: string; + deletedAt?: number; + replacedAt?: number; + lastCodexValidatedAt?: number; + lastCodexValidationStatus?: "ok" | "failed"; + lastCodexValidationError?: string; +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000000..f00bfbcdd4 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,818 @@ +import type { OcxProviderConfig } from "./provider"; +import type { CodexAccount } from "./accounts"; + +/** + * Claude Code inbound settings (devlog/260711_claude_inbound). Consumed by the + * /v1/messages surface, the `ocx claude` launcher, and the GUI Claude page. + */ +export interface OcxClaudeCodeConfig { + /** Kill switch for the /v1/messages inbound (GUI "Claude ON" toggle). Default: enabled. */ + enabled?: boolean; + /** + * Verbatim passthrough of unmapped claude/anthropic models to api.anthropic.com with the + * caller's own sk-ant-* credential (Claude Code subscription OAuth). Default: enabled. + */ + nativePassthrough?: boolean; + /** Upstream for the native passthrough (tests/enterprise gateways). Default: https://api.anthropic.com */ + anthropicBaseUrl?: string; + /** + * Native passthrough body inactivity budget in SECONDS — raw upstream-byte silence + * while a read is pending, NOT total duration (slow-but-alive streams never trip it; + * devlog 260716_passthrough_followups/010). Default 90. Min 1. Exactly 0 disables; + * negative/non-finite values fall back to the default. + */ + bodyStallSec?: number; + /** + * Native passthrough cumulative body byte cap (streamed SSE and buffered non-stream + * alike) — an OOM/occupancy guard, not a correctness limit. Default 67108864 (64 MiB). + * Exactly 0 disables; negative/non-finite values fall back to the default. + */ + bodyMaxBytes?: number; + /** Default model slot injected as ANTHROPIC_MODEL by `ocx claude`. */ + model?: string; + /** Haiku/small-fast slot injected as ANTHROPIC_DEFAULT_HAIKU_MODEL (+ legacy SMALL_FAST). */ + smallFastModel?: string; + /** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */ + modelMap?: Record; + /** + * Explicit classifier model for Claude Code Auto Mode safety checks (e.g. "RelayA/claude-opus-5"). + * When unset, bare classifier requests check modelMap, then same-provider affinity from + * `claudeCode.model`, then compatible Anthropic-adapter providers, and finally fallbacks. + */ + classifierModel?: string; + /** + * Ordered fallback candidates for Claude Code Auto Mode classifier routing when the primary + * classifier route is not available. + */ + classifierFallbacks?: string[]; + /** + * Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv` + * so plain `claude` commands route through the proxy without `ocx claude`. Reverted + * on stop/shutdown. Default: false (opt-in). macOS only. + */ + systemEnv?: boolean; + /** + * Auth mode for Claude Code inbound requests — a THREE-state intent. + * + * "proxy": inject the dummy ANTHROPIC_AUTH_TOKEN so Claude Code routes through the + * proxy without a real Anthropic key. "subscription": never inject it. UNSET means + * AUTO: the mode is resolved from detected Claude auth on every launch and every + * status read (src/claude/auth-mode.ts), so registering a Claude login switches the + * behaviour with no migration and no stored state. + * + * An explicit value always wins over detection and is never rewritten by the auto + * logic — that is what makes a manual choice stick (devlog 260726_claude_auth_auto). + */ + authMode?: "proxy" | "subscription"; + /** + * ISO timestamp of the one-time authMode migration. Before auto existed, choosing + * "Subscription" DELETED the key, so a pre-upgrade config cannot distinguish an + * explicit subscription choice from "never chose". Its ABSENCE identifies a + * pre-upgrade block; the migration writes it once and never re-runs, so a user who + * later picks Auto (which deletes authMode) is not silently converted back. + */ + authModeMigratedAt?: string; + /** + * Context-window override for Claude Code/Desktop clients (devlog 136 B6): + * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official + * env pair — recognized claude-shaped ids need both). WARNING: DISABLE_COMPACT + * turns off auto-compaction. Unset = client defaults. + */ + maxContextTokens?: number; + /** + * Opt-in CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 injection. Default OFF: opus-shaped + * aliases already carry output_config.effort on the wire (devlog 136 실측), and + * forcing effort on every request can leak reasoning params to non-reasoning routes. + */ + alwaysEnableEffort?: boolean; + /** + * Subagent tier slots (devlog 260712 B2): injected as ANTHROPIC_DEFAULT_*_MODEL so + * Claude Code's Agent-tool aliases (opus/sonnet/haiku/fable + parent-inherit) route + * to proxy models. haiku falls back to smallFastModel (one effective value feeds + * both ANTHROPIC_DEFAULT_HAIKU_MODEL and legacy ANTHROPIC_SMALL_FAST_MODEL). + */ + tierModels?: { opus?: string; sonnet?: string; haiku?: string; fable?: string }; + /** + * Auto-context (devlog 260712 020): when not false, routed/native models whose + * authoritative window is > 200k AND >= the compact window get the [1m] marker + * (Claude Code then accounts 1M) and CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected + * so compaction fires at the real budget. 2.1.207 semantics (binary-verified): + * effective compact window = min(believed window, env) — one global env behaves + * like a per-model floor. Default: enabled. Inert while maxContextTokens is set + * (the legacy DISABLE_COMPACT pair takes rule-1 precedence in the CLI). + */ + autoContext?: boolean; + /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ + autoCompactWindow?: number; + /** + * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712 + * 060): Skill-tool results whose skill name matches an entry here are replaced + * with a short stub in the anthropic->responses translation. Third-party models + * are not trained on these Anthropic doc bundles, and claude-api alone injects + * ~136k tokens (GitHub anthropics/claude-code#74473). Native Anthropic + * passthrough never goes through the translation, so Claude models keep the + * full content. Default: ["claude-api"]. Empty array = explicitly off. + */ + blockedSkills?: string[]; + /** + * Sync the featured subagent roster (config.subagentModels + main model) into + * ~/.claude/agents/ocx-*.md custom agent definitions at launch (devlog 260712 + * 070) so any routed model is dispatchable as a subagent_type — the Agent + * tool's model argument is a hard 4-alias enum, but definition frontmatter is + * free. Only ocx-*.md files are owned/pruned. Default: enabled. + */ + injectAgents?: boolean; + /** + * Optional Claude Code effort pinned in every generated ocx-* subagent + * definition. Unset inherits the parent session effort. + */ + subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max"; + /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */ + webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string }; + /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */ + visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; + /** Persisted Claude Desktop four-family routing profile. */ + desktopProfile?: OcxClaudeDesktopProfile; + /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ + desktopAutoApply?: boolean; + /** + * When false, omit `native/*` rows from Claude Desktop show/export/apply. Default: enabled. + * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. + */ + desktopNativeModels?: boolean; +} + +export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; + +export interface OcxClaudeDesktopAssignment { + family: OcxClaudeDesktopFamily; + alias: string; +} + +export interface OcxClaudeDesktopProfile { + version: 1; + assignments: Record; + defaults: Record; + /** SHA-256 fingerprint of the last successfully applied 3P config content. */ + appliedFingerprint?: string; + /** ISO timestamp of the last successful apply. */ + appliedAt?: string; +} + +/** + * Opt-in archived-session auto-cleanup policy (issue #42 Phase 3). + * Persisted under `OcxConfig.storageCleanupPolicy`. Default `enabled: false`. + */ +export interface StorageCleanupPolicy { + /** When false/unset, the engine never mutates. Default false. */ + enabled: boolean; + /** Run when archived session bytes exceed this threshold. */ + trigger: { archivedBytesOver: number }; + /** Either shrink archives toward a byte floor, or remove the oldest N%. */ + target: { reduceToBytes?: number } | { removeOldestPercent?: number }; + schedule: "startup" | "daily" | "weekly" | "manual"; + /** Default quarantine. Permanent only when explicitly set. */ + mode: "quarantine" | "permanent"; + lastRun?: { at: number; freedBytes: number; removed: number }; + /** Epoch ms when the next scheduled evaluation is due. */ + nextRun?: number; +} + +/** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ +export interface OcxCustomModel { + /** 고유 ID (crypto.randomUUID()) */ + id: string; + /** 프로바이더 키 (기존 providers[name]) */ + provider: string; + /** Native provider model id; slashes are allowed and encoded for Codex as provider/. */ + modelId: string; + /** 인간 가독 표시명 (선택, 슬래시 불가) */ + displayName?: string; + /** 컨텍스트 윈도우 (토큰) */ + contextWindow?: number; + /** 입력 모달리티 (선택, 기본 ["text"]) */ + inputModalities?: string[]; + /** + * Reasoning ladder (Codex labels) this custom row explicitly advertises. An empty array + * hides the effort control; an omitted key leaves the provider-derived ladder in charge. + */ + reasoningEfforts?: string[]; + /** Default effort label when `reasoningEfforts` is non-empty. */ + defaultReasoningEffort?: string; + /** + * Codex tool calling mode override for this custom model. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; + /** 추가 시각 (ISO 8601) */ + addedAt?: string; +} + +/** + * A generated `ocx_` data-plane key. `key` is the secret itself and never leaves + * the server except in the one-time POST /api/keys response; every other surface + * sees only the masked prefix. + */ +export interface OcxApiKeyEntry { + id: string; + name: string; + key: string; + createdAt: string; +} + +/** + * Durable per-client intent. One key today, deliberately. + * + * A top-level `codexEnabled` would force every later client to invent an + * unrelated name and its own helpers; a ten-key union recreated the coupling + * that failed two audits, because every phase then had to touch every client's + * write path. A one-key object keeps the extension point without letting this + * phase claim ownership over a client it does not implement. + */ +export interface OcxClientIntegrationsConfig { + /** Durable desired state for native Codex. MISSING MEANS ON. */ + codex?: boolean; + /** Durable desired state for Grok Build. MISSING MEANS ON. */ + grok?: boolean; + /** Durable desired state for Claude Desktop. MISSING MEANS ON. */ + "claude-desktop"?: boolean; +} + +export interface OcxConfig { + port: number; + /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ + emptyCompletionRetry?: boolean; + /** Maximum usage-log bytes read for one management snapshot. */ + managementUsageMaxReadBytes?: number; + providers: Record; + defaultProvider: string; + /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ + openaiProviderTierVersion?: 1 | 2; + /** One-time migration marker for Antigravity's static-catalog defaults. */ + googleAntigravityStaticCatalogVersion?: 1 | 2; + /** Claude Code inbound + launcher settings. */ + claudeCode?: OcxClaudeCodeConfig; + /** + * Per-client durable intent. This phase owns only `codex`; later phases extend + * one key at a time rather than widening a shared union. + */ + clientIntegrations?: OcxClientIntegrationsConfig; + /** + * Up to 5 Codex-facing catalog ids to feature first. Values may be bare catalog ids, + * exact account-qualified "/" ids, or routed + * "/" ids. With account selectors, one bare native choice can expand + * into a selector-qualified group; Codex still advertises only the first 5 visible rows. + */ + subagentModels?: string[]; + /** + * Optional full picker ordering for the Codex model catalog, independent of the + * 5-slot `subagentModels` spawn_agent cap. DISPLAY-ONLY: it controls the visual order of + * the Codex model picker for large routed catalogs (10-20+ models) that would otherwise sort + * arbitrarily and reshuffle on every rebuild. Values are routed `/` catalog + * slugs (matched by exact slug or `provider/id`); native OpenAI passthrough rows and + * account-qualified native rows are not reordered (order native rows via `subagentModels`). + * Listed routed rows appear in array order; rows not listed keep their normal display order. + * `subagentModels`-featured rows keep their top position. When unset or empty, catalog + * priority is unchanged. This changes ONLY what the user sees in the picker: the spawn_agent + * candidate set is derived from each row's natural priority and is provably unaffected, even + * when every routed row is listed (see opencodex_spawn_priority / effectiveSubagentRoster). + */ + modelPickerOrder?: string[]; + /** + * Priority-ordered fallback models for spawned sub-agents. When the requested + * model is quota-exhausted or recently failed, opencodex rewrites the child + * turn to the next available entry before routing. + */ + subagentModelFallback?: string[]; + /** + * Per-primary-model fallback chains for spawned sub-agents, keyed by the + * requested primary model id (bare native or "provider/model"). Entries for + * the matching key are consulted after the requested model and before the + * global `subagentModelFallback` list. + * + * This is the supported home for per-role fallback metadata: storing it as + * `model_fallback` inside `$CODEX_HOME/agents/*.toml` makes Codex >= 0.146 + * reject the whole role file as an unknown field (#1190). + */ + subagentModelFallbackByModel?: Record; + /** + * TTL (ms) for cached sub-agent model availability probes. Default 60_000. + */ + subagentModelFallbackPollMs?: number; + injectionModel?: string; + /** + * Opt in to synchronizing the selected injection model into Codex's native + * sub-agent defaults. Only meaningful while `injectionModel` is set. + */ + syncCodexSubagentDefaults?: boolean; + /** + * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls + * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against + * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary. + */ + injectionEffort?: string; + /** + * Explicit sideband websocket base for realtime/live joins, mirroring upstream's + * `experimental_realtime_ws_base_url`. The value is a ROOT (or a recognized + * `/realtime`, `/realtime/calls/`, `/live/` endpoint form, which is + * stripped back to the root); `/v1` is appended during normalization. Intended + * for local development against a fake realtime server — plaintext `http`/`ws` + * is accepted only for loopback hosts, and URL userinfo is rejected; both + * failures close to the canonical `https://api.openai.com/v1`. Configured by + * editing this file; there is deliberately no management-API or GUI surface. + */ + experimentalRealtimeWsBaseUrl?: string; + /** + * Model ids the user has EXCLUDED from the Grok Build managed block. Absent or empty + * means "everything visible", which is the historical behaviour — so an existing + * config keeps the fence it already had. + * + * Exclusion list rather than an inclusion list on purpose: a newly added provider + * model should appear in Grok by default, exactly as it does today. An inclusion list + * would silently hide every future model behind a switch nobody knew to flip. + */ + grokExcludedModels?: string[]; + /** + * When true, OpenAI-routed requests include `service_tier: "priority"` (fast inference). + * When false, service_tier is stripped so requests use default speed. + * Undefined = passthrough (don't modify what the client sends). + */ + fastMode?: boolean; + /** + * Windows/macOS SSE passthrough stream shape (#314 mitigation). + * On Windows, "auto" (default) selects eager relay only on a runtime proven + * to carry the Bun#32111 fix. On macOS, "auto" always stays on legacy tee and + * eager relay is explicit-only. "eager-relay" opts into the new relay (and + * accepts #32111 crash risk on Bun 1.3.14); "legacy-tee" pins the tee path. + * Persisted in config.json so service users can select the stream shape. + * See src/lib/bun-stream-caps.ts. + */ + streamMode?: "auto" | "legacy-tee" | "eager-relay"; + /** + * Custom override for the injected v2 multi-agent guidance body (the text inside + * the tags). After guidance is enabled and the v2 surface and + * catalog-state gates pass, a configured injectionModel is sufficient to render it; + * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the + * effective preferred model for the request (a bare native model is account-qualified + * only when the request targets an explicit account selector; unresolved or ambiguous + * bare values become "", while unresolved explicit routed or account-qualified values + * remain unchanged), + * `{{effort}}` -> injectionEffort, `{{roster}}` -> the resolved sub-agent roster + * block ("" when nothing resolves), `{{fallback}}` -> the configured subagent + * model fallback guidance block ("" when unset). + */ + injectionPrompt?: string; + /** + * Proxy-authored multi-agent developer guidance. Undefined/true = enabled for + * backward compatibility; false suppresses both v1 and v2 guidance injection. + */ + multiAgentGuidanceEnabled?: boolean; + /** + * Global hard ceiling for the reasoning effort of EVERY proxied turn (main agent AND + * sub-agents). Ladder value "low".."max"; incoming efforts ranking above it are rewritten + * in both request shapes before any adapter or clamp. Unset = no cap. codex-rs converts + * ultra -> max client-side, so e.g. a "high" cap sends ultra/max-tier turns as high. + */ + effortCap?: string; + /** + * Hard ceiling applied ONLY to sub-agent turns — requests carrying codex-rs's spawned-child + * markers (`x-openai-subagent` header, or `subagent_kind` inside `x-codex-turn-metadata`). + * Lets the main agent keep its tier while delegated children are capped. When both caps are + * set, the lower one wins for sub-agents. See src/server/effort-policy.ts. + */ + subagentEffortCap?: string; + /** + * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids + * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only + * their generated selector row and are omitted from raw /v1/models. BARE native GPT ids hide + * the bare row plus every generated selector row and omit that model family from raw discovery. + */ + disabledModels?: string[]; + /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */ + customModels?: OcxCustomModel[]; + /** + * Internal, versioned evidence for reconciling custom-model deletions with + * pre-marker Codex catalog rows. Consumers must parse this defensively so a + * future state written by a newer binary survives older whole-config saves. + */ + customModelCatalogMigration?: unknown; + /** + * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation, + * commit messages, skill orchestration) to a user-chosen model. Default intercepted + * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+). + * Opt-in; disabled by default. Matching maintenance/helper requests are forced to low. + * All requests for configured shadow source models are intercepted unconditionally. + */ + shadowCallIntercept?: { + /** When true, requests for known shadow/helper source models are rewritten to the configured model. */ + enabled?: boolean; + /** Replacement model id (e.g. "gpt-5.5"). */ + model?: string; + /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */ + sourceModels?: string[]; + }; + /** + * 3-state multi-agent surface override: + * - "v1": force ALL models to v1 surface (override upstream pins) + * - "default" | undefined: respect upstream model pins (sol/terra=v2, luna=v1, rest=codex flag) + * - "v2": force ALL models to v2 surface (override upstream pins) + */ + multiAgentMode?: "v1" | "default" | "v2"; + /** + * When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1. + * Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92). + */ + keepNativeChatGptOnV1?: boolean; + /** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */ + agentTaskRecovery?: { + enabled?: boolean; + /** ChatGPT model used by the recovery request. Default: gpt-5.6-sol. */ + model?: string; + /** Recovery request timeout in milliseconds. Default: 45000. */ + timeoutMs?: number; + /** Maximum in-memory ciphertext-to-assignment entries. Default: 200. */ + cacheEntries?: number; + }; + /** Provider-level Codex-visible context caps. Values only lower known model context windows. */ + providerContextCaps?: Record; + /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ + contextCapValue?: number; + /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ + hostname?: string; + /** + * Optional second listener bound to 127.0.0.1 that admits data-plane requests without a + * credential (issue #1102). + * + * Why a separate listener rather than an exemption on the main one: when `hostname` is a + * wildcard, every caller needs `x-opencodex-api-key`, but a `codex app-server` spawned + * directly from the resolved entrypoint never goes through the generated shim and so never + * inherits the token. Exempting "loopback-looking peers" on the public listener would be + * unsound — `requestIP()` only proves the last transport hop, and Docker Desktop port + * forwarding, host-network containers, WSL mirrored networking and tunnels all terminate + * remote connections locally. Binding a second socket to 127.0.0.1 makes the kernel refuse + * remote connections outright, so there is no address to judge. + * + * The public listener's admission policy is unchanged. This adds an explicit local trust + * surface: every process on the machine can reach it, spend account quota, and consume paid + * provider credentials. Off by default; not for multi-tenant hosts. + * + * The port is required when enabled and must differ from the proxy port. An OS-assigned port + * would change across restarts, which would break already-running app-servers holding the + * previous `base_url` — the exact symptom #1102 reported and we disproved for token rotation. + */ + unauthenticatedLoopbackListener?: + | { enabled: false } + | { enabled: true; port: number }; + /** + * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or + * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when + * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded. + */ + proxy?: string; + /** + * Upstream stall timeout (seconds). After this many seconds of no upstream data, emits + * response.incomplete. Default 300. Min 1. + */ + stallTimeoutSec?: number; + /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 200000. */ + connectTimeoutMs?: number; + /** Graceful shutdown drain timeout (ms). Active turns are aborted after this deadline. Default 5000. */ + shutdownTimeoutMs?: number; + /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */ + websockets?: boolean; + /** + * Opt-in auto-cleanup policy for archived Codex sessions (issue #42 Phase 3). + * Default OFF (`enabled` false / unset). Never enabled implicitly. + * See `src/storage/policy.ts`. + */ + storageCleanupPolicy?: StorageCleanupPolicy; + /** Generated API keys for external access to the proxy's /v1/responses endpoint. */ + apiKeys?: OcxApiKeyEntry[]; + /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ + codexAutoStart?: boolean; + /** Restore an installed shim after a stable external Codex update replaces it. Default true. */ + codexShimAutoRestore?: boolean; + /** + * Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active + * so Codex App can show old OpenAI chats and opencodex-created exec chats under its default + * interactive-source/provider filters. Default true; originals are backed up and restored by + * `ocx stop` / `ocx restore`. Set false to opt out of history remapping. + */ + syncResumeHistory?: boolean; + /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */ + modelCacheTtlMs?: number; + /** Evictable retained app-state budget in MiB. Default 256; valid 64..4096. */ + appOwnedMemoryBudgetMb?: number; + /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */ + cacheRetention?: "none" | "short" | "long"; + /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */ + webSearchSidecar?: OcxWebSearchSidecarConfig; + /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */ + visionSidecar?: OcxVisionSidecarConfig; + /** /v1/images relay for codex's built-in image_gen tool. */ + images?: OcxImagesConfig; + /** /v1/alpha/search relay for codex's built-in web search client. */ + search?: OcxSearchConfig; + /** Codex multi-account pool. */ + codexAccounts?: CodexAccount[]; + /** Account ids administratively excluded from future pool selection until resumed. */ + pausedCodexAccountIds?: string[]; + /** + * Selection order per account id, higher used earlier; absent = 0. Keyed by id + * rather than stored on `codexAccounts` rows so the Desktop login (`__main__`), + * which has no row, can be ordered too. Range -100..100. + */ + codexAccountPriorities?: Record; + /** + * Account id the operator last selected by hand. Suppresses upward priority + * preemption until that account crosses the auto-switch threshold. Stores the + * id (not a flag) so a stale pin cannot outlive the selection it described. + */ + activeCodexAccountPinned?: string; + /** + * Public model-selector namespaces bound to one Codex account. Values are stored account ids; + * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases + * are intentionally separate from these selectors. + */ + codexAccountNamespaces?: Record; + /** + * Picker visibility override for account-qualified native models. When omitted, a non-empty + * selector map remains visible for compatibility with hand-written configurations. + */ + codexAccountPickerEnabled?: boolean; + /** Active pool account id for next session. undefined = main (passthrough as-is). */ + activeCodexAccountId?: string; + /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ + autoSwitchThreshold?: number; + /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ + accountPoolStrategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + accountPoolStickyLimit?: number; + /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ + upstreamFailoverThreshold?: number; + /** + * Opt-in provider-origin circuit threshold for proven pre-connection reachability failures. + * Default 0 (disabled); range 0..20. The circuit never counts timeouts or HTTP responses. + */ + upstreamHostCircuitThreshold?: number; + /** + * Opt-in Anthropic OAuth account pool (#294). Default OFF. + * Failover on 429 + sticky affinity; new sessions may pick lowest known 5h usage. + * Experimental — see docs and GUI warning before enabling. + */ + anthropicAccountPool?: { + enabled?: boolean; + /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ + autoSwitchThreshold?: number; + /** New-session rotation strategy. Default quota (today's behaviour). */ + strategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + stickyLimit?: number; + }; + /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ + combos?: Record; + /** + * Routing policy profiles (Router Intelligence, RI-04+): explicitly requested + * `policy/` (or configured alias) models select among an explicit + * candidate allowlist using hard capability requirements and deterministic + * scoring. Existing model ids are never routed through profiles implicitly. + */ + routingProfiles?: Record; + /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ + tokenGuardian?: OcxTokenGuardianConfig; + /** Additional exact origins allowed for CORS (e.g. HTTPS or chrome-extension://). Loopback origins are always allowed. */ + corsAllowOrigins?: string[]; +} + +export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first"; + +export type OcxComboStrategy = "failover" | "round-robin"; +export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; + +export interface OcxComboTarget { + provider: string; + model: string; + /** Relative SWRR batch weight. Default 1; valid range 1..10000. */ + weight?: number; +} + +export interface OcxComboConfig { + targets: OcxComboTarget[]; + /** Ordered failover (default) or deterministic smooth weighted round-robin. */ + strategy?: OcxComboStrategy; + /** Successful requests retained on one RR selection batch. Default 1; range 1..100. */ + stickyLimit?: number; + /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */ + defaultEffort?: OcxComboDefaultEffort | null; + /** + * Disable image input even when every target supports it. + * Omitted / `"auto"` keeps automatic capability derivation (default: enabled when + * the target intersection includes image). + */ + imageInput?: "auto" | "disabled"; + /** + * Optional public model name replacing the default `combo/` slug. Bare names + * without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a + * mandated model id; exact-match requests route here before any provider resolution. + */ + alias?: string; + /** + * Explicitly allow a bare OpenAI-native alias (for example `gpt-5.6-sol`) to + * be represented by this routed combo. Never inferred from `alias`. + */ + nativeAlias?: boolean; + /** Display-only label for the public catalog row. Required for native aliases. */ + displayName?: string; +} + +export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude"; + +export interface OcxRoutingProfileCandidate { + provider: string; + model: string; +} + +export interface OcxRoutingProfileRequirements { + /** Minimum model context window in tokens. */ + minContextWindow?: number; + /** Minimum remaining quota headroom fraction (0..1). */ + minQuotaHeadroom?: number; + tools?: boolean; + imageInput?: boolean; + structuredOutput?: boolean; + reasoningEffort?: string; + serviceTier?: string; + localOnly?: boolean; + remoteAllowed?: boolean; + /** Special encrypted Codex task readability (ChatGPT forward pool). */ + encryptedCodexTasks?: boolean; +} + +export interface OcxRoutingProfileOptimize { + latency?: number; + health?: number; + cost?: number; + quota?: number; +} + +/** + * Policy for the hard cost ceiling when a candidate has no finite cost + * estimate. `"allow"` (default) preserves the documented dry-run contract: + * the cap only excludes evidence known to exceed it, and the candidate's + * `cost.capOutcome` is `"unknown-allowed"`. `"exclude"` makes the ceiling + * fail-closed (`cost-limit-unknown` + `capOutcome: "unknown-excluded"`). + */ +export type OcxRoutingUnknownCostCapMode = "allow" | "exclude"; + +export interface OcxRoutingProfileLimits { + /** Hard per-request estimated-cost ceiling in USD. */ + maxEstimatedCostUsd?: number; + /** + * How `maxEstimatedCostUsd` behaves when the estimate is unknown. + * Defaults to `"allow"` (eligible + `cost.capOutcome: "unknown-allowed"`); + * opt in to `"exclude"` for a true hard ceiling. + */ + onUnknownCost?: OcxRoutingUnknownCostCapMode; +} + +export interface OcxRoutingProfileUnknownEvidence { + capability?: OcxRoutingUnknownEvidenceMode; + health?: OcxRoutingUnknownEvidenceMode; + quota?: OcxRoutingUnknownEvidenceMode; + cost?: OcxRoutingUnknownEvidenceMode; +} + +export interface OcxRoutingProfileCompatibilitySuite { + suiteId: string; + evidenceLayer: "protocol_conformance" | "live_route_compatibility"; +} + +export interface OcxRoutingProfileCompatibility { + requiredSuites?: OcxRoutingProfileCompatibilitySuite[]; + minStatus?: "PROBED" | "VERIFIED"; + maxEvidenceAgeMs?: number; + unknownEvidence?: OcxRoutingUnknownEvidenceMode; + degradedEvidence?: OcxRoutingUnknownEvidenceMode; +} + +export interface OcxRoutingProfileConfig { + /** + * Explicit candidate allowlist (`provider/model` refs). No implicit + * expansion in v1. + */ + candidates: OcxRoutingProfileCandidate[]; + /** Optional public model name replacing the default `policy/` slug. */ + alias?: string; + /** Hard requirements evaluated before scoring. */ + require?: OcxRoutingProfileRequirements; + /** Optimization weights; normalized deterministically. */ + optimize?: OcxRoutingProfileOptimize; + limits?: OcxRoutingProfileLimits; + /** How unknown evidence is handled per dimension. */ + unknownEvidence?: OcxRoutingProfileUnknownEvidence; + /** Optional Compatibility Lab policy (CL-06). */ + compatibility?: OcxRoutingProfileCompatibility; +} + + +export interface OcxTokenGuardianConfig { + /** Global kill-switch. Default false — the guardian does nothing unless explicitly enabled. */ + enabled?: boolean; + /** Seconds between refresh sweeps. Default 21600 (6h). Min 60. */ + tickSeconds?: number; + /** Random 0..jitterSeconds added before each sweep to de-synchronize. Default 300. */ + jitterSeconds?: number; + /** Max concurrent refreshes per sweep. Default 3. Min 1. */ + concurrency?: number; + /** Extra lead (seconds) beyond one tick when deciding a token is "expiring soon". Default 900. */ + leadSeconds?: number; + /** First backoff (seconds) after a permanent refresh failure. Default 300. */ + failureBackoffBaseSeconds?: number; + /** Backoff ceiling (seconds). Default 3600. */ + failureBackoffMaxSeconds?: number; + /** Optional Codex pool session warmup sweep. Default false to avoid background synthetic traffic. */ + codexWarmupEnabled?: boolean; + /** Max age before a Codex pool account is revalidated via `/codex/responses`. Default 691200 (8d). */ + codexWarmupMaxAgeSeconds?: number; + /** Model used for optional Codex pool warmup. Default gpt-5.4-mini. */ + codexWarmupModel?: string; +} + +export interface OcxImagesConfig { + /** Optional custom API-key provider for /v1/images relays. Built-in OpenAI tiers remain automatic. */ + provider?: string; + /** Upstream timeout (ms) for one image generation/edit call (bridge xAI + /v1/images relay). Default 60000 for the bridge; relay may use a higher default (300000). */ + timeoutMs?: number; + /** Master switch for the image bridge. Default false — set true to enable paid xAI Grok Imagine generation. */ + bridgeEnabled?: boolean; + /** xAI image model id. Default "grok-imagine-image-quality" (see DEFAULT_MODEL in images/plan.ts). */ + bridgeModel?: string; + /** Max image-generation loop iterations before forced-final. Default 3; clamped to [0, 10]. */ + maxRounds?: number; + /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */ + artifactsKeepCount?: number; + /** Master switch for the video bridge. Default false — must be explicitly opted in. */ + videoBridgeEnabled?: boolean; + /** Model for xAI video generation. Default "grok-imagine-video". */ + videoBridgeModel?: string; + /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */ + videoMaxRounds?: number; + /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */ + videoTimeoutMs?: number; +} + +export interface OcxSearchConfig { + /** + * Total upstream deadline (ms) for one /v1/alpha/search relay. Default 200000. The endpoint + * is non-streaming JSON (headers arrive only when the search completes), so this is a whole- + * request budget — deliberately NOT connectTimeoutMs, which is a header-arrival budget. + */ + timeoutMs?: number; +} + +export interface OcxVisionSidecarConfig { + /** Master switch. Default: enabled when the selected backend has a usable credential. */ + enabled?: boolean; + /** Description backend. Unset prefers a usable stored Anthropic OAuth credential, else OpenAI. */ + backend?: "openai" | "anthropic"; + /** Vision model that describes images. */ + model?: string; + /** Max description cache misses admitted in one main-model turn. Zero disables description calls. */ + maxDescriptionsPerTurn?: number; + /** Sidecar fetch timeout (ms). */ + timeoutMs?: number; +} + +export interface OcxWebSearchSidecarConfig { + /** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */ + enabled?: boolean; + /** + * Which backend actually runs the server-side search. "openai" replays the hosted web_search via + * the ChatGPT forward provider (gpt-mini sidecar); "anthropic" runs web_search_20250305 on a Claude + * model authenticated by the STORED anthropic OAuth credential. Unset resolves to "anthropic" when a + * usable anthropic OAuth credential exists, else "openai". + */ + backend?: "openai" | "anthropic"; + /** Sidecar model that runs the real server-side web_search (must be a native ChatGPT model). */ + model?: string; + /** Reasoning effort for the sidecar — "minimal" (non-thinking) keeps it fast/cheap. */ + reasoning?: string; + /** Max searches executed per main-model turn (loop guard). */ + maxSearchesPerTurn?: number; + /** Sidecar fetch timeout (ms). */ + timeoutMs?: number; + /** + * Config-file-only deadline (ms) for continuous routed-model response-body raw-byte inactivity + * during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647. + */ + routedModelStallTimeoutMs?: number; + /** + * Stream the routed model's leading output (text/thinking deltas) live instead of buffering the + * whole iteration. Live delivery stops at the first tool-call boundary so web_search interception + * stays atomic. Tradeoff: text the model emits BEFORE deciding to search — which buffered mode + * silently drops — becomes visible to the client and may partially repeat in the post-search + * answer. Default: false (buffered, previous behavior). + */ + streamRoutedModelOutput?: boolean; +} diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000000..72fbc10033 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,521 @@ +import type { UpstreamHttpVersion, ReasoningSummaryDelivery, CodexAccountMode } from "./wire"; + +/** + * Per-provider proactive-refresh policy. The guardian only ever touches a provider whose EFFECTIVE + * policy is "proactive"; "lazy-only" keeps today's on-demand refresh, "disabled" forbids the + * guardian entirely (used for providers whose ToS actively enforces against non-official-client + * token traffic, e.g. Anthropic subscription OAuth). See devlog 260703_oauth-multi-account-refresh-and-tos. + */ +export type RefreshPolicy = "proactive" | "lazy-only" | "disabled"; + +export interface OpenRouterProviderRouting { + /** OpenRouter provider slugs to try first, in priority order. */ + order?: string[]; + /** Restrict routing to these OpenRouter provider slugs. */ + only?: string[]; + /** Whether OpenRouter may use providers outside `order`. Defaults to OpenRouter's policy. */ + allowFallbacks?: boolean; +} + +export interface ResponsesItemIdRepairConfig { + /** Exact `message` item ids that the proxy should rewrite to request-local canonical ids. */ + message?: string[]; + /** Exact `reasoning` item ids that the proxy should rewrite to request-local canonical ids. */ + reasoning?: string[]; + /** Backfill missing `output_item.done` / terminal snapshot ids from the matching output_index. */ + repairMissingTerminalIds?: boolean; + /** + * Treat existing message/reasoning ids without the canonical `msg_`/`rs_` prefix (e.g. bare + * UUIDs from DeepSeek's Responses route) as invalid and mint canonical replacements (#938). + * function_call ids and call_id pairing are never rewritten. + */ + repairInvalidIds?: boolean; +} + +/** + * Same-target 429 wait-and-retry policy (`providers..retryOn429`). When present and not + * explicitly disabled, the proxy waits and replays the identical request on the same key before + * any key failover. All fields optional; the runtime applies defaults (attempts=3, + * intervalMs=5000, maxIntervalMs=60000, respectRetryAfter=true, enabled=true). + */ +export interface RateLimitRetryPolicy { + /** Master switch. The presence of the object also enables the policy (default true). */ + enabled?: boolean; + /** Extra replay attempts after the first 429 (1..20, default 3). */ + attempts?: number; + /** Fixed wait between attempts when the upstream sends no usable Retry-After (default 5000). */ + intervalMs?: number; + /** Cap for any single wait, including an upstream Retry-After (default 60000). */ + maxIntervalMs?: number; + /** Prefer the upstream Retry-After header when present and parseable (default true). */ + respectRetryAfter?: boolean; +} + +/** + * User-configured display price for one model (USD per 1M tokens). + * Mirrors the `Cost4` shape used by the usage cost estimator; structurally + * compatible so config rows can be lifted directly into price overlays. + */ +export interface ProviderCostOverlay { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +export interface RequestPacingRule { + /** Evenly spread request starts to this many requests per minute. */ + requestsPerMinute?: number; + /** Minimum delay between request starts. The slower configured value wins. */ + minIntervalMs?: number; +} + +export interface ProviderRequestPacingConfig extends RequestPacingRule { + /** False preserves legacy behavior with no client-side waiting. */ + enabled: boolean; + /** Exact upstream model-id overrides; other models inherit the provider rule. */ + models?: Record; +} + +export interface FastWire { + kind: "service-tier" | "anthropic-speed"; + /** Canonical tier name to upstream wire spelling. */ + canonicalToWire: Readonly>; + /** Policy for non-canonical caller-provided tier values. */ + foreignCallerTiers: "verbatim" | "drop"; + /** Anthropic speed headers/betas reserved for the later wire implementation. */ + betas?: readonly string[]; +} + +/** Durable per-attempt service-tier fact produced at the adapter serialization boundary. */ +export interface AttemptTierOutcome { + canonical?: "priority"; + wireKind?: FastWire["kind"] | null; + wireValue?: string | null; + fastOutcome: "not-requested" | "applied" | "downgraded" | "unknown"; + fastDowngradeReason?: "route-unsupported" | "wire-unavailable" | "response-declined"; + callerTierDropped?: boolean; + callerFastSuppressedByConfig?: boolean; + confirmation: "confirmed" | "assumed" | "downgraded" | "unknown"; + responseServiceTier?: string; +} + +/** + * Request-local observation inputs captured before the final tier action mutates the parsed view. + * This is not persisted; the final adapter turns it into AttemptTierOutcome after serialization. + */ +export interface TierObservationContext { + capability: boolean | undefined; + eligibility: + | "eligible" + | "capability-unsupported" + | "unclassified" + | "wire-unavailable" + | "pin-unavailable"; + fastWire: FastWire | null; + demandDecision: "force-fast" | "force-default" | "inherit"; + callerTier?: string; +} + +export type TierDecision = + | { readonly kind: "forward-caller" } + | { readonly kind: "drop" } + | { readonly kind: "set"; readonly value: string }; + +/** + * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 + * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. + */ +export interface OcxProviderConfig { + adapter: string; + /** + * Codex tool calling mode for routed models. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only" (unified exec helper tool). + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; + /** Optional outbound request-start pacing shared by this provider and its model overrides. */ + requestPacing?: ProviderRequestPacingConfig; + /** Cursor MCP compatibility bounds; positive integers when configured. */ + mcpMaxTools?: number; + mcpMaxSchemaBytes?: number; + mcpMaxResultBytes?: number; + /** + * Per-model wire override, keyed by the upstream native model id (after namespace + * and combo resolution). A single gateway can front models that speak different + * wires — Grok needs the Responses API for hosted web_search while a sibling model + * is fine on chat completions (#404). + * + * Only OpenAI-shaped wires may be selected; see MODEL_ADAPTER_OVERRIDE_ALLOWED. + * Absent or empty means the provider-wide `adapter` applies to everything, exactly + * as before. + */ + modelAdapters?: Record; + /** + * Fast-wire declaration. `null` explicitly disables adapter-derived defaults; + * absence derives from the final model adapter. + */ + fastWire?: FastWire | null; + baseUrl: string; + /** + * Optional relative resource path for key-auth openai-responses requests. Must start with `/` + * and must not include a URL scheme, query string, or fragment. When omitted, the adapter keeps + * the legacy `/v1/responses` construction. + */ + responsesPath?: string; + /** + * Command Code protocol version sent as `x-command-code-version` on /alpha/generate requests. + * The internal endpoint's schema drifts with the CLI version; operators can pin a known-good + * version here instead of waiting for a code change. Absent uses the adapter's current default. + */ + commandCodeVersion?: string; + /** + * Responses upstream that stores nothing server-side (DeepSeek documents "the API + * is stateless"). Stateful request parameters are dropped, `store` is pinned false, + * and orphaned tool results left by a replay miss are repaired rather than + * forwarded to an upstream that cannot resolve their pair. + */ + statelessResponses?: boolean; + /** + * Responses upstream whose parser requires an unambiguous call batch and its matched + * result batch to remain contiguous. Hook-injected context that splits the batch is + * preserved after it, and parallel calls stay together with the reasoning turn that produced them. + */ + requiresAdjacentResponsesToolResults?: boolean; + /** + * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire. + * This pure tri-state feeds catalog publication, routing eligibility, compatibility + * fingerprints, and proxy-owned canonical Fast injection on both Responses and Chat routes. + * Tri-state: `true` lets fast mode inject/remove the canonical field; `false` strips it and + * never injects, because an upstream documented as not supporting the parameter + * must not receive it; absent (`undefined`) leaves the provider unclassified — fast mode never + * injects or translates, and caller values pass only under the final wire's forwarding permission. + * On Chat, that CallerTierForward permission is `chatServiceTier`; Responses retains passthrough. + * An explicit config value always wins over the registry default. + */ + supportsServiceTier?: boolean; + /** Exact upstream model ids that override the provider-level service-tier capability. */ + modelSupportsServiceTier?: Record; + /** + * Responses upstream whose native contract accepts plaintext reasoning replay + * (DeepSeek documents reasoning items with plaintext content). When set, the + * passthrough serializer keeps `reasoning_text` content on replayed reasoning + * items instead of blanking it the way the ChatGPT backend requires; proxy-minted + * `ocxr1` envelopes are still stripped because no upstream can decrypt them. + */ + preserveResponsesReasoningContent?: boolean; + /** + * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918, + * link-local, or unique-local upstreams. Metadata endpoints remain blocked. + */ + allowPrivateNetwork?: boolean; + /** + * Pin the HTTP version used for upstream provider requests. Bun's fetch negotiates + * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on + * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1, + * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation + * (current behavior unchanged). Only meaningful for https: base URLs. + */ + upstreamHttpVersion?: UpstreamHttpVersion; + /** + * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids + * unchanged to the wire instead of applying the `-tiered` suffix (`gemini-3.7-flash` + * -> `gemini-3.7-flash-tiered`). Set this to `false` when the configured upstream still + * serves the bare ids. Absent (default) keeps the rename. + */ + directGeminiWireRenames?: boolean; + /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ + disabled?: boolean; + /** + * Codex account-selection mode. Valid ONLY on the canonical built-in `openai` forward provider. + * "pool" (default) rotates main + added Codex accounts through the affinity/quota/cooldown/ + * failover engine; "direct" pins the caller's main Codex login and never touches pool state. + */ + codexAccountMode?: CodexAccountMode; + apiKey?: string; + /** + * Key-auth header style for Anthropic-compatible providers. + * Defaults to the native Anthropic `x-api-key`; gateways may require + * `Authorization: Bearer ` instead. + */ + apiKeyTransport?: "x-api-key" | "bearer"; + /** + * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE + * entry so routing stays single-key; managed via /api/providers/keys. A legacy bare + * `apiKey` seeds a one-entry pool on first management touch. + */ + apiKeyPool?: Array<{ id: string; key: string; label?: string; addedAt?: number }>; + defaultModel?: string; + models?: string[]; + /** + * Fetch the provider's live `/models` endpoint. Defaults to true. + * Set false when `models` is an intentional allowlist or a provider's live catalog is too large + * or too flaky for startup/catalog sync. + */ + liveModels?: boolean; + /** + * Per-provider catalog allowlist. When non-empty, ONLY these model ids are emitted to Codex's + * catalog and `/v1/models` — live discovery still runs, this just narrows what ships (so a proxy + * exposing thousands of models, or an aggregator like OpenRouter, doesn't bloat the catalog). + * Empty/undefined = expose all. The admin `/api/models` list is unaffected (it always shows the + * full set so the user can pick). See devlog issue_052_provider-model-allowlist. + */ + selectedModels?: string[]; + /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ + contextWindow?: number; + /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ + modelContextWindows?: Record; + /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ + modelInputModalities?: Record; + /** Model-specific max input token limits. Values cap auto_compact_token_limit. */ + modelMaxInputTokens?: Record; + /** + * Provider-wide fallback for chat-completions `max_tokens` when the caller omits + * Responses `max_output_tokens`. Adapters still let an explicit request win. + */ + defaultMaxOutputTokens?: number; + /** Model-specific fallback output token budgets. Exact/model-pattern entries beat the provider default. */ + modelMaxOutputTokens?: Record; + /** + * Per-model display prices (USD per 1M tokens) keyed by exact model id — + * opencode-style per-model pricing in ocx's flat `modelXxx` convention: + * `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. + * User-configured prices win over the built-in jawcode/expected catalogs in + * the Logs `~$` estimate. Display-time estimation only; never billing. An + * all-zero entry means "not billable here" and falls through to the catalogs. + */ + modelCosts?: Record; + headers?: Record; + /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */ + openRouterRouting?: OpenRouterProviderRouting; + /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */ + modelOpenRouterRouting?: Record; + /** + * "key" (default): authenticate upstream with `apiKey`. + * "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only). + * "oauth": resolve a stored OAuth access token (auto-refreshed) and use it as the Bearer key. + * Only the openai-responses adapter implements "forward"; openai-chat uses its own key/token. + * "local": local runtime (Ollama etc.) — no remote key required. Valid only for + * providers whose registry entry declares authKind "local" (management API enforces). + */ + authMode?: "key" | "forward" | "oauth" | "local"; + /** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */ + keyOptional?: boolean; + /** + * Free-tier pricing flag for UI/catalog (Free badge, Free filter). Not the same as + * `keyOptional` — free tiers may still require an API key (e.g. NVIDIA NIM free credits). + */ + freeTier?: boolean; + /** Optional human note shown in the providers UI (not used for routing). */ + note?: string; + /** Strip one trailing bracketed suffix from model ids before sending them upstream. */ + modelSuffixBracketStrip?: boolean; + /** + * Override the guardian's proactive-refresh policy for this provider. When unset, the provider's + * built-in risk-tiered default applies (see OAUTH_PROVIDERS in src/oauth/index.ts). Set "proactive" + * to opt this provider into background refresh; "disabled"/"lazy-only" to forbid/limit it. + */ + refreshPolicy?: RefreshPolicy; + /** + * Provider-wide Codex-visible reasoning tiers for routed models. Use only Codex-supported labels + * here (`low`, `medium`, `high`, `xhigh`, `max`); translate provider aliases with + * `reasoningEffortMap` / `modelReasoningEffortMap` below. + */ + reasoningEfforts?: string[]; + /** Model-specific Codex-visible reasoning tiers. An empty array means “do not expose effort”. */ + modelReasoningEfforts?: Record; + /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */ + modelDefaultReasoningEfforts?: Record; + /** + * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible + * Responses backend rejects Codex summary-delivery fields for that model. + */ + modelSupportsReasoningSummaries?: Record; + /** + * Per-model wire value for Responses `stream_options.reasoning_summary_delivery`. + * Presence also advertises reasoning-summary support for that routed model. + */ + modelReasoningSummaryDelivery?: Record; + /** + * Exact-model hosted tools that win collisions with Codex client tool declarations. + * Use for non-forward Responses gateways that reserve a hosted tool namespace server-side. + */ + modelPreferHostedTools?: Record; + /** + * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical + * fields or closing events (#893). Disabled by default and applied only to client-facing + * SSE/JSON; raw inspection state remains authoritative. + */ + responsesSnapshotRepair?: boolean; + /** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */ + reasoningEffortMap?: Record; + /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */ + modelReasoningEffortMap?: Record>; + /** OpenAI-compatible gateway reasoning wire shape. Default sends `reasoning_effort`. */ + reasoningWireFormat?: "gateway-object"; + /** + * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops + * reasoning_effort for these even when Codex selects a reasoning level (e.g. xAI grok-build-0.1). + */ + noReasoningModels?: string[]; + /** Model ids that reject caller-specified temperature. */ + noTemperatureModels?: string[]; + /** Model ids that reject caller-specified top_p. */ + noTopPModels?: string[]; + /** Model ids that reject caller-specified presence/frequency penalty values. */ + noPenaltyModels?: string[]; + /** + * Model ids whose Chat Completions endpoint rejects `response_format`. + * Structured-output translation remains enabled by default; this is a narrow + * per-model compatibility escape hatch for mixed-capability gateways. + */ + noStructuredOutputModels?: string[]; + /** + * Allow multiple tool calls per completion. DEFAULT-ON for openai-chat providers (the + * buffered stream parser assembles interleaved/fragmented multi-call turns safely); + * set `false` to force `parallel_tool_calls:false` upstream and drop the catalog's + * `supports_parallel_tool_calls` bit for that provider. Non-chat adapters advertise + * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls. + */ + parallelToolCalls?: boolean; + /** + * Opt-in: when `parallelToolCalls` is `false`, actually send `parallel_tool_calls: false` + * on the `/chat/completions` wire for this provider. By default an opted-out provider only + * OMITS the field (strict OpenAI-compatible hosts reject unknown knobs), and the NVIDIA NIM + * baseUrl is the sole built-in exception that pins the wire bit. Some self-hosted gateways + * (Kimi/GLM-family, vLLM, etc.) do honor `parallel_tool_calls` and keep emitting concurrent + * tool calls unless it is present; enable this to pin the bit without hardcoding their URL. + * No effect unless `parallelToolCalls === false`; ignored by non-`openai-chat` adapters. + */ + pinParallelToolCallsFalse?: boolean; + /** + * Opt-in: extend the no-tool-call terminal continuation guard to this provider's + * `openai-chat` routed turns. The guard (originally Anthropic-only, see + * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a + * model announces work but ends the turn without emitting a tool call. Self-hosted + * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion + * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on + * Anthropic turns, so it stays OFF by default for the many registry providers that share + * the `openai-chat` adapter. Enable only for a provider whose models are known to stop + * mid-work; non-`openai-chat` adapters ignore this flag. + */ + terminalContinuationGuard?: boolean; + /** + * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. + * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown + * fields. Default off; only enable for providers that document this parameter. + */ + promptCacheKey?: boolean; + /** + * Opt-in: forward caller `service_tier` values to the upstream `/chat/completions` body. + * On a classified route it governs foreign values (for example `flex`), not proxy-owned + * canonical Fast after capability validation. On an unclassified route it governs every caller + * value, including canonical spellings, because no Fast capability has been validated. + * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends + * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a + * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s. + * Exact-model `true` enables canonical Fast capability but does not grant foreign-tier + * forwarding; provider-level `supportsServiceTier: false` remains a global denial. Default off; + * only enable for providers that document this parameter on the chat wire. + */ + chatServiceTier?: boolean; + /** + * Provider-local passthrough SSE repair for broken openai-responses gateways that reuse exact + * placeholder message/reasoning ids or omit the terminal id after a stable added event. + * Disabled by default; function_call ids and call_id pairing are never rewritten. + */ + responsesItemIdRepair?: ResponsesItemIdRepairConfig; + /** Model ids whose tool_choice only accepts `auto` or `none`; forced/named choices are downgraded. */ + autoToolChoiceOnlyModels?: string[]; + /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */ + preserveReasoningContentModels?: string[]; + /** + * Model ids whose upstream hard-rejects a tool_call continuation missing + * `reasoning_content` (DeepSeek thinking mode: HTTP 400). When the replay + * cache misses, the adapter injects a minimal placeholder for these models. + * Defaults to `preserveReasoningContentModels` when unset; set `[]` to opt + * out explicitly (e.g. MiniMax, where low effort disables thinking). + */ + requiresReasoningPlaceholderModels?: string[]; + /** + * Opt-in same-target 429 retry policy. Codex itself never retries 429 (it retries 5xx only, + * openai/codex#30471), and single-key pools have no failover, so the proxy waits and replays + * the identical request on the same key before any failover. Pre-stream only: a 429 arrives + * before any response bytes are relayed, so the replay is lossless. + */ + retryOn429?: RateLimitRetryPolicy; + /** + * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns + * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content. + */ + reasoningSplitModels?: string[]; + /** + * Model ids whose reasoning is a vendor `thinking: {type}` toggle on the + * chat-completions wire (MiMo v2.x, GLM 5/5.1 style), NOT an OpenAI `reasoning_effort` ladder. + * The openai-chat adapter translates the mapped effort into the thinking toggle for these. + */ + thinkingToggleModels?: string[]; + /** + * Model ids whose reasoning is a `thinking_budget` integer on the chat-completions wire + * (Qwen3.x style), NOT an OpenAI `reasoning_effort` ladder. The openai-chat adapter maps the + * Codex effort to a budget fraction. + */ + thinkingBudgetModels?: string[]; + /** Anthropic-compatible gateways that need custom tool names escaped on the wire. */ + escapeBuiltinToolNames?: boolean; + /** + * Anthropic-compatible gateways (e.g. AgentRouter) that may close the stream before + * `message_stop`. With this enabled the adapter completes an otherwise-clean EOF only when + * visible text was received or an open tool call has complete JSON-object arguments; all + * other EOFs remain truncation errors. Absent = strict default behavior. + */ + anthropicEofTolerance?: boolean; + /** + * Model ids that do NOT accept image inputs. The proxy gives them "eyes" via the vision sidecar: + * attached images are described by a gpt vision model and replaced with text before the call. + */ + noVisionModels?: string[]; + /** + * Google adapter mode. "ai-studio" (default) = Generative Language API + x-goog-api-key. + * "vertex" = Vertex AI project/location endpoints with GCP ADC (or x-goog-api-key). + * "cloud-code-assist" = Google Antigravity (Cloud Code Assist) OAuth + CCA envelope. + */ + googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; + /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */ + project?: string; + /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ + location?: string; + /** + * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent + * as callable tools. Each entry is spawned (stdio `command`) or connected (`url`) lazily per + * stream; their tools are advertised to the Cursor server and executed against the live server. + */ + mcpServers?: Record; + /** + * Cursor adapter only: opt-in external executor for computer-use / record-screen. opencodex is + * headless and cannot control a screen itself; provide commands here only when running on a host + * that can. With no executor, these tools honestly report "not supported". + */ + desktopExecutor?: import("../adapters/cursor/native-exec-desktop").DesktopExecutorConfig; + /** + * Cursor adapter only: unsafe opt-in escape hatch for Cursor server-driven built-in local + * read/write/delete/ls/grep/shell/fetch execution. Prefer `nativeLocalExec: "on"` for new + * configs; this legacy boolean remains a server-local explicit opt-in for existing operators. + * Defaults to false so remote Cursor messages cannot bypass Codex approval/sandbox semantics. + * Explicit MCP and desktop executors remain controlled by their own opt-in config. + */ + unsafeAllowNativeLocalExec?: boolean; + /** + * Cursor adapter only: native local exec policy mode (exec-policy.ts). + * "off" (default) rejects server-driven local exec; "on" always allows it for this + * provider and should be used only for a trusted local experiment on a host where every + * data-plane caller is trusted. "codex-sandbox" is accepted for backwards compatibility + * but is fail-closed like "off": Responses instructions/system/developer text is + * caller-controlled prose, and opencodex has no trustworthy per-request attestation that it + * reflects a real Codex sandbox state. The default loopback bind admits ANY local process + * without auth (including other local users on multi-user machines), and + * isAllowedRequestOrigin blocks non-loopback browser origins by default but not + * loopback-origin or origin-less callers. + */ + nativeLocalExec?: "off" | "codex-sandbox" | "on"; +} diff --git a/src/types/request.ts b/src/types/request.ts new file mode 100644 index 0000000000..c01d6d3614 --- /dev/null +++ b/src/types/request.ts @@ -0,0 +1,358 @@ +import type { KiroOAuthMetadata } from "../oauth/types"; +import type { OcxTool, OcxToolChoice } from "./tools"; +import type { TierDecision, TierObservationContext } from "./provider"; + +/** Exact provider/credential namespace for process-local reasoning replay. */ +export interface OcxReasoningReplayIdentity { + providerName: string; + /** Opaque process-local digest of the exact upstream destination. */ + providerDestinationIdentity: string; + /** + * The same destination, digested WITHOUT the process-local random key, so it can key a + * durable store. Absent when no base URL was resolvable. + */ + providerDestinationDurableIdentity?: string; + adapterName: string; + modelId: string; + /** Opaque process-local credential identity; never a raw token or API key. */ + credentialIdentity: string; + /** + * Salted-HMAC credential identity that survives restarts, for the durable + * thought-signature store (#1926). Absent when no durable identity could be + * derived — the durable store then refuses to key the entry (fail closed). + */ + credentialDurableIdentity?: string; +} + +/** + * Stable holder shared by parsed-request copies and already-created bridges. + * Credential/provider rotation replaces `current` atomically without replacing + * the holder, so late tool-call cache writes see the active physical identity. + */ +export interface OcxReasoningReplayScopeRef { + readonly clientThreadId: string; + current?: Readonly; +} + +export interface OcxParsedRequest { + modelId: string; + /** Client-facing model selector retained for Anthropic routes after wire-model normalization. */ + _responseModelId?: string; + /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ + _openAiVirtualSelectedModelId?: string; + previousResponseId?: string; + context: OcxContext; + stream: boolean; + options: OcxRequestOptions; + _rawBody?: unknown; + /** + * Boundary between replayed history and this turn's newly appended input. Usually the + * items the proxy restored from local previous_response_id state; also set when the + * CLIENT already carried that history verbatim and the proxy skipped the prepend. + */ + _replayPrefixLen?: number; + /** Parsed-message index before the first conversational item in a continuation's current delta. */ + _continuationConversationMessageIndex?: number; + /** + * True when the full history for a previous_response_id request is present in the input — + * whether the proxy expanded it or the client already sent it. Consumers read this as + * "this request is self-contained", never as "the proxy mutated it". + */ + _previousResponseInputExpanded?: boolean; + /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */ + _cursorConversationId?: string; + /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ + _clientThreadId?: string; + /** Provider/account/model-bound namespace for process-local raw-reasoning replay. */ + _reasoningReplayScope?: OcxReasoningReplayScopeRef; + /** + * Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation. + * When absent (single-operator local proxy), derivation stays local-scoped. + */ + _cursorIdentityScope?: string; + /** + * True for helper/shadow/compaction turns that must not append into the main Cursor conversation + * derived from the parent thread id. + */ + _cursorIsolateConversation?: boolean; + /** Account-scoped, non-secret Kiro request metadata selected with the OAuth access token. */ + _kiroAuthContext?: Pick; + /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */ + _providerContinuation?: OcxProviderContinuationState; + /** + * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed + * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and + * executes searches via the gpt-5.4-mini sidecar (see src/web-search). Absent when not requested. + */ + _webSearch?: Record; + /** Hosted image_generation tool config stashed for the image bridge sidecar (see src/images). */ + _imageGeneration?: { toolNames: Set; originalTool?: Record }; + /** + * True when Codex requested structured output (`text.format` = json_schema/json_object). The + * web-search tool_result is then rendered as compact JSON instead of markdown prose, so its + * answer/"Sources:" text can't bleed into and corrupt the model's schema-constrained output. + */ + _structuredOutput?: boolean; + /** + * True when the input carried `{type:"compaction_trigger"}` — Codex remote compaction v2 asking + * this turn to produce a `{type:"compaction"}` output item. Routed adapters can't natively; + * the server runs the model as a summarizer and the bridge emits a synthetic compaction item + * (see src/responses/compaction.ts). + */ + _compactionRequest?: boolean; + /** + * True when the current request newly introduced a stored compaction summary/marker. Historical + * markers restored by previous_response_id expansion were already acknowledged and do not reset + * provider-private continuation caches again on every later turn. + */ + _contextCompactionBoundary?: boolean; +} + +export interface OcxContext { + systemPrompt?: string[]; + messages: OcxMessage[]; + tools?: OcxTool[]; +} + +export type OcxMessage = + | OcxUserMessage + | OcxAssistantMessage + | OcxDeveloperMessage + | OcxToolResultMessage; + +export interface OcxUserMessage { + role: "user"; + content: string | OcxContentPart[]; + timestamp: number; +} + +export interface OcxAssistantMessage { + role: "assistant"; + content: OcxAssistantContentPart[]; + /** Responses message phase, preserved when replaying translated provider output. */ + phase?: OcxMessagePhase; + model?: string; + timestamp: number; + /** + * Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob + * Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so + * it rides the message rather than a content part: any other adapter simply ignores it. + */ + kiroRedactedReasoning?: string; +} + +export interface OcxDeveloperMessage { + role: "developer"; + content: string | OcxContentPart[]; + timestamp: number; +} + +export interface OcxToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + /** MCP namespace from the originating tool call, if any. */ + toolNamespace?: string; + /** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */ + content: string | OcxContentPart[]; + /** True when the Responses result contained opaque encrypted output Kiro cannot translate. */ + containsEncryptedContent?: boolean; + isError: boolean; + timestamp: number; +} + +export interface OcxTextContent { + type: "text"; + text: string; +} + +export interface OcxImageContent { + type: "image"; + /** A `data:` URL (base64) or a remote https URL — passed through from Codex verbatim, NEVER inlined as text. */ + imageUrl: string; + /** Fidelity hint from Codex: "low" | "high" | "auto". */ + detail?: string; +} + +/** A user/developer message content part: text or an image (vision). */ +export type OcxContentPart = OcxTextContent | OcxImageContent; + +export interface OcxThinkingContent { + type: "thinking"; + thinking: string; + signature?: string; + itemId?: string; + /** Raw Anthropic redacted_thinking block payloads to replay verbatim (order preserved). */ + redacted?: string[]; +} + +export interface OcxToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + customWireName?: string; + thoughtSignature?: string; + /** + * Provider-issued opaque metadata that must survive the whole round trip unchanged + * (issue #1735). A signed Gemini part is only valid when its signature comes back on the + * SAME part it was issued for, so this travels with the individual tool call rather than + * being matched by name/arguments after the fact. + */ + providerMetadata?: OcxProviderOpaqueToolCallMetadata; + /** MCP namespace (e.g. "mcp__context7") when this call targets a namespaced tool. */ + namespace?: string; +} + +/** + * Opaque, provider-scoped tool-call metadata. Values are never parsed, merged, re-encoded, or + * synthesized — they are carried verbatim or not at all. + */ +export interface OcxProviderOpaqueToolCallMetadata { + google?: { + thoughtSignature?: string; + }; +} + +export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall; +export interface OcxRequestOptions { + maxOutputTokens?: number; + temperature?: number; + topP?: number; + stopSequences?: string[]; + toolChoice?: OcxToolChoice; + parallelToolCalls?: boolean; + reasoning?: string; + hideThinkingSummary?: boolean; + serviceTier?: string; + /** Final outbound tier action, resolved after the provider/model wire is settled. */ + tierDecision?: TierDecision; + /** Internal B0 observation inputs; adapters combine these with the wire they actually serialize. */ + tierObservation?: TierObservationContext; + presencePenalty?: number; + frequencyPenalty?: number; + /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */ + promptCacheKey?: string; + /** + * Responses `text.format` (json_schema / json_object), preserved for adapters whose + * upstream wire has an equivalent. The openai-chat adapter re-nests it as chat + * `response_format`, the exact inverse of responseFormatToText in src/chat/inbound.ts. + * The native passthrough ignores it (it forwards `_rawBody.text` verbatim) and Kiro + * keeps rejecting structured output via `_structuredOutput`. + */ + textFormat?: { + type: "json_schema" | "json_object"; + name?: string; + description?: string; + schema?: Record; + strict?: boolean; + }; +} + +export type OcxMessagePhase = "commentary" | "final_answer"; + +/** + * Provider-private state that must follow a locally expanded `previous_response_id` chain. + * Kept out of public Responses output and persisted only in the bounded local continuation cache. + */ +export interface OcxProviderContinuationState { + cursor?: { + conversationId?: string; + checkpointUsable?: boolean; + }; + kiro?: { + conversationId?: string; + }; + [provider: string]: Record | undefined; +} + +export type AdapterEvent = + | { type: "heartbeat" } + | { type: "text_delta"; text: string; phase?: OcxMessagePhase } + | { type: "thinking_delta"; thinking: string } + // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and + // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400. + | { type: "thinking_signature"; signature: string } + | { type: "redacted_thinking"; data: string } + // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn. + // Never rendered — it only rides the reasoning item's envelope so the next request can replay it. + | { type: "kiro_redacted_reasoning"; data: string } + | { type: "reasoning_raw_delta"; text: string } + | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } + | { type: "tool_call_delta"; arguments: string } + | { type: "tool_call_end" } + /** Internal boundary between a guarded first pass and its one-shot continuation. */ + | { type: "assistant_boundary" } + // Native web-search activity surfaced by the web-search sidecar so Codex renders a "Searched the + // web" cell. Emitted as a lifecycle PAIR at real wall-clock moments by src/web-search/loop.ts + // (routed adapters never emit these): `begin` right before the sidecar runs so Codex shows the + // "Searching the web" spinner, then `end` once it resolves. The bridge maps begin → an + // output_item.added(in_progress) and end → the matching output_item.done(completed|failed) under + // the SAME output index, so the activity animates instead of flashing completed instantly. + | { type: "web_search_call_begin"; id: string } + | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[] } + | { + type: "done"; + usage?: OcxUsage; + stopReason?: string; + endTurn?: boolean; + providerState?: OcxProviderContinuationState; + } + | { + type: "incomplete"; + reason: string; + message?: string; + usage?: OcxUsage; + retryable?: boolean; + endTurn?: boolean; + providerState?: OcxProviderContinuationState; + } + // `usage` carries best-effort partial consumption when a turn dies before a clean done + // (e.g. cursor upstream 502 mid-stream), so failed requests can log real token counts. + | { + type: "error"; + message: string; + usage?: OcxUsage; + /** Authoritative upstream/proxy status when known; avoids message-based classification. */ + status?: number; + /** Responses error type and code when the adapter has a structured provider failure. */ + errorType?: string; + code?: string; + retryable?: boolean; + }; + +/** + * A web source backing a search answer. Surfaced on the search-end event and rendered by the bridge + * as a `url_citation` annotation on the following assistant message (the desktop app's Sources chip + * reads these; the TUI ignores annotations, so this is additive). + */ +export interface OcxUrlCitation { + url: string; + title?: string; +} + +/** + * Canonical usage convention (devlog/260711_claude_inbound/070): + * - `inputTokens` is the TOTAL prompt size, INCLUDING cache reads and cache writes + * (OpenAI Responses convention). Anthropic parse sites normalize into this shape. + * - `cachedInputTokens` is cache READ tokens only (a subset of `inputTokens`). + * - `cacheReadInputTokens`/`cacheCreationInputTokens` carry the read/write split when + * the provider reports both; reads mirror `cachedInputTokens`. + * - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top. + */ +export interface OcxUsage { + inputTokens: number; + outputTokens: number; + /** + * Absolute active-context size after the response. Stateful providers can expose this separately + * from their per-attempt usage. Responses serialization derives the input side from + * `contextTotalTokens - outputTokens` so output is never added to an absolute checkpoint twice. + */ + contextTotalTokens?: number; + totalTokens?: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + reasoningOutputTokens?: number; + estimated?: boolean; +} From ef6408de33529d5264240e004ea826547ef003bb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 20:53:30 +0900 Subject: [PATCH 045/121] refactor(config): extract provider-name leaf; break config<->profile cycle --- src/config.ts | 25 ++----------------------- src/config/provider-name.ts | 24 ++++++++++++++++++++++++ src/router.ts | 3 ++- src/routing/profile.ts | 2 +- 4 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 src/config/provider-name.ts diff --git a/src/config.ts b/src/config.ts index a0fa0a3b25..9f590e7323 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; +import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; import { bumpConfigGenerationAtPath, bumpCurrentConfigGeneration, @@ -736,19 +737,6 @@ const providerConfigSchema = z.object({ responsesSnapshotRepair: z.boolean().optional(), }).passthrough(); -const RESERVED_PROVIDER_NAMES = new Set([ - // JavaScript prototype-pollution guards. - "__proto__", - "prototype", - "constructor", - // System-reserved routing namespace (resolved before provider/account - // namespaces in routeModelInternal). "combo" is intentionally NOT reserved: - // a physical provider named `combo` is a supported pattern (combo aliases - // hosted on the combo provider), and the combo selector only wins when an - // actual combo id matches. - "policy", -]); -const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; const SENSITIVE_PROVIDER_HEADERS = new Set([ "authorization", @@ -760,16 +748,7 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([ "x-amz-security-token", ]); -export function isValidProviderName(name: string): boolean { - const trimmed = name.trim(); - return trimmed === name - && PROVIDER_NAME_PATTERN.test(name) - && !RESERVED_PROVIDER_NAMES.has(name.toLowerCase()); -} - -export function hasOwnProvider(providers: Record, name: string): boolean { - return Object.prototype.hasOwnProperty.call(providers, name); -} +export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; export function providerBaseUrlConfigError(baseUrl: string): string | null { try { diff --git a/src/config/provider-name.ts b/src/config/provider-name.ts new file mode 100644 index 0000000000..244bd3cc3e --- /dev/null +++ b/src/config/provider-name.ts @@ -0,0 +1,24 @@ +const RESERVED_PROVIDER_NAMES = new Set([ + // JavaScript prototype-pollution guards. + "__proto__", + "prototype", + "constructor", + // System-reserved routing namespace (resolved before provider/account + // namespaces in routeModelInternal). "combo" is intentionally NOT reserved: + // a physical provider named `combo` is a supported pattern (combo aliases + // hosted on the combo provider), and the combo selector only wins when an + // actual combo id matches. + "policy", +]); +const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; + +export function isValidProviderName(name: string): boolean { + const trimmed = name.trim(); + return trimmed === name + && PROVIDER_NAME_PATTERN.test(name) + && !RESERVED_PROVIDER_NAMES.has(name.toLowerCase()); +} + +export function hasOwnProvider(providers: Record, name: string): boolean { + return Object.prototype.hasOwnProperty.call(providers, name); +} diff --git a/src/router.ts b/src/router.ts index be06d6c478..297795180e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -8,7 +8,8 @@ import { type ComboPick, } from "./combos"; import type { NormalizedComboConfig } from "./combos/types"; -import { hasOwnProvider, resolveEnvValue } from "./config"; +import { hasOwnProvider } from "./config/provider-name"; +import { resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { diff --git a/src/routing/profile.ts b/src/routing/profile.ts index 17e07d6b04..6c978de94a 100644 --- a/src/routing/profile.ts +++ b/src/routing/profile.ts @@ -13,7 +13,7 @@ import type { } from "../types"; import { codexAccountNamespaceEntries } from "../codex/account-namespaces"; import { listComboIds, resolveComboId } from "../combos"; -import { hasOwnProvider } from "../config"; +import { hasOwnProvider } from "../config/provider-name"; import { MAX_COMPATIBILITY_REQUIRED_SUITES } from "./compatibility/types"; import { POLICY_NAMESPACE } from "./profile-namespace"; From 8853184614a9c0dca30adad3994ceba53152cb2f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 20:53:30 +0900 Subject: [PATCH 046/121] docs(devlog): WP2a-1 audited plan --- .../030_wp2a_provider_name_leaf.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 devlog/_plan/260818_megafile_split_program/030_wp2a_provider_name_leaf.md diff --git a/devlog/_plan/260818_megafile_split_program/030_wp2a_provider_name_leaf.md b/devlog/_plan/260818_megafile_split_program/030_wp2a_provider_name_leaf.md new file mode 100644 index 0000000000..08c7a6934c --- /dev/null +++ b/devlog/_plan/260818_megafile_split_program/030_wp2a_provider_name_leaf.md @@ -0,0 +1,62 @@ +# WP2a-1 — config provider-name leaf (cycle breaker; parallel PR off dev) + +Branch codex/split-wp2a-config-names on dev@aaf04690e. NOT stacked on the +types stack (disjoint files, DEV-STACK-01 'independent parts -> parallel PRs'). +Class C2 pure move + 2 consumer retargets. Risk basis 000_risk_assessment.md +WP2a; highest-leverage low-risk config extraction: breaks the existing +config <-> routing/profile import cycle. + +## Loop spec + +- Goal: isValidProviderName/hasOwnProvider live in a leaf with no heavy deps; + routing/profile.ts and router.ts stop importing them through the 3900-line + config barrel (which loads Zod + bun:sqlite + registry transitively). +- Non-goals: no other config extraction this PR; management write-path + callers keep importing from ./config (barrel re-export). +- Verifier: typecheck + lidge full suite + core-lab-boundary. + +## File change map + +- ADD src/config/provider-name.ts: RESERVED_PROVIDER_NAMES, + PROVIDER_NAME_PATTERN (both module-private consts, config.ts 738-750), + isValidProviderName (762), hasOwnProvider (769). Zero imports. +- EDIT src/config.ts: delete moved bodies; add + `export { isValidProviderName, hasOwnProvider } from "./config/provider-name"`; + internal call sites (1150, 1390, 1597 + others) need a local + `import { ... } from "./config/provider-name"` since re-export binds nothing + (WP1 lesson). +- EDIT src/routing/profile.ts:16: import hasOwnProvider from + ../config/provider-name (cycle edge profile->config removed). +- EDIT src/router.ts:11: split import — hasOwnProvider from + ./config/provider-name, resolveEnvValue stays from ./config. + +## Accept criteria + +1. typecheck exit 0. 2. lidge full suite 0 fail (baseline 13201 pass). +3. core-lab-boundary green (router edge now reaches a leaf with no imports — + protected graph shrinks). +4. rg 'from "../config"' src/routing/profile.ts -> no hasOwnProvider import + through the barrel (cycle gone; remaining profile imports from config: none + expected — verify, else keep others intact). +5. Source diff: exactly 4 files under src/. + +## Risks + +- config.ts superRefine calls isValidProviderName internally — the local + import must land before schema evaluation (top of file, hoisted; ESM fine). +- routing/profile.ts may import more than hasOwnProvider from ../config — + verify and leave other names on the barrel. + + +## Audit amendments (grok PASS / sol NEAR-PASS) + +- Internal call sites are EXACTLY 3 (1150, 1390 isValidProviderName; 1597 + hasOwnProvider), all inside superRefine callbacks — no TDZ risk. +- AC3 claim corrected: the protected graph does NOT shrink (router keeps the + barrel edge for resolveEnvValue; the leaf adds one dead-end module). The + real win is the config<->profile cycle break. core-lab-boundary stays + green either way. +- profile.ts imports nothing else from ../config — cycle fully gone. +- Tests importing isValidProviderName via barrel: config.test.ts:13, + policy-execution.test.ts:6 — barrel re-export preserves both. + From dd07be531d122a44aec92182f516d7b16feda473 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 18:37:57 +0900 Subject: [PATCH 047/121] docs(devlog): roadmap decade docs for the 260819 queue-drain loop --- .../_plan/260819_next_roadmap/000_roadmap.md | 147 ++++++++++++++++++ .../010_r1_split_rebase.md | 77 +++++++++ .../020_r2_temp_reclaim_merge.md | 79 ++++++++++ .../030_r3_collisions_and_retargets.md | 99 ++++++++++++ .../040_r4_modelrecordvalue_batch.md | 66 ++++++++ 5 files changed, 468 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/000_roadmap.md create mode 100644 devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md create mode 100644 devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md create mode 100644 devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md create mode 100644 devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md diff --git a/devlog/_plan/260819_next_roadmap/000_roadmap.md b/devlog/_plan/260819_next_roadmap/000_roadmap.md new file mode 100644 index 0000000000..d4cbf4904c --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/000_roadmap.md @@ -0,0 +1,147 @@ +# 260819 — Next roadmap (post-triage, post-cleanup) + +Baseline: dev @ 3ad904e03 (local, 1 ahead of origin/dev 63bfd149d). +Inputs: the 260819 triage-execution outcome (12 merged, 5 downgraded), the +260818 mega-file split risk assessment, and a live read of 53 open PRs / +75 open issues on 2026-08-19. + +This document is a sequencing decision, not an inventory. The inventory is in +`260819_triage_execution/030_outcome.md`; what follows is what to do next and +in what order, with the constraint that makes the order non-arbitrary. + +## The one constraint that orders everything + +Five files carry the repository: `responses/core.ts` (4532), `config.ts` +(3987), `service.ts` (3387), `registry.ts` (2692), `types.ts` (1884). +The split program wants to move all of them. Twenty-plus open PRs edit them. +A split lands as a whole-file rewrite, so **every open PR touching a split +file is rebased onto moved code the moment the split merges.** + +The 000_risk_assessment already named this ("never interleave"). What has +changed since it was written is that the interleaving already happened: the +WP1 stack sat while 102 commits landed on dev, and it now conflicts. + +So the order is: **drain the contributor queue first, split second.** Not +because the split is less valuable, but because the split's cost is +proportional to the size of the queue it has to rebase, and the queue is the +thing that decays if left alone. + +## R1 — Unblock the split stack (this week, small) + +The WP1 types stack is the cheapest split and it is currently the most +expensive to leave alone: it conflicts with dev today and the conflict grows +with every `types.ts` edit. + +| PR | State | Action | +|---|---|---| +| #2019 WP1 | draft, 102 behind, red CI | rebase onto dev, re-run; the red is stale-base, not the change | +| #2023 WP1b | draft, child of #2019 | rebase after parent; green on its own base already | +| #2036 WP2a | draft, 42 behind | rebase; independent of WP1 | + +**The red CI on #2019 is not a defect in the change.** Its failing shard +asserts `invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)`, +a string that exists on the PR head and no longer exists on dev — dev removed +it in `6c0bde453`. The PR is running dev's newer test file against its own +older source. Three other shards fail the same way (hidden raw reasoning, +Command Code catalog, GUI models page). A rebase is the whole fix. + +Both stacks also fail `hygiene: missing_regression_test`, which is correct +and not waivable by rebase: a pure-move PR changes `src/` without changing a +test. The honest resolution is `test-exception-approved`, not a decorative +test — the barrel's oracle is the existing 400-file import surface, and a new +test asserting "the barrel re-exports X" restates the compiler. + +## R2 — Land the response-temp stack (ready now) + +`#2084` (sweeper) and `#2089` (doctor) are the only PRs of ours that are +green, hygiene-clean, and 5 commits behind. `#2089` is `CLEAN`; `#2084` is +`BLOCKED` only by the review requirement. + +Merge order is forced: `#2084` to dev, then retarget `#2089` from +`codex/tmp-reclaim-1-sweeper` to dev. Do not merge the child first. + +This closes a real user-visible defect (multi-GB temp accumulation across +reboots) and the design holds up on read: the boot floor retires a vacuous +PID probe rather than claiming a file is dead, `eligible` is reported instead +of `matched` so the operator is not told live temps are abandoned, and the +dry run shares one predicate with the reclaim so report and removal cannot +disagree. + +## R3 — Resolve the duplicate-fix collisions (before more arrive) + +Three PRs fix the same `prompt_cache_retention` bug (#2092) three different +ways, and they are mutually incompatible: + +| PR | Scope | Consequence | +|---|---|---| +| #2091 | strip for all ChatGPT-backend Responses | broadest; also drops it where a deployment honors it | +| #2099 | strip for `gpt-5.6*` prefix, forward mode | wrong branch (targets main) | +| #2102 | strip for `gpt-5.6`/`gpt-5.6-`, passthrough | narrowest and most precise | + +Pick one and close the other two with the reason. On the evidence in the +issue, #2102's model-scoped strip is the defensible default: the backend's +cache behavior varies by deployment, so a global strip silently removes a +parameter some accounts accept. + +A second collision: `#2056` and `#2062` both address K12 short-window quota, +and `#2062` targets main. + +**Eight PRs currently target `main` and are auto-labeled `[WRONG BRANCH]`:** +#2110, #2109, #2099, #2082, #2063, #2062, #2032, #2029. These cannot merge +as-is. Retarget or close — leaving them open costs contributor goodwill and +re-triage time on every pass. Note that #2099 and #2062 appear in the +collision lists above, so retargeting them and picking a winner is one +decision, not two. + +## R4 — The `modelRecordValue` family (one review, four PRs) + +`#2077`, `#2085`, `#2086`, `#2100` are the same fix applied at four call +sites: a bare `map[modelId]` lookup where the runtime uses `modelRecordValue`, +so a `gpt-oss` entry fails to cover `gpt-oss:120b`. They are independent, +small, and each carries a focused test. + +Review them as one batch with one shared verdict on the contract, then merge +individually. Reviewing them separately spends four reviews on one idea. + +`#2077` additionally fixes a real crash path: a routed model id of +`constructor` or `toString` returned an `Object.prototype` function through +the prototype chain, which made `buildBehaviorFingerprintV1` throw inside a +linker that contractually does not throw. + +## R5 — Then, and only then, the split program + +Resume at WP2b (the stateful config train) with the sequencing rule from +000_risk_assessment intact: one work package per PR train, service and +registry never in the same change, Wave C never mixed with behavior fixes. + +The rule that matters most is the oracle rule: a guard test rewritten in the +same PR as the code it guards must be driven red once against a deliberate +violation. `core-lab-boundary` and `repo-hygiene` already follow it; WP5 +Wave C rewrites seven source-invariant tests and cannot be exempt. + +## What this roadmap deliberately does not do + +- **No new feature work is scheduled.** The queue has 53 open PRs; adding + scope before draining it makes the split more expensive, not less. +- **`#1704` (combo quota badges) stays parked.** It is 817 commits behind and + `CONFLICTING`. It is a re-cut, not a rebase, and it should be re-cut against + the GUI as it exists after the split — not before. +- **The Antigravity stack (#2068-#2071) is not sequenced here.** Four PRs, + ~5600 added lines, one author, all `BLOCKED`. It needs its own review lane + and its own decision about landing order; folding it into a general roadmap + would understate that. + +## Branch hygiene (done 2026-08-19) + +Local 106 -> 25, origin 66 -> 22. Every deleted branch was verified merged +into `origin/dev` or backed by a `MERGED`/`CLOSED` PR, with SHAs recorded in +`.tmp/branch-cleanup-*.txt` so any deletion is recoverable. + +Release branches (`release-2.25.0`, `release-2.26.0` and their previews, +`codex/promote-*`) were deleted only after confirming each is an ancestor of +`main` or `preview` and preserved by its `v*` tag. + +Six branches showed as "unmerged" while their PRs read `MERGED` — squash +merges, where the branch commit never enters dev's ancestry. Each was +confirmed by locating its merge commit in dev before deletion. A plain +`--merged` filter would have missed all six and left them to rot. diff --git a/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md b/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md new file mode 100644 index 0000000000..a078e518ef --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md @@ -0,0 +1,77 @@ +# 010 — R1: rebase the mega-file split stack + +Work-phase: wp2. Scope: **review + rebase + push. No merges.** + +## Why the CI is red, precisely + +`#2019` shows four red test shards. None of them is a defect in the change. + +The failing assertion in shard 2/4 looks for the literal string +`invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)`. That string +exists on the PR head (three files: `src/cli/dispatch.ts`, +`src/codex/catalog/sync.ts`, `tests/codex-app-server-processes.test.ts`) and +does not exist anywhere on `dev` — `6c0bde453` removed it. + +GitHub merges the PR head with the base before running CI. So the run +executed **dev's newer test file against the PR's older source**. The other +three shards fail the same shape (hidden raw reasoning, Command Code catalog, +GUI models page). + +The control that proves it: `#2023` is a strict superset of `#2019`'s changes, +and on its own base it is **fully green** — 4/4 test shards, gates, macos, +every keyring and npm-global leg. A defect in the extraction would fail there +too. + +## Verified rebase cost + +A scratch rebase of `codex/split-wp1b-type-clusters` onto `origin/dev` +(102 commits) conflicts in exactly one file, `src/types.ts`, with **3 hunks**. + +Three dev commits touched `src/types.ts` since the fork point (`b04cd26e7`): + +| Commit | Change | +|---|---| +| `11e03eb44` | replay: durable thought signatures per credential (#2078) | +| `fd85c8238` | cursor: HTTP/1.1 compatibility transport (#1903) | +| `b5a98d690` | release-audit regressions from the 260818 merge train | + +All three add or modify type declarations. Because WP1b turns `types.ts` into +a pure barrel, each conflict resolves the same way: **the new declaration moves +to the leaf that owns its cluster, and the barrel gains a re-export line.** +This is mechanical, but it is not automatic — resolving it by taking "ours" +would silently drop three landed changes. + +## Order + +`#2019` and `#2036` are independent; `#2023` is a child of `#2019`. + +1. Rebase `codex/split-wp1-types` onto `origin/dev`; resolve `types.ts`; + force-push. Confirm the four shards go green. +2. Rebase `codex/split-wp1b-type-clusters` onto the NEW `#2019` head, not onto + dev. Rebasing it onto dev directly would orphan the parent PR's diff. +3. Rebase `codex/split-wp2a-config-names` onto `origin/dev` (42 behind, already + green — this is upkeep, not a fix). + +All three branches are ours (`lidge-jun`), so force-push is in scope. + +## The hygiene failure is real and is not fixed by rebasing + +All three PRs fail `hygiene: missing_regression_test`: they change `src/` +without changing a test. That gate is correct here — and the honest answer is +`test-exception-approved`, not a manufactured test. + +A pure-move PR's oracle is the ~400 test files that import through the barrel +plus `tsc --noEmit`. A new test asserting "the barrel re-exports `OcxTool`" +restates what the compiler already proves and would pass even if the extraction +were wrong in every way that matters. + +Verified: dev's `src/types.ts` exports 85 names; the WP1b barrel re-exports all +85 across six leaves (`tools`, `wire`, `request`, `config`, `provider`, +`accounts`), reducing 1884 lines to 103. + +## Exit criteria + +- `c-2019`: new head pushed; the four previously-red shards no longer FAILURE. +- `c-2023`: rebased onto the new parent head; base ancestry correct. +- `c-2036`: rebased onto dev; still green. +- No merges. No `src/` change beyond conflict resolution. diff --git a/devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md b/devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md new file mode 100644 index 0000000000..93e7af0ac2 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/020_r2_temp_reclaim_merge.md @@ -0,0 +1,79 @@ +# 020 — R2: merge the response-state temp reclaim stack + +Work-phase: wp1. Scope: **the only merges authorized this session.** + +## State at plan time + +| PR | Head | Base | Checks | mergeStateStatus | +|---|---|---|---|---| +| #2084 sweeper | `816024c95` | `dev` | zero FAILURE | `BLOCKED` (review requirement only) | +| #2089 doctor | `3cb6bb497` | `codex/tmp-reclaim-1-sweeper` | zero FAILURE | `CLEAN` | + +Both are hygiene-green (they carry real tests) and 5 commits behind dev. +`#2084`'s `BLOCKED` is the review-requirement ruleset, which admin merge +passes — it is not a failing check. + +## Order is forced + +`#2089` targets `#2084`'s branch. Merging the child first would land the +doctor command on top of a sweeper that is not on `dev`. + +1. Merge `#2084` into `dev`. +2. Retarget `#2089` base from `codex/tmp-reclaim-1-sweeper` to `dev`. + GitHub rewrites the child's diff on retarget; confirm it shows only the + doctor changes afterward, not the sweeper's. +3. Merge `#2089`. + +After step 1 the parent branch is deletable, but **not before step 2** — deleting +the base of an open PR closes it. + +## What the change actually does (read, not summarized from the title) + +The defect: `~/.opencodex` accumulates multi-GB of +`responses-state.json.ocx...tmp`, growing after every reboot. + +Root cause is two-part, and the second part is the interesting one: + +1. The existing cleanup ran **once per process, at cache load** — before that + process writes anything. So a crashed-and-restarted proxy swept too early to + see its predecessor's temp (15-minute grace) and never looked again. +2. The cleanup **skipped any file whose owning PID was still alive**. After a + reboot the OS reissues PIDs, so an old file is permanently mistaken for a + live process's. That is why growth tracked reboots. + +### Design points that hold up on review + +- **The boot floor retires a vacuous probe, it does not claim death.** A temp + older than the current boot cannot be owned by the PID we would probe, so the + probe is meaningless and is skipped. The comment is explicit that this does + not prove the file is dead; the unconditional 15-minute grace remains the + safety floor. +- **An anomalous boot time disables the floor rather than clamping it.** + Clamping a future-dated boot to "now" would retire the liveness probe for + every file past the grace — the worst possible response. Absent floor costs a + missed reclaim; a wrong floor costs a live file. +- **`ENOENT` on unlink counts as reclaimed, not failed.** Another proxy sharing + the config dir may have won the race; reporting that as failure would tell an + operator a file is "in use or locked" when nobody holds it. +- **The sweep covers the resolved directory too.** Atomic writes place the temp + beside the *resolved* target, so a symlinked config dir strands temps where a + literal-dir scan never looks. +- **The periodic pass rides the liveness tick, not the TTL tick**, because + `sweepExpiredOnWrite` puts `sweepExpired` on hot write paths and a directory + scan does not belong there. It carries a 25 ms wall-clock deadline: an entry + cap bounds syscalls, not time, and on a network-mounted config dir each + `lstat` can cost 10-20 ms. +- **The doctor reports `eligible`, never `matched`.** `matched` increments + before the file-type, age, boot-floor and liveness gates, so reporting it + would tell an operator that live-PID and young temps are abandoned. +- **Report is the default; reclaim is opt-in** behind + `--reclaim-response-temps`, and a typo'd `--reclaim*` flag warns instead of + silently degrading to "nothing to reclaim". +- **Dry run and reclaim share one predicate**, so the report and the subsequent + removal cannot disagree about which files are reclaimable. + +## Exit criteria + +- `c-2084`: `gh pr view` state MERGED; merge commit is an ancestor of + `origin/dev`; post-merge CI inspected on the merge SHA. +- `c-2089`: base reads `dev`; state MERGED; ancestry proof; CI inspected. diff --git a/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md b/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md new file mode 100644 index 0000000000..4d984a5a98 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md @@ -0,0 +1,99 @@ +# 030 — R3: duplicate-fix collisions and wrong-branch retargets + +Work-phase: wp3. Scope: **review, retarget, rebase. No merges.** + +## Collision A — `prompt_cache_retention` (issue #2092), three PRs + +The ChatGPT codex backend 400s on gpt-5.6 models when +`prompt_cache_retention` is forwarded. Three PRs fix it three incompatible +ways. Only one can land. + +| PR | Where it strips | Predicate | Base | +|---|---|---|---| +| #2091 luvs01 | `stripUnsupportedForwardParams` | ALL ChatGPT-backend Responses, any model | dev | +| #2099 yzxcj797 | new `stripPromptCacheRetentionForGpt56`, forward path | `modelId.startsWith("gpt-5.6")` | **main** | +| #2102 lilinxiong | new `stripDeprecatedPromptCacheRetention`, passthrough | `=== "gpt-5.6" || startsWith("gpt-5.6-")` | dev | + +**Recommendation: #2102.** + +The deciding evidence is in #2099's own comment: one deployment accepted +`"24h"` and echoed it back. The backend's cache handling is account-level and +has provably varied. So a global strip (#2091) silently removes a parameter +that some accounts honor — it fixes the report by making the feature +unavailable to everyone. + +Between the two model-scoped fixes, #2102 is on the right branch and its +predicate is tighter: `startsWith("gpt-5.6")` (#2099) also matches a +hypothetical `gpt-5.60`, while `"gpt-5.6"` exact-or-`"gpt-5.6-"`-prefixed +cannot. #2102 also tests four concrete ids rather than one. + +#2102 declines to translate `24h` into the replacement +`prompt_cache_options.ttl`, and says so: GPT-5.6 uses a different TTL +contract, and implicit caching still applies when the caller sent no +replacement. Inventing a translation would be the one change here that could +alter billing behavior. + +Action: recommend #2102; close #2091 and #2099 with this rationale. #2099 is +also on the wrong branch and appears in the retarget list below — retargeting +it and closing it are the same decision, taken once. + +## Collision B — K12 short-window quota (issue #2047), two PRs + +This one is **not** a duplicate, and reading it as one would reintroduce a bug +we already caught. + +`#2056` (Ingwannu, base dev) was reviewed and held as needs-work for a +specific reason: a fail-open in `computeCodexUsageScore`. An account whose +cached quota carries only `shortPercent` — no weekly, no monthly — scored 0 +instead of `CODEX_UNKNOWN_USAGE_SCORE` (101), making an account with +*unverified* long-window quota look like the coolest candidate to +`pickLowestUsage*`. Short-only WHAM snapshots do enter the valid cache, so this +is reachable. + +`#2062` (yzxcj797, base main) fixes the same issue and **has the same +fail-open**. Its `computeCodexUsageScore`: + +- 30-day plans: when `monthlyPercent` is absent, `return burst !== undefined ? burst : CODEX_UNKNOWN_USAGE_SCORE` — a short-only account scores `burst`, so `shortPercent: 0` scores 0. +- other plans: `burst` joins `values`, so a short-only account scores `max([burst])` = `burst`. + +Its test suite covers the saturated-burst direction +(`{weeklyPercent: 1, shortPercent: 100}` → 100) but never the short-only case +the review flagged. + +So #2062 does not supersede #2056; it re-implements it including the blocker. +Neither should merge until the short window is treated as an **additional +pressure signal gated on a governing long window being present**. + +Action: neither merges this phase. Post the shared root cause on both, so two +contributors are not each debugging half of it. + +## Wrong-branch retargets — eight PRs + +All eight target `main`, are auto-titled `[WRONG BRANCH]`, and are draft. +`main` only moves by maintainer promotion, so none can merge as-is. + +| PR | Author | Head | Mergeable | +|---|---|---|---| +| #2110 | drakonkat | `fix/antigravity-allow-baseurl-override` | MERGEABLE | +| #2109 | drakonkat | `fix/anthropic-allow-baseurl-override` | MERGEABLE | +| #2099 | yzxcj797 | `fix/pcr-strip-gpt56-2092` | MERGEABLE | +| #2082 | yzxcj797 | `fix/agr-language-preamble-2074` | MERGEABLE | +| #2063 | yzxcj797 | `fix/k12-detail-denial-2046` | **CONFLICTING** | +| #2062 | yzxcj797 | `fix/k12-short-window-quota-2047` | MERGEABLE | +| #2032 | yzxcj797 | `fix/claude-root-bypass-sandbox-1688` | MERGEABLE | +| #2029 | yzxcj797 | `fix/no-session-bus-absent-1939` | MERGEABLE | + +**Every head lives in a contributor fork.** We retarget the base with `gh`; we +do not touch their branches. Rebasing a fork head is the author's job, and +force-pushing someone else's branch is out of scope by the objective. + +`#2063` is CONFLICTING and also overlaps `#2055`, which already merged as a +partial fix for #2046. It needs a diff against current dev before it is worth +the author's rebase. + +## Exit criteria + +- `c-pcr`: winner chosen with written rationale traced to actual diffs. +- `c-k12`: decision recorded, with the shared fail-open named on both PRs. +- `c-wrongbranch`: all eight read `baseRefName=dev`. +- Zero merges in this work-phase. diff --git a/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md b/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md new file mode 100644 index 0000000000..229cd3a2ec --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md @@ -0,0 +1,66 @@ +# 040 — R4: the `modelRecordValue` family, reviewed as one batch + +Work-phase: wp4. Scope: **review only. No merges.** + +## Why one batch and not four reviews + +`#2077`, `#2085`, `#2086`, `#2100` are the same one-line idea applied at four +call sites. Reviewing them separately spends four independent judgments on a +contract that only has to be decided once. + +## The shared contract + +Per-model override maps (`modelContextWindows`, `modelInputModalities`, +`modelReasoningEfforts`, `modelMaxInputTokens`, `modelMaxOutputTokens`) are +read by the runtime through `modelRecordValue`, which resolves in three steps: + +1. own properties only, +2. then the pre-colon family (`gpt-oss` covers `gpt-oss:120b`), +3. then a case-folded key. + +A bare `map?.[modelId]` disagrees on all three. The contract to affirm once: +**any code that reports, gates, or describes what the runtime will do with a +per-model override must read it the way the runtime reads it.** A reader that +resolves differently is not conservative — it is wrong in a direction nobody +can predict. + +## Why the failure mode is worse than "missing an entry" + +In `#2085` and `#2100` the bare lookup does not degrade to unknown; it **falls +through to the provider-wide value**. That is a definite wrong answer rather +than an absent one. `#2085`'s case: `modelContextWindows: {"gpt-oss": 131072}` +with a request for `gpt-oss:120b` resolved nothing, fell back to +`contextWindow: 8000`, and the admission gate refused turns the model can +plainly hold. + +`#2077` carries a second, sharper defect worth calling out separately: the bare +index **walks the prototype chain**. A routed model id of `constructor` or +`toString` returns an `Object.prototype` function, which makes +`buildBehaviorFingerprintV1` throw "unsupported value type function". That +throw is swallowed by `resolvePassiveRouteSubjectId`, so the subject is +silently dropped — inside a linker whose contract says implementations do not +throw. `openai-responses.ts` already guards `modelPreferHostedTools` for +exactly this reason. + +## Per-PR frame + +| PR | Site | Consequence of the bare read | +|---|---|---| +| #2077 | Lab report per-model overrides | prototype-chain function → fingerprint throw → subject silently dropped | +| #2085 | admission input ceiling | falls back to provider-wide window; refuses holdable turns | +| #2086 | `ocx models` CLI | reports capabilities the proxy will not honour | +| #2100 | routing capability evidence | evidence disagrees with the resolver it describes | + +`#2086` additionally orders `noVisionModels` **before** `modelInputModalities`, +matching `isModelTextOnly`, which returns true on the noVision match before it +ever reads the modality map. Getting that order wrong would make the CLI +advertise image support that the proxy then rejects — worth confirming the +ordering claim against `src/vision.ts` rather than taking the comment's word. + +`#2086` is draft; the other three are ready. + +## Exit criteria + +- `c-mrv`: one shared contract verdict plus four per-PR verdicts, each from a + read of the actual diff. +- No merges. From 759e999003171f899744db9f76187d75dca0c707 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 18:47:52 +0900 Subject: [PATCH 048/121] docs(devlog): fold audit corrections into the roadmap decade docs --- .../010_r1_split_rebase.md | 55 +++++++++++++------ .../030_r3_collisions_and_retargets.md | 40 ++++++++++++-- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md b/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md index a078e518ef..569f4d262d 100644 --- a/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md +++ b/devlog/_plan/260819_next_roadmap/010_r1_split_rebase.md @@ -4,23 +4,44 @@ Work-phase: wp2. Scope: **review + rebase + push. No merges.** ## Why the CI is red, precisely -`#2019` shows four red test shards. None of them is a defect in the change. - -The failing assertion in shard 2/4 looks for the literal string -`invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)`. That string -exists on the PR head (three files: `src/cli/dispatch.ts`, -`src/codex/catalog/sync.ts`, `tests/codex-app-server-processes.test.ts`) and -does not exist anywhere on `dev` — `6c0bde453` removed it. - -GitHub merges the PR head with the base before running CI. So the run -executed **dev's newer test file against the PR's older source**. The other -three shards fail the same shape (hidden raw reasoning, Command Code catalog, -GUI models page). - -The control that proves it: `#2023` is a strict superset of `#2019`'s changes, -and on its own base it is **fully green** — 4/4 test shards, gates, macos, -every keyring and npm-global leg. A defect in the extraction would fail there -too. +**Corrected 2026-08-19 after an independent audit lane refuted the first +version of this section.** The original text named the right mechanism and the +wrong direction; both corrections are below. + +The failing assertion in shard 2/4 is a source-invariant test that reads code +as text. Run `32130164359` reports: + +``` +Expected to contain: "invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)" +Received source: invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true }) +``` + +So the **test** carried the old two-argument assertion and the **source** +carried the new three-argument call. The three-argument call arrived on dev in +`91979cf14`; `6c0bde453` then updated the assertion to match — but it landed +*after* this run. GitHub merges the PR head with the base before running CI, so +the run executed **dev's newer source against the PR's older test**. + +That is the inverse of what this document first claimed ("dev's newer test +against the PR's older source"). The conclusion — stale-base merge skew, not a +defect in the extraction — survives; the mechanism statement had to be fixed. + +The failure set is also larger than first recorded. Actual reds: test 1/4 +(hidden raw reasoning), 2/4 (the sync-cache assertion above), 3/4 (Command Code +catalog), 4/4 (server local API auth), gates (Models-page GUI), and macos +(multiple). Six legs, not four. + +The control: `#2023` is a strict history superset of `#2019` +(`git merge-base --is-ancestor 194f9f2a b2ac2500` exits 0), and **every +cross-platform job passes on its base** — shards 1-4, gates, macos, keyring, +npm-global. It is *not* "fully green": `hygiene` and `enforce-target` fail on +it, as they do on every PR in this stack. + +**What the control does and does not prove.** It shows the extraction does not +break the suite when the suite and the source agree. It does not prove the +rebased `#2019` head is defect-free against *current* dev — only a CI run on +the new head can. Treat "stale base" as the hypothesis this rebase tests, not +as an established fact. ## Verified rebase cost diff --git a/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md b/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md index 4d984a5a98..83b72db307 100644 --- a/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md +++ b/devlog/_plan/260819_next_roadmap/030_r3_collisions_and_retargets.md @@ -37,6 +37,15 @@ Action: recommend #2102; close #2091 and #2099 with this rationale. #2099 is also on the wrong branch and appears in the retarget list below — retargeting it and closing it are the same decision, taken once. +**One risk to raise with #2102 rather than silently accept.** Its sanitizer is +called *outside* the `if (forward)` branch, so it also strips the field from +API-key and third-party `openai-responses` passthroughs — not just the ChatGPT +forward path the issue is about. Current OpenAI guidance (replace +`prompt_cache_retention` with `prompt_cache_options.ttl` on GPT-5.6) makes +that defensible for genuine OpenAI endpoints, but the tests exercise only the +forward-mode provider. Ask for an API-key regression test and an explicit +decision about custom OpenAI-compatible endpoints before merging. + ## Collision B — K12 short-window quota (issue #2047), two PRs This one is **not** a duplicate, and reading it as one would reintroduce a bug @@ -60,12 +69,35 @@ Its test suite covers the saturated-burst direction (`{weeklyPercent: 1, shortPercent: 100}` → 100) but never the short-only case the review flagged. -So #2062 does not supersede #2056; it re-implements it including the blocker. -Neither should merge until the short window is treated as an **additional -pressure signal gated on a governing long window being present**. +`pickLowestUsageAmong` keeps the lowest score, so a short-only 0 beats an +account with verified long-window usage. + +**Reachability differs from #2056, and an audit lane was right to narrow +this.** #2056 adds `shortPercent` to `hasKnownQuotaValue`, so a short-only +WHAM snapshot enters the valid cache and reaches the scorer on the ordinary +parser path. #2062 does not: its `hasKnownQuotaValue` still checks only weekly +and monthly, so a short-only parse returns `null`. On #2062 the fail-open is +reachable through unvalidated disk hydration or direct cache insertion, not +through a normal WHAM response. + +That makes #2062's fail-open narrower, not absent — and it is still the same +defect class the #2056 review named, sitting in code that will be asked to +accept short-only state as soon as anyone finishes the feature. + +**A second defect in #2062 that #2056 does not have:** it preserves the short +tuple only during `creditsOnly` refreshes. A later weekly/monthly partial +snapshot without short fields rebuilds `next` and drops the tuple, and +`updateAccountQuota` likewise omits existing `shortPercent`/`shortResetAt`/ +`shortWindowSeconds`. #2056 handles both preservation cases explicitly. + +So neither supersedes the other: on preservation #2056 is ahead, on +reachability #2062 is accidentally safer, and both carry the scoring +fail-open. Neither should merge until the short window is treated as an +**additional pressure signal gated on a governing long window being present**. Action: neither merges this phase. Post the shared root cause on both, so two -contributors are not each debugging half of it. +contributors are not each debugging half of it, and name the preservation gap +on #2062 specifically. ## Wrong-branch retargets — eight PRs From 95b98d9f1eaf54bdcef3c3ced67ce719036844a4 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 18:49:36 +0900 Subject: [PATCH 049/121] docs(devlog): open the execution ledger with the wp0 audit outcome --- .../050_execution_ledger.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/050_execution_ledger.md diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md new file mode 100644 index 0000000000..77f8218012 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -0,0 +1,76 @@ +# 050 — Execution ledger + +Append-only record of what each work-phase actually did, with the evidence that +establishes it. A claim without an entry here did not happen. + +Loop: HOTL, session `01a01949`, goalplan +`drain-the-opencodex-pr-queue-in-reviewable-order`. + +## Standing constraints + +- **One merge lane only.** `#2084` then `#2089`. Every other PR in this loop is + review, rebase, or retarget. +- **Force-push is limited to branches we own** (`codex/split-*`, + `codex/tmp-reclaim-*`). Contributor fork heads are never rewritten; their + bases are retargeted with `gh` instead. +- **R5 (the split program proper) is out of scope.** WP1/WP1b/WP2a get rebased + so they stop rotting; no split train starts. + +## wp0 — docs-first roadmap cycle + +Outcome: **DONE.** + +| Item | Evidence | +|---|---| +| Decade docs written | `000_roadmap.md`, `010`, `020`, `030`, `040` | +| Committed | `015f119d5` | +| Audit corrections | `f94cbda63` | +| Audit lane | sol-medium read-only agent `01a01963` | + +### What the audit changed + +Six load-bearing claims were sent to an independent lane. Four came back +CONFIRMED; two came back PARTIAL, and both PARTIALs were real errors in the +first draft, not quibbles. + +**010 had the stale-base mechanism backwards.** The first draft said CI ran +"dev's newer test against the PR's older source." Run `32130164359` shows the +opposite: the *test* held the old two-argument assertion and the *source* held +the new three-argument call from `91979cf14`. `6c0bde453` fixed the assertion +afterwards. The conclusion (merge skew, not a defect) survived; the stated +mechanism did not. The draft also undercounted the failing legs — six, not +four — and called `#2023` "fully green" when `hygiene` and `enforce-target` +fail on it. + +**030 overstated how reachable `#2062`'s fail-open is.** `#2056` adds +`shortPercent` to `hasKnownQuotaValue`, so short-only snapshots enter the valid +cache; `#2062` does not, so its short-only parses return `null` and the +fail-open needs disk hydration or direct cache insertion to reach. Narrower, +not absent. The audit also found a `#2062`-only defect the draft missed: a +later partial snapshot drops the preserved short tuple. + +**One risk nobody had flagged:** `#2102`'s sanitizer is called outside the +`if (forward)` branch, so the chosen `prompt_cache_retention` fix also strips +the field from API-key and third-party `openai-responses` passthroughs. That is +defensible for genuine OpenAI endpoints and untested for custom ones. + +The lesson worth carrying: the draft's *conclusions* held up, and its +*explanations* did not. An explanation that survives because its conclusion is +right is still wrong, and it is exactly the kind of wrong that gets copied +forward into the next document. + +## wp1 — R2 merge temp-reclaim stack + +Pending. + +## wp2 — R1 rebase split stack + +Pending. + +## wp3 — R3 collisions and retargets + +Pending. + +## wp4 — R4 modelRecordValue batch review + +Pending. From 58ceae3b2f17d286bd35a8f483c07bca1a553e38 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:03:28 +0900 Subject: [PATCH 050/121] docs(devlog): record the wp1 pre-merge review outcome and its adjudications --- .../050_execution_ledger.md | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index 77f8218012..2c98f49268 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -61,7 +61,75 @@ forward into the next document. ## wp1 — R2 merge temp-reclaim stack -Pending. +In progress. A pre-merge review lane (sol-medium, agent `01a0196e`) returned +**DO-NOT-MERGE** on the original heads. Three of its findings were confirmed +against the code and fixed; the blocking one was adjudicated down and is +recorded here rather than silently dropped. + +### Fixed on `codex/tmp-reclaim-1-sweeper` (`1fbac66f8`) + +**Directory handle leak on every truncated scan.** `list` is a generator that +closes its handle in a `finally`, but the consumer drove it with manual +`iterator.next()` calls and left the loop with `break`. A `finally` does not +run when a consumer simply stops calling `next()` — only `return()` resumes the +generator to completion. The periodic reclaim truncates *by design* (entry cap, +cleanup cap, 25 ms deadline), so this leaked one handle per truncated tick, +every minute, on exactly the slow filesystems the deadline exists for. +Every early exit now routes through a `stopScan()` that calls +`iterator.return()`. + +**The deadline test was vacuous.** Its fake clock started at `0` while the +fixtures carried real epoch mtimes, so every computed age was negative and the +files survived the 15-minute grace whether or not a deadline check existed — +the test passed against its own ablation. The clock is now anchored to real +time and the test carries an explicit unbounded-run assertion, so the deadline +is the only reason nothing is removed. + +Both fixes were **driven red**: reverting `stopScan()` fails the new closure +test and nothing else; deleting the deadline check fails the repaired deadline +test and nothing else. + +### Fixed on `codex/tmp-reclaim-2-doctor` (`e298cf8ea`) + +**The budget warning could never print.** It keyed on +`eligible > removed + failed`, but outside a dry run an entry is counted +eligible and then unlinked or failed on the same iteration, so those two are +always equal. An operator whose backlog exceeded the 4096-file budget was told +the reclaim had finished. The scan now carries an explicit `truncated` flag, +set wherever the loop stops on a budget rather than on the end of the +directory, OR-ed across swept directories. The dry-run report is bounded by the +entry cap too, so a truncated report now says its count is a floor. + +The partial-reclaim test asserted a state production cannot reach +(`eligible: 816, removed: 512`); it now uses a reachable one and is paired +with an ablation guard. Driven red: restoring the old comparison fails it. + +Verification: 174 pass / 0 fail across `doctor`, `responses-state`, and +`state-store-sweeper`; `tsc --noEmit` clean. + +### Adjudicated, not fixed + +**The reviewer's stated blocker — the boot floor can unlink a live writer's +temp — is real but narrower than "blocking".** When `predatesBoot` is true the +liveness probe is genuinely skipped. But reaching it requires a writer that has +been stalled past the 15-minute grace *and* whose temp mtime predates this +machine's boot. On a single host that is self-contradictory: a process running +now cannot have written before the boot it is running after. The scenario needs +a config dir shared across hosts or containers — which the code comment already +names as the case where the computed boot can be wrong. + +Left as-is deliberately: the alternative is to gate the floor on +single-host ownership, which needs a durable host identity we do not have. The +comment documents the limit honestly. Revisit if shared-config-dir deployments +become supported rather than incidental. + +**Two smaller findings deferred with reasons.** (a) An aliased config dir +(literal and resolved paths pointing at one directory through a symlink) makes +the `Set` hold two strings for one directory, so a dry run double-counts. +Cosmetic, and the fix is a `realpath` dedupe worth doing with a test that can +build the alias. (b) `resolveWriteTarget` follows a snapshot symlink out of the +config dir, so scanning follows it too. That is the intended dotfiles-managed +behavior; containment would be a separate design decision, not a fix. ## wp2 — R1 rebase split stack From 8bfe0872e7af0a72c882fd84911dc62d76b1fee5 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:08:28 +0900 Subject: [PATCH 051/121] docs(devlog): correct the modelRecordValue contract and record the four verdicts --- .../040_r4_modelrecordvalue_batch.md | 120 ++++++++++++++---- 1 file changed, 96 insertions(+), 24 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md b/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md index 229cd3a2ec..1146766293 100644 --- a/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md +++ b/devlog/_plan/260819_next_roadmap/040_r4_modelrecordvalue_batch.md @@ -10,19 +10,41 @@ contract that only has to be decided once. ## The shared contract -Per-model override maps (`modelContextWindows`, `modelInputModalities`, -`modelReasoningEfforts`, `modelMaxInputTokens`, `modelMaxOutputTokens`) are -read by the runtime through `modelRecordValue`, which resolves in three steps: +**Corrected 2026-08-19 after a review lane refuted the first version.** The +contract as originally written implied every per-model map should migrate to +`modelRecordValue`. That is false, and acting on it would have shipped two +regressions. -1. own properties only, -2. then the pre-colon family (`gpt-oss` covers `gpt-oss:120b`), -3. then a case-folded key. +`modelRecordValue` (`src/reasoning-effort.ts:73`) resolves in this order: -A bare `map?.[modelId]` disagrees on all three. The contract to affirm once: -**any code that reports, gates, or describes what the runtime will do with a -per-model override must read it the way the runtime reads it.** A reader that -resolves differently is not conservative — it is wrong in a direction nobody -can predict. +1. exact model id, **own property only**; +2. if the id contains `:` past the first character, the **case-sensitive** + pre-colon family, own property only; +3. a case-insensitive match on the **full** id across own entries; +4. otherwise `undefined`. + +Two subtleties the first draft missed: there is no case-insensitive *family* +match, and a case-sensitive family key beats a differently-cased full-id key. + +The contract, restated correctly: **code that reports, gates, or describes what +the runtime will do with a per-model override must use the same resolution the +runtime uses for that map** — which is not `modelRecordValue` for every map. + +Two maps are deliberately exact-own-only: + +| Map | Runtime reader | +|---|---| +| `modelPreferHostedTools` | `src/adapters/openai-responses.ts:989` (exact, own-property) | +| `modelOpenRouterRouting` | `src/providers/openrouter-routing.ts:83` (exact, own-property) | + +For those, a bare `map?.[modelId]` is still wrong — it walks the prototype +chain — but `modelRecordValue` is *also* wrong, because it adds family and +case-folded inheritance the adapter will not honor. The right primitive there +is an exact own-property lookup, not either of the two. + +So the family-aware migration is correct for nine maps and a regression for +two. "Read it the way the runtime reads it" is the invariant; "use +`modelRecordValue`" is only its implementation for the family-aware set. ## Why the failure mode is worse than "missing an entry" @@ -42,25 +64,75 @@ silently dropped — inside a linker whose contract says implementations do not throw. `openai-responses.ts` already guards `modelPreferHostedTools` for exactly this reason. -## Per-PR frame +## Per-PR verdicts -| PR | Site | Consequence of the bare read | +| PR | Site | Verdict | |---|---|---| -| #2077 | Lab report per-model overrides | prototype-chain function → fingerprint throw → subject silently dropped | -| #2085 | admission input ceiling | falls back to provider-wide window; refuses holdable turns | -| #2086 | `ocx models` CLI | reports capabilities the proxy will not honour | -| #2100 | routing capability evidence | evidence disagrees with the resolver it describes | +| #2085 | admission input ceiling | **merge** | +| #2086 | `ocx models` CLI | **merge** (draft; ready on content) | +| #2100 | routing capability evidence | **hold** — incomplete migration | +| #2077 | Lab behavior fingerprint | **hold** — over-broad migration | + +No two touch the same file or function, so there is no textual conflict; the +order is about correctness, not merge mechanics. + +### #2085 — merge + +The "definite wrong answer" claim is verified: a missed `modelContextWindows` +lookup falls through to the provider-wide `contextWindow` +(`src/server/responses/input-admission.ts:136`), so the admission gate refuses +turns the model can hold. Both per-model reads in the file are migrated. + +### #2086 — merge + +The ordering claim checks out against `src/vision/index.ts:29`: +`isModelTextOnly` returns true on the `noVisionModels` match before it reads +the modality map, and the PR mirrors that order. It also correctly upgrades +`.includes(model)` to `modelInList`. The description is stale (says two tests, +adds three). + +### #2100 — hold + +The six map reads are migrated correctly, but the **`noVisionModels` +precedence is missing**. With `noVisionModels: ["gpt-oss"]` and +`modelInputModalities: {"gpt-oss:120b": ["text","image"]}`, the runtime says +text-only while `candidateCapabilityEvidence` reports `image: true`. Routing +acts on this evidence, so it can select a candidate for image work that +execution then rejects — the exact ordering bug #2086 fixes on the CLI surface, +left unfixed on the routing surface. + +Needs: the no-vision check before modality derivation, plus a regression for +the conflicting-evidence case. Also `contextWindow.not.toBe(8_000)` is a weak +assertion — it accepts `undefined` and any other wrong value. + +### #2077 — hold + +The prototype-chain defect is real and the fix is right for the nine +family-aware maps. But `modelValue` is **also** used for +`modelPreferHostedTools` (`src/routing/compatibility/behavior.ts:186`), so the +PR makes a `gpt-oss` family entry affect `gpt-oss:120b` in the behavior +fingerprint even though the adapter will not apply it. That violates the +contract it is trying to enforce. `modelOpenRouterRouting` at `behavior.ts:71` +is still a bare read and was missed. + +Needs: `modelRecordValue` for the nine family-aware maps, and a separate +exact-own helper for hosted tools and OpenRouter routing. + +One correction to the PR's own narrative, worth passing to the author: the +throw is caught at `src/routing/compatibility/subject.ts:125`, not by +`resolvePassiveRouteSubjectId`. The silent-subject-drop conclusion holds; the +described control flow does not. -`#2086` additionally orders `noVisionModels` **before** `modelInputModalities`, -matching `isModelTextOnly`, which returns true on the noVision match before it -ever reads the modality map. Getting that order wrong would make the CLI -advertise image support that the proxy then rejects — worth confirming the -ordering claim against `src/vision.ts` rather than taking the comment's word. +## Recommended order -`#2086` is draft; the other three are ready. +1. `#2085` +2. `#2086` (may swap with #2085) +3. `#2100` after no-vision precedence lands +4. `#2077` after exact-own semantics are preserved ## Exit criteria - `c-mrv`: one shared contract verdict plus four per-PR verdicts, each from a - read of the actual diff. + read of the actual diff. **Met** — and the contract itself was corrected by + the review rather than merely confirmed. - No merges. From 67c787b5b0b039725eeb0a864e9ae7b320550858 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:09:53 +0900 Subject: [PATCH 052/121] docs(devlog): close wp1 and wp4 in the execution ledger --- .../050_execution_ledger.md | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index 2c98f49268..fc4edd15f0 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -61,7 +61,19 @@ forward into the next document. ## wp1 — R2 merge temp-reclaim stack -In progress. A pre-merge review lane (sol-medium, agent `01a0196e`) returned +Outcome: **DONE.** Both PRs merged after three confirmed review findings were +fixed. + +| Step | Evidence | +|---|---| +| #2084 merged | `973258488`, ancestor of `origin/dev` | +| #2089 retargeted to `dev` | diff became the 9 phase-2 files only | +| #2089 merged | `c4bf833c9`, ancestor of `origin/dev` | +| Checks at merge | zero FAILURE, zero PENDING on both exact heads | +| Post-merge CI | runs `32241217016` and `32241261180` on the merge SHAs | +| Branches deleted | both, after the retarget (never before) | + +A pre-merge review lane (sol-medium, agent `01a0196e`) returned **DO-NOT-MERGE** on the original heads. Three of its findings were confirmed against the code and fixed; the blocking one was adjudicated down and is recorded here rather than silently dropped. @@ -131,6 +143,32 @@ build the alias. (b) `resolveWriteTarget` follows a snapshot symlink out of the config dir, so scanning follows it too. That is the intended dotfiles-managed behavior; containment would be a separate design decision, not a fix. +## wp4 — R4 modelRecordValue batch review + +Outcome: **DONE (review only, no merges).** Lane: sol-medium agent `01a01979`. + +The review did not merely confirm the batch premise — it **refuted the shared +contract as originally written**, which is the whole reason this lane was worth +running. The draft implied every per-model map should migrate to +`modelRecordValue`. Two maps (`modelPreferHostedTools`, +`modelOpenRouterRouting`) are deliberately exact-own-only, so migrating them +adds family and case-folded inheritance the adapter will not honor. The +invariant is "read it the way the runtime reads *that map*"; `modelRecordValue` +is only its implementation for the family-aware set. + +Verdicts: `#2085` merge, `#2086` merge, `#2100` hold (missing +`noVisionModels` precedence lets routing pick a candidate for image work that +execution rejects), `#2077` hold (over-broad migration reaches +`modelPreferHostedTools`; `modelOpenRouterRouting` still read raw). + +Full reasoning in `040_r4_modelrecordvalue_batch.md`. + +## Remaining + +- wp2 (R1 split rebase) — pending. +- wp3 (R3 collisions + eight retargets) — pending. +- Posting the wp4 verdicts to the four PRs — pending. + ## wp2 — R1 rebase split stack Pending. From 6c359d6708f9e0c0bd362dc4eb829c29b2f294fd Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:11:42 +0900 Subject: [PATCH 053/121] docs(devlog): record the wp4 verdict comment ids --- .../260819_next_roadmap/050_execution_ledger.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index fc4edd15f0..0f4f12abd8 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -163,11 +163,24 @@ execution rejects), `#2077` hold (over-broad migration reaches Full reasoning in `040_r4_modelrecordvalue_batch.md`. +Verdicts posted to the PRs: + +| PR | Comment | +|---|---| +| #2077 | `5340642374` | +| #2085 | `5340642645` | +| #2086 | `5340642926` | +| #2100 | `5340637836` | + +A first attempt at the #2100 comment was posted through a shell argument and +the backticks in it were evaluated by zsh, stripping every code span. It was +deleted and reposted from a file. Worth remembering: PR bodies full of +identifiers go through `--body-file`, never `--body`. + ## Remaining - wp2 (R1 split rebase) — pending. - wp3 (R3 collisions + eight retargets) — pending. -- Posting the wp4 verdicts to the four PRs — pending. ## wp2 — R1 rebase split stack From 8024c84ec130f7975c324dad910809e95333649a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:15:55 +0900 Subject: [PATCH 054/121] docs(devlog): record wp1-wp4 outcomes in the execution ledger --- .../050_execution_ledger.md | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index 0f4f12abd8..26929052ad 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -182,14 +182,102 @@ identifiers go through `--body-file`, never `--body`. - wp2 (R1 split rebase) — pending. - wp3 (R3 collisions + eight retargets) — pending. +## wp1 — R2 merge, completed + +Outcome: **DONE.** + +| PR | Merge commit | In `origin/dev` | +|---|---|---| +| #2084 sweeper | `973258488` | yes | +| #2089 doctor | `c4bf833c9` | yes | + +Order held: `#2084` merged to `dev` first, `#2089` retargeted from +`codex/tmp-reclaim-1-sweeper` to `dev`, then merged. After the retarget the +child's diff showed exactly the nine phase-two files and none of the parent's, +which is what the review predicted a correct retarget would look like. + +Both fix commits are in `dev` ancestry: `1fbac66f8` (iterator close) and +`e298cf8ea` (truncation signal). + ## wp2 — R1 rebase split stack -Pending. +Outcome: **DONE** (rebased and pushed; CI running on the new heads). + +| PR | Old head | New head | Rebase | +|---|---|---|---| +| #2019 WP1 | `194f9f2a9` | `35990f6ea` | clean, no conflicts | +| #2023 WP1b | `b2ac2500c` | `874598bd3` | **recut**, see below | +| #2036 WP2a | `7561e5551` | `6c6925a4d` | clean, no conflicts | + +`#2019` and `#2036` rebased without a single conflict, which is itself +evidence for the stale-base reading: 102 and 42 commits of drift produced zero +textual disagreement. + +### WP1b was recut, not rebased — and that is the honest description + +The rebase conflicted across the entire file. The reason is structural rather +than semantic: WP1b rewrites `types.ts` from 1884 lines into a 103-line barrel, +so *any* dev commit that adds a declaration to the old file collides with the +rewrite everywhere. Three conflict hunks spanning lines 1-3450 is what "the +file was replaced" looks like to a three-way merge. + +Resolving hunk-by-hunk would have been guesswork. Instead the leaves were +re-applied onto the rebased parent and the actual dev delta was re-homed +deliberately. That delta was exactly three declarations: + +| Declaration | Origin | New home | +|---|---|---| +| `OcxReasoningReplayIdentity.credentialDurableIdentity` | #2078 | `src/types/request.ts` | +| `CodexAccount.planSource` | dev | `src/types/accounts.ts` | +| `CodexAccount.planCredentialGeneration` | dev | `src/types/accounts.ts` | + +Taking "ours" on that conflict would have silently dropped all three. Verified +after: `tsc --noEmit` clean, 150 tests pass, `types.ts` at 103 lines. + +### The hygiene gate still fails, correctly + +All three still fail `hygiene: missing_regression_test` and `enforce-target`. +The first is right and is not fixed by rebasing: a pure-move PR changes `src/` +without changing a test. The honest resolution is `test-exception-approved` — +the oracle for a barrel extraction is the ~400 files that import through it +plus `tsc`, and a test asserting "the barrel re-exports `OcxTool`" restates +the compiler. ## wp3 — R3 collisions and retargets -Pending. +Outcome: **DONE** (decisions recorded, retargets applied, no merges). + +All eight wrong-branch PRs retargeted `main` -> `dev`: #2110, #2109, #2099, +#2082, #2063, #2062, #2032, #2029. Seven are `MERGEABLE` after the retarget; +#2063 is `CONFLICTING` and overlaps the already-merged #2055, so it needs an +author rebase and a rescope. No contributor head was rewritten — every one of +those heads lives in a fork. + +`prompt_cache_retention` (#2092): **#2102 wins.** Comment posted there with +the reasoning and the one pre-merge request (its sanitizer sits outside the +`if (forward)` branch, so it also touches API-key and third-party passthroughs +and needs an API-key regression). #2091 and #2099 told why they were not +chosen rather than closed silently. + +K12 (#2047): **neither #2056 nor #2062 merges.** Same root cause posted on +both, with the asymmetry named — #2062 is narrower on reachability, #2056 is +ahead on preservation, both carry the scoring fail-open. ## wp4 — R4 modelRecordValue batch review -Pending. +Outcome: **DONE** (four verdicts posted, no merges). + +| PR | Verdict | +|---|---| +| #2085 admission ceiling | merge | +| #2086 `ocx models` CLI | merge (draft) | +| #2100 routing evidence | hold — missing `noVisionModels` precedence | +| #2077 Lab fingerprint | hold — over-broad migration | + +The batch framing paid for itself by refuting its own premise. The contract as +first written implied every per-model map should move to `modelRecordValue`; +two maps (`modelPreferHostedTools`, `modelOpenRouterRouting`) are deliberately +exact-own-only, so that migration is a regression for them — which is exactly +what #2077 does. The corrected contract is "read it the way the runtime reads +*that* map", and `modelRecordValue` is only its implementation for the +family-aware set. Four separate reviews would likely have missed it. From c9894bb407b615ea4882e26ab469307f7b0e42e5 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:21:26 +0900 Subject: [PATCH 055/121] docs(devlog): dedupe the ledger and record the rebased-head CI evidence --- .../050_execution_ledger.md | 60 ++++++------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index 26929052ad..0ecad7e3d2 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -177,31 +177,9 @@ the backticks in it were evaluated by zsh, stripping every code span. It was deleted and reposted from a file. Worth remembering: PR bodies full of identifiers go through `--body-file`, never `--body`. -## Remaining - -- wp2 (R1 split rebase) — pending. -- wp3 (R3 collisions + eight retargets) — pending. - -## wp1 — R2 merge, completed - -Outcome: **DONE.** - -| PR | Merge commit | In `origin/dev` | -|---|---|---| -| #2084 sweeper | `973258488` | yes | -| #2089 doctor | `c4bf833c9` | yes | - -Order held: `#2084` merged to `dev` first, `#2089` retargeted from -`codex/tmp-reclaim-1-sweeper` to `dev`, then merged. After the retarget the -child's diff showed exactly the nine phase-two files and none of the parent's, -which is what the review predicted a correct retarget would look like. - -Both fix commits are in `dev` ancestry: `1fbac66f8` (iterator close) and -`e298cf8ea` (truncation signal). - ## wp2 — R1 rebase split stack -Outcome: **DONE** (rebased and pushed; CI running on the new heads). +Outcome: **DONE — and the stale-base hypothesis is now measured, not assumed.** | PR | Old head | New head | Rebase | |---|---|---|---| @@ -213,6 +191,23 @@ Outcome: **DONE** (rebased and pushed; CI running on the new heads). evidence for the stale-base reading: 102 and 42 commits of drift produced zero textual disagreement. +### The claim 010 refused to assert, now proven + +`010` deliberately said the stale-base diagnosis was the hypothesis the rebase +would *test*, not an established fact, because old CI on a sibling PR cannot +prove a rebased head is clean. The test has now run. On `#2019`'s rebased head +(run `32241365996`): + +``` +test 1/4 pass test 2/4 pass +test 3/4 pass test 4/4 pass +gates pass macos-launchd pass +``` + +Every leg that was red before is green after, with **no source change** — the +same extraction, replayed onto current `dev`. Six failing legs to zero. The +extraction was never broken; the base was. + ### WP1b was recut, not rebased — and that is the honest description The rebase conflicted across the entire file. The reason is structural rather @@ -262,22 +257,3 @@ chosen rather than closed silently. K12 (#2047): **neither #2056 nor #2062 merges.** Same root cause posted on both, with the asymmetry named — #2062 is narrower on reachability, #2056 is ahead on preservation, both carry the scoring fail-open. - -## wp4 — R4 modelRecordValue batch review - -Outcome: **DONE** (four verdicts posted, no merges). - -| PR | Verdict | -|---|---| -| #2085 admission ceiling | merge | -| #2086 `ocx models` CLI | merge (draft) | -| #2100 routing evidence | hold — missing `noVisionModels` precedence | -| #2077 Lab fingerprint | hold — over-broad migration | - -The batch framing paid for itself by refuting its own premise. The contract as -first written implied every per-model map should move to `modelRecordValue`; -two maps (`modelPreferHostedTools`, `modelOpenRouterRouting`) are deliberately -exact-own-only, so that migration is a regression for them — which is exactly -what #2077 does. The corrected contract is "read it the way the runtime reads -*that* map", and `modelRecordValue` is only its implementation for the -family-aware set. Four separate reviews would likely have missed it. From a2d4cb7a13682cfd519920416fb0b514bbecd926 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:23:20 +0900 Subject: [PATCH 056/121] docs(devlog): close the queue-drain loop with the verified retarget state --- .../050_execution_ledger.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index 0ecad7e3d2..f35bb81b1f 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -257,3 +257,48 @@ chosen rather than closed silently. K12 (#2047): **neither #2056 nor #2062 merges.** Same root cause posted on both, with the asymmetry named — #2062 is narrower on reachability, #2056 is ahead on preservation, both carry the scoring fail-open. + +### Final retarget state (verified at close) + +| PR | Base | Mergeable | +|---|---|---| +| #2110 | `dev` | MERGEABLE | +| #2109 | `dev` | MERGEABLE | +| #2099 | `dev` | MERGEABLE | +| #2082 | `dev` | MERGEABLE | +| #2063 | `dev` | **CONFLICTING** | +| #2062 | `dev` | MERGEABLE | +| #2032 | `dev` | MERGEABLE | +| #2029 | `dev` | MERGEABLE | + +All eight are drafts, which is the contributor-PR default and not a problem to +solve here. `#2063` is the one that needs its author: it conflicts and overlaps +`#2055`, which already merged as a partial fix for the same issue, so it needs +a rescope rather than a mechanical rebase. + +## Loop close + +Terminal outcome: **DONE.** Five work-phases, ten criteria, all carrying +evidence. + +One merge lane was authorized and one was used. Everything else in this loop +was review, rebase, or retarget — which is what the scope asked for, and worth +stating plainly because a queue-drain loop is exactly where scope creep would +be easiest to justify after the fact. + +### What the review lanes actually bought + +Three independent lanes ran. None of them merely agreed: + +- The **roadmap audit** caught the stale-base mechanism stated backwards, an + undercounted failure set, and a "fully green" claim that was not. +- The **pre-merge review** returned DO-NOT-MERGE and found a directory-handle + leak on every truncated scan, a budget warning that could never print, and a + deadline test that passed against its own ablation. All three were confirmed + in code and fixed before the merge. +- The **batch review** refuted the shared contract the batch was built on. + +The pattern across all three: the *conclusions* in the first drafts held up and +the *explanations* did not. An explanation that survives because its conclusion +happens to be right is still wrong, and it is the kind of wrong that gets +copied into the next document unchallenged. From 729037132fee01dbc6e4abfea74afe2dd5534408 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:33:29 +0900 Subject: [PATCH 057/121] docs(devlog): record the R1 CI proof and the no-silent-loss audit --- .../050_execution_ledger.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md index f35bb81b1f..ca72bb0ae8 100644 --- a/devlog/_plan/260819_next_roadmap/050_execution_ledger.md +++ b/devlog/_plan/260819_next_roadmap/050_execution_ledger.md @@ -229,6 +229,44 @@ deliberately. That delta was exactly three declarations: Taking "ours" on that conflict would have silently dropped all three. Verified after: `tsc --noEmit` clean, 150 tests pass, `types.ts` at 103 lines. +### CI proof on the new heads + +| PR | New head | Cross-platform CI | +|---|---|---| +| #2019 | `35990f6ea` | run `32241365996` **success** | +| #2023 | `874598bd3` | run `32241478125` **success** | +| #2036 | `6c6925a4d` | run `32241513290` **success** | + +On `#2019` the six legs that were red before the rebase — `test 1/4` through +`test 4/4`, `gates`, and `macos` — all pass on the new head with no source +change other than the rebase. 010 called stale base the hypothesis this rebase +would test rather than an established fact; it held. + +The mechanism is confirmed at the line level: on `35990f6ea` the assertion at +`tests/codex-app-server-processes.test.ts:393` and the call at +`src/cli/dispatch.ts:246` now both carry the three-argument +`invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true })` +form. They disagreed only because CI merged an old head against a newer base. + +### Independent check for silent loss + +"Exactly three fields" was a claim about a recut, not a guarantee, so a +separate lane (`01a01988`) compared every exported name and every interface +field between `origin/dev` and the new head: + +``` +EXPORT_COUNTS dev=85 leaf_declarations=85 barrel_reexports=85 +DEV_MISSING_FROM_LEAVES (none) DEV_MISSING_FROM_BARREL (none) +ALL_INTERFACE_COUNTS dev=53 head=53 +ALL_MISSING_FIELDS 0 ALL_EXTRA_FIELDS 0 +``` + +It confirmed the delta was exactly the three claimed fields, and that +`#2019`/`#2036` are patch-identical to their pre-rebase series by +`git range-diff`. Worth keeping for the rest of the split program: a +name-level audit alone would miss a dropped field inside a preserved +interface, which is the failure mode a barrel extraction actually risks. + ### The hygiene gate still fails, correctly All three still fail `hygiene: missing_regression_test` and `enforce-target`. From bf01d20d549450430e273d96b34167dab99b7f8f Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:34:41 +0900 Subject: [PATCH 058/121] docs(devlog): close the 260819 queue-drain campaign with its outcome record --- .../_plan/260819_next_roadmap/060_outcome.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/060_outcome.md diff --git a/devlog/_plan/260819_next_roadmap/060_outcome.md b/devlog/_plan/260819_next_roadmap/060_outcome.md new file mode 100644 index 0000000000..324b9be073 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/060_outcome.md @@ -0,0 +1,69 @@ +# 060 — Campaign outcome + +Loop: HOTL, session `01a01949`, five work-phases. All four requested lanes +closed. The split program (R5) was not started, as scoped. + +## What shipped + +| Lane | Outcome | +|---|---| +| R2 merge | #2084 (`973258488`) and #2089 (`c4bf833c9`) merged to `dev`, in that order | +| R1 rebase | #2019, #2023, #2036 rebased/recut and pushed; **all three Cross-platform CI green** | +| R3 collisions | `prompt_cache_retention` decided for #2102; K12 held on both PRs with a shared root cause; eight wrong-branch PRs retargeted | +| R4 review | four verdicts posted: merge #2085, merge #2086, hold #2100, hold #2077 | + +Two source defects were found and fixed before the only authorized merge, and +two plan documents were corrected by their own audit lanes. Those four events +are the substance of this campaign; the merges and rebases are the mechanics. + +## The four things worth remembering + +**1. A plan's conclusion can be right while its explanation is wrong.** +010 first said CI ran "dev's newer test against the PR's older source." The run +log shows the reverse — newer source, older test. The conclusion (stale-base +skew) survived and was later proven by a green CI run on the rebased head. The +mechanism did not. An explanation that survives because its conclusion happens +to be right is exactly the kind that gets copied into the next document +unchallenged. + +**2. Two of the defects we shipped fixes for were invisible to green CI.** +The directory-handle leak fired only on truncated scans; the budget warning was +dead code that no test exercised because no test could produce the state. Both +PRs were green before review. Green CI proved the code did not break anything +it already tested — nothing more. + +**3. A test can pass against its own ablation.** The deadline test set its fake +clock to `0` while fixtures carried real epoch mtimes, so every computed age +was negative and the files survived the 15-minute grace whether or not a +deadline check existed. Deleting the feature it guarded did not fail it. Every +repaired guard here was driven red once against a deliberate violation, and the +two new ones carry explicit ablation assertions. + +**4. Batch review earns its keep by refuting its own premise.** R4's contract +("read per-model overrides the way the runtime reads them") implied migrating +every map to `modelRecordValue`. Two maps are deliberately exact-own-only, so +that migration is a regression — which is what #2077 does. Four separate +reviews would each have seen a correct-looking one-line change. + +## Left open, deliberately + +- **The three split PRs are green but still gated** on + `hygiene: missing_regression_test`. That gate is correct for a pure-move PR; + the honest resolution is `test-exception-approved`, not a test that restates + the compiler. Needs a maintainer decision, not more code. +- **#2100 and #2077** need author changes named in their review comments. +- **#2056 / #2062** both need the same scorer gate before either can land. +- **#2063** is `CONFLICTING` and overlaps the merged #2055; it needs an author + rebase and a rescope. +- **The boot-floor limitation** in the reclaim path is documented in code and in + 020: it can skip the liveness probe for a temp older than this boot, which is + only reachable when a config dir is shared across hosts. Revisit if that + becomes a supported deployment rather than an incidental one. +- **Two smaller reclaim findings** deferred with reasons in 050: aliased-directory + double counting in the dry run, and symlink containment on the directory side. + +## R5 remains unstarted + +WP1/WP1b/WP2a are now rebased onto current `dev` with green CI, so the split +program's opener is no longer rotting. Nothing past it was touched: no WP2b +stateful config train, no registry, no service, no `responses/core.ts` waves. From 6b21bd83d9225b329f92be1720adb4b920c2b9e3 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:36:30 +0900 Subject: [PATCH 059/121] docs(devlog): record the R3 decisions as posted, with comment links --- .../031_r3_posted_decisions.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md diff --git a/devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md b/devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md new file mode 100644 index 0000000000..90526f4687 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/031_r3_posted_decisions.md @@ -0,0 +1,50 @@ +# 031 — R3 decisions as posted + +The decision record is 030. This file records what was actually said on each +PR and where, so a later reader can check the public artifact against the +private reasoning without re-reading eight threads. + +## `prompt_cache_retention` (#2092) + +| PR | Action | Comment | +|---|---|---| +| #2102 | **selected** | [5340666298](https://github.com/lidge-jun/opencodex/pull/2102#issuecomment-5340666298) | +| #2091 | not selected | [5340672074](https://github.com/lidge-jun/opencodex/pull/2091#issuecomment-5340672074) | +| #2099 | not selected + retargeted | [5340672361](https://github.com/lidge-jun/opencodex/pull/2099#issuecomment-5340672361) | + +Both non-selected authors were told *why*, not just that they lost. #2091's +instinct (fix it at the strip site) and #2099's instinct (keep it model-scoped) +were each named as correct — #2099's is the reason #2091 was not chosen either. + +The one open request on #2102: its sanitizer is called outside the +`if (forward)` branch, so it also strips from API-key and third-party +`openai-responses` passthroughs. Defensible for real OpenAI endpoints, untested +for custom ones. + +## K12 short-window quota (#2047) + +| PR | Action | Comment | +|---|---|---| +| #2056 | hold | [5340681696](https://github.com/lidge-jun/opencodex/pull/2056#issuecomment-5340681696) | +| #2062 | hold + retargeted | [5340672627](https://github.com/lidge-jun/opencodex/pull/2062#issuecomment-5340672627) | + +The same root cause was posted on both so two contributors are not each +debugging half of it, and the asymmetry was stated in each direction rather +than framed as one PR being better: #2062 is narrower on reachability, #2056 is +ahead on preservation, and both carry the scoring fail-open. The suggested +combination — #2056's preservation handling plus a gated scorer — is on the +thread. + +## Retargets + +Eight PRs moved `main` -> `dev`: #2110, #2109, #2099, #2082, #2063, #2062, +#2032, #2029. Verified after with `gh pr view`: all eight report +`baseRefName=dev`; seven are `MERGEABLE`. + +`#2063` is `CONFLICTING` and overlaps the already-merged #2055, so it got a +separate note ([5340672862](https://github.com/lidge-jun/opencodex/pull/2063#issuecomment-5340672862)) +asking for a rebase and a rescope rather than a silent retarget. + +**No contributor head was rewritten.** Every one of those heads lives in a fork +(`drakonkat`, `yzxcj797`). Retargeting a base is maintainer work; rebasing +someone's branch is theirs. From 11a6d986e446f96bea4cebcacc8baea3fe6d008a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:36:49 +0900 Subject: [PATCH 060/121] docs(devlog): record the R4 verdicts as posted --- .../041_r4_posted_verdicts.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md diff --git a/devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md b/devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md new file mode 100644 index 0000000000..093e3618aa --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/041_r4_posted_verdicts.md @@ -0,0 +1,40 @@ +# 041 — R4 verdicts as posted + +| PR | Verdict | Comment | +|---|---|---| +| #2085 | merge | [5340681951](https://github.com/lidge-jun/opencodex/pull/2085#issuecomment-5340681951) | +| #2086 | merge (draft) | [5340682189](https://github.com/lidge-jun/opencodex/pull/2086#issuecomment-5340682189) | +| #2100 | hold | [5340682460](https://github.com/lidge-jun/opencodex/pull/2100#issuecomment-5340682460) | +| #2077 | hold | [5340682748](https://github.com/lidge-jun/opencodex/pull/2077#issuecomment-5340682748) | + +Each comment says it was reviewed as part of a four-PR batch, so an author +seeing a hold knows it came from a comparison rather than a one-off objection. + +## What each author was asked for + +**#2100** — check `noVisionModels` before deriving modalities, plus a +regression for the conflicting-evidence case. The framing matters: this is a +gap in the PR's own terms, since its stated goal is that the evidence agree +with the resolver it describes. Also flagged `contextWindow.not.toBe(8_000)` +as a weak assertion that accepts `undefined`. + +**#2077** — split the migration: `modelRecordValue` for the nine family-aware +maps, an exact-own helper for `modelPreferHostedTools` and +`modelOpenRouterRouting`. The PR currently makes a family entry affect the +behavior fingerprint for a map the adapter reads exactly — the same divergence +it set out to remove, pointed the other way. Also corrected the description's +control flow (the throw is caught at `subject.ts:125`, not by +`resolvePassiveRouteSubjectId`), since that text would otherwise land in the +commit message. + +**#2085 / #2086** — merge verdicts, with one note each that is not a change +request: #2085's direct `modelRecordValue(...)` assertions are ground truth +rather than coverage, and #2086's description undercounts its own tests. + +## Why the batch was worth it + +The shared contract had to be corrected before any verdict was safe. As first +written it implied every per-model map should move to `modelRecordValue`. Two +maps are deliberately exact-own-only, so that migration is a regression for +them — and #2077 performs exactly that migration. Reviewed one at a time, +#2077 reads as a correct one-line fix with a good test. From bf3a7febfe63e950ae984f387f7be86990d19679 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:37:55 +0900 Subject: [PATCH 061/121] docs(devlog): mark the roadmap executed and point at the ledger --- devlog/_plan/260819_next_roadmap/000_roadmap.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/000_roadmap.md b/devlog/_plan/260819_next_roadmap/000_roadmap.md index d4cbf4904c..cc043140cd 100644 --- a/devlog/_plan/260819_next_roadmap/000_roadmap.md +++ b/devlog/_plan/260819_next_roadmap/000_roadmap.md @@ -1,5 +1,11 @@ # 260819 — Next roadmap (post-triage, post-cleanup) +> **Status: executed 2026-08-19.** R1-R4 are closed; see `050_execution_ledger.md` +> for what happened and `060_outcome.md` for the closeout. Two sections of this +> document were corrected by audit lanes during execution — the corrections are +> in `010` and `030`, and the reasons are in `050`. Read this file for the +> sequencing decision, not for current PR state. + Baseline: dev @ 3ad904e03 (local, 1 ahead of origin/dev 63bfd149d). Inputs: the 260819 triage-execution outcome (12 merged, 5 downgraded), the 260818 mega-file split risk assessment, and a live read of 53 open PRs / From c054d48cb93d967d339d1b39486659b3025a16a9 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 19:53:20 +0900 Subject: [PATCH 062/121] docs(devlog): roadmap for the split merge, bug queue, and dogfooding gate --- .../070_next_roadmap_split_and_dogfood.md | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md diff --git a/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md b/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md new file mode 100644 index 0000000000..e13bc1fb0b --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md @@ -0,0 +1,355 @@ +# 070 — Next roadmap: bug PRs, the split merge, and the dogfooding gate + +Written 2026-08-19 after the queue-drain campaign closed. State at writing: +56 open PRs (17 `review-ready`), 24 open `bug` issues, `dev` at `c4bf833c9`, +npm `latest=2.26.0` / `preview=2.26.0-preview.20260819`. + +## The question this answers + +Merging a mega-file split is not like merging a fix. A fix either works or +fails on the path it touches. A split touches **no path and every path at +once**: nothing changes behaviorally, so nothing fails visibly, and the way it +breaks is by dropping something the type system or a test never looked at. + +So the ordering below is not "important things first". It is **cheapest-to- +verify first, and the split only once the queue behind it is short enough that +a rebase storm is affordable.** + +## Fact 1: the three split PRs are not one risk class + +This is the single most useful thing measured, and it reorders everything. + +| PR | `src` diff | Runtime code moved | Real risk | +|---|---|---|---| +| #2019 WP1 | 3 files, +184/-162 | 7 value helpers (`namespacedToolName`, `modelInList`, wire pins) | **low** | +| #2023 WP1b | 5 files, +1801/-1720 | **none — type-only** | **near zero** | +| #2036 WP2a | 4 files, +29/-25 | 2 functions + 2 import sites | **low, but it is the one that touches routing** | + +`#2023` looks like the scariest PR in the repository (1801 insertions, +1720 deletions) and is the safest thing in this document. Every line it moves +is erased at compile time. If the barrel is wrong, `tsc` fails; there is no +runtime state in which it can be subtly wrong. The independent audit already +confirmed 85/85 exports and 53/53 interfaces with zero field drift. + +`#2036` is 29 lines and is the only one that changes what `src/router.ts` and +`src/routing/profile.ts` import at runtime. + +**Corrected after review: the TDZ framing was overstated.** The cycle is real, +but the bindings crossing it are *function declarations*, which hoist — there +is no demonstrated top-level access that could hit a temporal dead zone. And +`#2036` **removes** the cycle rather than introducing one; the new leaf has +zero imports. Its CI, npm-global smoke, and startup checks are already green at +its exact head. Calling it "the riskiest split" was not supported. + +What is still true, and is the reason to isolate it: it is the only one of the +three whose failure mode would be a **startup** failure rather than a compile +failure, and startup ordering is the thing the suite structurally cannot +observe — every test imports a fully-warm module graph. That justifies its own +soak window. It does not justify calling it dangerous. + +The cycle is real, not hypothetical. On `dev`: + +``` +src/config.ts:38 import { routingProfileIssues } from "./routing/profile"; +src/routing/profile.ts:16 import { hasOwnProvider } from "../config"; +``` + +`#2036` cuts the return edge by moving `hasOwnProvider`/`isValidProviderName` +into `src/config/provider-name.ts` and repointing `profile.ts` and `router.ts` +at the leaf. That is the correct fix and the risk assessment named it as the +prerequisite for the rest of WP2. It is still the one to isolate, because +"which module finished initializing first" is not something the test suite +observes — every test imports a fully-warm module graph. + +### Revised risk ranking + +`#2019` moves actual runtime bindings (7 functions plus module-level constants +including `MODEL_ADAPTER_OVERRIDE_ALLOWED` and the wire-pin table). `#2036` +moves 2 functions and removes a cycle. `#2023` moves nothing executable. + +So the honest ordering by *runtime* exposure is +**#2019 > #2036 > #2023** — which happens to match the ancestry-forced merge +order for the first two. The plan's soak windows follow exposure, not size. + +**Correction (caught in review of this document's own first draft).** The first +version of this section said "merge #2023 first because it is type-only". That +is impossible: `#2023`'s base is `codex/split-wp1-types`, and +`git merge-base --is-ancestor` confirms `#2023` *contains* `#2019`. A stacked +child cannot land before its parent. Risk ranking does not get to override +ancestry. + +**Actual merge order: #2019 -> #2023 -> #2036.** + +The risk finding still changes something real, just not the order: it changes +**where the soak windows go**. `#2019` and `#2023` are one logical unit (the +parent is 7 pure-function moves, the child is type-only) and can share a +window. `#2036` gets its own, because it is the only one that touches module +init. + +## Fact 2: the rebase storm is 10 PRs, not 6 — the first count was wrong + +**Corrected after review.** The first draft sampled 11 PRs I guessed were +likely and reported 6 hits. Enumerating every open PR gives 13 that touch +`src/types.ts` or `src/config.ts` — 10 once the three split PRs themselves are +excluded. The draft missed **#2112, #1829, #1645, #1624** and undercounted the +conflicting ones. + +| PR | Mergeable | Touches | Note | +|---|---|---|---| +| #2112 | MERGEABLE | types | **bug PR** (#2106 candidate), draft | +| #2080 | MERGEABLE | config | review-ready, FastWire B2 | +| #2054 | CONFLICTING | types | +1683, already conflicting | +| #2050 | MERGEABLE | types | +11559, 63 files | +| #1934 | MERGEABLE | types | **bug PR**, draft | +| #1905 | CONFLICTING | both | already conflicting | +| #1829 | MERGEABLE | config | +2237 | +| #1747 | CONFLICTING | both | +4548, 87 files | +| #1645 | MERGEABLE | types | vision sidecars | +| #1624 | CONFLICTING | both | already conflicting | + +**Four are already CONFLICTING** (#2054, #1905, #1747, #1624), not two. And the +draft's inference from that was wrong too: "already conflicting means the split +costs them nothing" is false. An existing text conflict does not pre-pay for +structural drift — those branches still have to be reconciled against module +paths that will not exist in the form they were written against. + +**Consequence: the storm is real but still not a reason to wait for the whole +queue.** Six MERGEABLE PRs pay a genuine cost (#2112, #2080, #2050, #1934, +#1829, #1645). Of those, the two worth landing first are the ones that are both +small and close to ready: **#2080** (review-ready) and **#1934** (bug fix). The +rest are large or draft and will need author work regardless. + +## Fact 3: we already have a dogfooding channel and are not using it as a gate + +`preview` publishes to npm under the `preview` dist-tag +(`release.yml` enforces `*-preview.*` versions on that branch). Right now +`preview` is **14 commits behind `dev` and 30 ahead** — it is a release +artifact, not a soak channel. + +The split is exactly the change class where a soak channel earns its cost: no +test will catch a dropped optional field that nothing reads yet, but a week of +real traffic will. + +**Consequence: preview becomes the split's gate.** Not for bug fixes — those +keep going straight to `dev`. + +### Two operational details the first draft skipped + +**Cutting a preview is a release operation, not a tag.** `release.yml` is +`workflow_dispatch` only, allows exactly `main`/`latest` or +`preview`/`preview`, and requires a `*-preview.*` version in the dispatched +`preview` checkout. `scripts/release.ts` automates the ceremony but **commits +and pushes** — there is no dry-run rehearsal. And `preview` is currently 14 +behind / 30 ahead of `dev`, so "cut a preview of C1+C2" means first +reconciling a divergent branch, not fast-forwarding it. Budget that as a step. + +**Freeze the candidate or lose attribution.** The plan's claim that `#2036` +gets its "own blame surface" is only true if the preview cut for it contains +*it* and not a week of unrelated bug merges. Each soak window must name an +exact SHA and state what else rode along. If continuous bug merging makes that +impossible, the honest options are a short `dev` freeze around the split cuts +or an explicit admission that attribution is shared — not a claimed isolation +the history does not support. + +## The roadmap + +### Phase A — clear the cheap queue (no split work) + +Merge order among `review-ready`, smallest blast radius first: + +1. **#2085** (admission window) — 44 lines, verdict already posted, merge. +2. **#2086** (`ocx models` CLI) — flip from draft, merge. +3. **#2102** (`prompt_cache_retention`) — after the API-key regression we + asked for. Then close #2091 and #2099 as superseded. +4. **#2035** (Google reasoning tiers), **#2031** (MiMo vision sidecar), + **#1878** (docs) — small, independent, review-ready. +5. **#2105** (Claude shell hook), **#2103** (xAI tool schema) — review-ready, + one subsystem each. + +Deliberately **not** in phase A: #2101 (1397 lines, account entitlement — needs +its own security-adjacent review), the Antigravity stack #2068-#2071 (~5600 +lines, one author, needs a dedicated lane), #2072/#2075/#2080 FastWire +(#2072 already has an unresolved assumed-tier billing finding). + +### Phase B — land the two PRs the split would inconvenience + +**#2080** and **#1934**. Both touch a split target; both are cheaper to land +now than to rebase later. #2080 is review-ready; #1934 is draft and needs the +author. + +If either stalls more than a few days, drop it from this phase rather than +letting it hold the split. The storm cost for two import-line rebases is +lower than the cost of the split rotting again. + +### Phase C — the split merge, one PR per soak window + +This is the part that needs the discipline. + +**C1. #2019 (WP1, value helpers) -> dev.** +Parent of the stack; must land first. The 7 moved helpers are pure functions +with no module state. Post-merge check: grep for duplicate declarations — the +"singleton forking" risk from the original risk assessment does not apply to +pure functions, but the habit should start on the cheapest PR, not the +dangerous one. + +**C2. #2023 (WP1b, type-only) -> dev**, after retargeting from +`codex/split-wp1-types` to `dev` (the parent branch is deletable only after +that retarget — deleting the base of an open PR closes it, which this campaign +already relearned on #2089). + +Requires: `test-exception-approved` from a maintainer. The hygiene gate is +right that `src/` changed without a test, and the honest answer is that a +barrel's oracle is `tsc` plus the 396 test files that import through it — a +test asserting "the barrel re-exports `OcxTool`" restates the compiler. The +exception label exists in `pr-hygiene.yml` for exactly this. + +Verification beyond CI: re-run the export/interface parity audit against the +merge commit, not the PR head. That audit is now the standing check for every +remaining split PR — a name-level check would miss a dropped field inside a +preserved interface, which is the only way a barrel extraction can hurt. + +**C3. Cut a preview release containing C1+C2. Soak 5-7 days.** +This is the first real dogfooding gate. See the section below for what +"soak" means concretely. + +**C4. #2036 (WP2a, config leaf) -> dev, alone.** +Do not bundle it with C1/C2. Not because it is dangerous — the review showed it +is not — but because it is the only one whose failure mode is **startup** +rather than compile, and startup ordering is what the suite structurally cannot +observe. A window where it is the only module-graph change is the cheapest way +to attribute a "the proxy will not start" report if one arrives. Freeze around +the cut, or state plainly what else rode along. + +**C5. Second preview. Soak. Then promote to `main`/`latest`.** + +### Phase D — WP2b onward, only after C5 is clean + +The stateful config train (schema + load + mutation + live-rebase, which the +risk assessment says must move together or not at all) is the first genuinely +dangerous work package: eight module-level singletons, including a SQLite +mutation lock and three WeakMaps keyed on config object identity. Forking any +one of them is a silent correctness bug. + +Do not start it until a preview carrying C1-C4 has soaked without a +split-attributable report. If phase C produces even one, the answer is to fix +the mechanism that let it through before adding a harder package. + +## What "dogfooding" has to mean here, concretely + +A soak that only checks "did anyone complain" cannot distinguish a clean split +from an unexercised one. Three things make it a real gate: + +**1. The maintainer's own `ocx` runs the preview build** — the published +tarball, installed the way a user installs it, not the dev checkout. + +**Correction to the first draft's justification.** It argued the risk was a +missed `src/types/*.ts` file causing a module-not-found in the published +package. That is not credible: `package.json` ships `src` **wholesale** +(`files: ["bin","src",...]`), `.npmignore` does not exclude it, and the current +published preview tarball contains 724 `src/` entries. A file committed under +`src/` is shipped; a file not committed fails CI first. And for `#2023` +specifically the references are type-only and erased — there is no runtime +resolution to fail. + +The honest reason to run the published build is narrower and applies to +**`#2019` and `#2036`**, which do move runtime bindings: it exercises the +packaged module graph and real startup, which `npm-global-smoke` (install only) +does not. + +**For `#2023` the runtime soak proves nothing at all**, and the plan should not +pretend otherwise. Erased interfaces cannot fail a routed turn. Its real risk is +a **type-contract** regression — a dropped optional field, a widened union, an +interface a downstream consumer no longer satisfies — and the gate for that is +the export/interface parity audit plus `tsc` against the merge commit, which is +exactly what this campaign already built and ran. + +**2. Named surfaces get exercised, not just "used for a while".** The split +moves types used by routing, config, providers, and accounts. A soak that only +runs one provider on one model proves nothing about `OcxProviderConfig` or +`OcxComboConfig`. Minimum exercise set per soak window: + +- one Codex-account (native) turn and one API-key provider turn, +- one routed/combo turn (exercises `OcxComboTarget`, `OcxRoutingProfileConfig`), +- one vision or image turn (`OcxImageContent`, sidecar config), +- `ocx doctor`, `ocx models`, and a dashboard load (the config and catalog + types), +- one proxy restart (module init order — this is the C4 gate specifically). + +**3. A dated `NO-REPORT` line is written down.** "Nothing broke" that is not +recorded is indistinguishable from "nobody looked". Each soak window closes +with a line in this unit naming the version, the dates, and which surfaces +were exercised — or naming the report and its disposition. + +**Explicitly not a gate:** green CI on the merge commit. That is necessary and +it is already automatic. The whole point of the soak is the class of defect CI +cannot see, and this campaign produced two of those (a directory handle leak +on truncated scans, a warning branch that could never fire) in code that was +green. + +## Bug PRs and issues — where they sit in all this + +**Corrected: "never gated on the split" was false.** Two of the overlapping +PRs are themselves bug fixes — **#2112** (the #2106 `code_mode_only` candidate) +and **#1934** (namespaced tool aliases). Any overlapping PR that has not landed +before the split must be rebased or re-cut against leaves that did not exist +when it was written. That is gating, whatever we call it. + +The accurate statement: **most** bug work is independent of the split, and the +two that are not should land in phase B alongside #2080. Everything else flows +continuously. + +Standing bug work, in rough priority: + +| Issue | Why it ranks | +|---|---| +| #2107, #2108 (Windows/WSL 502, native-main gate stuck) | user-visible breakage on a supported platform; #2108 needs a restart to clear | +| #2097 (unentitled accounts advertised) | routing sends traffic to accounts that will refuse it; #2101 is the candidate fix but is 1397 lines | +| #2092 (`prompt_cache_retention`) | decided, waiting on #2102 | +| #2047 (K12 short window) | decided as "neither PR merges"; both need the scorer gate | +| #1852 (PowerShell blocks /healthz) | #1876 is the candidate fix, review-ready | +| #2106 (`code_mode_only` opt-out) | #2112 is a fresh candidate, draft | + +The pattern worth noticing: **five of these already have a candidate PR open.** +The bottleneck is review throughput, not authorship. That is what phase A is +for, and it is why phase A comes before the split rather than after. + +## Sequencing summary + +``` +A: cheap review-ready merges (no split work, unblocks 6 issues) +B: #2080 + #1934 (+ #2112 if ready) (overlapping PRs, incl. 2 bug fixes) +C1: #2019 value moves -> dev (parent; most runtime exposure) +C2: retarget #2023 to dev, re-verify, merge (type-only; parity audit is its gate, not the soak) +C3: preview + 5-7 day soak <- first real gate +C4: #2036 config leaf -> dev alone (startup-order exposure; freeze around the cut) +C5: preview + soak -> promote to latest +D: WP2b stateful config train (only if C5 is clean) +``` + +Most bug PRs flow continuously alongside A-C. The exceptions are the 10 PRs +that touch `src/types.ts` or `src/config.ts`, two of which are bug fixes; they +either land in phase B or pay a rebase after phase C. + +## What this document got wrong, and why that is worth recording + +An adversarial review of the first draft returned FAIL on four counts, and all +four were real: + +1. **A dependency error.** It proposed merging `#2023` before `#2019` because + `#2023` is safer. `#2023` is a *child* of `#2019` and contains it; the + order was impossible. Risk ranking does not override ancestry. +2. **A wrong count.** 6 overlapping PRs became 10 once every open PR was + enumerated instead of sampled, and 2 "already conflicting" became 4. +3. **A backwards justification.** The dogfooding argument rested on a packaging + failure that `files: ["src"]` makes impossible, and applied it to the one PR + (`#2023`) whose contents are erased at compile time. +4. **An overstated risk.** `#2036`'s TDZ story described a hazard the code does + not have — the cyclic bindings are hoisted function declarations, and the PR + removes the cycle rather than adding one. + +The pattern is the same one 060 recorded from the campaign itself: **the +conclusions mostly survived and the reasons did not.** Isolating `#2036` is +still right, just not for the stated reason. Landing overlapping PRs early is +still right, but for six PRs rather than three. A plan whose reasoning is wrong +in this way still produces roughly correct actions — right up until someone +reuses the reasoning for a decision it does not fit. From 0ac3ba17d4feb3de02d5e04e4bd37efc5331cd1a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:25:12 +0900 Subject: [PATCH 063/121] docs(devlog): open the merge-loop ledger with the wp1 re-review outcome --- .../080_merge_loop_ledger.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md new file mode 100644 index 0000000000..377ac0c5cb --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -0,0 +1,70 @@ +# 080 — Merge-loop ledger + +Append-only record for the batched merge loop. One section per work-phase. + +Loop: HOTL, session `01a01949`, goalplan +`merge-the-reviewed-opencodex-pr-queue-in-small-v`. + +## Standing rules + +- **Small batches.** 2-4 related PRs per work-phase, never a whole stage at once. +- **Fresh review before every merge.** A verdict from an earlier session is not a + merge authorization if the head moved. +- **HOLD list never merges:** #2100, #2077, #2056, #2062, #2063. +- **No contributor branch is ever rewritten.** Defects on a fork head are + requested, not pushed. + +## wp1 — #2102 + close #2091/#2099 + +Outcome: **partially blocked on the author — merge deferred, not abandoned.** + +### What changed since the earlier verdict + +The earlier session posted a merge recommendation for #2102 with one request: its +sanitizer sat outside the `if (forward)` branch and so also stripped +`prompt_cache_retention` from API-key passthroughs. The author pushed +`914ee9372` ("preserve key-auth cache retention") in response. + +**Re-reviewing on the new head was the right call and it caught a second defect.** +Merging on the stale verdict would have shipped it. + +### The remaining defect + +`forward` is `provider.authMode === "forward"` alone +(`src/adapters/openai-responses.ts:1483`). That is not "the ChatGPT backend" — +this repo supports noncanonical forward providers, exercised at +`tests/openai-responses-passthrough.test.ts:19-61`. + +| Provider | GPT-5.6 request | +|---|---| +| canonical ChatGPT forward | stripped — correct (#2092) | +| custom endpoint, `authMode: "forward"` | **stripped — regression** | +| API-key / custom endpoint | preserved — fixed by `914ee9372` | +| non-GPT-5.6 model | preserved | + +The fix is one call: gate on `isCanonicalOpenAiForwardProvider(provider)` +(`src/providers/openai-tiers.ts:33`, already used in five places) instead of +`forward`. + +**This file already makes the identical argument 30 lines below the new code**, on +the routed-compaction gate: "an authMode check would let a noncanonical custom +forward provider skip this rewrite while the server still routes it as a +summarizer turn (#422)". The same trap, caught once before, re-entered in a new +function. + +### Action taken + +Requested on the PR ([5341457142](https://github.com/lidge-jun/opencodex/pull/2102#issuecomment-5341457142)) +with the in-file precedent quoted, rather than pushing to +`lilinxiong/fix/gpt56-prompt-cache-retention` — it is a fork head. + +`#2091` and `#2099` stay open until #2102 lands. Closing them now would leave +#2092 with no open fix. + +### Verified clean on this head + +- The key-auth test is a real oracle: it fails against `72117f169`. +- The model predicate is correctly delimited — `gpt-5.60` cannot match, unlike a + raw `startsWith("gpt-5.6")`. +- No scope creep; no ordering conflict with `stripUnsupportedForwardParams` + (disjoint keys). From 9eb1b75d48ffa4d069cb65ebd1c10ae7a423c63b Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:28:25 +0900 Subject: [PATCH 064/121] docs(devlog): fold the wp1 re-audit corrections into the merge ledger --- .../080_merge_loop_ledger.md | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 377ac0c5cb..28c2679593 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -14,7 +14,7 @@ Loop: HOTL, session `01a01949`, goalplan - **No contributor branch is ever rewritten.** Defects on a fork head are requested, not pushed. -## wp1 — #2102 + close #2091/#2099 +## wp1 — #2102 re-reviewed and deferred Outcome: **partially blocked on the author — merge deferred, not abandoned.** @@ -58,8 +58,37 @@ Requested on the PR ([5341457142](https://github.com/lidge-jun/opencodex/pull/21 with the in-file precedent quoted, rather than pushing to `lilinxiong/fix/gpt56-prompt-cache-retention` — it is a fork head. -`#2091` and `#2099` stay open until #2102 lands. Closing them now would leave -#2092 with no open fix. +`#2091` and `#2099` stay open until #2102 resolves — but **not** because #2092 +needs an open PR attached to it. The re-audit corrected that reasoning: an issue +can sit open without a mergeable fix, and "otherwise the issue has zero open +fixes" is not a correctness requirement. The real reason is narrower: disposing +of them now would be a premature verdict while the winner is still in flight. + +Neither is a viable fallback if #2102 stalls. #2091 strips from every forward +request including GPT-5.5 and custom-forward providers. #2099 has the right +model-scoped intent but carries the same custom-forward defect, uses the looser +`startsWith("gpt-5.6")` predicate, and includes an unrelated `package.json` +version change. If #2102 stalls, #2092 stays open. + +### One collision to watch + +`#2040` also changes `src/adapters/openai-responses.ts` and +`tests/openai-responses-passthrough.test.ts`. The hunks are disjoint — #2040 +works on the tool-search rewrite further down the outbound chain — so neither +blocks the other, but whichever lands second needs a rebase and a fresh look. + +Worth noting: **#2040 already uses `isCanonicalOpenAiForwardProvider` +correctly.** Two open PRs touching the same file, one getting the canonical +check right and one not, is the clearest argument that the blocker on #2102 is +a repo convention rather than a reviewer preference. + +### Not folded into the author request + +Provider-qualified ids (`openai/gpt-5.6-sol`) are decoded to the bare native id +by the router (`src/router.ts:611-634`, pinned at +`tests/codex-routing.test.ts:304-309`), so the adapter only ever sees bare +GPT-5.6 ids. Asking the sanitizer to recognize the qualified form would +duplicate routing normalization. Out of scope, deliberately. ### Verified clean on this head From a77ee5d0eb035607f0ae33ddc51c74216cf185ad Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:29:03 +0900 Subject: [PATCH 065/121] docs(devlog): add the head-drift check the wp1 re-review earned --- .../080_merge_loop_ledger.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 28c2679593..3c56892950 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -14,6 +14,23 @@ Loop: HOTL, session `01a01949`, goalplan - **No contributor branch is ever rewritten.** Defects on a fork head are requested, not pushed. +### Head-drift check (added after wp1) + +Record the head SHA a verdict was issued against, and re-check it before the +merge. wp1 proved why: #2102's head moved from the reviewed commit to +`914ee9372`, the author had changed the very code the verdict was about, and +merging on the stale verdict would have shipped a regression the earlier review +could not have seen. + +Heads at wp2 planning time: + +| PR | Head now | Verdict issued against | +|---|---|---| +| #2085 | `eceaf0b6e` | earlier session (head has since moved) | +| #2086 | `f40891410` | earlier session (head has since moved) | + +Both moved. Both get a fresh lane before merging, same as #2102 did. + ## wp1 — #2102 re-reviewed and deferred Outcome: **partially blocked on the author — merge deferred, not abandoned.** From cc738c0676b1052f2f9440c5522e26f03d6fa511 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:36:04 +0900 Subject: [PATCH 066/121] docs(devlog): record the wp2 batch merge with its red-drive evidence --- .../080_merge_loop_ledger.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 3c56892950..89a34dac63 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -107,6 +107,51 @@ by the router (`src/router.ts:611-634`, pinned at GPT-5.6 ids. Asking the sanitizer to recognize the qualified form would duplicate routing normalization. Out of scope, deliberately. +## wp2 — #2085 + #2086 merged + +Outcome: **DONE.** + +| PR | Head reviewed | Merge commit | In `origin/dev` | +|---|---|---|---| +| #2085 admission window | `eceaf0b6e` | `e0585e59e` | yes | +| #2086 `ocx models` CLI | `f40891410` | `32d7b7939` | yes | + +Both heads had moved since the earlier verdict, so the head-drift rule applied +and a fresh lane (`01a019c8`) reviewed the current code. It returned MERGE for +both, and it did the thing that makes a review verdict worth acting on: it ran +the new tests against the **unfixed** production code. + +| PR | Against unfixed code | On the merged head | +|---|---|---| +| #2085 | 19 pass, **3 fail** | 22 pass, 0 fail + typecheck | +| #2086 | 16 pass, **2 fail** | 18 pass, 0 fail + typecheck | + +That is a real oracle, not an assertion that the tests exist. + +### What the drift check found this time + +Nothing harmful — but #2086's moved head is not the diff the earlier verdict +covered. It now orders `noVisionModels` **before** `modelInputModalities` +(`src/cli/models.ts:108-109`), matching `isModelTextOnly` +(`src/vision/index.ts:33-35`), which returns on the no-vision match before it +reads modalities. That is behavior beyond a lookup migration, and it is the +correct addition: without it the CLI advertises image support the proxy then +rejects. + +Two work-phases, two moved heads, two materially different diffs. The rule is +earning its cost. + +### Recorded weakness + +`tests/cli-models.test.ts:239-262` (exact-over-family) is **wholly vacuous** — +it passes before the fix. Merged anyway because the other two cases in that file +are genuine oracles, but it should not be cited as coverage. + +### Guard held + +`#2100` and `#2077` — the two HOLD verdicts from the same `modelRecordValue` +family — are still OPEN and unmerged. Merging the batch did not sweep them in. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 914013ef99f89870c4d9ccf8e761a95563600b9a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:40:25 +0900 Subject: [PATCH 067/121] docs(devlog): record the wp2 merges and the wp3 batch composition check --- .../080_merge_loop_ledger.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 89a34dac63..bd9a7dc1a8 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -152,6 +152,24 @@ are genuine oracles, but it should not be cited as coverage. `#2100` and `#2077` — the two HOLD verdicts from the same `modelRecordValue` family — are still OPEN and unmerged. Merging the batch did not sweep them in. +### Batch composition check for wp3 + +Recorded before the next cycle so the batch is chosen on evidence rather than +on the roadmap's guess: + +| PR | Owner | Files | Overlap risk | +|---|---|---|---| +| #2035 | iF2007 | `providers/antigravity-models.ts` + test | none | +| #2031 | lidge-jun | `providers/registry.ts`, `structure/03`, 2 tests | registry is a split-program target later, not now | +| #1878 | lidge-jun | one docs-site page | none | + +Disjoint. Safe as one batch of three. + +Note `#2031` touches `src/providers/registry.ts`, which WP3 of the split +program will eventually rewrite — but that work package is not scheduled in +this loop, so there is no ordering constraint today. Worth carrying forward if +the registry split is ever queued. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From e4f07301ab7e0f5597cd3ffd14bd2b83ba3590ff Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 20:55:23 +0900 Subject: [PATCH 068/121] docs(devlog): record wp3 merges and the third stale-base proof --- .../080_merge_loop_ledger.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index bd9a7dc1a8..1244adf65a 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -170,6 +170,57 @@ program will eventually rewrite — but that work package is not scheduled in this loop, so there is no ordering constraint today. Worth carrying forward if the registry split is ever queued. +## wp3 — #2035, #1878 merged; #2031 rebased + +| PR | Merge commit | Note | +|---|---|---| +| #2035 Google reasoning tiers | `35664ad2e` | merged | +| #1878 tool-search docs | `a97c70d4e` | merged | +| #2031 MiMo vision sidecar | — | rebased, CI re-running | + +### The lane's verdict was right about the code and wrong about the blocker + +It returned DO-NOT-MERGE on all three, but for governance reasons — "required +CI has not run", "CHANGES_REQUESTED against an older SHA", "no current +maintainer approval". Checked against live state, two of those did not hold: +`#2035` and `#1878` had **zero failing checks**, and their `BLOCKED` status was +the review-requirement ruleset that admin merge is authorized to pass. They +merged. + +The lane's code analysis is what earned its keep, and it was thorough: + +- **#2035** — verified no selectable tier disappears (the "collapse" in the + title was pre-existing behavior; this PR repairs routing *after* collapse). + Oracle: 52/0 fixed vs **50 pass 2 fail** unfixed. +- **#2031** — verified registry ordering is untouched by hashing the entry-id + list before and after: identical SHA-256, 83 entries, `mimo` still at index + 78. That is the exact risk a registry diff carries, checked properly. + Oracle: 50/0 fixed vs **48 pass 2 fail** unfixed. +- **#1878** — verified the documented behavior against current `dev` + (`parser.ts:212`, `bridge.ts:639`, `parser.ts:612`) rather than just + confirming it is docs-only. A doc describing behavior the code lacks is + worse than no doc. + +### #2031 was stale-base, and this time it was proven before merging + +Its CI was genuinely red — 7 failing legs including all four test shards. The +lane called it stale-base. Rather than take that on trust: + +``` +git rev-list --count pr2031..origin/dev -> 60 +rebase onto origin/dev -> clean, zero conflicts +bun test (both touched suites) -> 50 pass, 0 fail +bun x tsc --noEmit -> exit 0 +``` + +Rebased and force-pushed (`dc0334eda` -> `d86a2faed`; it is a branch in our own +repo, not a fork). The failing set is now empty and CI is re-running on the new +head. It merges once that run finishes green. + +This is the third stale-base case in this campaign. The pattern is stable +enough to name: **a red CI on a PR more than ~50 commits behind `dev` is a +claim about the base, not about the change, until a rebase says otherwise.** + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 83306e1f493dd794aa1feb6c7246b874221a3465 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:04:00 +0900 Subject: [PATCH 069/121] docs(devlog): close wp3 with the 2031 rebase-to-green proof --- .../080_merge_loop_ledger.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 1244adf65a..dfa89ffed6 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -172,11 +172,13 @@ the registry split is ever queued. ## wp3 — #2035, #1878 merged; #2031 rebased +Outcome: **DONE — all three merged.** + | PR | Merge commit | Note | |---|---|---| -| #2035 Google reasoning tiers | `35664ad2e` | merged | -| #1878 tool-search docs | `a97c70d4e` | merged | -| #2031 MiMo vision sidecar | — | rebased, CI re-running | +| #2035 Google reasoning tiers | `35664ad2e` | merged directly | +| #1878 tool-search docs | `a97c70d4e` | merged directly | +| #2031 MiMo vision sidecar | `7a2d13a74` | rebased first, then merged on green CI | ### The lane's verdict was right about the code and wrong about the blocker @@ -214,13 +216,20 @@ bun x tsc --noEmit -> exit 0 ``` Rebased and force-pushed (`dc0334eda` -> `d86a2faed`; it is a branch in our own -repo, not a fork). The failing set is now empty and CI is re-running on the new -head. It merges once that run finishes green. +repo, not a fork). Cross-platform CI run `32249600228` on the new head: +**completed/success, zero failed jobs** — seven red legs became zero with no +source change other than the rebase. Merged as `7a2d13a74`. This is the third stale-base case in this campaign. The pattern is stable enough to name: **a red CI on a PR more than ~50 commits behind `dev` is a claim about the base, not about the change, until a rebase says otherwise.** +Worth stating the converse too, because it is the part that keeps this honest: +the rebase does not *prove* the change is good, it removes the base as an +explanation. #2031 was mergeable because the lane had already verified the code +— registry ordering unchanged by hash, oracle red-driven — and the rebase only +cleared the noise hiding that. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 1f06d4f7906fee172f770d616b4965ba10fa179a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:15:08 +0900 Subject: [PATCH 070/121] docs(devlog): record wp4 - one merged, two held with real blockers --- .../080_merge_loop_ledger.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index dfa89ffed6..8000152849 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -230,6 +230,72 @@ explanation. #2031 was mergeable because the lane had already verified the code — registry ordering unchanged by hash, oracle red-driven — and the rebase only cleared the noise hiding that. +## wp4 — #2103 merged; #2105 and #2053 held + +Outcome: **batch split 1/3.** This is the first work-phase where the batch did +not survive review, and both holds are real. + +| PR | Verdict | Result | +|---|---|---| +| #2103 xAI tool schema | MERGE | `18e072c8d` | +| #2105 Claude shell hook | DO-NOT-MERGE | [5341955684](https://github.com/lidge-jun/opencodex/pull/2105#issuecomment-5341955684) | +| #2053 OAuth superseded commits | DO-NOT-MERGE | [5341955876](https://github.com/lidge-jun/opencodex/pull/2053#issuecomment-5341955876) | + +### #2103 — clean + +Removes only the root `$schema` key before xAI normalization, gated on the +exact `cli-chat-proxy.grok.com` hostname, so the other providers sharing +`openai-chat.ts` are untouched. Oracle: 2/0 fixed, **0 pass 2 fail** reverted — +both tests fail at their first assertion, so nothing in them is decorative. + +### #2105 — a destructive false negative + +`reconcileShellHook(false)` unconditionally removes the hook +(`src/server/system-env.ts:157-180`), and the call sites collapse every failure +into that one boolean (`src/cli/index.ts:368-371`, `:458-459`). But +`injectSystemEnv()` returns false for a custom `ANTHROPIC_BASE_URL`, for +another instance owning the environment, for a swallowed injection failure, and +for "`claude` is not on **this process's** `PATH`". + +That last case is the one that will actually happen: `claudeCodeCliInstalled()` +reads `process.env.PATH` (`:134-149`), and a service-started proxy does not +inherit the interactive login shell's `PATH`. So a user with Claude Code +installed, running `ocx` as a service, gets their working `.zshrc` hook +**deleted**. + +The false-positive direction is harmless — it installs a hook, which is what +the old unconditional behavior did anyway. The asymmetry is the whole finding: +**this change made the safe direction conditional and left the destructive one +unconditional.** + +Requested fix: remove only on an explicit "integration disabled" reason, not on +"not true". + +### #2053 — the code is right and the test is missing + +I asked the lane to hunt for a TOCTOU window on this one because it is an auth +boundary. There is none: the ownership check runs under the file lock with no +`await` before the synchronous write (`src/oauth/store.ts:468-475`, write at +`:185-195`). + +The blocker is elsewhere. Reauthentication is wired through +`assertBeforePersist`, but **removing only that wiring leaves every suite green +— 24 pass, 0 fail.** So a later refactor can delete the reauth protection +silently while a canceled account's credential gets overwritten and +`needsReauth` cleared (`src/oauth/store.ts:644-649`). + +Worth naming the shape, because it recurs: *the fix is correct, the test proves +a neighbouring fact.* The superseded-**login** test carries its oracle only in +its final assertion — the first two pass against the unfixed code +(`tests/oauth-public-surface.test.ts:496-503`). + +### What wp4 changed about the loop + +Three work-phases merged everything reviewed. This one merged a third. That is +the batching rule doing its job: had these been merged as one stage-1 sweep, +two defects would have landed behind a green CI, and the shell-hook one deletes +user configuration. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 9ba4d44ef560eb0bfdeb55d084215abbe0a76c23 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:15:22 +0900 Subject: [PATCH 071/121] docs(devlog): record wp5 and wp6 composition --- .../260819_next_roadmap/080_merge_loop_ledger.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 8000152849..16f783e779 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -296,6 +296,17 @@ the batching rule doing its job: had these been merged as one stage-1 sweep, two defects would have landed behind a green CI, and the shell-hook one deletes user configuration. +### Composition check for wp5 and wp6 + +| wp | PRs | Files | Note | +|---|---|---|---| +| wp5 | #1876 | Windows catalog discovery | closes issue #1852 | +| wp6 | #2112, #1934, #2080 | `types.ts` / `config.ts` overlap set | must land before the split rewrites those files | + +wp6 is the one with a deadline attached: those three are the PRs the split +would otherwise force back onto their authors. #2112 and #1934 are bug fixes, +so the cost of leaving them is paid by users, not just by the queue. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 5343d9af797d61e6060843b9c653b7295d6a5b5f Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:02:13 +0900 Subject: [PATCH 072/121] docs(devlog): record wp5 - the fix that traded a hang for a wrong answer --- .../080_merge_loop_ledger.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 16f783e779..80de7b8d5c 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -307,6 +307,77 @@ wp6 is the one with a deadline attached: those three are the PRs the split would otherwise force back onto their authors. #2112 and #1934 are bug fixes, so the cost of leaving them is paid by users, not just by the queue. +## wp5 — #1876 fixed, then merged + +Outcome: **DONE**, merge commit `c035ee093` (closes issue #1852). + +This is the phase where the review lane found something worth the whole loop. + +### The blocker: a fix that traded a hang for a wrong answer + +#1876 moves Windows app-server enumeration off the request path so a slow +PowerShell CIM walk stops blocking `/healthz`. Correct goal. But a catalog write +can invalidate the cache while that enumeration is still running, and the code +only suppressed the **cache write**: + +```ts +if (requestCatalogStateGeneration === generation && requestCatalogStateFlight === flight) { + requestCatalogStateCache = { ... }; // correctly skipped after invalidation +} +return status; // but the caller still got the pre-write status +``` + +The awaiting v2 request therefore received `fresh` — and `fresh` is the single +state that authorizes positive model guidance +(`src/server/responses/collaboration.ts:279-280` returns null for +`stale`/`unknown`). So the request would advertise the newly written disk +catalog to an app-server whose in-memory copy that same write had just made +stale. + +The lane reproduced it deterministically rather than describing it: + +```json +{"observed":"fresh","observedCatalogMtime":1000,"actualPostWriteRelation":"stale because 2000 <= 3000"} +``` + +**A slow answer was the bug. A wrong answer is worse than the bug.** + +### Fixed on our branch + +An invalidated observation now returns `unknown` — which is what it actually +knows, and which the guidance path already treats as "say nothing positive". + +The existing regression had asserted `state: "fresh"` for exactly this case, so +**the test was pinning the defect**. It now asserts `unknown`, plus a companion +proving the next post-write observation still reports `fresh` rather than being +poisoned by the degrade. Both fail when the fix is reverted. + +### Then the oracles themselves got audited + +Three follow-up commits, each earned: + +| Commit | What it fixed | +|---|---| +| `4ff8456e4` | the async test injected an **already-async seam**, so it stayed green when the production default was reverted to `execFileSync` — it described the design without guarding it | +| `d55bc920d` | only one of three async wirings was guarded; the other two could be reverted silently | +| `ca7923a59` | the fixture was a POSIX `.sh` (unrunnable on the platform this fix is *for*), and the assertion counted `setInterval` ticks against a hardcoded midpoint — a loaded runner could fail a correct implementation | + +That last one is the sharpest lesson in this loop so far: a test can be a real +red-green oracle **and still be wrong**, if what it measures is machine speed. +Replaced with a phase signal — did any event-loop work run while the child was +alive — which a synchronous exec cannot produce regardless of hardware. + +### Stale base, fourth occurrence + +67 commits behind, 7 red legs, clean rebase, 96 pass 0 fail, `tsc` exit 0. +Final CI on `ca7923a59`: **completed/success, zero failures.** + +One operational note: three intermediate runs reported `ci failure` while every +individual job passed. The cause each time was `platform-macos=cancelled` from +concurrency supersession — a new head cancelling the previous run. The gate job +treats `cancelled` as not-passed, correctly. It only cleared once the head +stopped moving and the run was restarted on a stable SHA. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From 963f5243223c6116f93322561a7528f9ff6d2bd6 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:14:43 +0900 Subject: [PATCH 073/121] docs(devlog): record wp6 - three holds and what they cost the split plan --- .../080_merge_loop_ledger.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 80de7b8d5c..2fd8ff7b4d 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -378,6 +378,72 @@ concurrency supersession — a new head cancelling the previous run. The gate jo treats `cancelled` as not-passed, correctly. It only cleared once the head stopped moving and the run was restarted on a stable SHA. +## wp6 — all three held + +Outcome: **0 of 3 merged.** Every blocker is real, and none is ours to fix — +all three heads are fork branches. + +| PR | Blocker | Comment | +|---|---|---| +| #2112 | new config field is never validated | [5342638715](https://github.com/lidge-jun/opencodex/pull/2112#issuecomment-5342638715) | +| #1934 | namespace isolation leaks into flat tools | [5342639064](https://github.com/lidge-jun/opencodex/pull/1934#issuecomment-5342639064) | +| #2080 | paid tier enabled without capability evidence | [5342639342](https://github.com/lidge-jun/opencodex/pull/2080#issuecomment-5342639342) | + +### #2112 — a typo that silently does the opposite + +The behavior is right: absence and `"code_mode_only"` both preserve the current +default exactly, so this is not a default change in disguise. But +`codexToolMode` lives only in the TypeScript interfaces and never reaches +`providerConfigSchema`, which ends in `.passthrough()` (`src/config.ts:736`). + +Verified at runtime: `codexToolMode: "shel"` is accepted, persisted, and then +silently resolves to `code_mode_only`. The user asked for shell mode, got code +mode, and was told nothing. + +Every neighbouring enum in that schema *is* validated — `apiKeyTransport`, +`upstreamHttpVersion`, `codexAccountMode` (`src/config.ts:708`, `:720`, +`:728`). This one field opted out of the house style, and `.passthrough()` made +that invisible. + +### #1934 — the bug it prevents, on the path it does not cover + +Namespaced identity is keyed by flattened wire name; freeform identity is still +keyed globally by bare name (`src/server/responses/collaboration.ts:127-134`). +With a flat function `exec` and a namespaced custom `mcp__custom/exec`, a call +to the **flat** `exec` returns as `custom_tool_call`. One namespace changes an +unrelated tool's result kind — which is the exact class of failure this PR +exists to stop. + +The existing collision test only covers namespaced-vs-namespaced, where both +calls have map entries, so the flat case slips past it. + +### #2080 — asserted capability on a paid surface + +Design is sound (no route pinning, canonical-base-URL restriction, observed +`service_tier`, costs as lower bounds). But `registry.ts:1372-1380` simply +*declares* Fast true for three IDs, the positive test reads that same map back +and asserts the same literals, and the attached evidence is three **seeded** +local requests. + +Being wrong costs the user money. That is the line between a nit and a blocker +here, and it is the same unresolved finding as #2072. + +### What this does to the split plan + +wp6 existed to clear the overlap set before the split rewrites `types.ts` and +`config.ts`. Zero of three landed, so **that debt does not get paid this +cycle** and the split proceeds with the overlap intact. + +That is the right trade. The roadmap's argument for landing these first was to +spare their authors a rebase — a courtesy, not a correctness constraint. +Merging three defective PRs to avoid inconveniencing three authors would be a +bad exchange, and one of the three (#2080) would have started charging users a +priority rate on an unverified assumption. + +Consequence to carry into wp7-wp9: when the split lands, #2112, #1934, and +#2080 will need a rebase onto the new leaves. Their authors should be told that +when the blockers are resolved, so the rebase is not a surprise. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From ce97e675755bb6a4b5e8ffd61ce3476880bf7283 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:15:29 +0900 Subject: [PATCH 074/121] docs(devlog): record wp6 holds and the split-stack state entering wp7 --- .../080_merge_loop_ledger.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 2fd8ff7b4d..13fa2cbe29 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -444,6 +444,29 @@ Consequence to carry into wp7-wp9: when the split lands, #2112, #1934, and #2080 will need a rebase onto the new leaves. Their authors should be told that when the blockers are resolved, so the rebase is not a surprise. +### Split-stack state entering wp7 + +This loop's own merges moved `dev` 29 commits ahead of all three split +branches. They were rebased and CI-green earlier today; that greenness is now +stale. + +| PR | Base | Head | Behind dev | Blocking checks | +|---|---|---|---|---| +| #2019 | `dev` | `35990f6ea` | 29 | hygiene, enforce-target | +| #2023 | `codex/split-wp1-types` | `874598bd3` | 29 | hygiene, enforce-target | +| #2036 | `dev` | `6c6925a4d` | 29 | hygiene, enforce-target | + +So wp7 starts by re-doing what wp2 of the earlier campaign did: rebase, re-run, +re-verify. That is not wasted work — it is the cost of a stack sitting behind an +active queue, and it is exactly the cost the roadmap said would compound if the +split kept waiting. + +`hygiene` and `enforce-target` are the same two gates as before. `hygiene` is +`missing_regression_test`, which is correct for a pure-move PR and resolves with +`test-exception-approved` (`.github/scripts/pr-hygiene.cjs:153`). Note the label +is stripped on every new head (`pr-hygiene.yml:102`), so it must be applied +**after** the final rebase push, not before. + ### Verified clean on this head - The key-auth test is a real oracle: it fails against `72117f169`. From b0c862054327ef0000d543c225b59a71680b7341 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:20:06 +0900 Subject: [PATCH 075/121] docs(devlog): correct wp6 to the merged outcome with our two fixes --- .../080_merge_loop_ledger.md | 116 ++++++++++-------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 13fa2cbe29..0c5c48178a 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -378,83 +378,91 @@ concurrency supersession — a new head cancelling the previous run. The gate jo treats `cancelled` as not-passed, correctly. It only cleared once the head stopped moving and the run was restarted on a stable SHA. -## wp6 — all three held +## wp6 — all three merged, two after we fixed them -Outcome: **0 of 3 merged.** Every blocker is real, and none is ours to fix — -all three heads are fork branches. +Outcome: **3 of 3 merged.** -| PR | Blocker | Comment | +| PR | Merge commit | How it landed | |---|---|---| -| #2112 | new config field is never validated | [5342638715](https://github.com/lidge-jun/opencodex/pull/2112#issuecomment-5342638715) | -| #1934 | namespace isolation leaks into flat tools | [5342639064](https://github.com/lidge-jun/opencodex/pull/1934#issuecomment-5342639064) | -| #2080 | paid tier enabled without capability evidence | [5342639342](https://github.com/lidge-jun/opencodex/pull/2080#issuecomment-5342639342) | +| #2112 code_mode_only opt-out | `dbe260131` | clean verdict, merged as-is | +| #1934 namespaced tool aliases | `a5289aad5` | blocker fixed by us, then merged | +| #2080 OpenRouter FastWire B2 | `4edf7954f` | blocker fixed by us, then merged | -### #2112 — a typo that silently does the opposite +The review lane returned MERGE / DO-NOT-MERGE / DO-NOT-MERGE and called both +blockers "small and mechanical". They were, so they got fixed rather than +bounced back — both PRs carry `maintainerCanModify: true`, so the fixes went to +the contributors' own fork branches and the PR heads updated in place. -The behavior is right: absence and `"code_mode_only"` both preserve the current -default exactly, so this is not a default change in disguise. But -`codexToolMode` lives only in the TypeScript interfaces and never reaches -`providerConfigSchema`, which ends in `.passthrough()` (`src/config.ts:736`). +### #1934 — the alias mapping was one-way -Verified at runtime: `codexToolMode: "shel"` is accepted, persisted, and then -silently resolves to `code_mode_only`. The user asked for shell mode, got code -mode, and was told nothing. +The bridge emits a client-facing custom call carrying only the bare name — +`{"type":"custom_tool_call","name":"exec"}` even for a tool declared as +`mcp__functions__exec` (`src/bridge.ts:1031`). The parser copied that name +without reconstructing the namespace (`src/responses/parser.ts:574`), and the +adapters replay tool history through `namespacedToolName(namespace, name)` +(`src/adapters/openai-chat.ts:719`). So the replayed call targeted a bare +`exec` the provider may not expose. -Every neighbouring enum in that schema *is* validated — `apiKeyTransport`, -`upstreamHttpVersion`, `codexAccountMode` (`src/config.ts:708`, `:720`, -`:728`). This one field opted out of the house style, and `.passthrough()` made -that invisible. +The lane reproduced it rather than describing it: -### #1934 — the bug it prevents, on the path it does not cover +```json +{"responseItem":{"type":"custom_tool_call","name":"exec"}, + "replayedCall":{"name":"exec","customWireName":"exec"}} +``` + +Fixed by rebuilding the namespace from the request's own tool catalog at parse +time. `function_call` items were never affected — they carry `namespace` on the +wire, which is exactly why the gap was easy to miss. -Namespaced identity is keyed by flattened wire name; freeform identity is still -keyed globally by bare name (`src/server/responses/collaboration.ts:127-134`). -With a flat function `exec` and a namespaced custom `mcp__custom/exec`, a call -to the **flat** `exec` returns as `custom_tool_call`. One namespace changes an -unrelated tool's result kind — which is the exact class of failure this PR -exists to stop. +One subtlety worth recording: the reserved `functions` namespace must stay +flattened, because `buildTools` deliberately drops it. Reconstructing a +namespace there would invent one the request never advertised and break the +mapping in the other direction. Both directions are pinned; removing the +reconstruction fails the first test and nothing else. -The existing collision test only covers namespaced-vs-namespaced, where both -calls have map entries, so the flat case slips past it. +Pushed as `135872d25` to `jenfonro/opencodex`. -### #2080 — asserted capability on a paid surface +### #2080 — a definite price for an outcome nobody observed -Design is sound (no route pinning, canonical-base-URL restriction, observed -`service_tier`, costs as lower bounds). But `registry.ts:1372-1380` simply -*declares* Fast true for three IDs, the positive test reads that same map back -and asserts the same literals, and the attached evidence is three **seeded** -local requests. +An assumed Fast attempt reported the standard total with no uncertainty marker +(`src/usage/cost.ts:367`, `:438`, `:457`, `:478`). OpenRouter bills by the tier +actually served and documents priority as more expensive, so the UI was shown a +definite cost for a request that may have been billed at a premium. -Being wrong costs the user money. That is the line between a nit and a blocker -here, and it is the same unresolved finding as #2072. +The confirmed case was already treated as a lower bound, because the premium +endpoint price is not bundled here. The assumed case needed the same marker for +a stronger reason: **the outcome itself was never observed.** Same treatment, +different justification — and that distinction is the whole finding. -### What this does to the split plan +Route pinning turned out to be a non-issue: the adapter writes `service_tier` +independently and preserves any existing `provider.order`, `provider.only`, and +`allow_fallbacks` (`src/adapters/openai-chat.ts:1308`). -wp6 existed to clear the overlap set before the split rewrites `types.ts` and -`config.ts`. Zero of three landed, so **that debt does not get paid this -cycle** and the split proceeds with the overlap intact. +Pushed as `e1ef7942b` to `olddonkey/opencodex`. -That is the right trade. The roadmap's argument for landing these first was to -spare their authors a rebase — a courtesy, not a correctness constraint. -Merging three defective PRs to avoid inconveniencing three authors would be a -bad exchange, and one of the three (#2080) would have started charging users a -priority rate on an unverified assumption. +### The overlap debt is paid -Consequence to carry into wp7-wp9: when the split lands, #2112, #1934, and -#2080 will need a rebase onto the new leaves. Their authors should be told that -when the blockers are resolved, so the rebase is not a surprise. +wp6 existed to land the PRs touching `src/types.ts` and `src/config.ts` before +the split rewrites those files. All three landed, so their authors will not be +handed a rebase onto leaves that did not exist when they wrote the code. + +Two of the three were bug fixes (#2112 closes issue #2106), which is why the +courtesy argument was never the real one: leaving them unmerged costs users, not +just contributors. ### Split-stack state entering wp7 -This loop's own merges moved `dev` 29 commits ahead of all three split -branches. They were rebased and CI-green earlier today; that greenness is now -stale. +This loop's own merges moved `dev` well ahead of the split branches. They were +rebased and CI-green earlier today; that greenness is now stale. | PR | Base | Head | Behind dev | Blocking checks | |---|---|---|---|---| -| #2019 | `dev` | `35990f6ea` | 29 | hygiene, enforce-target | -| #2023 | `codex/split-wp1-types` | `874598bd3` | 29 | hygiene, enforce-target | -| #2036 | `dev` | `6c6925a4d` | 29 | hygiene, enforce-target | +| #2019 | `dev` | `a2eb3c30c` | 13 | hygiene, enforce-target | +| #2023 | `codex/split-wp1-types` | `874598bd3` | 42 | hygiene, enforce-target | +| #2036 | `dev` | `6c6925a4d` | 42 | hygiene, enforce-target | + +#2019 is only 13 behind because its head already moved once during this loop; +the other two carry the full drift. So wp7 starts by re-doing what wp2 of the earlier campaign did: rebase, re-run, re-verify. That is not wasted work — it is the cost of a stack sitting behind an From d697e2553eb83181fd81efbc5cdba5ed84a10433 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:28:08 +0900 Subject: [PATCH 076/121] fix(config): validate codexToolMode instead of passing it through The field landed with #2112 in the TypeScript interfaces only. providerConfigSchema never declared it and ends in .passthrough(), so an undeclared key survived verbatim: codexToolMode: "shel" was accepted, persisted, and then silently resolved to the code_mode_only default. The operator asked for shell mode, got code mode, and was told nothing. Every neighbouring enum in that schema is validated -- apiKeyTransport, upstreamHttpVersion, codexAccountMode. This one had opted out, and passthrough made that invisible. The regression drives red: deleting the enum line fails the new test and nothing else. --- src/config.ts | 5 +++++ tests/config.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/config.ts b/src/config.ts index 9f590e7323..60178f3d4f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -728,6 +728,11 @@ const providerConfigSchema = z.object({ .optional(), retryOn429: retryOn429PolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), + // Validated rather than passed through: this schema ends in `.passthrough()`, so an + // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be + // accepted, persisted, and then silently resolved to the `code_mode_only` default — the + // operator asked for shell mode, got code mode, and was told nothing (#2106). + codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), diff --git a/tests/config.test.ts b/tests/config.test.ts index f05f107a8e..4f86db8743 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -801,6 +801,34 @@ describe("opencodex config defaults", () => { } }); + test("accepts both codexToolMode values and rejects a misspelled one (#2106)", () => { + for (const codexToolMode of ["code_mode_only", "shell"] as const) { + writeConfig({ + port: 12345, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", codexToolMode }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().config.providers.custom.codexToolMode).toBe(codexToolMode); + expect(readConfigDiagnostics().error).toBeNull(); + } + + // The regression this guards: `providerConfigSchema` ends in `.passthrough()`, so an + // undeclared key survives verbatim. Before the enum was declared, "shel" was accepted, + // persisted, and then silently resolved to the `code_mode_only` default — the operator + // asked for shell mode, got code mode, and was told nothing. + writeConfig({ + port: 12345, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", codexToolMode: "shel" }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("codexToolMode"); + }); + test("accepts the exact responsesItemIdRepair shape and rejects the old nested placeholderIds proposal", () => { writeConfig({ port: 12345, From b84f8e55b4df0ce0d620e67f0c42048672b886fb Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:28:59 +0900 Subject: [PATCH 077/121] docs(devlog): record the split-stack landing and the blocker that survived its hold --- .../080_merge_loop_ledger.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 0c5c48178a..0bd47439f5 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -482,3 +482,37 @@ is stripped on every new head (`pr-hygiene.yml:102`), so it must be applied raw `startsWith("gpt-5.6")`. - No scope creep; no ordering conflict with `stripUnsupportedForwardParams` (disjoint keys). +## wp7-wp9 — the split stack landed, and one blocker survived + +Outcome: **DONE**, bottom-up and in order. + +| PR | Merge commit | +|---|---| +| #2019 WP1 value helpers | `da86a830a` | +| #2023 WP1b type-only barrel | `2235f456d` | +| #2036 WP2a config leaf | `eca18d0c8` | + +All three are ancestors of `origin/dev` in dependency order — parent before +child before the independent leaf, exactly as ancestry required. + +### A blocker that survived its own hold + +wp6 held #2112 for a specific reason: `codexToolMode` existed only in the +TypeScript interfaces, never in `providerConfigSchema`, and that schema ends in +`.passthrough()`. It merged anyway (`dbe260131`) with the other two overlap PRs, +which did get follow-up fixes (`135872d25` for the #1934 namespace leak, +`e1ef7942b` for the #2080 assumed-priority cost). This one did not. + +Verified on the landed tree rather than assumed: `grep -c 'codexToolMode' +src/config.ts` returned **0**, while `apiKeyTransport`, `upstreamHttpVersion`, +and `codexAccountMode` are all validated enums in that same schema. + +Fixed on `dev` as `d697e2553`: `codexToolMode` is now a declared +`z.enum(["code_mode_only", "shell"])`, with a regression that drives red when +the enum line is deleted (152 pass / 1 fail) and green with it (153 / 0). + +**The lesson is about hold hygiene, not about this field.** Three PRs were held +with three blockers; two were fixed and one was not, and the merge did not +distinguish between them. A hold is only worth what the re-check before merge is +worth — and `.passthrough()` is exactly the kind of defect that leaves no trace +at the merge boundary, because nothing fails. From 8f0c1e67499950f2c5e3a93351b057f731a7a481 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:30:44 +0900 Subject: [PATCH 078/121] test(types): pin the split barrel to re-export identity, not copies The hygiene exception for the types split argued that a pure move's oracle is tsc plus the ~400 files importing through the barrel. That holds for the types, which are erased -- a wrong one fails compilation. It does not hold for the runtime values. No test imported src/types/tools.ts or src/types/wire.ts directly, so barrel and leaf were never compared to each other. A barrel that re-declared a value instead of re-exporting it would pass every existing suite. Demonstrated: forking MODEL_ADAPTER_OVERRIDE_ALLOWED into a second Set in the barrel leaves tsc --noEmit at exit 0. Two Set instances where the code assumes one is the singleton-forking hazard the split risk assessment listed as a MEDIUM program risk and left to review greps. Reference identity is the right assertion: an ESM re-export binds the same object, so toBe passes for a real re-export and fails for a copy or a wrapper. Driven red -- the forked Set fails exactly two assertions and nothing else. --- tests/types-barrel-identity.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/types-barrel-identity.test.ts diff --git a/tests/types-barrel-identity.test.ts b/tests/types-barrel-identity.test.ts new file mode 100644 index 0000000000..7508d45402 --- /dev/null +++ b/tests/types-barrel-identity.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import * as barrel from "../src/types"; +import * as tools from "../src/types/tools"; +import * as wire from "../src/types/wire"; + +/** + * The mega-file split turned `src/types.ts` into a re-export barrel. Its correctness + * argument was "tsc plus the ~400 files that import through it", which is true for + * TYPES — they are erased, so a wrong one fails compilation. + * + * It is not true for the runtime values. A barrel that copied an implementation, wrapped + * one, or re-declared a `Set` would typecheck cleanly and pass every existing suite, + * because no test imported a leaf directly — every import went through the barrel, so + * barrel and leaf were never compared to each other. + * + * A forked `MODEL_ADAPTER_OVERRIDE_ALLOWED` is the concrete hazard: two `Set` instances + * where the code assumes one, diverging the moment anything mutates or identity-checks it. + * The original split risk assessment named singleton forking as a MEDIUM program risk and + * relied on review greps to catch it. This is that check, mechanised. + * + * Reference identity is the right assertion: an ESM re-export binds the same object, so + * `toBe` passes for a genuine re-export and fails for a copy, a wrapper, or a re-declaration. + */ +describe("types barrel re-exports the leaves by identity, not by copy", () => { + test.each([ + "namespacedToolName", + "toolChoiceAliases", + "toolChoiceCandidates", + "toolAllowedByChoice", + "resolveToolChoiceWireName", + "modelInList", + "isAllowedToolChoice", + "toolChoiceToolPredicate", + ] as const)("types/tools %s is the same binding", name => { + expect(barrel[name]).toBe(tools[name]); + }); + + test.each([ + "UPSTREAM_HTTP_VERSION_VALUES", + "REASONING_SUMMARY_DELIVERY_VALUES", + "OPENAI_PROVIDER_TIER_VERSION", + "MODEL_ADAPTER_OVERRIDE_ALLOWED", + "captureWireAdapterHardPins", + "isWirePinnedModel", + "pinnedWireAdapter", + ] as const)("types/wire %s is the same binding", name => { + expect(barrel[name]).toBe(wire[name]); + }); + + test("the wire allowlist is one Set, not two", () => { + // Called out explicitly because it is the only mutable-shaped value in the moved set, + // and a fork here is invisible to every other assertion in the repository. + expect(barrel.MODEL_ADAPTER_OVERRIDE_ALLOWED).toBe(wire.MODEL_ADAPTER_OVERRIDE_ALLOWED); + expect([...barrel.MODEL_ADAPTER_OVERRIDE_ALLOWED]).toEqual([...wire.MODEL_ADAPTER_OVERRIDE_ALLOWED]); + }); + + test("every runtime value the leaves export is reachable from the barrel", () => { + // Guards the other direction: a leaf can grow a new export that the barrel forgets to + // re-export, which no consumer notices until one tries to import it from the barrel. + for (const [leafName, leaf] of [["tools", tools], ["wire", wire]] as const) { + for (const [name, value] of Object.entries(leaf)) { + if (typeof value !== "function" && typeof value !== "object" && typeof value !== "number") continue; + expect({ leaf: leafName, name, present: name in barrel }).toEqual({ leaf: leafName, name, present: true }); + } + } + }); +}); From f788affecc6e50303cbd201fae7ac4b6a178b708 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:31:13 +0900 Subject: [PATCH 079/121] docs(devlog): record that the hygiene-exception argument was wrong --- .../080_merge_loop_ledger.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md index 0bd47439f5..f8785f69a5 100644 --- a/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md +++ b/devlog/_plan/260819_next_roadmap/080_merge_loop_ledger.md @@ -516,3 +516,35 @@ with three blockers; two were fixed and one was not, and the merge did not distinguish between them. A hold is only worth what the re-check before merge is worth — and `.passthrough()` is exactly the kind of defect that leaves no trace at the merge boundary, because nothing fails. + +### The exception argument was wrong, and the review proved it + +A lane reviewing `#2019` was asked to judge whether the +`test-exception-approved` argument actually held. Its answer: **no**, and it +named the cheap test that would have caught the plausible mistake. + +The exception said a pure move's oracle is `tsc` plus the ~400 files importing +through the barrel. That is true for the **types** — they are erased, so a wrong +one fails compilation. It is false for the **runtime values**, and nobody had +separated the two cases. + +No test imported `src/types/tools.ts` or `src/types/wire.ts` directly. Every +import went through the barrel, so barrel and leaf were never compared to each +other. A barrel that re-declared a value instead of re-exporting it would pass +every suite in the repository. + +Demonstrated rather than argued: forking `MODEL_ADAPTER_OVERRIDE_ALLOWED` into a +second `Set` inside the barrel leaves `tsc --noEmit` at **exit 0**. Two `Set` +instances where the code assumes one — the singleton-forking hazard the original +split risk assessment listed as a MEDIUM program risk and left to *review greps*. + +Added as `8f0c1e674`: `tests/types-barrel-identity.test.ts` asserts reference +identity between barrel and leaf for all 15 moved runtime values, plus a +both-directions reachability check. An ESM re-export binds the same object, so +`toBe` passes for a genuine re-export and fails for a copy, a wrapper, or a +re-declaration. Driven red: the forked `Set` fails exactly two assertions. + +**"No test is possible" was a claim, not a fact.** It survived three campaigns +of this document asserting it — including one where I wrote that a barrel test +"restates the compiler". It does not. It states something the compiler cannot +see. From 7c23b5a93ce6f32ea4d913d0e4e9cfe3deb33b10 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:43:09 +0900 Subject: [PATCH 080/121] fix(service): bake outbound proxy env into installed service definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A service manager does not inherit the environment of the shell that installed it, and ExecStart runs /bin/sh -lc — dash on Ubuntu/WSL, which reads .profile rather than .bashrc where proxy exports usually live. A user who needs a proxy to reach the upstream therefore got a service that dialed direct: the socket was reset, the retry budget drained, and the request surfaced as 502 Provider unreachable. The same install driven through ocx codex-shim worked, because that path spawns with { ...process.env } — which is what made the report look like a WSL networking problem rather than a service-definition gap. Resolve HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY (either case) at install time and bake them into all three builders: the systemd unit, the launchd plist, and the Windows wrapper. Each builder already drops falsy values, so an unset key produces no assignment rather than an empty one. Only the canonical upper-case name is written, so a definition never carries two spellings of the same setting. Closes #2107. --- src/service.ts | 30 +++++++++++++++++++++++++++ tests/service.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/service.ts b/src/service.ts index 8ba2b417b1..4256820ee2 100644 --- a/src/service.ts +++ b/src/service.ts @@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from import type { BunRuntimeSource } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; import { serviceApiTokenFilePath } from "./lib/service-secrets"; +import { PROXY_ENV_KEYS } from "./lib/proxy-env"; import { randomUUID } from "node:crypto"; import { ELEVATION_REQUEST_TIMEOUT_MS, @@ -404,6 +405,8 @@ export function buildPlist(): string { codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, + ...resolvedProxyEnv().map(({ name, value }) => + ` ${name}${plistString(value)}`), ].filter((line): line is string => Boolean(line)).join("\n"); const command = buildServiceShellCommand(bun, cli); return ` @@ -640,6 +643,31 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined): return `Environment=${systemdQuote(`${name}=${value}`)}`; } +/** + * Outbound proxy settings the installing shell had, resolved for baking into a service + * definition. + * + * A service manager does not inherit the environment of the shell that installed it, and + * `ExecStart=/bin/sh -lc` is dash on Ubuntu/WSL — login dash reads `.profile`, not + * `.bashrc`, which is where proxy exports usually live. So a user who needs a proxy to + * reach the upstream got a service that dialed direct: the socket was reset, the retry + * budget drained, and the request surfaced as `502 Provider unreachable` (#2107). The + * same install driven through `ocx codex-shim` worked, because that path spawns with + * `{ ...process.env }`. + * + * Lower-case variants are honored because curl-style tooling sets them and the runtime's + * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical + * upper-case name is baked, so a definition never carries two spellings of one setting. + */ +function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { + const resolved: { name: string; value: string }[] = []; + for (const key of PROXY_ENV_KEYS) { + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + if (value) resolved.push({ name: key, value }); + } + return resolved; +} + function systemdOutputTarget(value: string): string { // StandardOutput/StandardError use output specifiers such as append:/path. // Quoting the full specifier makes systemd reject it as an invalid output target. @@ -1531,6 +1559,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), + ...resolvedProxyEnv().map(({ name, value }) => windowsBatchSet(name, value)), windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), windowsBatchSet("OCX_BUN", bun, "path"), @@ -2430,6 +2459,7 @@ export function buildUnit(): string { codexHome, codexSqliteHome, opencodexHome, + ...resolvedProxyEnv().map(({ name, value }) => systemdEnvironmentAssignment(name, value)), ].filter((line): line is string => Boolean(line)).join("\n"); return `[Unit] Description=OpenCodex Proxy Server diff --git a/tests/service.test.ts b/tests/service.test.ts index d4e6b17465..75d570f091 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -110,6 +110,54 @@ describe("systemd service unit", () => { expect(unit).not.toContain('StandardError="append:'); }); + test("bakes outbound proxy env into the unit so the service is not cut off from upstream (#2107)", () => { + // systemd does not inherit the installing shell's environment, and ExecStart runs + // /bin/sh -lc — which is dash on Ubuntu/WSL and reads .profile, not .bashrc. A user + // whose proxy lives in the shell therefore gets a service that dials upstream direct, + // the socket is reset, and the request surfaces as 502 Provider unreachable. + const saved = { ...process.env }; + try { + process.env.HTTP_PROXY = "http://127.0.0.1:7890"; + process.env.HTTPS_PROXY = "http://127.0.0.1:7890"; + process.env.NO_PROXY = "localhost,127.0.0.1"; + delete process.env.ALL_PROXY; + + const unit = buildUnit(); + expect(unit).toContain('Environment="HTTP_PROXY=http://127.0.0.1:7890"'); + expect(unit).toContain('Environment="HTTPS_PROXY=http://127.0.0.1:7890"'); + expect(unit).toContain("NO_PROXY="); + // An unset key must not produce an empty assignment. + expect(unit).not.toContain('Environment="ALL_PROXY="'); + + const plist = buildPlist(); + expect(plist).toContain("HTTP_PROXYhttp://127.0.0.1:7890"); + expect(plist).not.toContain("ALL_PROXY"); + } finally { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } + }); + + test("omits proxy env entirely when the installing shell has none (#2107)", () => { + const saved = { ...process.env }; + try { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) delete process.env[key]; + + const unit = buildUnit(); + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]) { + expect(unit).not.toContain(`${key}=`); + } + } finally { + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) { + if (saved[key] !== undefined) process.env[key] = saved[key]; + } + } + }); + test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; From 3fda507490e280ca2ff34c600d3f8fa7fadc48a9 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:44:21 +0900 Subject: [PATCH 081/121] docs(devlog): carry the unclaimed-bug selection unit onto the fix branch --- .../000_investigation.md | 308 ++++++++++++++++++ .../010_ranking.md | 249 ++++++++++++++ .../020_2114_systemd_bus.md | 239 ++++++++++++++ .../030_2107_service_proxy_env.md | 93 ++++++ .../040_2108_windows_reboot_gate.md | 127 ++++++++ .../050_1587_deferred_catalog.md | 124 +++++++ .../060_1933_tray_encoding.md | 99 ++++++ .../070_sequencing.md | 113 +++++++ .../075_verification.md | 168 ++++++++++ .../080_outcome.md | 95 ++++++ 10 files changed, 1615 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/075_verification.md create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md b/devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md new file mode 100644 index 0000000000..0c5b92dc6e --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/000_investigation.md @@ -0,0 +1,308 @@ +# 000 — Unclaimed bug issues: investigation + +Date: 2026-08-19. Scope: open `bug` issues with **no open PR planning to close +them**, for work starting after stage 3d of `260819_next_roadmap/070`. + +## Candidate derivation (re-derived live, not copied) + +Method: list every open `bug` issue, then scan every open PR's title+body for +`#NNNN` references and subtract. + +``` +open bug issues (19): + 1049 1225 1419 1527 1587 1688 1730 1852 1924 1933 + 1939 2047 2074 2092 2097 2106 2107 2108 2114 + +referenced by some open PR: + 1225(#2041) 1527(#2054) 1688(#2032) 1852(#1876) 1924(#2027) 1939(#2029) + 2047(#2056,#2062) 2074(#2082) 2092(#2099) 2097(#2101) 2106(#2112) + +first pass => UNCLAIMED (8): 1049 1419 1587 1730 1933 2107 2108 2114 +``` + +Timestamp of the derivation: `2026-08-19T11:45:42Z`. + +### Correction: reference-counting is not claim-counting (9, not 8) + +An audit lane re-derived this independently and returned PARTIAL. Nothing was +missing from the union, but **one exclusion was wrong**. + +A `#NNNN` in a PR body proves a *mention*, not an intent to close. PR **#2054** +mentions #1527 and then says, verbatim: + +> Does not close #1527. ... Refs #1527 (residual) + +Its purpose is Cursor conversation-checkpoint reuse, which relieves some of the +token pressure behind #1527 but is not the reported failure (large-context +turns collapsing or rate-limiting while direct Cursor stays healthy). The +author explicitly reserved the residual. + +**`#1527` is therefore still unclaimed. The set is 9, not 8.** + +The other ten pairs were each read individually and are strong — every one is +"Fixes/Closes/Implements #N" with a diff whose whole purpose is that issue: +#2041→1225, #2032→1688, #1876→1852, #2027→1924, #2029→1939, #2056 and #2062 +both→2047, #2082→2074, #2099→2092, #2101→2097, #2112→2106. + +**Method note worth keeping.** The cheap derivation (scan for `#NNNN`, +subtract) is a *starting* filter, not the answer. It over-excludes exactly +where an author was being honest about scope — which is the opposite of what a +triage pass should punish. Any future run of this must read the referencing PR +and ask whether it intends to close the issue. + +## Method + +Eight read-only subagent lanes, one per candidate. Each was told to read the +full issue thread, locate the responsible code in the current tree, and report +a mechanism with `file:line` — or say CANNOT-DETERMINE rather than guess. + +Two lanes had to be re-dispatched (the first batch went silent past three wait +cycles, DISPATCH-RETIRE-01). One candidate, `#1587`, ended up with two +independent lanes, which turned out to be useful: they agreed on the mechanism +and one of them produced a measurement the other did not. + +## Findings + +### #2114 — native-main 503 when systemctl exists but the user bus does not + +**Mechanism (confirmed, two independent lanes).** `inspectSystemd()` maps only +`spawnFailed` to `absent`. Any non-zero `systemctl --user show` exit becomes +`unknown`: + +``` +src/service-manager-probe.ts:267 if (shown.spawnFailed) return { kind: "absent" }; // #1612 fix +src/service-manager-probe.ts:269 if (shown.status !== 0) return unknown(...) // this bug +``` + +That `unknown` then closes native traffic for the life of the process: +`ownership-preflight.ts:155` → `ownership: "unknown"` → `server/index.ts:702` +→ `blockNativeMainStartupForUnownedServiceHome` → `auth-context.ts:313` → +`CodexMainProfileDrainingError` → 503. + +**Why #1612 missed it.** #1612 covered *spawn* failure — `systemctl` not on +PATH. Here spawn succeeds and the bus is unreachable, so the escape hatch does +not apply. + +**Regression, in two steps.** `a2e4fcf47` (2026-08-11) made +`ownership: unknown` block native-main at all; `bb45902ef` (2026-08-15, #1612) +relieved only the spawn branch. This environment has been broken since the +fence shipped. + +**Blast radius.** Linux only, Codex integration enabled. Any host where +`systemctl` is present but the user bus is not: systemd-containing Docker / +devcontainer images under tini, and plausibly WSL without `systemd=true`. +Affected users get **100% native-OpenAI failure**, not degradation. + +**Workaround.** Remove `systemctl` from the proxy's PATH — verified by the +reporter as a single-variable control. Setting `XDG_RUNTIME_DIR` does not help. + +**Evidence.** Strong. Exact stderr, exit code, single-variable isolation, and a +traced chain that matches the source line for line. The repo currently **pins +the bug**: `tests/codex-service-manager-probe.test.ts:277` asserts that +`status: 1, stderr: "Failed to connect to bus"` is `unknown`. + +### #2108 — Windows reboot leaves the native-main gate stuck + +**Mechanism (partially determined — and the lane was right to say so).** The +503 is the same process-wide fence as #2114, but the *trigger* is not logged, +so two candidates remain: + +1. **Owner ACL fail-closed.** A second `ETIMEDOUT` in the icacls hardening is + terminal (`native-main-owner.ts:272`), and `observeOwner()` settles the gate + to `owner-unavailable` and stops (`native-profile-startup.ts:227`). The ACL + module's own comment already records this exact symptom. The reporter's first + 503 is ~74s after wrapper start, past the ~60s owner budget. +2. **Probe fail-closed.** `SERVICE_PROBE_TIMEOUT_MS` is 2000ms. A + scheduler-only install still runs `sc.exe query` for WinSW; if that times out + with the WinSW assets absent, `walkWinswChain()` returns `unknown` instead of + `absent`. + +**The important structural finding:** `startServer()` takes a **one-shot** +ownership verdict and never retries it (`server/index.ts:702-710`). That is why +the gate stays closed until `ocx restart` and why waiting does not help. + +**What it is not.** The lane disproved two plausible readings: the +"did not shut down cleanly" log line is the *injection* journal +(`codex/journal.ts:209`), not the native-profile journal; and disk +`manual-recovery` residue would survive a restart, which contradicts the +reporter's restart-cures-it observation. + +**Relationship to #2114.** Same fence, different trigger. #2114 is deterministic +and restart does not help; #2108 is transient and restart does help. They share +the *unknown → permanent fence* layer, which is the reusable fix. + +### #2107 — WSL 502 after service install + +**Mechanism (confirmed, and it is not what the title suggests).** This is +**not** the #2108 gate and **not** a WSL loopback problem. Codex reached +OpenCodex fine; OpenCodex could not reach ChatGPT. + +`buildUnit()` (`service.ts:2418-2444`) bakes `OCX_SERVICE`, Bun provenance, +`PATH`, `CODEX_HOME`, `CODEX_SQLITE_HOME`, `OPENCODEX_HOME` — and **no proxy +variables**. systemd does not inherit the installing shell's environment, and +`ExecStart=/bin/sh -lc` is dash on Ubuntu WSL, which reads `.profile`, not +`.bashrc`. So a user whose proxy lives in `.bashrc` gets a service that talks +to ChatGPT directly, and the socket is reset → `fetchWithResetRetry` exhausts → +502 `Provider unreachable`. + +The distinguishing evidence is the status code itself: #2108 is **503** with +the native-main string; this is **502** with `recoveryKinds: ["connection-reset"]`. + +**Same hole in launchd and the Windows wrapper** (`service.ts:392-407`, +`1516-1533`), though Windows logon tasks often already carry user env. + +**Regression.** No — `git log -S HTTP_PROXY -- src/service.ts` is empty. This +has always been true; it only shows up when a proxy is required. + +### #1933 — Windows tray registration reported foreign/stale + +**Mechanism (confirmed).** Not a missing-file problem despite the title. The +title is a *collapsed summary string*, and the real cause is a text encoding +bug. + +`runRegistry`/`runRegistryAsync` decode `reg.exe` output with +`encoding: "utf8"` (`src/tray/windows.ts:120-125`, `335-345`). Redirected +`reg query` emits the console ACP, not UTF-8. The reporter's username is +`MötzJensen`; `ö` is `0xF6` in Windows-1252 and decodes to `U+FFFD`. The +round-trip comparison `registered === state.runCommand` then fails, and +`registrationOwned` goes false → the stale summary. + +**This is a known class with an existing fix that was never wired here.** +`decodeWindowsTextBytes` (`src/lib/windows-text.ts:75`) already solves exactly +this for `schtasks` (#1573, with a `C:\Users\Jörg` fixture). The tray reader was +missed. + +**Blast radius.** Windows users with non-ASCII in the profile path or +`OPENCODEX_HOME` on a non-UTF-8 ACP. Also blocks the GUI repair path: Install +is hidden when `tray.stale`, and uninstall refuses on a mismatched parse. + +### #1587 — routed first-turn tool catalog is 3-5x native + +**Mechanism (confirmed by two independent lanes).** `buildTools()` +(`src/responses/parser.ts:155`) never reads Codex's `defer_loading` flag. +`pushFn` and the namespace flattener copy every tool's full `parameters` into +`OcxTool`, and the flag is not on the type, so it is gone by parse time. The +routed adapters then serialize all of them (`openai-chat.ts:1197`, +`anthropic.ts:740`, `google.ts:270`). + +The native path is asymmetric **on purpose**: `openai-responses.ts:406` +preserves `defer_loading` and strips it only when a `tool_search_output` +actually loads the tool. + +**Measured, not asserted.** One lane ran this tree's real `parseRequest` +against a captured Codex Desktop catalog: + +| Sample | Deferred tools | Deferred bytes | After parse | +|---|---|---|---| +| 2026-08-12 rollout | 8 of 8 | 32,927 / 34,404 (**95.7%**) | all 8 emitted with full schemas, 32,887 bytes (~8.2k tokens), zero defer flags surviving | +| second sample | 4 namespaces / 10 tools | 24,227 bytes (~6.1k tokens) | same | + +**A caveat both lanes raised.** The headline "3-5x" is not a clean byte +multiplier: the thread's numbers compare *different tokenizers* (OpenAI vs Kimi +vs Claude) and the Opus row also carried a repo `AGENTS.md`. The mechanism is +real and measured; the exact ratio is not. + +**Regression.** No. Flattening was added 2026-06-19 so chat models could call +MCP tools; the routed path never honored deferral. + +### #1730 — Camel DeepSeek V4 Flash first-round tool call + +**Already half-fixed, and the reporter withdrew the rest.** The shared +conversion half — every converted custom tool getting a generic +`input.description`, which broke `exec` — was fixed by `ea0608611` (#1763), an +ancestor of the current head. The current tree special-cases `exec` at +`custom-tool-compat.ts:73`. + +The remaining claim (a first-round structured-tool miss) is **CANNOT-DETERMINE +as an OpenCodex defect**: there is no Camel code in the tree, the passthrough +forwards the client's `tool_choice` unchanged, and the reporter's own local +`required` patch proves the *model* will tool-call when forced — not that we +dropped a call. The reporter later attributed it to a config error (Responses +override against a Chat Completions host) and asked to close. + +**Proposed action: close as reporter-withdrawn.** Do not implement the +suggested `stream.camelai.com` + `deepseek-v4-flash` hardcode: a hostname/model +special case changing tool-selection semantics for every user of that route, +with no public contract and the reporter now opposing it. + +### #1419 — macOS Bun SIGTRAP after TLS failure + +**CANNOT-DETERMINE as our defect, and the lane was right to refuse.** The crash +is a native `EXC_BREAKPOINT` in Bun after a TLS handshake failure. +`installCrashGuards()` only hooks `unhandledRejection`/`uncaughtException` +(`crash-guard.ts:332`), so a native trap never reaches JS — which matches the +reporter seeing no `crash.log`. `unknown certificate verification error` is a +Bun string, not ours. + +**Bundled Bun is still 1.3.14** (`package.json:65`), and upstream's latest +release is still `bun-v1.3.14`, so there is nothing to bump into. #1691's Bun +1.4 train is blocked for the same reason. + +**One real, separable gap the lane found:** `ocx gui` spawns the proxy +detached and unsupervised (`cli/dispatch.ts:255`), while launchd `KeepAlive` +exists only for `ocx service`. That is a *survivability* fix we own, and it is +testable, unlike the trap. + +### #1049 — adopt pre-substrate Codex homes into the write coordinator + +**Still real; the substrate did not make it moot — it created the leftover +class.** `codexWriteCoordinationEligibility` returns `legacy-uncoordinated` +when there is no coordinator file and residue is not `clean` +(`inject-coordination.ts:46`), and inject/restore then write directly. The +specified adoption path is entirely absent: `adoption-pending` matches **zero** +times in `src/` and `tests/`, and the live schema CHECK does not include it. + +**Important for scheduling:** the lane split this into two phases and warned +that phase 2 is the invasive one — a wrong publish can corrupt the user's Codex +home, and Windows needs a real no-replace primitive rather than a POSIX +hardlink. It is also **not a field incident**: no user logs, no crash. The +`bug` label here marks a known gap, not a live failure. + +### #1527 — Cursor large-context collapse (added after the candidate correction) + +Investigated once the audit established that #2054 does not claim it. + +**What #2054 actually covers.** Process-local checkpoint reuse, so validated +linear follow-ups send `continuationMode=checkpoint` with `rootBytes=0` instead +of rebuilding history. Its own body says "Not run: #1527 large-context / 429 / +kimi-k3", and its live-transport change is capture-only. + +**Residual after it lands — five items, and they are not one bug.** + +1. `kimi-k3` premature completion at ~79-95k input: HTTP 200 with 4-36 output + tokens while direct `cursor-agent --model kimi-k3` produces ~10k at the same + scale. Not re-run on the checkpoint branch. +2. `claude-fable-5` 429 asymmetry vs direct Cursor. Unprovable either way today: + Connect does not expose `cache_read_tokens`, so usage stays estimated and + `cached_tokens: 0` cannot distinguish a cache hit from a miss. +3. **Teardown misclassification.** Normal completion never sets + `expectedClose` — only `cancelCursorRun()` does + (`src/adapters/cursor/live-transport.ts:738`) — while the abort listener + unconditionally `failAndClear("Cursor request was aborted")` (`:1157`), and + `"aborted"` is not benign (`cursor-errors.ts:74`). So a turn that already + emitted `turnEnded` still logs `turn-failed` / `expectedClose:false`. +4. First turn, restart, compaction and helper isolation still full-replay into + the 512 KiB / 192-blob envelope (`protobuf-request.ts:70`, `:68`). +5. Request-shape parity with official Cursor (e.g. `maxMode: false` at + `protobuf-request.ts:879`) is untested. + +**The useful finding:** item 3 is small, low-risk, and independently testable — +it is a misclassification in the abort listener, not a context-window mystery. +Items 1 and 2 are acceptance work that cannot start until #2054 lands, and item +2 may not be provable at all without an upstream field. + +**Evidence.** Strong that the OpenCodex Cursor path diverges from direct Cursor +and that abort classification is wrong. Partial that full replay *causes* the +429/kimi-k3 symptoms — #2054 assumes it and did not re-run the workload. + +## Cross-issue observation + +Three of the eight (#2114, #2108, and the ownership half of #1939, which +already has PR #2029) are the **same architectural fault**: a probe that cannot +answer produces `unknown`, and `unknown` is treated as permanent evidence of +foreign ownership for the life of the process. #2114 is the deterministic case, +#2108 the transient one. + +That is worth naming before ranking, because it changes what "fix #2114" means: +the cheap fix is one classification branch, but the shared fix is making the +fence retryable. They are different sizes and different risks. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md b/devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md new file mode 100644 index 0000000000..2bfb39bc13 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/010_ranking.md @@ -0,0 +1,249 @@ +# 010 — Ranking and selection + +Evidence: `000_investigation.md`. Criteria are stated first, then applied. + +> **Revised after audit.** Two corrections landed here: `#1527` was wrongly +> excluded from the candidate set (PR #2054 says "Does not close #1527"), and +> `#2114` is not greenfield — open PR #2029 already edits the same function and +> is `CHANGES_REQUESTED` for the exact hazard this unit rediscovered. Both are +> folded in below. + +## Criteria (stated before the ranking) + +1. **Severity of user-visible breakage.** Total loss of a path outranks + degradation, which outranks cost. +2. **Workaround availability.** A user who is stuck with no way out ranks above + one who has a documented escape. +3. **Blast radius.** Platform reach and what fraction of that platform's users + can hit it. +4. **Regression or long-standing.** A path that *used to work* and now does not + ranks above a gap that was never filled — we broke it, and someone upgraded + into it. +5. **Evidence quality.** Can a fix be written and verified from what is in the + thread today, without waiting on a reporter. +6. **Fix cost and risk.** Cheap and containable outranks invasive, at equal + severity. Risk of *causing* a worse failure counts against. + +Deliberately **not** criteria: issue age, comment count, or how loud the thread +is. Two of the strongest candidates here were filed today. + +## Ranked + +| # | Issue | Sev | Workaround | Radius | Regression | Evidence | Cost/Risk | +|---|---|---|---|---|---|---|---| +| 1 | **#2114** systemd bus | total native loss | yes, obscure | Linux containers/WSL | **yes** | strong | low, but **owned by PR #2029** | +| 2 | **#2107** proxy env | total upstream loss | yes | Linux/WSL behind a proxy | no | partial-strong | low | +| 3 | **#2108** Windows reboot | total native loss | yes, restart | Windows scheduler installs | **yes** | partial | medium | +| 4 | **#1587** deferred catalog | cost, every turn | lossy only | all platforms | no | strong+measured | medium, product risk | +| 5 | **#1933** tray encoding | feature unusable | partial | Windows non-ASCII paths | no | strong | **very low** | +| 6 | **#1527** Cursor residual | collapse at 80k+ | yes, use CLI | Cursor adapter users | no | strong on teardown, partial on cause | split: one small, rest acceptance | +| 7 | **#1049** substrate adoption | none observed | n/a | pre-substrate homes | no | strong mechanism, no incident | **high** | +| 8 | **#1419** Bun SIGTRAP | process death | yes, service | macOS + local TLS proxy | no | partial | not ours | +| 9 | **#1730** Camel | none | yes | one custom provider | no | withdrawn | close, do not patch | + +## Selected: #2114, #2107, #2108, #1587, #1933, and one slice of #1527 + +### Why #2114 is first + +It is the only candidate that scores worst-case on severity **and** regression +**and** evidence at once. A user in a systemd-containing container gets 100% +native-OpenAI failure, the path worked before `a2e4fcf47`, and the only escape +is to notice that hiding `systemctl` from PATH fixes it — which no one will +guess. + +The clincher is that the repository **currently asserts the bug is correct +behavior**: `tests/codex-service-manager-probe.test.ts:277` pins +`status: 1, "Failed to connect to bus"` → `unknown`. That test has to be +amended deliberately, which makes this a decision rather than a patch, and it +is the kind of decision that quietly ages badly if deferred. + +**But "first" means unblocking, not opening.** PR **#2029** (fixes #1939) +already edits `inspectSystemd()` and its test, and is `CHANGES_REQUESTED` +because a reviewer objected that a missing bus is not proof the unit file is +absent — the same fail-open hazard `020` independently arrived at. + +So #2114 and #1939 are one probe-policy decision with two symptoms: a refused +sync and a native 503. Ranking #2114 first is right; treating it as a fresh PR +would mean two people making the same fail-closed call in two places. + +### #1527 — added to the selected set, but only one slice + +It reached the candidate set late, so it is ranked on the same criteria rather +than grandfathered in. The residual after #2054 is five items, and they do not +share a cost: + +- **Take now:** the abort-teardown misclassification. Normal completion never + sets `expectedClose` (`live-transport.ts:738`) while the abort listener + unconditionally fails the turn (`:1157`), so a turn that already emitted + `turnEnded` still logs `turn-failed`. Small, independently testable, and + independent of #2054. +- **Defer:** the kimi-k3 collapse and the 429 asymmetry are **acceptance work** + that cannot start until #2054 lands, and the 429 half may not be provable at + all — Connect does not expose `cache_read_tokens`, so `cached_tokens: 0` + cannot distinguish a cache hit from a miss. + +Splitting it this way is the point: the issue as filed is unfixable in one +step, and one third of it is a clean small fix hiding behind two thirds that +need a live workload. + +### Why #2107 is second despite not being a regression + +Same severity class — the proxy cannot reach upstream at all — and the +mechanism is the cleanest of the eight: `buildUnit()` bakes six environment +variables and no proxy ones. It is a small, well-bounded change to a file we +own, and the same hole exists in the launchd and Windows builders, so one fix +closes three surfaces. + +It ranks below #2114 only because it is long-standing rather than a regression, +and because the affected population needs a proxy in the first place. + +### Why #2108 is third and not first + +Higher-profile platform, and the reporter is a Windows user hitting it on every +reboot. But: the trigger is **not identified** — the lane found two plausible +paths and could not distinguish them because the gate reason is never logged. + +That makes the honest first step *logging the reason*, not fixing a mechanism +we have not confirmed. It also shares the fence layer with #2114, so doing +#2114 first produces the retryable-fence groundwork this one needs. + +### Why #1587 is fourth + +It is the only candidate with a hard measurement: 95.7% of a real captured +catalog was deferred, and all of it was emitted anyway. That is a permanent tax +on every routed first turn for every user with connectors installed. + +It ranks below the three outages because it is cost rather than breakage, and +because the fix has genuine product risk in both directions: strip too much and +routed models lose plugin visibility (the #1522 class), strip too little and +nothing improves. The headline "3-5x" also does not survive scrutiny — the +thread compares three different tokenizers — so the goal should be stated in +bytes we control, not in a ratio. + +### Why #1933 is fifth despite being the cheapest + +The fix is close to trivial: route two `reg.exe` reads through +`decodeWindowsTextBytes`, which already exists and already has a +`C:\Users\Jörg` fixture from #1573. It is fifth only because the tray is not on +the request path — nobody's requests fail because of it. + +It is worth doing precisely *because* it is cheap: it closes a +known-class-missed-a-site bug, and leaving a fixed class half-applied is how +the next one gets missed too. + +## Deferred, with reasons + +## Audit challenge to this ranking, and what changed + +An audit lane argued the ranking is "wrong as a user-harm ordering — it listed +blast radius third, then let evidence and *we can patch today* pick the +winner." Three specific challenges. Two are accepted, one is not. + +### Accepted: #2108 outranks #2114 + +The lane is right. Both are total native loss and both are regressions. Windows +scheduler installs dwarf "Linux host where systemctl is present but the user +bus is not", and #2108 recurs **on every reboot** rather than once at install. +Ranking #2114 first because its trigger is known and a test pins it is +maintainer convenience dressed as impact. + +The "do #2114 first for fence groundwork" argument was also refuted separately +(see `040`): it was a preference, not a dependency. With that gone, nothing +defends the original order. + +**Revised: 1 #2108, 2 #2114, 3 #2107, 4 #1587.** Partial evidence on #2108 is +the reason its phase 1 is *logging*, not the reason to bury it at rank 3. + +### Accepted with a change of shape: #1049 returns as detection-only + +"Integrity bugs are silent; the first report is a corrupted Codex home" is a +better argument than the one this doc made. Waiting for a field incident is the +wrong posture for a data-integrity gap, and `000` already grades the mechanism +as strong. + +But the original deferral was not only about the incident count — the invasive +half can corrupt the thing it protects. Both concerns are satisfied by +splitting it: + +- **In:** phase 1, the atomic no-clobber publish for the ordinary clean + `{0,null}` row, plus refusing to write when adoption state is indeterminate. + That is detect-and-refuse; it reduces risk rather than adding it. +- **Out for now:** phase 2 (`adoption-pending` schema, the native handoff), + which is where the corruption risk lives and which needs a Windows + no-replace primitive we do not have. + +### Not accepted: drop #1933 + +The lane called #1933 "the ranking's worst trade" and would fold it into the +Windows pass. Folding it in is fine. **Dropping it is not**, and the reason is +not severity: + +`decodeWindowsTextBytes` already exists and is already wired into the service +probe. #1933 is that same class at a site that was missed. A class fix left +half-applied is how the *next* site gets missed, and the cost here is two call +sites plus a test that reuses an existing fixture. + +It also is not costless to the user: with a stale tray the GUI offers **no +repair path** (Install hidden, Uninstall refuses), so the person who hits it is +stuck without a documented manual registry edit. + +Accepting the fold: it rides the #2108 Windows pass rather than occupying its +own slot. + +### Final selection + +`#1527` has since been investigated (see `000`), and a fourth audit round found +that `#2114` is not greenfield: open PR **#2029** already edits +`inspectSystemd()` and is `CHANGES_REQUESTED` for the same fail-open hazard +`020` rediscovered. Folding both in: + +``` +1 #2108 Windows reboot gate phase 1 = log the reason; coordinate with PR #2101 +2 #2114 systemd bus UNBLOCK PR #2029 — do not open a parallel PR +3 #2107 service proxy env clean of open work +4 #1527 abort-teardown slice small, independent of #2054 +5 #1587 deferred catalog last: most contested files +6 #1049 phase 1 only atomic publish + refuse-on-indeterminate + #1933 folded into the #2108 Windows pass +``` + +Still deferred: `#1049` phase 2 (schema + native handoff, where the corruption +risk lives), `#1527`'s kimi-k3 and 429 halves (acceptance work that cannot +start until #2054 lands, and the 429 half may be unprovable while Connect hides +`cache_read_tokens`), `#1419` (upstream-blocked), `#1730` (close as withdrawn). + +**This supersedes the header table at the top of this document**, which records +the first-pass ordering before the audit rounds moved it. The table is kept +deliberately — the movement from it to here is the useful part. + +**#1049 — defer, and say why in the issue.** The mechanism is real and well +traced, but there is no field incident behind it: no user logs, no crash, no +report. Meanwhile the lane's phase 2 carries the highest risk in this entire +set — a wrong publish can corrupt a user's Codex home, and Windows needs a real +no-replace primitive rather than a POSIX hardlink. Spending that risk budget on +a gap with no observed failure, while three total-outage bugs are open, is the +wrong trade. Revisit when either a real incident arrives or the split program +has settled and there is appetite for careful substrate work. + +**#1419 — defer as upstream-blocked, keep needs-info.** The trap is inside Bun. +Bundled Bun is 1.3.14 and upstream's latest release is still 1.3.14, so there +is nothing to bump into, and the reporter never supplied the `.ips` frames that +would let us file a useful upstream issue. Do **not** weaken TLS verification to +work around it. + +One separable piece *is* ours and should be split out rather than lost: `ocx +gui` spawns the proxy detached and unsupervised while launchd `KeepAlive` only +covers `ocx service`. That is a survivability fix with a real test, and it is +worth its own small issue instead of riding a crash we cannot reproduce. + +**#1730 — close as reporter-withdrawn.** The half that was ours (`exec` losing +its description in custom-tool conversion) shipped in `ea0608611`. The remaining +claim has no OpenCodex mechanism, and the reporter attributed it to their own +Responses-vs-Chat-Completions misconfiguration and asked to close. The proposed +fix — a `stream.camelai.com` + `deepseek-v4-flash` first-round +`tool_choice: required` hardcode — would change tool-selection semantics for +every user of that route based on one host, with no public contract and the +reporter now opposing it. + +Closing it is a real outcome, not a dodge: it removes a `bug`-labelled issue +that would otherwise keep re-surfacing in triage as unclaimed. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md b/devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md new file mode 100644 index 0000000000..df9cbc2024 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/020_2114_systemd_bus.md @@ -0,0 +1,239 @@ +# 020 — #2114: native-main 503 when systemctl exists but the user bus does not + +**FIRST ACTION: extend or rebase open PR [#2029], do not open a parallel PR.** + +Starts after stage 3d of `260819_next_roadmap/070`. + +## This is not greenfield work — #2029 already owns this function + +An audit lane caught what the candidate filter could not: the filter asked +"does an open PR mention issue #2114", and the answer was no. It never asked +"does an open PR already edit `inspectSystemd()`", and the answer to that is +**yes**. + +``` +PR #2029 fix(probe): classify a missing user session bus as absent + reviewDecision: CHANGES_REQUESTED + files: src/service-manager-probe.ts, tests/codex-service-manager-probe.test.ts +``` + +#2029 fixes #1939 by classifying two D-Bus messages as `absent`, and +**deliberately keeps** `Failed to connect to bus` → `unknown` — which is +exactly the pin #2114 needs changed. It is blocked on a review objection that +this document independently rediscovered and wrote down as "test 3": a missing +bus is not proof that the unit file is absent, so a naive widening fails open +when a foreign unit is still on disk. + +So #2114 and #1939 are **one probe-policy decision with two user-visible +symptoms** — a refused sync (#1939) and a native 503 (#2114). Treating them as +two units means two people making the same fail-closed security-adjacent call +in two PRs, with the second one silently overwriting the first. + +**Consequence for ranking:** the work is real and still first, but it is +"unblock #2029 by supplying the containment its reviewer asked for", not "open +a new PR". The container/foreground gate below is the shape of that answer. + +## Failure mechanism + +> **STOP — read this before planning any work on this issue.** +> +> **Open PR #2029 already rewrites `inspectSystemd()`'s non-zero branch**, and +> it deliberately keeps the #2114 case as `unknown`. This doc was written as if +> that function were unowned. It is not. +> +> What #2029 actually does: +> +> ``` +> + err.includes("Failed to get D-Bus connection: No such file or directory") +> + ... "System has not been booted with systemd" +> + return { kind: "absent" }; +> + return unknown(...) // "other bus failures stay unknown" +> ``` +> +> and it adds a test that **cements the #2114 shape as `unknown`**: +> +> ``` +> + test("other bus failures stay unknown — the user manager may be running", () => { +> + stderr: "Failed to connect to bus: $DBUS_SESSION_BUS_ADDRESS not set", +> + expect(...kind).toBe("unknown"); +> ``` +> +> The #2114 reporter's stderr is +> `Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined` +> — which is exactly the family #2029 is choosing to leave closed. +> +> **Consequence: this is not a fresh patch, it is a conversation with #2029.** +> Either extend that PR's classifier to cover this stderr family, or land it and +> follow up on the same branch. Opening a competing PR means two changes fighting +> over one function, and the later one may silently re-pin the bug. +> +> This also changes the ranking: a fail-closed probe change is not "cheap" when +> an overlapping PR is already open on it. See `010`. + +``` +src/service-manager-probe.ts:267 if (shown.spawnFailed) return { kind: "absent" }; +src/service-manager-probe.ts:269-272 if (shown.status !== 0) { ... return unknown(...) } +``` + +(:269 is the `if`; the `return unknown(...)` is :272.) + +The comment on :270 says a non-zero status means "the question never reached the +bus" — which is exactly right, and is exactly why returning `unknown` is wrong. +A question that never reached the bus is evidence about the bus, not evidence +that a foreign service owns this home. + +From there the verdict is terminal for the process: + +| Step | File | +|---|---| +| `manager.kind === "unknown"` → `ownership: "unknown"` | `src/integrations/native/ownership-preflight.ts:155` | +| not `owned` → `blockNativeMainStartupForUnownedServiceHome("ownership-unknown")` | `src/server/index.ts:702-708` (:702 is the probe call, the block is :706) | +| snapshot blocked → `isNativeMainTrafficBlocked()` true | `src/codex/native-profile-startup.ts:351` | +| throws `CodexMainProfileDrainingError` | `src/codex/auth-context.ts:313`, `:318` | +| 503 `OpenCodex local native-main profile maintenance is active` | `src/codex/auth-context.ts:125-126` | + +## Why the existing #1612 fix does not cover it + +`bb45902ef` mapped **spawn** failure to `absent` — `systemctl` missing from +PATH. Here spawn succeeds and returns exit 1 with +`Failed to connect to user scope bus via local transport`. Same user-visible +outcome, different branch. + +## Fix shape + +**Primary change: one classification branch in `inspectSystemd()`.** + +Match bus-unreachable stderr specifically rather than widening every non-zero +exit, and gate the widening on an environment that already cannot host a user +service. The product already owns that signal and does not pass it to the +probe: `service.ts:3122` refuses service install when `/.dockerenv` exists and +reports `unsupported in Docker`. + +``` +if (shown.status !== 0) { + if (busUnreachable(shown.stderr) && deps.serviceHostingUnsupported()) { + return { kind: "absent" }; + } + return unknown(...) // unchanged for every other case +} +``` + +Thread the signal through `ProbeDeps` — the probe is already injectable +(`ProbeRunner`, `ProbeDeps`), so there is no call-site churn. + +### That snippet is unsafe as written — corrected + +An audit lane found the flaw and it is the important finding of this doc. +**With the bus down, `systemctl` cannot see a foreign unit either.** The +snippet returns `absent` on stderr + container signal alone, with no other +ownership evidence. A temporary bus outage inside a container that *does* host +a user service is exactly the fail-open the risk section warns about — and +test 3 below asserts a behavior the code shape cannot deliver. + +The classification must consult the **filesystem**, which does not need the +bus: + +``` +if (shown.status !== 0) { + if (!busUnreachable(shown.stderr) || !deps.serviceHostingUnsupported()) { + return unknown(...); // unchanged for every other case + } + // The bus could not answer. Ask the disk instead: a unit file is proof of + // installation that does not require a running bus. + const unit = deps.readUnitFile?.(UNIT_PATH); + if (unit === undefined) return { kind: "absent" }; // no unit, no owner + return unitOwnershipFrom(unit); // foreign stays foreign +} +``` + +`inspectSystemd()` already parses `FragmentPath` for the bus-answered path, so +the unit-file reader and the "does this unit name our home" logic exist in some +form; this reuses them on the offline path rather than inventing a second +notion of ownership. + +**Open decisions the implementer must make, which this doc cannot make for +them:** + +- `serviceHostingUnsupported()` **does not exist**. `service.ts:3122` is an + *install-time* `/.dockerenv` check. Whether the probe signal is Docker-only + or the broader "container/foreground" the risk section mentions is unset — + and it matters, because Podman and Kubernetes often have no `/.dockerenv`. +- `busUnreachable()` does not exist, and the locale policy is unchosen: match + strings, ignore stderr entirely, or force `LC_ALL=C` on the probe. +- The unit path constant and reader are not named here. + +**Stderr variants to match.** At minimum +`Failed to connect to user scope bus` and +`$DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined`. #1939 reports a +third shape, `Failed to get D-Bus connection`. Locale sensitivity is a real +weakness of string matching here and should be called out in the PR rather than +papered over; a non-English systemd will not match. If that is unacceptable, +the alternative is to key only on the container/foreground signal and ignore +stderr entirely — narrower, but locale-proof. + +**Files:** `src/service-manager-probe.ts`, `src/integrations/native/ownership-preflight.ts` +(signal plumbing only), `tests/service-probe-docker.test.ts`, +`tests/codex-service-manager-probe.test.ts`. + +**Coordination:** those are the same two files #2029 already changes. Rebase on +it or fold this into it; do not race it. + +## The test that must change, deliberately + +`tests/codex-service-manager-probe.test.ts:277` currently asserts +`status: 1, stderr: "Failed to connect to bus"` → `unknown`. **Amend it, do not +delete it**: keep that assertion for the non-container case, so the widening +stays honest. + +## Regression tests + +Red today, green after: + +1. Container signal set + `systemctl` spawn ok + exit 1 bus error + no unit + file → `{ kind: "absent" }` → `inspectNativeCodexOwnership` `owned` → + native-main not blocked. + +Must stay red (guards against over-widening): + +2. Same stderr, **no** container signal → still `unknown`. +3. Container signal + an installed unit naming a foreign home → still blocked. + +A `startServer` test injecting that probe result should return 503 today and +200 after. + +## Verification + +``` +bun test tests/service-probe-docker.test.ts tests/codex-service-manager-probe.test.ts +bun x tsc --noEmit +``` + +## Risk + +This is a **fail-closed security-adjacent boundary**. The failure mode of a bad +fix is admitting native-main on a host where a genuinely foreign unit exists but +was temporarily unqueryable. The container/foreground gate plus test 3 is what +keeps that closed. Do not widen all non-zero exits. + +**Correction:** the container gate alone does *not* keep that closed — that was +the audit's finding above. The disk check is what keeps it closed; the +container gate only limits where the offline path is taken at all. + +**Coverage limit to state plainly.** Even corrected, this fix only relieves +hosts that hit the container signal. WSL without `systemd=true`, bare SSH +sessions with no `XDG_RUNTIME_DIR`, CI runners, and Podman/k8s without +`/.dockerenv` keep returning `unknown` and keep 503-ing. If those matter, the +answer is #2108 phase 2's retryable fence, not a wider classifier here — which +is an argument for doing that work regardless of this fix. + +## Explicitly out of scope + +Two secondary bugs surfaced in the same thread. Both are real; neither should +ride this fix: + +- `ocx ready` / `/readyz` ignores `isNativeMainTrafficBlocked()` + (`src/server/index.ts:850`), so readiness can read ready while every native + request 503s. +- Codex CLI renders the local 503 as "Selected model is at capacity", because + the code is remapped to `server_is_overloaded` (`src/lib/errors.ts:229`). + The body is correct; the user-facing sentence is not. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md b/devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md new file mode 100644 index 0000000000..4a186d55b4 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/030_2107_service_proxy_env.md @@ -0,0 +1,93 @@ +# 030 — #2107: service unit drops outbound proxy env + +Rank 2. + +## Failure mechanism + +The title says "502 after service install in WSL", and both obvious readings +are wrong. Codex **did** reach OpenCodex; OpenCodex could not reach ChatGPT. + +`buildUnit()` (`src/service.ts:2418-2444`) bakes exactly `OCX_SERVICE`, Bun +provenance, `PATH`, `CODEX_HOME`, `CODEX_SQLITE_HOME`, `OPENCODEX_HOME`. No +`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`. systemd does not +inherit the installing shell's environment. + +`ExecStart=/bin/sh -lc` (`service.ts:457`) makes it worse in a way that is easy +to miss: on Ubuntu WSL `/bin/sh` is dash, and login dash reads `.profile`, not +`.bashrc` — where proxy exports usually live. + +`applyProxyEnv()` (`src/config.ts:3441`) only fills from `config.proxy`, so a +shell-only proxy is invisible to the service. + +Result: outbound TLS goes direct, the socket is reset, +`fetchWithResetRetry` exhausts (`src/lib/upstream-retry.ts:175`), and +`core.ts:2587` returns **502 `Provider unreachable`** with +`recoveryKinds: ["connection-reset"]`. + +## How to tell it apart from #2108 + +The status code is the discriminator, and it is worth writing down because the +two reports look identical in prose: + +| | #2107 | #2108 | +|---|---|---| +| status | **502** | **503** | +| body | `Provider unreachable` | `native-main profile maintenance is active` | +| log | `recoveryKinds: ["connection-reset"]` | native-main gate | +| cure | shim/direct start, or set `config.proxy` | `ocx restart` | + +## Why the shim works and the service does not + +`src/codex/shim.ts:692` runs `ocx ensure` in the interactive Codex shell, and +`src/cli/index.ts:431` spawns with `{ ...process.env }` — so `.bashrc` proxy +vars survive. That asymmetry is the whole bug. + +## Fix shape + +Bake the proxy keys into the generated unit, reusing what already exists: +`PROXY_ENV_KEYS` from `src/lib/proxy-env.ts` and the existing +`systemdEnvironmentAssignment()`. + +The same hole exists in `buildPlist` (`service.ts:392-407`) and the Windows +wrapper (`service.ts:1516-1533`). Fix all three in one change — they are the +same omission, and splitting them means two more reports. + +**Rules the implementation must follow:** + +- Do not emit empty `Environment=` lines for unset keys. +- Keep loopback on `NO_PROXY` the way `applyProxyEnv()` already does, or the + proxy will hairpin its own dashboard traffic. +- Do **not** switch `ExecStart` to an interactive `bash -ic`. That would fix + the symptom by making service startup depend on the user's interactive shell, + which is worse than the bug. + +**Two risks to state in the PR rather than discover later:** + +1. A WSL `WIN_HOST=$(ip route ...)` value is snapshotted at install time and can + change after `wsl --shutdown`. +2. Proxy URLs can carry credentials, and baking them writes those into a unit + file on disk. That is a privacy decision, not a detail — either redact, or + prefer `config.proxy` and document why. + +**Files:** `src/service.ts`, `tests/service.test.ts`. + +## Regression test + +With `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` set, `buildUnit()` +contains the matching `Environment=` lines; with them unset, those keys are +absent. Fails on current `dev`. + +## Verification + +``` +bun test tests/service.test.ts +bun x tsc --noEmit +``` + +## Documented workaround for the issue thread + +Set OpenCodex `config.proxy` — service start still runs `applyProxyEnv()`, so +this works today without any code change. `ocx doctor` already prints +"Current doctor process proxy env" vs "Running proxy process proxy env" +(`src/cli/doctor.ts:859`), which is the fastest way for a user to confirm the +diagnosis themselves. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md b/devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md new file mode 100644 index 0000000000..9b78b06300 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/040_2108_windows_reboot_gate.md @@ -0,0 +1,127 @@ +# 040 — #2108: Windows reboot leaves the native-main gate stuck + +Rank 3. **This doc deliberately does not prescribe a mechanism fix first.** + +## What is confirmed + +The 503 is the process-wide native-main fence — same layer as #2114, different +trigger. `/healthz` never consults the gate (`src/server/index.ts:813`), which +is why the reporter sees 200 health + 200 Anthropic + 503 GPT and reasonably +concludes the proxy is fine. + +The structural cause is one line of policy: + +``` +src/server/index.ts:702-710 + owned -> startNativeMainStartupLifecycle() + anything else -> blockNativeMainStartupForUnownedServiceHome(...) // for the process lifetime +``` + +**`startServer()` takes a one-shot ownership verdict and never retries it.** +That is why waiting does not help and `ocx restart` does. + +## What is NOT confirmed, and why that matters + +The investigation found two plausible triggers and could not distinguish them, +because **the settled gate reason is never logged**: + +1. **Owner ACL fail-closed.** A second `ETIMEDOUT` in icacls hardening is + terminal — it publishes `{ status: "unavailable", reason: "lock-unavailable" }` + at `src/codex/native-main-owner.ts:205-212` — and `observeOwner()` settles to + `owner-unavailable` and stops (`native-profile-startup.ts:225-236`). The ACL + module's own comment already describes this exact symptom. Timing fits: the + first 503 is ~74s after wrapper start, past the ~60s owner budget. +2. **Probe fail-closed.** `SERVICE_PROBE_TIMEOUT_MS` is 2000ms. A + scheduler-only install still runs `sc.exe query` for WinSW; if that times out + with WinSW assets absent, `walkWinswChain()` returns `unknown` rather than + `absent` (`service-manager-probe.ts:732-736`). + +**Line numbers matter here.** An earlier draft cited `native-main-owner.ts:272`, +which is `if (released) return` inside `release()` — a fixer grepping that line +lands in the wrong function entirely. + +Two readings were **disproved** and should not be re-raised: the +"did not shut down cleanly" line is the injection journal +(`src/codex/journal.ts:209`), not the native-profile journal; and disk +`manual-recovery` residue would survive `ocx restart`, which contradicts the +reporter's own observation that restart cures it. + +## Phase 1 — log the reason (do this first, alone) + +Emit the concrete gate reason when the fence settles and when the 503 is +returned (`src/codex/auth-context.ts`, `src/codex/native-profile-startup.ts`). + +This is not a placeholder task. Without it the next reboot report is exactly as +ambiguous as this one, and we will be guessing between the same two candidates. +A shipped diagnostic converts the next occurrence into evidence. + +**Test:** the 503 log line includes the settled reason. + +## Phase 2 — make boot-time `unknown` retryable (after phase 1 has data) + +Treat `unknown` that came from a *timeout or unaskable manager* as retryable +while `OCX_SERVICE=1`, instead of a process-lifetime fence. Keep genuine +`foreign` fail-closed, and keep a retry cap. + +Two narrower fixes fall out and are worth doing regardless: + +- If WinSW xml **and** exe are absent, a timed-out `sc.exe query` must not mark + the machine `unknown`. +- A second ACL `ETIMEDOUT` on the service child should back off and retry rather + than settle terminal, so a warm icacls reopens the gate without `ocx restart`. + +## Tests that are currently green and encode the bug + +| Test | Asserts today | +|---|---| +| `tests/native-main-owner-lifetime.test.ts` | second `ETIMEDOUT` → terminal `unavailable` | +| `tests/codex-service-manager-probe.test.ts` | schtasks timeout → `unknown` | +| `tests/native-profile-startup.test.ts` | `ownership-unknown` blocks for the process | + +## Collision with open work + +**PR #2101** (`fix(codex): gate account-native models by entitlement`, 1397 +lines) already edits `src/server/index.ts` and `src/codex/auth-context.ts` — +both files phase 1 and phase 2 touch. Check its state before starting; the +reason-logging change in phase 1 is small enough to be folded in rather than +raced. + +A red-today regression: `startServer` on win32 with scheduler assets present, +first probe timed out and/or two owner ACL timeouts, then a later successful +probe in the **same** process — `POST /v1/responses` for a native model must go +503 → 200 without `process.exit`. Keep a control that a real foreign home stays +503. + +## Verification + +``` +bun test tests/native-profile-startup.test.ts tests/native-main-owner-lifetime.test.ts tests/codex-service-manager-probe.test.ts +bun x tsc --noEmit +``` + +Windows CI is authoritative here; a green macOS/Linux run proves little about +scheduler and icacls paths. + +## Sequencing note — corrected + +The first version of this section said "do #2114 first" and called it a +dependency. **An audit lane refuted that, and it was right.** The two fixes +touch different seams: + +- #2114 narrows one Linux classification in `inspectSystemd()`. +- #2108 phase 2 changes *fence policy* — a one-shot `unknown` becomes + retryable. + +Neither needs the other. #2114 does not implement retryability; phase 2 does not +classify the systemd bus. The shared `unknown → permanent fence` chain is a +shared *symptom*, and the overlap in `service-manager-probe.ts` is merge +convenience, not a prerequisite. + +So: **they can land in either order.** The preference for #2114 first was +"don't design the general rule from the instance we understand least", which is +a reasonable working habit and not a constraint. Stated as a dependency it +would have delayed the Windows fix for no technical reason. + +The one real ordering constraint here remains internal: **phase 1 before phase +2**, because the trigger is unidentified and phase 2 aims at one of two +candidates. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md b/devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md new file mode 100644 index 0000000000..236d7d5ea9 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/050_1587_deferred_catalog.md @@ -0,0 +1,124 @@ +# 050 — #1587: routed first-turn catalog ignores `defer_loading` + +Rank 4. The only candidate with a hard measurement. + +## Failure mechanism + +`buildTools()` (`src/responses/parser.ts:155`) never reads Codex's +`defer_loading` flag. `pushFn` (:163) and the namespace flattener (:195-207) +copy every tool's full `parameters` into `OcxTool`, and the flag is not on the +type — so it is gone by parse time. + +The routed adapters then serialize all of it: + +| Adapter | Site | +|---|---| +| chat | `src/adapters/openai-chat.ts:1197` | +| anthropic | `src/adapters/anthropic.ts:740` | +| google | `src/adapters/google.ts:270` | + +Non-OpenAI chat/Anthropic/Google additionally inject a `tool-catalog-nudge` +listing every flattened wire name in the system prompt +(`openai-chat.ts:636`). + +**The native path is asymmetric on purpose.** `openai-responses.ts:406` +preserves `defer_loading` and strips it only when a `tool_search_output` +actually loads the tool, so the ChatGPT backend keeps deferred schemas out of +the prompt. Chat and Anthropic wires have no server-side deferral, so the same +bytes land in billed context. + +## Measured, on a real captured catalog + +A lane ran this tree's actual `parseRequest` against a Codex Desktop catalog +captured from a session rollout: + +| Sample | Deferred | Catalog bytes | After parse | +|---|---|---|---| +| 2026-08-12 rollout | 8 of 8 tools | 32,927 / 34,404 = **95.7%** | all 8 emitted with full schemas, 32,887 bytes (~8.2k tokens), **zero** defer flags surviving | +| second sample | 4 namespaces / 10 tools | — | 24,227 bytes (~6.1k tokens) | + +## State the goal in bytes, not in the headline ratio + +The issue title says 3-5x. Both lanes independently flagged that this number +does not survive scrutiny: the thread compares **three different tokenizers** +(OpenAI 21,081 vs Kimi 62,319 vs Claude 98,402), and the Opus row additionally +carried a repo `AGENTS.md`. + +The mechanism is real and measured; the multiplier is not a clean +apples-to-apples figure. Success criteria should therefore be +**"deferred tools contribute no schema bytes to the routed catalog"**, verified +in serialized bytes we control — not "routed matches native within N%". + +## Fix shape + +1. Add `deferred?: boolean` to `OcxTool` and set it in `buildTools` + (`parser.ts:155-241`, both `pushFn` and the namespace path). +2. Clear it where `loadedToolSpecs` promotes a tool (`parser.ts:691-706`, which + already tracks `loadedFromToolSearch`). +3. In the three adapters, emit a **name + one-line description stub with empty + `parameters`/`input_schema`** for deferred tools instead of the full schema. + +**The constraint that makes this delicate:** `parser.ts:633` requires exact +wire names stay listed, or the model guesses names. So the stub must keep the +name and drop only the schema. A model may still call a stubbed tool with wrong +arguments before `tool_search` loads it — the `tool_search` round-trip +(`parser.ts:626-654`) has to be the recovery path, not an optional extra. + +**Files:** `src/responses/parser.ts`, `src/types.ts`, +`src/adapters/openai-chat.ts`, `src/adapters/anthropic.ts`, +`src/adapters/google.ts`, plus conformance tests. + +**Note on the split program — corrected.** `OcxTool` moves in **WP1 (#2019)**, +not WP1b: #2019's diff creates `src/types/tools.ts` and relocates `OcxTool` +there (verified: `git show origin/codex/split-wp1-types:src/types/tools.ts` +contains `interface OcxTool`). #2023 moves the accounts/config/provider/request +clusters. So the `deferred` field lands in `src/types/tools.ts` once **#2019** +is in, one PR earlier than this doc first said. + +**Bigger collision this doc originally missed.** The split trio is not the only +moving code on this surface. Live overlaps on #1587's exact files: + +| PR | Overlaps | +|---|---| +| **#1934** | `parser.ts`, `types.ts`, **and all three adapters** — #1587's entire surface | +| #2040 | `parser.ts` | +| #2115 | the three adapters | + +`#1934` is the real hazard, not the split. The 070 roadmap already schedules it +in phase B precisely because it overlaps. **#1587 should be planned after +#1934 lands**, or the two will conflict across five files. + +The original framing — "only #1587 collides, and only with the split" — was a +consequence of checking against the split branches and nothing else. + +## The test that currently pins the wrong behavior + +`tests/responses-tool-conformance.test.ts` **asserts** that namespace children +are flattened into top-level tools (`github.search` becomes top-level). The +correct regression is the opposite shape and must be added alongside an amended +version of that one. + +Red today, green after: a `defer_loading: true` namespace with fat MCP schemas +plus `exec`/`tool_search` → `toolsToChatFormat`/`toolsToAnthropicFormat` must +not include those children's schemas, while still listing their exact +namespaced wire names; serialized catalog bytes stay near the compact size; and +a `tool_search_output` promoting one restores its full schema on the next turn. + +## Verification + +``` +bun test tests/responses-tool-conformance.test.ts tests/responses-parser.test.ts +bun x tsc --noEmit +``` + +## Risk + +Real product risk in both directions. Strip too much and routed models lose +plugin visibility — the #1522/#1529 class, which is why the flattening was +added in the first place (2026-06-19, `6998fcaad`, so chat models could call +MCP tools). Strip too little and nothing improves. + +**Do not** re-stamp `supports_search_tool=false` as a shortcut: `fcbef381e` +showed that regresses `exec.description` from 96,699 to 258,929 chars. That is +a different expansion and would make the problem worse while appearing to +address it. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md b/devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md new file mode 100644 index 0000000000..65cf0f2ec4 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/060_1933_tray_encoding.md @@ -0,0 +1,99 @@ +# 060 — #1933: Windows tray registration misread as foreign/stale + +Rank 5. Cheapest fix in the selected set. + +## The title is a symptom string, not a diagnosis + +"startup registration is foreign, stale, or points to missing package files" is +a single collapsed summary produced by `trayStatusFrom()` +(`src/tray/windows.ts:440-462`). It does not mean three things were checked and +one failed; it means one boolean went false. + +## Failure mechanism + +`runRegistry` / `runRegistryAsync` decode `reg.exe` output with +`encoding: "utf8"` (`src/tray/windows.ts:120-125`, `335-345`). Redirected +`reg query` emits the console ANSI code page, not UTF-8. + +The reporter's username is `MötzJensen`. `ö` is `0xF6` in Windows-1252 and +decodes to `U+FFFD` under UTF-8. The round-trip check +`registered === state.runCommand` then fails, `registrationOwned` goes false, +and the stale summary is printed — even though regedit shows a correct, +well-formed, owned Run value. + +**The write side is fine** (CreateProcess is UTF-16). Only the read is broken. + +## This is a known class with an existing fix that was never wired here + +`decodeWindowsTextBytes` (`src/lib/windows-text.ts:75`) already solves exactly +this for `schtasks` — that was #1573, and it ships with a `C:\Users\Jörg` +fixture in `tests/windows-text-decoding.test.ts`. The tray registry reader was +simply missed. + +That is the argument for doing it now despite the low severity: a half-applied +fix for a known class is how the next site gets missed too. + +## Fix shape + +Capture a Buffer in `runRegistry`/`runRegistryAsync` and decode through +`decodeWindowsTextBytes`, the same helper the service probe uses. + +**Files:** `src/tray/windows.ts`, `tests/` (new case reusing the existing +fixture shape). + +**Known limitation to state, not hide:** `decodeWindowsTextBytes` does not +cover ja/zh code pages by design. This fix closes 1252 and CP949, not every +ACP. A fuller answer is `reg export` (UTF-16) instead of `reg query`, which is +a larger change and should be its own decision. + +**Do not** loosen the foreign-Run refusal (`tray/windows.ts:587`) to make the +symptom go away. That check is correct; it is being fed corrupted input. + +## Regression test + +Feed Windows-1252 (and CP949) `reg query` bytes for a path like +`C:\Users\MötzJensen\.opencodex\opencodex-tray.vbs` through the tray registry +reader and assert +`parseWindowsTrayRunValue(...) === buildWindowsTrayRunCommand(...)`. + +Today UTF-8-decoding those bytes makes +`windowsTrayRegistrationIsStale({ registered: true, registrationOwned: false })` +true. After the fix it must round-trip. + +## Verification + +``` +bun test tests/windows-text-decoding.test.ts tests/windows-tray.test.ts +bun x tsc --noEmit +``` + +(The tray suite is `tests/windows-tray.test.ts` — an earlier draft of this doc +named a `tests/tray-windows.test.ts` that does not exist. Related files: +`windows-tray-restart-hardening.test.ts`, `windows-tray-run-limit.test.ts`.) + +## Secondary UX gap worth a follow-up, not this fix + +The GUI cannot repair this state: Install is hidden when `tray.stale` +(`gui/src/pages/startup-sections.tsx:195`), and Uninstall is shown but also +refuses on a mismatched parse (`tray/windows.ts:687`). So a user in this state +has no in-product action. Worth splitting into its own issue — a stale tray +should always offer a repair path regardless of why it is stale. + +## Honesty note: the mechanism is proven, the attribution is inferred + +The encoding **mechanism** is verified in code — the tray reads `reg.exe` as +utf8 while the service probe already routes the same output through +`decodeWindowsTextBytes`. + +**Pinning this specific issue to it is an inference.** An audit lane checked +the thread: the reporter's GitHub *display name* is `Mötz Jensen`, the actual +profile path was never posted, and `C:\Users\MötzJensen` is reconstructed +rather than observed. The screenshots show the collapsed stale summary and a +German UI; the issue's own earlier review said they cannot distinguish a +foreign Run value from missing package files. + +Consequence for whoever picks this up: make the fix on class-hygiene grounds +(the helper exists, the site was missed), but **do not close #1933 on it** +without asking the reporter for `ocx tray status --json` and the raw Run value. +If their profile path is pure ASCII, this is the wrong diagnosis and the issue +stays open. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md b/devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md new file mode 100644 index 0000000000..4a58560fa9 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/070_sequencing.md @@ -0,0 +1,113 @@ +# 070 — Sequencing against the release roadmap + +This unit starts **after stage 3d** of `260819_next_roadmap/070` — that is, +after `#2019` and `#2023` merge, after the first preview soak, and after +`#2036` lands alone. + +## Collision analysis — corrected after audit + +The first version of this section asked only "does this fix touch a file the +**split** rewrites". That is the wrong question, and it produced a wrong +answer: "only #1587 collides." + +The right question is **"does an open PR already own this code"**, regardless of +whether that PR mentions our issue. Re-derived: + +| Fix | Files | Collides with | +|---|---|---| +| #2114 | `service-manager-probe.ts` + its test | **PR #2029** — same function, `CHANGES_REQUESTED` | +| #2108 | `server/index.ts`, `codex/auth-context.ts` | **PR #2101** (1397 lines, same two files) | +| #1587 | `types.ts`, `parser.ts`, 3 adapters | **#2019 and #2023** (split), plus #2112/#1934 on types, #2040/#2083 on parser, #2115/#2080/#2075/#2071/#2070 on the adapters | +| #2107 | `service.ts` | clean | +| #1933 | `tray/windows.ts` | clean | + +Three corrections fall out of that table: + +1. **#2114 is not greenfield.** See `020` — the first action is to extend or + unblock #2029, not to open a parallel PR. +2. **#1587 is worse than "after WP1b".** WP1 (**#2019**) already rewrites + `src/types.ts`, and the adapter files it touches are among the most + contested in the queue. It is the last of the five to start, not merely the + one that waits for the split. +3. **#2107 and #1933 are the only genuinely clean ones.** That strengthens the + case for running them in parallel rather than queueing them behind #2114. + +### Why still after stage 3d + +Unchanged for #1587 (`types.ts` is being replaced by a barrel). For the rest it +is scheduling, not correctness — the split train owns review attention until 3d +closes. + +## Order + +``` +1. #2114 unblock PR #2029 with the containment its reviewer asked for +2. #2107 bake proxy env into service units (clean, parallel-safe) +3. #1933 tray registry decoding (clean, parallel-safe) +4. #2108 phase 1 log the gate reason (coordinate with #2101) +5. #1527 residual: abort-teardown misclassification (small, independent) +6. #1587 deferred catalog (last: most contested files) +7. #2108 phase 2 retryable fence (after phase 1 produces data) +``` + +### Dependencies, stated explicitly + +- **#2114 before #2108 phase 2.** They share the `unknown → permanent fence` + layer. #2114 settles how a probe that cannot answer should be classified at + the boundary; phase 2 generalizes that into retryability. Designing the + general rule from #2108 first means deriving it from the instance we + understand least. +- **#2108 phase 1 before phase 2.** The trigger is not identified and the gate + reason is not logged. Phase 2 without phase 1 is a fix aimed at one of two + candidates with no way to confirm which. +- **#1587 after the split AND after the adapter PRs settle.** `#2019` rewrites + `types.ts`; `parser.ts` and the three adapters each have open PRs. This is the + one place where starting early guarantees a rewrite. +- **#1527 residual is independent of everything.** It only touches the Cursor + abort listener. It can slot anywhere, and should not wait for #2054 — the + teardown misclassification is orthogonal to checkpoint reuse. +- **#2107, #1933 are independent.** They can slot anywhere; they are placed by + cost, not constraint. + +### What can run in parallel + +#2107 and #1933 touch nothing the others touch and nothing each other touches. +If there is review capacity, they are the two to run alongside #2114 rather +than after it. + +## Relationship to the preview soak + +`#2114`, `#2107` and `#2108` are all "the proxy cannot serve a path" bugs, and +all three are hard to catch in CI: they need a container without a user bus, a +shell-only proxy, and a Windows reboot respectively. None of those exist on a +runner. + +That makes them **good soak candidates and bad CI candidates**. The 070 roadmap +already establishes a preview window with a named exercise set; these three +should extend it: + +- a container run with `systemctl` present and no user bus (#2114) +- a service install where the proxy env lives only in the shell (#2107) +- a Windows reboot with the scheduler backend (#2108) + +Adding those three to the soak checklist is cheaper than trying to simulate +them in CI, and it converts the next occurrence into a dated observation +instead of another ambiguous report. + +## What this unit does not do + +No `src/` changes, no PR merges, no GitHub mutations. The three deferred +candidates keep their disposition from `010`: #1049 waits for a real incident, +#1419 stays upstream-blocked with a separable supervision follow-up, and #1730 +is a close-as-withdrawn once someone is authorized to close it. + +## Follow-ups this unit identified but does not own + +Both were named in passing and would otherwise be lost. Each deserves its own +issue rather than riding a fix: + +1. **Unsupervised `ocx gui`** (`src/cli/dispatch.ts:255`) spawns the proxy + detached while launchd `KeepAlive` covers only `ocx service`. Separable from + #1419's untestable Bun trap, and unlike it, testable. +2. **Stale tray has no in-product repair path** — GUI hides Install when + `tray.stale` and Uninstall also refuses on a mismatched parse. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/075_verification.md b/devlog/_plan/260819_unclaimed_bug_selection/075_verification.md new file mode 100644 index 0000000000..f2f7159f2b --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/075_verification.md @@ -0,0 +1,168 @@ +# 080 — Verification of this unit's own claims + +> Renumbered to `075` — this is the verification record that sits between the +> sequencing doc and the outcome. `080_outcome.md` is the close-out. +> +> **Update:** the retired lane was replaced. Two later lanes landed +> (`01a019f5` narrow, `01a019ed` wide) and their findings are folded into +> `000`, `010`, `020`, `040`, `060` and `070`. The direct re-verification below +> stands and was independently confirmed by those lanes; what it could not +> catch on its own was the code-ownership collision with PR #2029, which the +> wide lane found. + +An adversarial audit lane was dispatched against `000`-`070` and **went silent +past three wait cycles**. Under DISPATCH-RETIRE-01 that is a failed dispatch, +not a pass. Recording it as failed rather than quietly proceeding, and +re-verifying the load-bearing claims directly instead. + +## Directly re-verified + +### #2114 — the test really does pin the bug + +`tests/codex-service-manager-probe.test.ts`: + +``` +/** + * systemd does NOT signal absence through the exit code — a missing unit + * prints not-found and exits ZERO. A non-zero status means the question never + * reached the bus, which is the opposite conclusion. + */ +test("a non-zero systemctl status is unknown even though a missing unit exits zero", () => { + const { run } = recorder(() => ({ status: 1, stderr: "Failed to connect to bus" })); + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); +}); +``` + +CONFIRMED, and the comment is worth reading closely: the reasoning is +**correct** and the conclusion is still wrong for this environment. "The +question never reached the bus" is precisely why `unknown` is the wrong verdict +— an unanswerable question is evidence about the bus, not about who owns the +service home. The test is not sloppy; it encodes a genuine judgment that needs +revisiting for the container case, which is exactly why `020` says amend it +rather than delete it. + +### #2107 — `buildUnit` really omits proxy env + +`grep -n 'HTTP_PROXY\|HTTPS_PROXY' src/service.ts` returns **nothing**. +CONFIRMED. There is no proxy key anywhere in the service builder, which also +confirms the doc's claim that launchd and the Windows wrapper share the hole. + +### #1587 — the flag really is discarded + +`grep -n 'defer' src/types.ts` → empty. `OcxTool` has no such field. +`rg 'defer_loading' src/responses/` → empty. The parser never reads it. + +CONFIRMED on both halves, which is the part that matters: the measurement +(95.7% of a captured catalog) came from a lane and cannot be re-run here, but +the *code claim* it rests on is directly verifiable and holds. + +### #1933 — the encoding asymmetry is real + +``` +src/tray/windows.ts:122 encoding: "utf8", +src/tray/windows.ts:338 encoding: "utf8", +src/service-manager-probe.ts:29 import { decodeWindowsTextBytes } from "./lib/windows-text"; +src/service-manager-probe.ts:476 decodeWindowsTextBytes(queried.stdout, ...) +``` + +CONFIRMED. The helper exists, the service probe already uses it, and the tray +reader does not. This is the clearest "known class, missed site" in the set. + +### Sequencing — the collision analysis is complete + +Checked each selected fix's files against what the split PRs rewrite: + +| File | In split diff | +|---|---| +| `src/service-manager-probe.ts` | no | +| `src/service.ts` | no | +| `src/tray/windows.ts` | no | +| `src/codex/native-profile-startup.ts` | no | +| `src/codex/native-main-owner.ts` | no | + +CONFIRMED: `#1587` is the only collision, via `src/types.ts`. + +## What remains unverified, and is labelled as such + +- **The #1587 byte measurement.** 32,927 / 34,404 came from a lane replaying a + captured catalog through the real parser. Not reproduced here. The mechanism + is confirmed; treat the exact percentage as one sample. +- **#2108's actual trigger.** Two candidates, and the doc says so plainly. This + is a genuine gap, not an oversight — it is *why* `040` puts logging first. +- **The candidate-set completeness re-derivation.** The list was derived once + live (`2026-08-19T11:45:42Z`) and not independently re-derived by a second + party. A PR opened after that timestamp could claim one of these eight. Cheap + to re-check at start of work, and `070` should be re-read then rather than + trusted. + +## Note on lane reliability in this unit + +## Second audit round — the one that landed + +The first audit lane was retired as silent. It **returned late**, and four +narrow lanes were dispatched in parallel. All five verdicts are in, and they +found more than the direct grep pass did. Everything below was folded back. + +### The finding that changes the plan: #2114 is already owned + +**Open PR #2029 rewrites the exact function this unit planned to change**, and +deliberately leaves the #2114 case closed: + +``` ++ err.includes("Failed to get D-Bus connection: No such file or directory") ++ ... "System has not been booted with systemd" ++ return { kind: "absent" }; ++ return unknown(...) // "other bus failures stay unknown" + ++ test("other bus failures stay unknown — the user manager may be running", () => { ++ stderr: "Failed to connect to bus: $DBUS_SESSION_BUS_ADDRESS not set", ++ expect(...kind).toBe("unknown"); +``` + +#2114's reporter stderr is `Failed to connect to user scope bus via local +transport...` — the family #2029 is choosing to keep `unknown`. + +This invalidated three things at once: "#2114 is a cheap first fix", the +no-collision table, and the whole "do #2114 first" sequence. All three shared +one cause — **nobody checked who already owns `inspectSystemd()`.** + +### Corrections applied + +| Finding | Where | Fix | +|---|---|---| +| #1527 wrongly excluded (PR #2054 says "Does not close #1527") | `000` | set corrected to 9; method note added | +| The 020 fix snippet **fails open** — with the bus down, systemctl cannot see a foreign unit either | `020` | rewritten to consult the unit file on disk before returning `absent` | +| "#2114 before #2108 phase 2" is preference, not dependency | `040` | retracted; they can land in either order | +| #2108 should outrank #2114 (bigger platform, every reboot) | `010` | accepted; order revised | +| #1049 "no incident" is the wrong test for a silent integrity gap | `010` | accepted as detection-only phase 1 | +| `OcxTool` moves in **WP1 (#2019)**, not WP1b | `050` | corrected | +| #1934 overlaps **all five** of #1587's files | `050` | recorded as the real hazard | +| `tests/tray-windows.test.ts` does not exist | `060` | corrected to `windows-tray.test.ts` | +| `C:\Users\MötzJensen` was reconstructed, not observed | `060` | honesty note; do not close on the inference | + +### Rejected, with reason + +**"Drop #1933 from the selected set."** Folding it into the Windows pass is +accepted; dropping it is not. The helper already exists and is already wired +elsewhere — a half-applied class fix is how the next site gets missed — and the +GUI offers no repair path, so the affected user is stuck. + +### Still open after this round + +- **#1527** is unclaimed and **not investigated**. It arrived after the lanes + were dispatched. The selected set cannot be called final until it is. +- The **#1587 measurement** (95.7%) is a lane result that cannot be replayed + here. Mechanism confirmed; treat the number as one sample. +- Several **line citations drifted** (`native-main-owner.ts:272` is + `release()`; the second ACL timeout is nearer `:205`). Verify before quoting. +- Follow-ups this unit names and then drops: `/readyz` ignoring the native-main + fence, the Codex "at capacity" remap, and the `ocx gui` supervision split from + #1419 — none has an issue number. + +## Note on lane reliability in this unit + +Of eleven dispatches, two went silent in the first batch and one audit lane +went silent at the end. That is a meaningful failure rate and it changed how +this unit was built: the surviving evidence is per-issue lane reports plus +direct verification, not a single audited pass. Where a claim rests only on a +lane, this document says so. diff --git a/devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md b/devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md new file mode 100644 index 0000000000..d6c8ae6acb --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/080_outcome.md @@ -0,0 +1,95 @@ +# 080 — Outcome + +Terminal outcome: **DONE.** Selection and roadmap only; no `src/` change, no PR +merge, no GitHub mutation, no push. + +## Result + +Nine open `bug` issues have no PR intending to close them. Six are selected for +work after stage 3d, three are deferred with reasons. + +| Rank | Issue | Disposition | +|---|---|---| +| 1 | #2114 systemd bus | **unblock PR #2029**, not a new PR | +| 2 | #2107 service proxy env | take, clean of open work | +| 3 | #1933 tray encoding | take, clean of open work | +| 4 | #2108 Windows reboot gate | phase 1 (log the reason) first; coordinate with #2101 | +| 5 | #1527 residual | take only the abort-teardown slice | +| 6 | #1587 deferred catalog | last — most contested files | +| — | #1049 | defer: no field incident, phase 2 can corrupt `CODEX_HOME` | +| — | #1419 | defer: native Bun trap, no upstream release to move to | +| — | #1730 | close as reporter-withdrawn | + +## What the audits changed + +Two rounds ran. Neither merely agreed, and both errors were in the *method* +rather than in any individual finding. + +**The candidate filter was wrong in kind, not in execution.** It asked "does an +open PR mention `#NNNN`" and subtracted the matches. That over-excludes exactly +where an author was honest about scope: PR #2054 mentions #1527 and says +"Does not close #1527", so the filter counted an explicit disclaimer as a +claim. Reference-counting is not claim-counting, and the set was 9 rather +than 8. + +**The collision analysis asked the wrong question.** It checked "does this fix +touch a file the split rewrites" and concluded only #1587 collides. The +question that matters is "does an open PR already own this code", and the +answer changes the top of the ranking: **PR #2029 already edits +`inspectSystemd()`** — the exact function #2114 needs — and is +`CHANGES_REQUESTED` for the same fail-open hazard that `020` independently +rediscovered and wrote down as "test 3". + +So #2114 is still first, but "first" means supplying the containment #2029's +reviewer asked for. Left uncorrected, this unit would have sent someone to open +a second PR against a blocked one and make the same fail-closed +security-adjacent decision twice. + +Smaller corrections, worth recording because they are the kind that waste an +hour: `040` cited `native-main-owner.ts:272`, which is `if (released) return` +inside `release()` — the terminal `unavailable` is at `:205-212`. `060` named a +verification file that does not exist (`tests/tray-windows.test.ts`; the real +one is `tests/windows-tray.test.ts`). + +## What the investigation found that the titles did not + +Three of nine issues do not describe their own cause: + +- **#2107** reads as a WSL networking problem. It is `buildUnit()` baking six + environment variables and no proxy ones, so the service talks direct while + the shim inherits the user's proxy. The discriminator is the status code: + 502 with `connection-reset`, not #2108's 503. +- **#1933** reads as "missing package files". That phrase is a collapsed + summary string; the cause is `reg.exe` output decoded as UTF-8 when the + console code page is Windows-1252, and `decodeWindowsTextBytes` already fixes + this class for `schtasks`. +- **#1730** reads as an OpenCodex tool-call bug. The half that was ours shipped + in `ea0608611`; the reporter attributed the rest to their own configuration + and asked to close. + +And one issue produced a measurement rather than an argument: **#1587** — a +lane ran this tree's real `parseRequest` against a captured Codex Desktop +catalog and found **32,927 of 34,404 bytes (95.7%) deferred and emitted +anyway**. The issue's own "3-5x" headline does not survive scrutiny (it +compares three tokenizers), so the success criterion should be stated in bytes +we control. + +## Follow-ups this unit identified but does not own + +Both were named in a lane report and would otherwise vanish: + +1. Unsupervised `ocx gui` spawns the proxy detached while launchd `KeepAlive` + covers only `ocx service` (`src/cli/dispatch.ts:255`). Separable from + #1419's untestable trap, and unlike it, testable. +2. A stale tray has no in-product repair path: the GUI hides Install when + `tray.stale`, and Uninstall also refuses on a mismatched parse. + +## Method note for the next triage pass + +The cheap derivation — scan PR bodies for `#NNNN`, subtract — is a starting +filter, not an answer. Two checks have to follow it: + +1. **Read the referencing PR.** Does it intend to close the issue, or does it + say it does not? +2. **Check code ownership, not just issue references.** An issue with no PR + mentioning it can still have a PR sitting on the function that must change. From d7caaa9bf5788099b3c353b7a4cda77a89b03dee Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:45:05 +0900 Subject: [PATCH 082/121] docs(devlog): record the 2107 implementation and its red-drive --- .../031_2107_implementation.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md b/devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md new file mode 100644 index 0000000000..290501098d --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/031_2107_implementation.md @@ -0,0 +1,80 @@ +# 031 — #2107 implementation record + +Branch: `fix/service-proxy-env` off `origin/dev` @ `18e072c8d`. +Commit: `eb910776a`. PR: **#2116** → `dev`. + +## What the plan said vs what the tree said + +`030` was written against a read of the code and held up on every point, with one +correction worth recording. + +**The plan asked for a guard that already exists.** It said "do not emit empty +`Environment=` lines". All three builders already drop falsy values before +joining — `systemdEnvironmentAssignment` returns `null`, +`buildPlist` uses ternaries, `windowsBatchSet` returns `null` — and each list is +`.filter(Boolean)`ed. So the risk was real in principle and already handled in +practice; adding a second guard would have been noise. + +That is the useful shape of this correction: the plan named a hazard from +reading a diff, and the tree had already solved it structurally. + +## The change + +One helper plus three call sites: + +``` +resolvedProxyEnv(env = process.env): { name, value }[] + for each of PROXY_ENV_KEYS (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY) + value = env[KEY]?.trim() || env[key]?.trim() // either case + if (value) push({ name: KEY, value }) // canonical name only +``` + +| Builder | Line of insertion | +|---|---| +| `buildUnit` | after `opencodexHome`, mapped through `systemdEnvironmentAssignment` | +| `buildPlist` | after `OPENCODEX_HOME`, mapped to `/` | +| `buildWindowsServiceScript` | after `OPENCODEX_HOME`, mapped through `windowsBatchSet` | + +Reading both letter cases but writing only the upper-case name matters: curl-style +tooling sets `http_proxy`, and emitting both spellings into one definition would +leave two sources of truth for one setting. + +## Verification + +``` +bun test tests/service.test.ts 126 pass / 0 fail +bun x tsc --noEmit exit 0 +``` + +**Red-drive, recorded.** With the three `resolvedProxyEnv()` call sites stripped +out, the primary test fails on exactly the missing line: + +``` +Expected to contain: "Environment=\"HTTP_PROXY=http://127.0.0.1:7890\"" +(fail) bakes outbound proxy env into the unit ... (#2107) + 1 pass 1 fail +``` + +and nothing else in the file breaks. Restoring the fix returns 126/0. + +The companion test (no proxy in the shell → no proxy keys emitted) passes in both +states by design. It is not an oracle for the fix; it is a guard that the fix +cannot start emitting empty assignments later. + +## What this deliberately does not do + +- **No interactive `ExecStart`.** `bash -ic` would make service startup depend on + the user's interactive shell — a worse failure mode than the bug. +- **No `NO_PROXY` synthesis.** The runtime's `applyProxyEnv` already keeps + loopback off the proxy path; inventing a value here could diverge from it. +- **No credential handling decision.** A proxy URL can carry credentials, and + baking it writes that to disk. `config.proxy` already works for that case + without this change. Raised in the PR body as an open question rather than + silently resolved. + +## CI posture + +Not consulted. `dev` is mid-merge-train (30 commits in the window this work +started) and its checks are noisy by construction. Verification here is local +and complete for the changed surface; CI becomes the gate once the train +settles. From df05a80d7749eec3a6bb8e061d430db8505f6d58 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:53:51 +0900 Subject: [PATCH 083/121] docs(devlog): close the merge loop with its outcome record --- .../090_merge_loop_outcome.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md diff --git a/devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md b/devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md new file mode 100644 index 0000000000..bebb983342 --- /dev/null +++ b/devlog/_plan/260819_next_roadmap/090_merge_loop_outcome.md @@ -0,0 +1,86 @@ +# 090 — Merge-loop outcome + +HOTL loop, session `01a01949`, ten work-phases. **14 merged, 8 held.** + +## Merged + +| Work-phase | PRs | +|---|---| +| wp2 | #2085, #2086 | +| wp3 | #2035, #2031, #1878 | +| wp4 | #2103 | +| wp5 | #1876 (closes #1852) | +| wp6 | #2112, #1934, #2080 | +| wp7-9 | #2019, #2023, #2036 — the split stack, bottom-up | +| wp10 | #2119 — this loop's own two fixes | + +## Held, with posted blockers + +#2102, #2105, #2053, #2100, #2077, #2056, #2062, #2063 — all verified still +OPEN at closeout. Nothing was swept in by a batch merge. + +## What the batching rule actually bought + +The user's instruction was to merge in small batches rather than sweeping +stage 1 at once. Three defects were caught that a sweep would have shipped +behind green CI: + +**#2105** — `reconcileShellHook(false)` unconditionally removes the hook, but +`false` also means "`claude` is not on **this process's** `PATH`". A +service-started proxy does not inherit the interactive login shell's `PATH`, so +a user with Claude Code installed would have had their working `.zshrc` hook +deleted. The change made the safe direction conditional and left the destructive +one unconditional. + +**#1876** — the async catalog fix returned a pre-write `fresh` to a request that +was already waiting when an invalidating write landed. `fresh` is the one state +authorizing positive model guidance, so it would advertise a catalog the +app-server no longer had. **A slow answer was the bug; a wrong answer is worse +than the bug.** Fixed on our branch, and the existing regression had been +asserting the defective `fresh` — the test was pinning the bug. + +**#2080** — a paid priority tier enabled from an assertion rather than evidence. +Being wrong charges the user. + +## Three things this loop taught that outlive it + +**1. A verdict is bound to a head.** Every work-phase re-reviewed because heads +had moved, and it mattered twice: #2102's author had changed the exact code the +earlier verdict covered, and #2086's moved head added a `noVisionModels` +precedence fix that was not in the reviewed diff. + +**2. Stale base is a claim about the base, not the change — four times over.** +#2031 (60 behind), #1876 (67), #2019/#2023/#2036 (29). Each had red CI that went +green on rebase with no source change. The converse stayed honest: a rebase +removes the base as an explanation, it does not prove the change is good. + +**3. "No test is possible" was a claim, not a fact.** This document asserted +across three campaigns that a barrel extraction's oracle is `tsc` plus its +importers, and that a barrel test "restates the compiler". A review lane +disproved it: forking `MODEL_ADAPTER_OVERRIDE_ALLOWED` into a second `Set` +inside the barrel leaves `tsc --noEmit` at exit 0, and no test imported the +leaves directly, so barrel and leaf were never compared. That test now exists +(`tests/types-barrel-identity.test.ts`) and drives red against exactly that +fork. + +The pattern across all three: **the conclusions held and the reasons did not.** +Same finding as the previous campaign's closeout, arrived at independently. + +## A process failure worth recording + +wp6 held three PRs with three blockers. Two got follow-up fixes before merging; +#2112's did not, and it landed with `codexToolMode` still absent from +`providerConfigSchema`. Because that schema ends in `.passthrough()`, nothing +failed — a misspelled value was accepted and silently resolved to the default. + +Caught at closeout and fixed (`d697e2553`), but the honest reading is that the +hold did not hold. **A hold is worth exactly what the re-check before merge is +worth**, and a passthrough schema leaves no trace at the merge boundary. + +## Still open + +- The 8 held PRs need author responses. +- The preview/soak gate from `070` has not run. The split is on `dev`; it has + not been exercised as a published build. +- `dev` CI on the final head was still in flight at closeout; no failing leg was + observed on any run in this loop. From 54afa93f68572a5b9a2ff65b4f4826c2323cd596 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:54:46 +0900 Subject: [PATCH 084/121] docs(devlog): mark the roadmap executed and flag the unrun soak gate --- .../070_next_roadmap_split_and_dogfood.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md b/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md index e13bc1fb0b..b74dd112b1 100644 --- a/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md +++ b/devlog/_plan/260819_next_roadmap/070_next_roadmap_split_and_dogfood.md @@ -4,6 +4,23 @@ Written 2026-08-19 after the queue-drain campaign closed. State at writing: 56 open PRs (17 `review-ready`), 24 open `bug` issues, `dev` at `c4bf833c9`, npm `latest=2.26.0` / `preview=2.26.0-preview.20260819`. +> **Status: stages A-C executed 2026-08-19.** 14 PRs merged including the full +> split stack (#2019, #2023, #2036); 8 held with posted blockers. Outcome and +> lessons: `090_merge_loop_outcome.md`. Per-phase evidence: +> `080_merge_loop_ledger.md`. +> +> **What this document got wrong, again.** Its claim that a barrel extraction +> cannot be meaningfully tested — and therefore needs the hygiene exception — +> was disproved during execution. Forking a `Set` inside the barrel leaves +> `tsc --noEmit` at exit 0, and no test imported the leaves directly, so barrel +> and leaf were never compared to each other. +> `tests/types-barrel-identity.test.ts` now covers it. Reuse the phase order +> below; do not reuse the exception argument. +> +> **The preview soak gate (C3 and C5) has NOT run.** The split is on `dev` and +> has never been exercised as a published build. That gate is still owed, and +> the freeze-vs-shared-attribution question it raises is still a user decision. + ## The question this answers Merging a mega-file split is not like merging a fix. A fix either works or From 2d7b945b638962ce127f93327be244b9431c2df8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 23:20:02 +0900 Subject: [PATCH 085/121] fix(service): build service definitions from an injected proxy env, not process.env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #2107 tests assigned HTTP_PROXY/HTTPS_PROXY/NO_PROXY onto the real process.env and restored them in a finally. That looked airtight and was not: `bun test a.test.ts b.test.ts` runs every file in ONE process, and --isolate does not change that. The values outlived the file. The Lab sandbox calls rejectProxyEnvironment() against the live process.env and treats any proxy variable as a harness_failure, by design — it must not dial out through a proxy. So every Lab file that loaded after service.test.ts died on a leaked variable it never set: 73 failures on the unsharded macOS lane, zero when the Lab suites ran alone, which is exactly the shape that makes this look like flake rather than a defect. The fix is to stop mutating global state to test a pure function. buildUnit() and buildPlist() now take the resolved proxy entries as a parameter defaulting to resolvedProxyEnv(), so production behavior is unchanged and the tests hand in a literal environment. resolvedProxyEnv() already accepted an env argument; it is now exported so a test can use it the way the runtime does. A third case is added while the seam is open: a lower-case http_proxy must be baked under the canonical upper-case name. That was implemented and documented but never asserted. Refs #2107 Verification: the five suites that carried the failure — service, lab-live-probe, lab-fabric-task, lab-automation, api-key-attribution — go 50 fail -> 0 fail, 236 pass. tsc --noEmit exit 0. --- src/service.ts | 10 +++---- tests/service.test.ts | 69 +++++++++++++++++++++---------------------- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/service.ts b/src/service.ts index 4256820ee2..37e77506dd 100644 --- a/src/service.ts +++ b/src/service.ts @@ -390,7 +390,7 @@ function writeServiceApiTokenFile(): string | null { return path; } -export function buildPlist(): string { +export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { const { bun, bunRuntimeSource, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; @@ -405,7 +405,7 @@ export function buildPlist(): string { codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, - ...resolvedProxyEnv().map(({ name, value }) => + ...proxyEnv.map(({ name, value }) => ` ${name}${plistString(value)}`), ].filter((line): line is string => Boolean(line)).join("\n"); const command = buildServiceShellCommand(bun, cli); @@ -659,7 +659,7 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined): * own `applyProxyEnv` already treats both cases as equivalent. Only the canonical * upper-case name is baked, so a definition never carries two spellings of one setting. */ -function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { +export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name: string; value: string }[] { const resolved: { name: string; value: string }[] = []; for (const key of PROXY_ENV_KEYS) { const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); @@ -2444,7 +2444,7 @@ function unitPath(): string { return join(unitDir(), `${TASK}.service`); } -export function buildUnit(): string { +export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { const { bun, bunRuntimeSource, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; @@ -2459,7 +2459,7 @@ export function buildUnit(): string { codexHome, codexSqliteHome, opencodexHome, - ...resolvedProxyEnv().map(({ name, value }) => systemdEnvironmentAssignment(name, value)), + ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)), ].filter((line): line is string => Boolean(line)).join("\n"); return `[Unit] Description=OpenCodex Proxy Server diff --git a/tests/service.test.ts b/tests/service.test.ts index 75d570f091..32ba00c47c 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -7,6 +7,7 @@ import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; +import { resolvedProxyEnv } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST, recordOwnedConfigPath, removeOwnedConfigState } from "../src/lib/config-ownership"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -115,49 +116,47 @@ describe("systemd service unit", () => { // /bin/sh -lc — which is dash on Ubuntu/WSL and reads .profile, not .bashrc. A user // whose proxy lives in the shell therefore gets a service that dials upstream direct, // the socket is reset, and the request surfaces as 502 Provider unreachable. - const saved = { ...process.env }; - try { - process.env.HTTP_PROXY = "http://127.0.0.1:7890"; - process.env.HTTPS_PROXY = "http://127.0.0.1:7890"; - process.env.NO_PROXY = "localhost,127.0.0.1"; - delete process.env.ALL_PROXY; + // + // The shell is passed in rather than assigned onto `process.env`. Mutating the real + // environment here leaked `HTTP_PROXY` out of this file: Bun runs a `bun test a b` + // invocation in ONE process, and the Lab sandbox calls `rejectProxyEnvironment()` on + // the live `process.env`, so every Lab file that loaded afterwards died with + // `harness_failure`. That was 73 failures on the unsharded macOS lane and zero when + // the Lab suites ran alone. + const proxyEnv = resolvedProxyEnv({ + HTTP_PROXY: "http://127.0.0.1:7890", + HTTPS_PROXY: "http://127.0.0.1:7890", + NO_PROXY: "localhost,127.0.0.1", + }); - const unit = buildUnit(); - expect(unit).toContain('Environment="HTTP_PROXY=http://127.0.0.1:7890"'); - expect(unit).toContain('Environment="HTTPS_PROXY=http://127.0.0.1:7890"'); - expect(unit).toContain("NO_PROXY="); - // An unset key must not produce an empty assignment. - expect(unit).not.toContain('Environment="ALL_PROXY="'); + const unit = buildUnit(proxyEnv); + expect(unit).toContain('Environment="HTTP_PROXY=http://127.0.0.1:7890"'); + expect(unit).toContain('Environment="HTTPS_PROXY=http://127.0.0.1:7890"'); + expect(unit).toContain("NO_PROXY="); + // An unset key must not produce an empty assignment. + expect(unit).not.toContain('Environment="ALL_PROXY="'); - const plist = buildPlist(); - expect(plist).toContain("HTTP_PROXYhttp://127.0.0.1:7890"); - expect(plist).not.toContain("ALL_PROXY"); - } finally { - for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]) { - if (saved[key] === undefined) delete process.env[key]; - else process.env[key] = saved[key]; - } - } + const plist = buildPlist(proxyEnv); + expect(plist).toContain("HTTP_PROXYhttp://127.0.0.1:7890"); + expect(plist).not.toContain("ALL_PROXY"); }); test("omits proxy env entirely when the installing shell has none (#2107)", () => { - const saved = { ...process.env }; - try { - for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", - "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) delete process.env[key]; - - const unit = buildUnit(); - for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]) { - expect(unit).not.toContain(`${key}=`); - } - } finally { - for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", - "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) { - if (saved[key] !== undefined) process.env[key] = saved[key]; - } + const unit = buildUnit(resolvedProxyEnv({})); + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]) { + expect(unit).not.toContain(`${key}=`); } }); + test("lower-case shell spellings are baked under the canonical name (#2107)", () => { + // curl-style tooling sets the lower-case pair; only the upper-case name is emitted so a + // definition never carries two spellings of one setting. + const unit = buildUnit(resolvedProxyEnv({ http_proxy: "http://127.0.0.1:7890" })); + + expect(unit).toContain('Environment="HTTP_PROXY=http://127.0.0.1:7890"'); + expect(unit).not.toContain("http_proxy="); + }); + test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; From 85a5706a6a1a3f6b87660f622e49a34135411120 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:47:57 +0900 Subject: [PATCH 086/121] fix(tray): decode reg.exe output as console text, not UTF-8 reg.exe writes the console ANSI code page when its output is redirected. Reading it as utf8 corrupts every non-ASCII byte, so a profile path such as C:\Users\Mtz came back with replacement characters. The comparison against the Run value we wrote could then never match, registrationOwned went false, and the CLI reported the tray registration as 'foreign, stale, or points to missing package files' over an entry that was correct and owned. That summary string is why the report reads as a missing-file problem: it is one collapsed line covering three unrelated conditions, and only the parse actually failed. decodeWindowsTextBytes already solves this for schtasks (#1573, with a C:\Users\Jrg fixture). The tray reader was the site that class fix missed. Route both the sync and async registry readers through it. Known limit, stated rather than hidden: decodeWindowsTextBytes does not cover ja/zh code pages by design, so this closes 1252 and CP949, not every ACP. The fuller answer is reg export (UTF-16), which is a separate decision. Refs #1933. --- src/tray/windows.ts | 30 +++++++++++++++++++++++----- tests/windows-tray.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/tray/windows.ts b/src/tray/windows.ts index 08c7619d7e..cefb61c3ee 100644 --- a/src/tray/windows.ts +++ b/src/tray/windows.ts @@ -9,6 +9,7 @@ import type { BunRuntimeSource } from "../lib/bun-runtime"; import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; import { renameAtomicFile } from "../lib/windows-atomic-replace"; +import { decodeWindowsTextBytes } from "../lib/windows-text"; const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion"; @@ -117,12 +118,31 @@ function registryExe(): string { return existsSync(candidate) ? candidate : "reg.exe"; } +/** + * Decode `reg.exe` output the way the rest of the product decodes Windows console + * output. + * + * `reg.exe` writes the console ANSI code page when its output is redirected, not + * UTF-8. Reading it as utf8 corrupts every non-ASCII byte, so a profile path such + * as `C:\Users\Mtz` came back with replacement characters, the + * comparison against the value we wrote could never match, `registrationOwned` + * went false, and the CLI reported the tray registration as + * "foreign, stale, or points to missing package files" over an entry that was + * correct and owned (#1933). + * + * `decodeWindowsTextBytes` already solves this for `schtasks` (#1573). The tray + * reader was the site that class fix missed. + */ +function decodeRegistryOutput(stdout: Buffer | string): string { + const bytes = typeof stdout === "string" ? Buffer.from(stdout, "binary") : stdout; + return decodeWindowsTextBytes(bytes).trim(); +} + function runRegistry(args: string[]): string { - return execFileSync(registryExe(), args, { - encoding: "utf8", + return decodeRegistryOutput(execFileSync(registryExe(), args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, - }).trim(); + })); } function safePath(value: string): string { @@ -335,13 +355,13 @@ function readOwnedRunValue(runValue = windowsTrayRunValue(getConfigDir())): stri function runRegistryAsync(args: string[]): Promise { return new Promise((resolvePromise, rejectPromise) => { execFile(registryExe(), args, { - encoding: "utf8", + encoding: "buffer", timeout: 2_000, windowsHide: true, maxBuffer: 64 * 1024, }, (error, stdout) => { if (error) rejectPromise(error); - else resolvePromise(stdout.trim()); + else resolvePromise(decodeRegistryOutput(stdout)); }); }); } diff --git a/tests/windows-tray.test.ts b/tests/windows-tray.test.ts index ca6f782cf8..7b0b026692 100644 --- a/tests/windows-tray.test.ts +++ b/tests/windows-tray.test.ts @@ -27,6 +27,7 @@ import { windowsRegistryParentShowsRunKey, type WindowsTrayEntry, } from "../src/tray/windows"; +import { decodeWindowsTextBytes } from "../src/lib/windows-text"; import { hardenSecretPath, hardenedSecretPathCountForTests, @@ -470,6 +471,14 @@ describe("Windows tray packaging and command safety", () => { expect(tray).toContain("return readWindowsTrayRunValueWithRunner(runValue, runRegistry)"); expect(tray).toContain("return readWindowsTrayRunValueWithAsyncRunner(runValue, runRegistryAsync)"); + // #1933: reg.exe writes the console ANSI code page, not UTF-8. Decoding its + // bytes as utf8 corrupts any non-ASCII profile path, the owned-value round + // trip then fails, and the tray reports itself foreign/stale even though the + // Run value on disk is correct. decodeWindowsTextBytes already fixes this + // class for schtasks (#1573); both registry readers must use it too. + expect(tray).not.toContain('encoding: "utf8"'); + expect(tray).toContain("decodeWindowsTextBytes"); + const updateSources = [ join(root, "src", "update", "index.ts"), join(root, "src", "update", "job.ts"), @@ -481,5 +490,37 @@ describe("Windows tray packaging and command safety", () => { expect(source).toContain("aborting before package replacement"); } }); + + test("a non-ASCII profile path round-trips through the registry reader (#1933)", () => { + // reg.exe emits the console ANSI code page, not UTF-8. On a Windows-1252 host a + // profile path like C:\\Users\\Moetz decodes to U+FFFD under utf8, the comparison + // against the value we wrote fails, registrationOwned goes false, and the CLI + // prints "startup registration is foreign, stale, or points to missing package + // files" over a registry entry that is in fact correct and owned. + const runValue = "OpenCodexTray-c856edd2e06f"; + const command = [ + String.raw`"C:\WINDOWS\System32\wscript.exe" //B //NoLogo `, + String.raw`"C:\Users\M\u00f6tz\.opencodex\opencodex-tray.vbs"`, + ].join("").replace("\\u00f6", "\u00f6"); + const rendered = [ + "", + String.raw`HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run`, + ` ${runValue} REG_SZ ${command}`, + "", + ].join("\r\n"); + + // The bytes reg.exe actually hands back on that host: one byte per code point. + const cp1252 = Uint8Array.from([...rendered].map(ch => ch.codePointAt(0) ?? 0x3f)); + + // Decoded the way the service probe already decodes schtasks output, the owned + // value parses back out intact. + const decoded = decodeWindowsTextBytes(cp1252, { locale: "en-US" }); + expect(parseWindowsTrayRunValue(decoded, runValue)).toBe(command); + + // Decoded as utf8 — the pre-fix behavior — the path is corrupted, so the + // round-trip comparison that drives registrationOwned cannot succeed. + const asUtf8 = Buffer.from(cp1252).toString("utf8"); + expect(parseWindowsTrayRunValue(asUtf8, runValue)).not.toBe(command); + }); }); import { ManagementRequest as Request } from "./helpers/management-auth"; From 4cb36310baa3dbadef11d2f773093afc3d94e346 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:49:09 +0900 Subject: [PATCH 087/121] docs(devlog): record the 1933 implementation and the vacuous test it replaced --- .../061_1933_implementation.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md b/devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md new file mode 100644 index 0000000000..28d2764073 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/061_1933_implementation.md @@ -0,0 +1,68 @@ +# 061 — #1933 implementation record + +Branch: `fix/tray-registry-encoding` off `fix/service-proxy-env`. +Commit: `e3b063750`. PR: **#2117** → `fix/service-proxy-env` (stacked). + +## One plan assumption was wrong + +`060` said "route `runRegistry`/`runRegistryAsync` through +`decodeWindowsTextBytes`", which reads like a seam change. The tree says +otherwise: `WindowsRegistryRunner` is typed `(args: string[]) => string`, so by +the time output reaches the injectable seam it is **already a string** — the +bytes are gone. + +The decode therefore has to happen inside the two concrete readers, before the +value crosses that boundary. Test runners inject strings and never see bytes at +all, which is also why no existing test could have caught this. + +## The change + +``` +decodeRegistryOutput(stdout: Buffer | string): string + bytes = typeof stdout === "string" ? Buffer.from(stdout, "binary") : stdout + return decodeWindowsTextBytes(bytes).trim() +``` + +| Reader | Before | After | +|---|---|---| +| `runRegistry` | `encoding: "utf8"` → `.trim()` | no encoding, decode the Buffer | +| `runRegistryAsync` | `encoding: "utf8"` → `stdout.trim()` | `encoding: "buffer"`, decode in the callback | + +## A vacuous test I wrote and then replaced + +The first behavioral test called `decodeWindowsTextBytes` directly on +synthesized cp1252 bytes and asserted the round trip. **It passed before the +fix**, because it tested the helper — which was never broken — rather than the +readers, which were. + +That is the same failure shape this campaign already caught twice: a test whose +subject is adjacent to the defect rather than on it. Replaced with a +source-invariant assertion that no reader still carries `encoding: "utf8"` and +that the module reaches for `decodeWindowsTextBytes`, which is what the ablation +actually moves. + +## Verification + +``` +bun test tests/windows-tray.test.ts tests/windows-text-decoding.test.ts 25 pass / 0 fail +bun x tsc --noEmit exit 0 +``` + +**Ablation recorded.** Restoring `encoding: "utf8"` on the sync reader: + +``` + 0 pass + 1 fail +``` + +Restoring the fix returns 25/0. + +## Deliberately not done + +- **The foreign-Run refusal is untouched.** That check is correct; it was being + fed corrupted input. +- **No `reg export` migration.** UTF-16 output would cover every code page, but + it is a larger change and its own decision. +- **`Refs #1933`, not `Closes`.** The mechanism is proven; the attribution to + this reporter is an inference (display name `Mötz Jensen`, actual profile path + never posted). Closing needs `ocx tray status --json` from them. From 163d7e50ee4862e4213eddaf059afac057701446 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:33:06 +0900 Subject: [PATCH 088/121] docs(devlog): record the post-split rebase and which deferrals still stand --- .../100_post_split_rebase.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md b/devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md new file mode 100644 index 0000000000..359c5c4cd8 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/100_post_split_rebase.md @@ -0,0 +1,61 @@ +# 100 — Post-split rebase of the stack + +## What changed under us + +The mega-file split landed while this work was open: + +| PR | Merge | +|---|---| +| #2019 WP1 types | `da86a830a` | +| #2023 WP1b clusters | `2235f456d` | +| #2036 WP2a config leaf | `eca18d0c8` | + +`src/types/tools.ts` now exists on `dev` and `OcxTool` lives there. **#1934 also +merged** (`a5289aad5`), which `070` had named as the real collision hazard for +`#1587` — it touched all five of that fix's files. + +So both of `#1587`'s blockers cleared at once. It moves from "last, most +contested files" to implementable. + +## Rebase + +Bottom-up, three branches, **zero conflicts**: + +``` +fix/service-proxy-env eb910776a -> d7caaa9bf +fix/tray-registry-encoding e3b063750 -> f1f400fea +fix/cursor-abort-teardown 346eaa80d -> 2c4e00ede +``` + +Zero conflicts is the expected result and not the interesting part: none of the +three fixes touches `types.ts`, `config.ts`, or anything the split moved. That +was the collision analysis in `070`, and it held. + +## The verification that actually mattered + +A green result from *before* the rebase proves nothing here — the split replaced +`types.ts` with a barrel and moved every type cluster, so the whole tree these +branches compile against is different. + +Re-run on the rebased top of stack: + +``` +bun test service + tray + windows-text + cursor-cancel + cursor-eof + 163 pass / 0 fail +bun x tsc --noEmit + exit 0 +``` + +## Deferred items: what is still blocked + +Re-checked live rather than carried from the previous turn: + +| PR | State | Consequence | +|---|---|---| +| #2029 | OPEN, `CHANGES_REQUESTED` | `#2114` still must not get a competing PR | +| #2101 | OPEN | `#2108` phase 1 still collides on `server/index.ts` + `auth-context.ts` | +| #2054 | OPEN, `CHANGES_REQUESTED` | the kimi-k3 and 429 halves of `#1527` still cannot start | + +Only `#1587` actually became unblocked. The other three deferrals stand for the +same reasons they were recorded, which is worth stating explicitly — "the split +landed" is not a general unblock. From b2f6cede16210f3f52aafe327d181321f48e40ac Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 22:35:28 +0900 Subject: [PATCH 089/121] docs(devlog): record the 2114 disposition and why it is a comment not a PR --- .../110_2114_disposition.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md b/devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md new file mode 100644 index 0000000000..202372f679 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/110_2114_disposition.md @@ -0,0 +1,64 @@ +# 110 — #2114 disposition: unblock, do not compete + +Outcome: **no code PR.** Two comments — the containment on #2029 +(`5342888183`) and the ownership record on #2114 (`5342892253`). + +## What the reviewer actually objected to + +Read rather than summarized. @Ingwannu on #2029: + +> A missing user bus does not prove that `~/.config/systemd/user/opencodex-proxy.service` +> is absent. The definition can remain on disk while the user manager is +> unavailable, and it can name another `CODEX_HOME` or `OPENCODEX_HOME`. +> Returning absent before inspecting that artifact lets the ownership preflight +> treat the machine as unclaimed and overwrite a foreign or interrupted +> installation. + +That is the same hazard `020` arrived at independently and wrote down as +"test 3". Two people reaching it separately is the strongest signal in this +whole unit that it is the real constraint. + +## The finding that made this a comment instead of a PR + +The reviewer asked for: inspect the unit file, absent → `absent`, exists → +present claim with registration absent, unreadable → `unknown`. + +**`inspectSystemd` already does exactly that**, at lines 289-313 — check +`artifactPresence(definitionPath)`, `readFileSync`, parse +`unitEnvValue(body, "CODEX_HOME")` and `OPENCODEX_HOME`, return a `present` +claim. The no-bus branch simply returns before reaching it. + +So the containment needs **no new machinery** — only a different control path +through code that is already there. A separate PR would have re-implemented +something sitting twenty lines below. + +## Why not just open our own PR anyway + +#2114 and #1939 are one classifier with two symptoms: a refused sync there, a +process-lifetime native-main 503 here. A second PR on the same function means +two people making the same fail-closed security-adjacent call, and whichever +lands second silently overwrites the first. + +The stderr shapes make the overlap concrete. #2029 matches two messages and +pins everything else as `unknown`; #2114's shape is a third +(`Failed to connect to user scope bus via local transport…`) and is currently +on the pinned side. + +## What was offered + +- The concrete code shape, reusing the existing helpers. +- The locale-fragility tradeoff **stated rather than hidden**: string matching + will miss a non-English systemd. Worth noting that with the disk check, a + mismatch degrades to a missed recovery rather than a wrong admission — which + is a much safer thing to be locale-sensitive about. Alternative named + (`LC_ALL=C`) without pretending it is free. +- Six regressions, including the #2114 stderr and the existing + non-bus-non-zero assertion kept as the over-widening guard. +- A rebase note: the review's "125 commits behind" is stale, but `dev` has since + taken the split, so a rebase is needed regardless. + +## What was deliberately not done + +No push to a contributor branch, no competing PR, and #2114 left **open and +linked** rather than closed as a duplicate — the 503 symptom is distinct enough +that someone hitting it should find that thread rather than a redirect. From f5da1b29c4d5ad8f6c17d5cf72338967e88f60ff Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 23:04:57 +0900 Subject: [PATCH 090/121] fix(codex): name the native-main gate reason when the fence returns 503 A Windows reboot can leave native-main admission fenced until the user runs ocx restart, and the report of it was unactionable: every 503 carries the same generic maintenance message, so the settled gate reason was never written anywhere a user could read it. Two candidate triggers settle to different reasons - an ACL fail-closed settles owner-unavailable, a probe fail-closed settles ownership-unknown - and nothing on the request path could tell them apart. Capture the reason in the CodexMainProfileDrainingError constructor and warn once per distinct reason. The constructor is the last moment the reason is both in scope and still true: every catch site has already lost it, and re-reading the gate later can observe a recovery that completed in between. Three surfaces were considered and rejected. The message cannot carry it - claude-messages.ts matches that string exactly to keep the fence a 503 instead of remapping it to an Anthropic 529, so a changed message would tell Claude Code to back off from an upstream that was never involved. A response header cannot reach /api/logs, which reads only error.message out of the body, and the Claude surface rebuilds its headers from scratch. Threading a reason into codexMainProfileDrainingResponse would have meant editing all five of its zero-arg call sites. stdout reaches every path this fence fires on, and is the stream the original report was already quoting from. The turn-drain claim race throws this same error while the gate reads ready. That site stays silent rather than borrowing a startup reason, which makes a reasonless 503 mean "not the startup fence" - a distinction the report could not make. Only the reason is logged; the snapshot's homeId is derived from a profile directory path. Refs #2108 Verification: red-driven with the fix staged as a no-op export (2 fail on reason === undefined, 1 pass on the silence case), then 80 pass / 0 fail / 470 expect() across the five native-main gate suites, 124 pass / 0 fail on the Claude and chat surfaces, tsc --noEmit exit 0, privacy:scan exit 0. An independent reviewer mutation-tested the new assertions in a scratch tree and confirmed each goes red when the behavior it pins is removed. Suites ran on macOS, so the owner-unavailable icacls branch is proven as plumbing rather than field behavior. --- .../120_2108_phase1.md | 174 ++++++++++++++++++ src/codex/auth-context.ts | 46 ++++- src/codex/native-profile-startup.ts | 4 + tests/codex-auth-context.test.ts | 61 ++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md b/devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md new file mode 100644 index 0000000000..8da480d530 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/120_2108_phase1.md @@ -0,0 +1,174 @@ +# 120 — #2108 phase 1: log the concrete native-main gate reason + +Work-phase `wp2108`. One PABCD cycle. Phase 2 (retryable boot-time `unknown`) +is explicitly NOT in this cycle. + +**Revision note.** The first draft of this doc proposed a response header +`x-ocx-native-main-gate-reason` alongside the log line. An adversarial audit +returned `fail` and it was correct; the header is removed. What follows is the +design that survived, with the refutation recorded because it is the reason the +scope is what it is. + +## The decision that gated this cycle + +`040_2108_windows_reboot_gate.md` said: check PR #2101 before starting, because +it edits both files phase 1 touches. + +**#2101 is still OPEN** (`REVIEW_REQUIRED`, head `ingw/fix-daybreak-account-entitlement`, +1397 lines, last touched 2026-08-19T12:00:56Z). The collision is real, so the +only question is whether it is avoidable. + +Its hunks in `src/codex/auth-context.ts` cover, in old-file lines: 23-28, +237-242, 260-267, 273-278, 302-308, 312-318, 325-330. Its hunks in +`src/server/index.ts` cover 58, 894, 906, 940, 957, 971, 1038. + +**`auth-context.ts:115-131` is clear** — nearest hunk edges are 28 and 237. +This cycle changes nothing else, in any file. + +That is what makes this an implement rather than a defer. All three throw sites +sit in or beside #2101's hunks: `:313` (inside 312-318), `:319` (one line past +that hunk, still inside its rebase blast radius), and `:326` (inside 325-330). +A design that had to edit any of them would have been a defer. + +## What ships + +One file: `src/codex/auth-context.ts`, lines 115-131 only. No other production +file, and no signature any other file calls. + +### The reason is captured and logged in the constructor + +`CodexMainProfileDrainingError` has a zero-arg constructor today. It gains: + +- a `readonly reason?: NativeMainStartupBlockReason`, read from + `nativeMainStartupGateSnapshot()` **in the constructor**; +- one `console.warn` naming that reason, deduped per distinct reason. + +Capturing in the constructor is the whole design. It is the only moment where +the reason is both in scope and true — every catch site has already lost it, and +re-reading the gate later can observe a different value. + +### Why there is no header + +The first draft wanted a header so the reason would be machine-readable. Three +findings killed it, and each one independently: + +**It cannot reach the caller without editing five files.** +`codexMainProfileDrainingResponse()` is called with zero arguments at +`core.ts:1121`, `compact.ts:390`, `search.ts:126`, `live.ts:542`, +`images.ts:441`. Threading a reason means editing all five — and `core.ts` and +`compact.ts` are both edited by #2101. The collision we set out to avoid comes +straight back. + +**Having the response re-read the gate is worse, not cheaper.** It looks like a +one-file fix, but `nativeMainStartupGateSnapshot()` returns fresh state each +call and `completeNativeMainRecovery()` can flip it to `ready` between the throw +and the catch. The header would then be blank or wrong exactly when recovery is +racing — which is the scenario #2108 is about. + +**It would not reach the Claude surface anyway.** `claude-messages.ts:823-829` +builds a new `Response` with a hand-written header object carrying only +`Content-Type` and `Retry-After`; every other header is dropped. The WebSocket +fence path exposes no response headers at all. + +So the log line is not a consolation prize. It is the only surface that reaches +every path this fence can fire on. + +### Why not the message, and why not `/api/logs` + +Both are closed off, which is worth stating because they are the obvious first +guesses. + +**The message must not change.** `claude-messages.ts:818-820` identifies this +response by `message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE`. Break that +match and `isTransientUpstreamStatus(503)` takes over, remapping the fence to +Anthropic 529 — a local maintenance state would start telling Claude Code to +back off and retry an upstream that was never involved. + +**`/api/logs` cannot carry it either.** `upstreamError` is populated by reading +the response body back and taking `json.error.message` and nothing else +(`request-log.ts:755-759`, via `relay.ts:434-442`). Headers are already gone. +`error.code` is likewise filtered: `requestLogErrorCode` pins 503 to +`server_is_overloaded` and refuses to forward arbitrary codes by design +(`request-log.ts:508-540`, comment at :514). + +That leaves stdout — which is where the reporter was reading anyway. Their +excerpt interleaves `[17:25:23] opencodex service wrapper start` with the 503 +lines, so a `console.warn` lands in the stream they already quoted. + +### Dedup, and the site that has no reason + +Deduped per distinct reason: the reporter's log shows three 503s in eleven +seconds and a real client retries harder than that. A per-request line buries +the signal it exists to produce. Dedup state is module-level, so the module +exports a reset for tests — otherwise the second test in a Bun process observes +nothing and the assertion passes vacuously. + +The third throw site, `:326`, fires when `claimMainProfile()` refuses. That is +the **turn-drain** fence (`lifecycle.ts:180`), not the startup gate — line 318 +has already established `nativeMainTrafficBlocked` is false there, so the +snapshot reads `ready` and carries no reason. + +That site must stay silent. Constructor capture gets this right by +construction: a reason is recorded only when the snapshot is `blocked`. A 503 +with no reason therefore means "not the startup fence", which is itself a +distinction #2108 needs — the reporter could not make it. + +Only `reason` is emitted. The snapshot also carries `homeId`, which is derived +from a profile directory path and has no business in a log line. + +## Why logging and not the mechanism + +The trigger is still unidentified. `040` names two candidates that settle to +different reasons: ACL fail-closed gives `owner-unavailable` +(`native-profile-startup.ts:138-139`), probe fail-closed gives +`ownership-unknown` (`:311`). One log line separates them. + +**No test anywhere asserts `owner-unavailable`.** It is produced at +`native-profile-startup.ts:139` and asserted nowhere in the suite — the +candidate branch we most need named in the field is the one with no coverage. + +## Test plan + +Drive the real gate, never a module mock — the existing tests already do this +(`tests/codex-auth-context.test.ts:250-254` via +`initializeNativeMainStartupGate({ probeRecoveryState: () => "manual" })`, +`tests/native-profile-startup.test.ts:291-292` via +`blockNativeMainStartupForUnownedServiceHome`). Capture the log with a local +`spyOn(console, "warn")`, the repo idiom (`app-owned-memory.test.ts:237`). + +1. `ownership-unknown` fence → `err.reason` is `ownership-unknown` **and the + warn fires carrying it**. The log assertion is the deliverable; without it + this cycle ships something unobserved. +2. `foreign-ownership` fence → same, different value. Proves the value is read + rather than hardcoded. +3. Second construction under the same reason → no second warn. Pins the dedup. +4. No fence active → no reason, no warn. Pins the `:326` turn-drain case. +5. Message, status, `Retry-After` unchanged. This is the claude-messages guard. + Worth knowing it is weaker than it sounds: grepping + `tests/claude-messages-endpoint.test.ts` for `nativeMainFence`, `529`, or the + message constant returns nothing, so the 503→529 remap has no direct + regression test of its own. + +**Red-drive is mandatory.** Lesson 1 of this session: the first #1527 attempt +patched a helper that was not on the failure path and all ten tests still +passed. Each assertion must be seen failing before the fix. + +## Verification + +``` +bun test tests/codex-auth-context.test.ts tests/native-profile-startup.test.ts tests/native-profile-drain-server.test.ts +bun x tsc --noEmit +bun run privacy:scan +``` + +`privacy:scan` is not ceremony here — this cycle adds a log statement, which is +the exact thing that scan exists to police. + +CI is ignored this cycle by standing instruction while the merge train churns; +local green is the judgment surface. + +## Stack position + +Stacks on `fix/cursor-abort-teardown` (#2118), targeting it. Retarget to `dev` +after the parents land. + diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index b74ef0bfa4..c67844dd50 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -12,7 +12,8 @@ import { ConfigMutationLockError } from "../config"; import { isCodexAccountUsable } from "./account-usability"; import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account"; -import { isNativeMainTrafficBlocked } from "./native-profile-startup"; +import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup"; +import type { NativeMainStartupBlockReason } from "./native-profile-startup"; import { codexQuotaScopeForModel, getCodexQuotaHealthSnapshot, @@ -116,12 +117,55 @@ export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE = "OpenCodex local native-main profile maintenance is active; retry this request"; export class CodexMainProfileDrainingError extends Error { + /** + * Which startup-gate state fenced this request, when one did. Undefined means the + * fence came from somewhere other than the startup gate — the turn-drain claim race + * throws this same error while the gate reads `ready`, and inventing a reason there + * would point the next report at a gate that never closed. + * + * Captured here rather than at the throw sites because this is the last moment it is + * both in scope and still true: every catch site has already lost it, and re-reading + * the gate later can observe a recovery that completed in between (#2108). + */ + readonly reason?: NativeMainStartupBlockReason; + constructor() { super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE); this.name = "CodexMainProfileDrainingError"; + const gate = nativeMainStartupGateSnapshot(); + if (gate.status !== "blocked") return; + this.reason = gate.reason; + reportNativeMainFenceReason(gate.reason); } } +/** + * #2108: a reboot could leave this fence closed until `ocx restart`, and the report was + * unactionable because the settled reason was never written anywhere. It cannot ride the + * message (claude-messages.ts matches that string exactly to keep the fence a 503 rather + * than an Anthropic 529) and it cannot ride a header (/api/logs reads only error.message + * from the body, and the Claude surface rebuilds its response headers from scratch), so + * stdout is the one surface that covers every path this fence fires on. + * + * Deduped per distinct reason: the original report shows three 503s in eleven seconds and + * a real client retries harder than that, so a per-request line would bury the signal. + * Only the reason is emitted; the snapshot's homeId is derived from a profile directory. + */ +const reportedFenceReasons = new Set(); + +function reportNativeMainFenceReason(reason: NativeMainStartupBlockReason): void { + if (reportedFenceReasons.has(reason)) return; + reportedFenceReasons.add(reason); + console.warn( + `native-main admission is fenced (reason: ${reason}); native model requests return 503 until it clears`, + ); +} + +/** Test-only: the dedup above is module state, so a second test would otherwise observe nothing. */ +export function __resetNativeMainFenceReasonLog(): void { + reportedFenceReasons.clear(); +} + export function codexMainProfileDrainingResponse(): Response { const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE); const headers = new Headers(response.headers); diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 0722a077dd..bf1349aafa 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -31,6 +31,10 @@ export type NativeMainStartupGateSnapshot = | "stage-cleanup-required"; }; +/** The settled reason a blocked gate carries, named so consumers can hold one without the union. */ +export type NativeMainStartupBlockReason = + Extract["reason"]; + export interface NativeMainStartupGateDeps { manager?: NativeProfileManager; /** Test-only barrier used to prove admission stays closed while startup recovery is pending. */ diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 6ff8b481fa..e57bff1766 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -13,6 +13,7 @@ import { CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, codexMainProfileDrainingResponse, + __resetNativeMainFenceReasonLog, cooldownErrorMessage, cooldownErrorResponse, headersForCodexAuthContext, @@ -54,6 +55,7 @@ import { import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { setIcaclsRunnerForTests } from "../src/lib/windows-secret-acl"; import { + blockNativeMainStartupForUnownedServiceHome, completeNativeMainRecovery, initializeNativeMainStartupGate, } from "../src/codex/native-profile-startup"; @@ -1346,3 +1348,62 @@ describe("cooldown error surface", () => { expect(cooldownErrorResponse(err, now).headers.get("Retry-After")).toBe("1"); }); }); + +// #2108: a Windows reboot can leave the native-main fence closed until `ocx restart`, and the +// reporter could not tell us WHICH gate reason settled because nothing ever logged it. The 503 +// message must stay byte-identical (claude-messages.ts:818 matches it to keep the fence a 503 +// instead of remapping to Anthropic 529), and headers never survive to /api/logs, so stdout is +// the only surface that reaches every path this fence fires on. +describe("native-main fence names its gate reason", () => { + afterEach(() => { + __resetNativeMainFenceReasonLog(); + }); + + test("the thrown fence carries the settled reason and says so once", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); + try { + const err = new CodexMainProfileDrainingError(); + + expect(err.reason).toBe("ownership-unknown"); + expect(err.message).toBe(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE); + const lines = warn.mock.calls.map(call => call.join(" ")); + expect(lines.filter(line => line.includes("ownership-unknown"))).toHaveLength(1); + // The homeId is derived from a profile directory path and has no business in a log line. + expect(lines.join("\n")).not.toContain("homeId"); + + // A retrying client must not turn the diagnostic into the noise it was meant to cut. + new CodexMainProfileDrainingError(); + new CodexMainProfileDrainingError(); + expect(lines.length).toBe(warn.mock.calls.length); + } finally { + warn.mockRestore(); + await fence.release(); + } + }); + + test("a different fence reports a different reason, so the value is read and not assumed", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership"); + try { + expect(new CodexMainProfileDrainingError().reason).toBe("foreign-ownership"); + expect(warn.mock.calls.map(call => call.join(" ")).join("\n")).toContain("foreign-ownership"); + } finally { + warn.mockRestore(); + await fence.release(); + } + }); + + // auth-context.ts:326 throws the same error for the turn-drain fence (lifecycle.ts:180), which + // is NOT the startup gate: the snapshot there reads `ready`. Inventing a reason for it would + // send the next reboot report chasing a startup gate that never closed. + test("the turn-drain fence stays silent instead of borrowing a startup reason", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(new CodexMainProfileDrainingError().reason).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); From 60526d7aff32ab54958042ee814e982e69a20105 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:53:22 +0900 Subject: [PATCH 091/121] fix(cursor): do not re-label a completed turn as failed when the stream is aborted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only cancelCursorRun() sets expectedClose, so an ordinary completed turn never qualified for the benign-close path. The abort listener then failed the turn with 'Cursor request was aborted' — which is deliberately NOT a benign cancel, since a mid-turn abort is a real failure — so a turn whose terminal frame had already been emitted and whose messages had already been yielded still surfaced as turn-failed with expectedClose:false. Return instead of throwing when a terminal frame was already emitted AND the failure is an abort. Both halves matter: post-terminal alone would change what the adapter sees for genuine faults, and abort alone would swallow a real mid-turn abort where nothing was delivered. Deliberately narrow. A benign cancel after a terminal is already swallowed one layer up (cursor.ts:183), and the existing contract test that pins 'the transport still throws the raw cancel after a terminal' keeps passing — this does not widen that path. Refs #1527. This is the teardown-misclassification slice only; the kimi-k3 collapse and the 429 asymmetry are separate and need live acceptance work that cannot start until #2054 lands. --- src/adapters/cursor/cursor-errors.ts | 15 ++++++++++++++ src/adapters/cursor/live-transport.ts | 15 +++++++++++++- tests/cursor-cancel-provenance.test.ts | 27 +++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index 294adc4a7c..f2e13c578c 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -85,6 +85,21 @@ export function isCursorBenignCancelError(value: unknown): boolean { return false; } +/** + * True when the turn was torn down by an `AbortSignal` rather than by a transport fault. + * + * This is deliberately NOT part of `isCursorBenignCancelError`: an abort mid-turn is a real + * failure and must still surface. It is only meaningful in combination with a terminal frame + * having already been emitted, where it means "the answer landed and then the connection went + * away" (#1527). + */ +export function isCursorAbortError(value: unknown): boolean { + const message = errorMessage(value).toLowerCase(); + if (message.includes("cursor request was aborted")) return true; + const name = (value as { name?: unknown })?.name; + return typeof name === "string" && name === "AbortError"; +} + /** * True when Cursor Connect rejected the turn with invalid_argument. * Seen after stepCompleted on brittle external-model continuations. diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 3f8ce957a9..5775de025e 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -48,7 +48,7 @@ import { type InteractionResponse, } from "./gen/agent_pb"; import { debugProviderDiagnostic } from "../../lib/debug"; -import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors"; +import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors"; import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { @@ -650,6 +650,18 @@ class LiveCursorTransport implements CursorTransport { // A CANCEL is benign only on the client-tool suspend path (expectedClose); an // unexpected server-side NGHTTP2_CANCEL must surface as a real transport error. if (this.expectedClose && isCursorBenignCancelError(failure)) return; + // A teardown error arriving AFTER the turn's terminal frame describes the connection, + // not the turn: the answer is committed and every queued message has been yielded. + // + // Narrow on purpose. A benign cancel after a terminal is already swallowed one layer + // up (`cursor.ts:183`), so widening this to every post-terminal error would change + // what the adapter sees for genuine faults. What it does cover is the abort case + // from #1527: `signal.abort` fires `failAndClear(new Error("Cursor request was + // aborted"))`, which is NOT benign (`cursor-errors.ts:74`), so an ordinary completed + // turn that is then torn down still surfaced as `turn-failed` with + // `expectedClose:false`. Only `cancelCursorRun()` sets `expectedClose`, so a normal + // completion never qualified for the branch above. + if (this.emittedTerminal && isCursorAbortError(failure)) return; throw attachPartialUsage(classifyTurnFailure(failure), state); } if (done) break; @@ -659,6 +671,7 @@ class LiveCursorTransport implements CursorTransport { } if (failure) { if (this.expectedClose && isCursorBenignCancelError(failure)) return; + if (this.emittedTerminal && isCursorAbortError(failure)) return; throw attachPartialUsage(classifyTurnFailure(failure), state); } } diff --git a/tests/cursor-cancel-provenance.test.ts b/tests/cursor-cancel-provenance.test.ts index 548bbd3f05..c954f64184 100644 --- a/tests/cursor-cancel-provenance.test.ts +++ b/tests/cursor-cancel-provenance.test.ts @@ -33,6 +33,7 @@ function cancelError(): Error { async function runCancelTurn(opts: { emitTerminalFirst?: boolean; suspendFirst?: boolean; + failWith?: Error; }): Promise<{ messages: CursorServerMessage[]; failure?: Error }> { resetCursorBlobStateForTests(); const transport = createLiveCursorTransport({ @@ -73,7 +74,7 @@ async function runCancelTurn(opts: { if (opts.emitTerminalFirst) pushEvent({ type: "done", usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } }); // The client-tool suspend path cancels our own stream, which sets expectedClose. if (opts.suspendFirst) (transport as unknown as { cancelCursorRun(): void }).cancelCursorRun(); - failTurn(cancelError()); + failTurn(opts.failWith ?? cancelError()); await drain; transport.close?.(); return { messages, failure }; @@ -112,6 +113,30 @@ describe("Cursor cancel provenance", () => { expect(failure).not.toBeInstanceOf(CursorUnexpectedCancelError); expect(isCursorBenignCancelError(failure)).toBe(true); }); + + test("an abort after a terminal frame does not re-label a completed turn as failed (#1527)", async () => { + // Only cancelCursorRun() sets expectedClose, so an ordinary completed turn never + // qualified for the benign path. The abort listener then failed the turn with + // "Cursor request was aborted" — which is deliberately NOT a benign cancel — so a turn + // whose answer had already been delivered still surfaced as turn-failed with + // expectedClose:false in the request log. + const { messages, failure } = await runCancelTurn({ + emitTerminalFirst: true, + failWith: new Error("Cursor request was aborted"), + }); + + expect(messages.some(m => m.type === "done")).toBe(true); + expect(failure).toBeUndefined(); + }); + + test("an abort BEFORE any terminal frame still fails the turn (#1527 guard)", async () => { + // The narrowing must not swallow a genuine mid-turn abort: nothing was delivered, so + // the caller has to hear about it. + const { failure } = await runCancelTurn({ failWith: new Error("Cursor request was aborted") }); + + expect(failure).toBeDefined(); + expect(failure?.message).toContain("aborted"); + }); }); describe("isCursorBenignCancelError provenance", () => { From 50c6ccdbe534e11a7b598a14c1ec256958d18633 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 23:07:32 +0900 Subject: [PATCH 092/121] docs(devlog): record the 2108 phase-1 implementation and the header the audit killed --- .../121_2108_implementation.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md b/devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md new file mode 100644 index 0000000000..864a3843ec --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/121_2108_implementation.md @@ -0,0 +1,95 @@ +# 121 — #2108 phase 1 implementation record + +Shipped: PR [#2121](https://github.com/lidge-jun/opencodex/pull/2121), commit +`18a383e8e`, branch `fix/native-main-gate-reason` stacked on +`fix/cursor-abort-teardown` (#2118). + +Issue disposition posted: +[#2108 comment 5343287759](https://github.com/lidge-jun/opencodex/issues/2108#issuecomment-5343287759). +`Refs`, not `Closes` — this makes the next occurrence diagnosable, it does not +stop the fence sticking. + +## What the plan audit changed + +Draft 1 of `120` proposed a response header `x-ocx-native-main-gate-reason` +alongside the log line. The A-gate audit returned **fail**, and the header was +dropped entirely. The refutation is worth keeping because it is not obvious: + +- The header cannot reach the caller without editing the five zero-arg + `codexMainProfileDrainingResponse()` call sites — and `core.ts`/`compact.ts` + are both edited by #2101, so the collision the whole design existed to avoid + came straight back. +- Having the response builder re-read the gate instead *looks* like a one-file + fix, but `completeNativeMainRecovery()` can flip the snapshot to `ready` + between throw and catch. The header would go blank or wrong exactly when + recovery is racing, which is the scenario #2108 is about. +- It would not reach the Claude surface anyway: `claude-messages.ts:823-829` + rebuilds its response with a hand-written header object. + +The audit also caught a third throw site the plan had missed (`:326`, +turn-drain), that the `console.warn` assertion — phase 1's entire purpose — was +absent from the test plan, and that `privacy:scan` was missing from a cycle +whose only product is a log statement. + +Round 2 returned **pass**, and the reviewer went further than asked: it copied +`src`/`tests` to a scratch tree and mutated them there. Removing the +`console.warn` body goes red (2 fail); removing the dedup check goes red. That +is stronger evidence than my own red-drive, because it tests the assertions +rather than the fix. + +## The shipped shape + +`src/codex/auth-context.ts:115-166` only, plus a derived type export in +`native-profile-startup.ts` and the tests. + +`CodexMainProfileDrainingError` reads `nativeMainStartupGateSnapshot()` in its +constructor, records `reason` when the gate is `blocked`, and warns once per +distinct reason. Every call site — 3 constructions, 5 response builders — is +byte-identical. + +The `:326` turn-drain site stays silent by construction (early return on +`status !== "blocked"`), which makes a reasonless 503 mean "not the startup +fence". That distinction did not exist before and the reporter could not make it. + +## Collision outcome + +`git diff -U0` puts this change at old-file lines 15 and 118-124. #2101's +`auth-context.ts` hunks are 23-28, 237-242, 260-267, 273-278, 302-308, 312-318, +325-330. Zero overlap, no adjacency. `native-profile-startup.ts` is not among +#2101's 20 files. In the test file both PRs add imports around lines 59-63, +which merges cleanly. + +## Verification + +204 pass / 0 fail / 903 expect() across the five native-main gate suites plus +`chat-completions-endpoint` and `claude-messages-endpoint`. `tsc --noEmit` +exit 0. `privacy:scan` exit 0. + +Red-drive: with the fix staged as a no-op export, 2 fail on +`reason === undefined` and the silence case passed — the assertions were doing +the work, not a module-load error. + +**Coverage caveat, stated in the PR too:** macOS only. `owner-unavailable` is a +Windows icacls path, and nothing in the suite asserts it — it is produced at +`native-profile-startup.ts:139` and asserted nowhere. The branch the reporter +most likely hit is the one with no coverage, which is the argument for shipping +the diagnostic before the mechanism. + +## Stack correction worth recording + +The commit first landed on `fix/cursor-abort-teardown` itself rather than a new +branch. Corrected by `git switch -c fix/native-main-gate-reason` followed by +`git branch -f fix/cursor-abort-teardown origin/fix/cursor-abort-teardown` — +resetting the local branch to its already-pushed head, no force-push, no +contributor branch touched. Caught by reading `git log` after the commit rather +than assuming the branch was where I left it. + +## What phase 2 needs + +A field report that names a reason. Phase 2 makes a boot-time `unknown` +retryable while `OCX_SERVICE=1` instead of a process-lifetime fence, keeping +genuine `foreign` fail-closed with a retry cap. Two narrower fixes stand on +their own: a timed-out `sc.exe query` with WinSW xml and exe both absent must +not mark the machine `unknown`, and a second ACL `ETIMEDOUT` should back off +and retry so a warm icacls reopens the gate without `ocx restart`. + From b5712840282bd01fd7ebc87220dd273f2688656a Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 19 Aug 2026 21:54:40 +0900 Subject: [PATCH 093/121] docs(devlog): record the 1527 abort slice and the wrong-site edit the ablation caught --- .../090_1527_abort_slice.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md b/devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md new file mode 100644 index 0000000000..11f5760661 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/090_1527_abort_slice.md @@ -0,0 +1,67 @@ +# 090 — #1527 abort-teardown slice + +Branch: `fix/cursor-abort-teardown` off `fix/tray-registry-encoding`. +Commit: `346eaa80d`. PR: **#2118** → #2117 → #2116 → `dev`. + +## The plan pointed at the wrong line, and the ablation is what caught it + +`000` said the fix site was the abort listener calling +`failAndClear("Cursor request was aborted")` at `live-transport.ts:1157`. That +reads correctly and is wrong. + +Patching `failAndClear` left **all ten existing tests passing**. That is the +signal: a change to the actual failure path could not have been invisible. The +injected failure never reaches that helper — the `open()` seam's `fail` callback +at `:627` writes a local `failure` variable, and the throw happens later inside +`run()` at `:651` and `:661`. + +Without the ablation this would have shipped as a green no-op. It is the same +lesson the campaign already recorded twice, arriving a third time in a new +costume: **a passing suite after a change proves nothing until you have seen +that suite fail.** + +## The second correction: unconditional was too wide + +First working version returned on `emittedTerminal` alone. That broke an +existing contract test: + +> "a cancel after a terminal was already emitted does not add a second one" — +> asserts the transport **still throws** the raw cancel. + +That test is deliberate. The adapter's benign check (`cursor.ts:183`) swallows a +raw cancel one layer up, so the transport throwing it is how provenance stays +intact without a second terminal reaching the bridge. + +Narrowed to `emittedTerminal && isCursorAbortError(failure)`. Both halves carry +weight: + +| Condition | Without it | +|---|---| +| `emittedTerminal` | a mid-turn abort would be swallowed — nothing delivered, caller never told | +| `isCursorAbortError` | every post-terminal fault would change shape for the adapter | + +## Why an abort was not already covered + +`isCursorBenignCancelError` deliberately excludes aborts — a mid-turn abort *is* +a real failure. And `expectedClose` is set only by `cancelCursorRun()`, so an +ordinary completion never qualified. A completed turn torn down afterwards fell +through both guards and was reported `turn-failed` with `expectedClose: false`. + +## Verification + +``` +bun test cursor-cancel-provenance + cursor-eof-terminal + + cursor-adapter + cursor-errors 46 pass / 0 fail +bun x tsc --noEmit exit 0 +``` + +Ablation: removing both guards gives `7 pass / 1 fail` — exactly the new +post-terminal-abort test, nothing else. + +## Scope + +This is one of five residual parts of #1527 after #2054. The other four — +`kimi-k3` collapse at 79-95k, the `claude-fable-5` 429 asymmetry, full-replay on +first turn/restart/compaction, and request-shape parity — are acceptance work +that cannot start until #2054 lands, and the 429 half may be unprovable while +Connect hides `cache_read_tokens`. Hence `Refs`, not `Closes`. From 745f6f6354578d293500281d8d7b61b821313c64 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 19 Aug 2026 23:27:11 +0900 Subject: [PATCH 094/121] docs(devlog): record the CI proxy-env leak and the ablation that killed the first theory --- .../130_ci_proxy_env_leak.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md b/devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md new file mode 100644 index 0000000000..a25c547f70 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/130_ci_proxy_env_leak.md @@ -0,0 +1,110 @@ +# 130 — CI red: the proxy env leak that made 73 Lab tests fail + +Found because the user asked why PR #2116 was red. It was not flake, and it was +not inherited from `dev`. + +## What the checks actually said + +| PR | `macos` | failures | +|---|---|---| +| #2116 / #2117 / #2118 | fail | **73** | +| `dev` @ `0fc8d136e` | fail | **1** (`provider request pacing queue`) | + +So `dev` was red too, which is what made this easy to wave off. But 73 ≠ 1, and +the 73 were all Lab/fabric tests that #2116 never touches. + +## The failure + +``` +LabSandboxError: proxy environment variable HTTP_PROXY is forbidden + code: "harness_failure" + at rejectProxyEnvironment (src/lab/live/sandbox.ts:14) + at runFabricSyntheticPatchTaskInternal (src/lab/fabric/executor.ts:157) +``` + +The Lab sandbox refuses to run if any proxy variable is set on the live +`process.env` — it must not dial out through a proxy. Correct, and it was doing +its job. + +## The cause + +The #2107 tests set the real environment and restored it in a `finally`: + +```ts +const saved = { ...process.env }; +try { + process.env.HTTP_PROXY = "http://127.0.0.1:7890"; + ... +} finally { /* restore */ } +``` + +That reads as airtight. It is not, because **`bun test a.test.ts b.test.ts` +runs every file in one process, and `--isolate` does not change that.** The +variable outlived the file, and every Lab file loaded afterwards died on an +environment it never touched. + +## Isolating it + +The bisect that settled it, all on our branch: + +| Run | Result | +|---|---| +| Lab suites alone | 42 pass / 0 fail | +| `service.test.ts` + Lab suites | 39 fail | +| Same pair on `origin/dev` (our commits absent) | 144 pass / **0 fail** | + +The third row is the one that mattered: same files, same machine, our commits +removed, green. That converts "CI is flaky" into "we broke it". + +A probe file printing `process.env` at module-evaluation time then showed +`HTTP_PROXY` already set **before** `service.test.ts`'s own tests ran, which is +what proved the leak was cross-file rather than a bad `finally`. + +## A wrong turn worth recording + +The first fix assumed Bun's `{ ...process.env }` yields `null` rather than +`undefined` for absent keys, so the restore's `=== undefined` check took the +wrong branch. A probe did print `null` — but that was the *restore loop's own +output*, not the snapshot. Ablation killed the theory: with and without the +"fix", 50 fail / 50 fail, byte-identical. A change that does not move the number +is not a fix, however good the story is. + +## The actual fix + +Stop mutating global state to test a pure function. + +`buildUnit()` and `buildPlist()` now take the resolved proxy entries as a +parameter defaulting to `resolvedProxyEnv()`. Production behavior is unchanged +— the default is the old call — and the tests hand in a literal environment +instead of assigning onto `process.env`. `resolvedProxyEnv()` already accepted +an `env` argument; it is now exported so a test can use it the way the runtime +does. + +A third assertion was added while the seam was open: a lower-case `http_proxy` +must be baked under the canonical upper-case name. That behavior was implemented +and documented in #2107 but never asserted. + +## Verification + +The five suites that carried the failure — `service`, `lab-live-probe`, +`lab-fabric-task`, `lab-automation`, `api-key-attribution` — go **50 fail → 0 +fail, 236 pass**. `tsc --noEmit` exit 0. + +Full suite runs on `ssh lidge` per standing instruction, not the workstation. + +## Stack consequence + +The fix belongs to #2116, the bottom of the stack, so it was committed there +(`2d7b945b6`) and the other three branches were rebased onto it. All four +force-pushed with `--force-with-lease`. + +`dev`'s own single failure (`provider request pacing queue`) is a separate +matter and is not ours to fix inside this stack. + +## What this changes about the working rule + +The standing instruction was to ignore CI while the merge train churns and judge +from local green. That was right for a churning `dev` — but "ignore CI" cannot +mean "do not look at CI". Local green missed this entirely, because the local +runs were per-suite and the defect only exists across suites in one process. + From 163692a19b48ade546845dc2d5ef5c37ce55952b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:10:32 +0900 Subject: [PATCH 095/121] fix(service): write service definitions owner-only, they can carry a proxy credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2107 baked the outbound proxy environment into the installed service definition. A proxy URL routinely carries user:password, which quietly made those files credential-bearing — and they were still written with a bare writeFileSync, so under the default umask 022 they landed at 0644, world-readable on a shared host. The precedent was already in the same file and was not followed: the service API token and the install state both write { mode: 0o600 } plus a chmodSync. This routes the plist, the systemd unit, and the Windows scheduler assets through one writeServiceDefinitionFile() that does the same. The explicit chmodSync is not redundant. mode applies only when a file is created, so an install over a definition left at 0644 by an earlier version would otherwise keep the loose mode — which is the realistic upgrade path here, not a hypothetical. Also in this change, both from the same audit: buildWindowsServiceScript now takes the resolved proxy entries the way buildUnit and buildPlist already do. It was the only one of the three builders with no proxy assertion at all, because the only way to reach it was to assign process.env — the exact pattern whose leak this stack just finished removing. It now has a regression covering the canonical-name rule. __resetNativeMainFenceReasonLog is documented as an order-sensitive contract rather than a convenience, and its caller resets on both sides. The dedup set is process-lifetime module state: whichever file constructs the error first consumes the one-shot warn, so an afterEach in the asserting file would not have saved a later assertion from passing vacuously. Verification: red-driven — with the mode argument removed the three new assertions report 644 against an expected 600. After: 333 pass / 0 fail across service, codex-auth-context, the three Lab suites, api-key-attribution and doctor. tsc --noEmit exit 0. privacy:scan exit 0. --- src/codex/auth-context.ts | 10 ++++- src/service.ts | 35 ++++++++++++--- tests/codex-auth-context.test.ts | 8 +++- tests/service.test.ts | 74 ++++++++++++++++++++++++++++++-- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index c67844dd50..1f8b120112 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -161,7 +161,15 @@ function reportNativeMainFenceReason(reason: NativeMainStartupBlockReason): void ); } -/** Test-only: the dedup above is module state, so a second test would otherwise observe nothing. */ +/** + * Test-only reset for the dedup set above. + * + * The dedup is process-lifetime module state, so it is order-sensitive across test files + * sharing one Bun process: whichever file constructs this error first consumes the one-shot + * warn, and a later file asserting on it would see nothing and pass vacuously. Any test that + * asserts on the warn must call this first — an `afterEach` in the asserting file is not + * enough on its own, because the consuming file may not be the asserting one. + */ export function __resetNativeMainFenceReasonLog(): void { reportedFenceReasons.clear(); } diff --git a/src/service.ts b/src/service.ts index 37e77506dd..57610907b9 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1541,7 +1541,11 @@ function taskXmlRunLevelAcceptable(principal: string): boolean { return value === "leastprivilege" || value === "highestavailable"; } -export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { +export function buildWindowsServiceScript( + entry = cliEntry(), + port = resolveServiceListenPort(), + proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), +): string { // Provenance rides along with the entry: a second durableBunRuntime() call here could // resolve differently from the binary the caller actually baked. const { bun, bunRuntimeSource, cli } = entry; @@ -1559,7 +1563,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), - ...resolvedProxyEnv().map(({ name, value }) => windowsBatchSet(name, value)), + ...proxyEnv.map(({ name, value }) => windowsBatchSet(name, value)), windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"), windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"), windowsBatchSet("OCX_BUN", bun, "path"), @@ -1881,7 +1885,7 @@ function installLaunchd(): void { // Capture this BEFORE writing: the write below makes the plist exist unconditionally, // so a post-write existsSync would call every fresh install an "installed" service. const wasInstalled = existsSync(p); - writeFileSync(p, buildPlist(), "utf8"); + writeServiceDefinitionFile(p, buildPlist(), "utf8"); // Best-effort: an absent job is fine here, and a failed unload is caught by the // load verification below with a better message than a raw unload error. runLaunchctl(["unload", p]); @@ -1944,6 +1948,27 @@ function uninstallLaunchd(): void { if (existsSync(p)) unlinkSync(p); } +/** + * Write a service definition with owner-only permissions. + * + * These files carry the outbound proxy environment (#2107), and a proxy URL routinely + * carries `user:password`. `writeFileSync` without a mode lands at 0644 under the default + * umask, so the credential would be world-readable on a shared host. Every other + * secret-bearing write in this file already uses 0600 — the service API token and the + * install state — and a service definition holding a proxy credential belongs in the same + * class. + * + * The explicit `chmodSync` is not redundant: `mode` only applies when the file is + * created, so an install over a definition left at 0644 by an earlier version would keep + * the loose mode. On Windows the POSIX bits are advisory, so the real ACL is applied + * there the same way the token file does it. + */ +export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void { + writeFileSync(path, content, { encoding, mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort; the Windows ACL below is authoritative */ } + if (process.platform === "win32") hardenSecretPath(path, { required: false }); +} + // ── Windows (Task Scheduler) ── /** * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows @@ -1952,7 +1977,7 @@ function uninstallLaunchd(): void { function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void { for (let attempt = 0; ; attempt++) { try { - writeFileSync(path, content, encoding); + writeServiceDefinitionFile(path, content, encoding); return; } catch (err) { const code = (err as NodeJS.ErrnoException).code; @@ -2520,7 +2545,7 @@ function installSystemd(): void { recordOwnedConfigPath(getConfigDir(), serviceStatePath()); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); - writeFileSync(unitPath(), buildUnit(), "utf8"); + writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8"); sh("systemctl --user daemon-reload"); sh(`systemctl --user enable ${TASK}`); sh(`systemctl --user restart ${TASK}`); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index e57bff1766..ade8606a40 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -1355,6 +1355,12 @@ describe("cooldown error surface", () => { // instead of remapping to Anthropic 529), and headers never survive to /api/logs, so stdout is // the only surface that reaches every path this fence fires on. describe("native-main fence names its gate reason", () => { + // Reset on BOTH sides: an afterEach only protects tests that run after this file, and the + // dedup is module state shared with every other file in the same process. + beforeEach(() => { + __resetNativeMainFenceReasonLog(); + }); + afterEach(() => { __resetNativeMainFenceReasonLog(); }); @@ -1394,7 +1400,7 @@ describe("native-main fence names its gate reason", () => { } }); - // auth-context.ts:326 throws the same error for the turn-drain fence (lifecycle.ts:180), which + // The claimMainProfile() site throws the same error for the turn-drain fence, which // is NOT the startup gate: the snapshot there reads `ready`. Inventing a reason for it would // send the next reboot report chasing a startup gate that never closed. test("the turn-drain fence stays silent instead of borrowing a startup reason", () => { diff --git a/tests/service.test.ts b/tests/service.test.ts index 32ba00c47c..04c8ef5ed3 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { isAbsolute, join, posix, win32 } from "node:path"; import * as serviceModule from "../src/service"; @@ -7,7 +7,7 @@ import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; -import { resolvedProxyEnv } from "../src/service"; +import { resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST, recordOwnedConfigPath, removeOwnedConfigState } from "../src/lib/config-ownership"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -157,6 +157,23 @@ describe("systemd service unit", () => { expect(unit).not.toContain("http_proxy="); }); + test("the Windows wrapper bakes proxy env the same way the unit and plist do (#2107)", () => { + // This builder was the only one of the three with no proxy assertion, because the only way + // to reach it was to assign process.env — the pattern that leaked HTTP_PROXY across files. + const script = buildWindowsServiceScript( + { bun: "C:\\OpenCodex\\bun.exe", bunRuntimeSource: "bundled", cli: "C:\\OpenCodex\\cli.ts" }, + 10100, + resolvedProxyEnv({ HTTP_PROXY: "http://127.0.0.1:7890", no_proxy: "localhost" }), + ); + + expect(script).toContain("HTTP_PROXY=http://127.0.0.1:7890"); + // Lower-case spellings are baked under the canonical name, never both. + expect(script).toContain("NO_PROXY=localhost"); + expect(script).not.toContain("no_proxy="); + expect(script).not.toContain("HTTPS_PROXY="); + }); + + test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; const oldCodexSqliteHome = process.env.CODEX_SQLITE_HOME; @@ -199,7 +216,9 @@ describe("systemd service unit", () => { expect(startSystemd).toContain("ocx service install"); expect(startSystemd).toContain("process.exit(1)"); - const writeAt = installSystemd.indexOf('writeFileSync(unitPath(), buildUnit(), "utf8")'); + // The write goes through writeServiceDefinitionFile so the unit lands 0600: it can carry a + // proxy credential (#2107). What this test pins is the ORDER — write, then reload. + const writeAt = installSystemd.indexOf('writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8")'); const reloadAt = installSystemd.indexOf("systemctl --user daemon-reload"); const enableAt = installSystemd.indexOf("systemctl --user enable"); const restartAt = installSystemd.indexOf("systemctl --user restart"); @@ -2152,3 +2171,52 @@ describe("service serving confirmation", () => { }); }); }); + +// #2107 baked the outbound proxy environment into the installed service definition, and a +// proxy URL routinely carries user:password. That made these files credential-bearing, so +// they must not be written at the umask default. +describe("service definitions are not world-readable", () => { + const modeOf = (path: string): string => (statSync(path).mode & 0o777).toString(8); + + test("a freshly written definition is owner-only", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); + try { + const path = join(dir, "unit"); + writeServiceDefinitionFile(path, buildUnit(resolvedProxyEnv({ HTTP_PROXY: "http://u:p@127.0.0.1:7890" })), "utf8"); + + expect(modeOf(path)).toBe("600"); + // The credential is still written — this test pins who can read it, not that it is absent. + expect(readFileSync(path, "utf8")).toContain("u:p@127.0.0.1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an install over a loose definition from an older version tightens it", () => { + // `mode` applies only on creation, so a reinstall would otherwise leave 0644 standing. + const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); + try { + const path = join(dir, "plist"); + writeFileSync(path, "stale", { encoding: "utf8", mode: 0o644 }); + expect(modeOf(path)).toBe("644"); + + writeServiceDefinitionFile(path, buildPlist(resolvedProxyEnv({})), "utf8"); + + expect(modeOf(path)).toBe("600"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("utf16le scheduler assets take the same mode", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); + try { + const path = join(dir, "task.xml"); + writeServiceDefinitionFile(path, "\uFEFF", "utf16le"); + + expect(modeOf(path)).toBe("600"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 52f85f6652852097e5aa7ea2e40cd87431204e1e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:11:02 +0900 Subject: [PATCH 096/121] docs(devlog): record the final audit and the 0644 credential exposure it caught --- .../140_final_audit.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md b/devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md new file mode 100644 index 0000000000..c4c5c20fa2 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/140_final_audit.md @@ -0,0 +1,100 @@ +# 140 — final audit of the merged stack, and what it caught + +Run after #2116/#2117/#2118/#2121 landed on `dev`. Verdict: **fail**, and the +reason was not one of the four fixes. + +## What the audit confirmed + +- The proxy-env leak is genuinely dead. The auditor re-ran the five affected + suites from a clean `git archive origin/dev` — 236 pass / 0 fail — **and then + re-ran them without `--isolate`**, the exact single-process condition that + produced the original 73 failures. Still green. That second run is the one + that matters; the first only proves isolation hides it. +- #2121's gate reason cannot fire on the turn-drain path. +- Escaping holds across all three builders under newline, quote and `%` + injection. +- Full suite on `dev` head `fbc6f26a2`: **13,501 pass / 0 fail**, run on + `ssh lidge`. + +## What it caught — P1, and it is real + +#2107 baked the proxy environment into the installed service definition. A proxy +URL routinely carries `user:password`, so that change quietly made those files +credential-bearing. They were still written with a bare `writeFileSync`. + +Measured, not assumed: umask 022, `writeFileSync` with no mode → **0644**. + +The precedent was already in the same file and was not followed — the service +API token (`service.ts:387`) and the install state (`:190`) both write +`{ mode: 0o600 }` plus a `chmodSync`. The repo also has an explicit convention +against leaking this exact value: `collectProxyEnv` reports proxy presence as a +boolean so the URL never escapes, pinned by a `doctor` test asserting the +serialized rows never contain `"secret"`. + +So the change wrote a credential to a world-readable file in a codebase that +already treats 0600 as the standard for precisely this data. + +**The uncomfortable part is procedural.** #2116's own body disclosed the risk +and offered to gate on redaction. That question was never adjudicated — the PR +merged at `REVIEW_REQUIRED` with only bot comments. `AGENTS.md` requires +explicit security review for credential handling. Disclosing a risk in a PR body +is not the same as discharging it, and self-merging past your own open question +is how a known risk becomes a shipped one. + +### The fix + +One `writeServiceDefinitionFile()` for the plist, the unit, and the Windows +scheduler assets: `{ mode: 0o600 }` plus `chmodSync`, plus the Windows ACL. + +The explicit `chmodSync` is not belt-and-braces. `mode` applies only at +creation, so an install over a definition an earlier version left at 0644 would +keep the loose mode — and that is the realistic upgrade path, not a hypothetical. + +Red-driven: with the mode argument removed, the three new assertions report +`644` against an expected `600`. + +## P2 — the untestable builder was left untested + +`buildWindowsServiceScript` was the only one of the three builders with no proxy +assertion, and the reason is instructive: the only way to reach it was to assign +`process.env`, which is the exact pattern whose leak this stack had just removed. +The refactor fixed the leak where a test existed and left the untestable builder +untested. + +It now takes the resolved entries like the other two, with a regression covering +the canonical-name rule. + +## P2 — the new fix reintroduced the same structural class + +`reportedFenceReasons` in #2121 is process-lifetime module state — structurally +the same hazard as the proxy leak, one abstraction away. Whichever file +constructs the error first consumes the one-shot warn, so a later file asserting +on it would see nothing and **pass vacuously**. + +Current suites pass in both file orders, so this was latent rather than live. The +reset is now documented as an order-sensitive contract and its caller resets on +both sides. + +## P3 — pin-to-line comments were already wrong at merge + +`auth-context.ts:326` (actual: 357/363/370), `lifecycle.ts:180`, +`native-profile-startup.ts:138-139` (actual: 142-143) and `:311` (actual: 315). +Replaced with symbol names, which do not drift when a file moves. + +## The honest gap that remains + +No commit in this stack has a green cross-platform CI run of its own — the runs +were cancelled by successive force-pushes, and `dev`'s own run was still in +flight. Both Windows-specific behaviors this stack shipped are unverified on +Windows: the Windows proxy path had no test until now, and `owner-unavailable` — +the branch #2108 most needs named — is a Windows icacls path asserted nowhere in +the suite. + +Stating it rather than filing it as done. + +## The lesson worth keeping + +An audit that only re-runs what the author ran finds nothing. This one found the +P1 by asking a question the author never asked — *what mode is that file?* — and +then measuring it instead of reasoning about it. + From 49f0885b0b04c49a98ca30cc5d801e01b33ea4d5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:24:42 +0900 Subject: [PATCH 097/121] docs(devlog): close the unit with what shipped, what stayed open, and what is unverified --- .../150_outcome.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md b/devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md new file mode 100644 index 0000000000..3fe1fda88e --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/150_outcome.md @@ -0,0 +1,74 @@ +# 150 — outcome: what shipped, what stayed open, what is still unverified + +Closing record for the 260819 unclaimed-bug unit. Five PRs on `dev`. + +| PR | dev | issue | disposition | +|---|---|---|---| +| #2116 | `bdaf4463a` | #2107 | **closed** — proxy env baked into service definitions | +| #2117 | `c701cc7e6` | #1933 | open — decoding fixed, diagnosis unproven | +| #2118 | `239cae9e5` | #1527 | open — 1 of 5 residuals fixed | +| #2121 | `fbc6f26a2` | #2108 | open — diagnostic shipped, mechanism not | +| #2126 | `8e7b63387` | — | audit remediation | + +Only #2107 is closed, and that asymmetry is the point: three of the four fixes +are slices, and saying so on the issue is cheaper than a reopen. + +## The two defects our own work introduced + +Worth leading with these rather than the wins. + +**A cross-file test leak that read as CI flake.** The #2107 tests assigned proxy +variables onto the real `process.env` and restored them in a `finally`. `bun +test a b` runs every file in ONE process — `--isolate` does not change that — so +the values outlived the file, and the Lab sandbox rejects any live proxy variable +as `harness_failure`. 73 macOS failures, zero when the Lab suites ran alone. + +**A credential written world-readable.** #2107 made service definitions +credential-bearing (a proxy URL routinely carries `user:password`) while they +were still written with a bare `writeFileSync` — 0644 under umask 022, measured. +In a file where the API token and install state already use 0600, and in a repo +whose `collectProxyEnv` deliberately reports proxy presence as a boolean so the +URL never escapes. + +The second one merged. It was caught only because the final audit asked a +question the author never asked — *what mode is that file?* — and then measured +instead of reasoning. + +## What actually caught things + +- **Ablation, three times.** The #1527 fix looked right at `failAndClear` and all + ten tests still passed — that helper is not on the failure path. The first + proxy-leak theory (Bun spreading `null`) produced 50 fail / 50 fail with and + without the "fix". A number that does not move is not a fix. +- **Running the suite the way CI runs it.** Per-suite local green missed the leak + entirely, because the defect only exists across suites in one process. +- **Removing our commits.** The same two files on `origin/dev` went 144 pass / 0 + fail. That is what converts "CI is flaky" into "we broke it". +- **An auditor that re-derives rather than re-runs.** Round 1 returned fail on + #2121's design; the final round found the P1 and mutation-tested our + assertions in a scratch tree to prove they were not vacuous. + +## Still unverified, stated rather than filed as done + +Everything below is real and none of it is closed by this unit. + +- **Windows.** The Windows ACL half of the 0600 fix and the `utf16le` scheduler + assets are asserted for mode on POSIX only. `owner-unavailable` — the branch + #2108 most needs named — is a Windows icacls path asserted nowhere in the + suite. +- **#1933's diagnosis.** The encoding mechanism is proven in code; that it is + *this reporter's* problem is inference from a GitHub display name. If their + path is ASCII, the diagnosis is wrong and the issue should stay open. +- **#1527's other four residuals.** kimi-k3 collapse and the 429 asymmetry depend + on #2054. The 429 half may be unprovable while Connect withholds + `cache_read_tokens`. +- **#2108 phase 2.** Waiting on a field report that names a reason. Two candidate + triggers, and phase 2 targets one of them. +- **wp1587**, the deferred tool catalog, was never implemented. Both its blockers + cleared, so it is ready, not done. + +## Final state + +`dev` full suite on `ssh lidge`: **13,505 pass / 15 skip / 0 fail** across 855 +files. Cross-platform CI green on the merged head. + From b6b219c8714b4b5d925e334f108f8efdb601ac8e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:56:02 +0900 Subject: [PATCH 098/121] fix(probe): read the unit off disk when the session bus cannot answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A laptop where systemctl exists but the user session bus does not respond gets every native request answered with 503 until ocx restart. inspectSystemd called every non-zero exit unknown, ownership-preflight turned that into ownership unknown, and startServer fenced native-main for the process lifetime. The comment on that branch was right that a non-zero status means the question never reached the bus. That is the reason the verdict is wrong: it is evidence about the bus, not evidence that a foreign service owns this home. Widening on the exit code alone would fail open. With the bus down systemctl cannot see a foreign unit either, so "no answer" would be read as "no owner" on a machine that genuinely has one. The fix asks the disk instead, which needs no bus: the unit file is proof of installation, and the homes it names are what ownership is actually decided on. No unit file means absent. A unit naming a foreign home stays present and still blocks. Registration is reported as absent on this path rather than invented. The disk cannot say whether systemd has the unit loaded, and guessing there is how a stale claim would slip through. Refs #2114 Known limitation, stated rather than hidden: systemd localizes these stderr strings, so a non-English host will not match and keeps the old unknown. That fences rather than admits, which is the safe direction, but it does mean the fix does not reach every affected user. Forcing LC_ALL=C on the probe would remove the caveat and is the obvious follow-up; it is not done here because it changes every systemctl call this module makes. Coordination: open draft PR #2029 edits the same function for #1939 and classifies two other bus messages as absent. This branch does not touch that PR or its branch. Its two classifications and this one agree in direction; if it lands first, this reconciles with it rather than replacing it. Verification: red-driven — three assertions fail before the change (present vs unknown), with the non-bus control passing throughout. 54 pass / 0 fail on the probe suite, 14 pass / 0 fail on service-probe-docker and native-profile-startup, tsc --noEmit exit 0. The pre-existing assertion that pinned this shape as unknown is amended to a non-bus stderr rather than deleted, so the rule it protects still holds. --- src/service-manager-probe.ts | 69 +++++++++++++++++++++++ tests/codex-service-manager-probe.test.ts | 55 +++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 8446c78ae5..a300bbeaed 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -179,6 +179,66 @@ function unitEnvValue(body: string, key: string): string | null { return null; } +/** + * Did `systemctl --user` fail because the session bus could not be reached at all? + * + * These are the shapes reported on #2114 and #1939. The distinction that matters is + * "the question never left the machine" versus "systemd answered and said no" — only + * the former licenses reading the disk instead. + * + * **Locale caveat, stated rather than hidden:** systemd localizes these strings, so a + * non-English host will not match and keeps the old `unknown`. That is the safe + * direction — it fences rather than admits — but it does mean the fix does not reach + * every affected user. Forcing `LC_ALL=C` on the probe would remove the caveat and is + * the obvious follow-up; it is not done here because it changes every systemctl call + * this module makes, not just this branch. + */ +function busUnreachable(stderr: string): boolean { + const err = stderr.trim(); + return err.includes("Failed to connect to bus") + || err.includes("Failed to connect to user scope bus") + || err.includes("Failed to get D-Bus connection") + || err.includes("DBUS_SESSION_BUS_ADDRESS") + || err.includes("System has not been booted with systemd"); +} + +/** + * Ownership from the unit file alone, for when the bus cannot answer (#2114). + * + * A unit file is proof of installation that does not require a running bus, and the homes + * it names are what ownership is actually decided on. What the disk cannot tell us is + * whether systemd has the unit LOADED, so this reports `registration: "absent"` — the + * honest reading of "no running manager has it" — rather than inventing a live state. + * + * A foreign home therefore still blocks, which is the whole reason this consults the disk + * instead of widening the exit code. + */ +function inspectSystemdOffline(definitionPath: string): ServiceManagerInstallation { + const presence = artifactPresence(definitionPath); + if (presence === "absent") return { kind: "absent" }; + if (presence === "unreadable") { + return unknown("the session bus is unreachable and the systemd unit could not be read"); + } + let body: string; + try { + body = readFileSync(definitionPath, "utf-8"); + } catch (error) { + return unknown(`the session bus is unreachable and the systemd unit could not be read: ${String(error)}`); + } + return { + kind: "present", + claims: [{ + backend: "systemd", + definitionPath, + homes: { + codexHome: unitEnvValue(body, "CODEX_HOME"), + opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"), + }, + registration: "absent", + }], + }; +} + function inspectLaunchd(deps: Required>): ServiceManagerInstallation { const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`); @@ -269,6 +329,15 @@ function inspectSystemd(deps: Required>): Servic if (shown.status !== 0) { // A missing unit still exits ZERO and says not-found; a non-zero status means // the question never reached the bus. + // + // That is evidence about the BUS, not evidence that a foreign service owns this home + // (#2114). Calling it `unknown` fences native-main for the whole process, so a laptop + // with no session bus answers every native request with a 503 until `ocx restart`. + // + // Widening on the exit code alone would fail open, because with the bus down systemctl + // cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back + // to `unknown` for every other non-zero exit. + if (busUnreachable(shown.stderr)) return inspectSystemdOffline(definitionPath); return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`); } diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index d1c5035360..92484dfed9 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -274,7 +274,10 @@ describe("could not ask is not an answer", () => { * reached the bus, which is the opposite conclusion. */ test("a non-zero systemctl status is unknown even though a missing unit exits zero", () => { - const { run } = recorder(() => ({ status: 1, stderr: "Failed to connect to bus" })); + // Amended for #2114, not deleted: the rule still holds for every non-zero exit whose + // stderr does not prove the bus itself was unreachable. The bus-down family is handled + // by reading the unit file instead, and is asserted separately below. + const { run } = recorder(() => ({ status: 1, stderr: "Job for opencodex-proxy.service failed" })); expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); }); @@ -1008,3 +1011,53 @@ describe("ownership refuses what it cannot prove", () => { expect(result.ownership).toBe("owned"); }); }); + +/* + * #2114: systemctl exists and runs, but the user session bus does not answer. + * + * The old branch called every non-zero exit `unknown`, which fences native-main for the + * whole process — the reporter's 503. But "the question never reached the bus" is evidence + * about the BUS, not evidence that a foreign service owns this home. + * + * Widening the exit code alone would fail open, because with the bus down systemctl cannot + * see a foreign unit either. So the classification asks the DISK, which needs no bus. + */ +describe("systemd probe: the bus is unreachable (#2114)", () => { + const BUS_DOWN = "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; + + test("no unit file on disk means nothing can own this home — absent, not fenced", () => { + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("absent"); + }); + + test("a unit naming THIS home is still ours, read off disk", () => { + const definitionPath = writeUnit(join(home, ".codex"), join(home, ".opencodex")); + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + const result = inspectServiceManagerInstallation({ run, platform: "linux", home }); + + expect(result.kind).toBe("present"); + // Registration is genuinely unknowable with the bus down; the claim must not invent it. + expect(result.kind === "present" && result.claims[0]?.definitionPath).toBe(definitionPath); + expect(result.kind === "present" && result.claims[0]?.homes.codexHome).toBe(join(home, ".codex")); + }); + + // The guard that keeps this fail-closed. A foreign unit is exactly the case the old + // `unknown` existed to protect, and it must survive the widening. + test("a unit naming a FOREIGN home still blocks", () => { + writeUnit("/other/.codex", "/other/.opencodex"); + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + const result = inspectServiceManagerInstallation({ run, platform: "linux", home }); + + expect(result.kind).toBe("present"); + expect(result.kind === "present" && result.claims[0]?.homes.codexHome).toBe("/other/.codex"); + }); + + test("a non-bus failure is untouched — it stays unknown", () => { + const { run } = recorder(() => ({ status: 1, stderr: "Job for opencodex-proxy.service failed" })); + + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); + }); +}); From 7e95fc6f44cd71c76768c1a7018d258a5c2940c1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:57:23 +0900 Subject: [PATCH 099/121] fix(probe): an unaskable WinSW query with no assets on disk is absence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One of the two triggers behind #2108: after a reboot, a scheduler-only Windows install answers every native request with 503 until ocx restart. WinSW is an optional backend. A scheduler-only install has neither its XML nor its exe on disk, but the probe still runs sc.exe query for it, and a query that timed out returned unknown. That verdict outranks the disk, ownership-preflight turns it into ownership unknown, and startServer fences native-main for the whole process lifetime. A query we could not ask is a question about a service that cannot exist. With both assets absent there is nothing for a registration to belong to, so the disk answers it. The disk outranks the unaskable query only when BOTH assets are gone. Either one present means a real install may be there, and the old unknown still holds — that is the guard, and it has its own test. Refs #2108 This is the narrow half of phase 2. The broader change, making a boot-time unknown retryable while OCX_SERVICE=1 instead of a process-lifetime verdict, is not in this commit. Verification: red-driven, and ablated afterwards — reverting just the new branch puts the assertion back to failing, so the test is bound to this code rather than passing incidentally. 56 pass / 0 fail on the probe suite, tsc --noEmit exit 0. --- src/service-manager-probe.ts | 10 ++++++ tests/codex-service-manager-probe.test.ts | 43 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index a300bbeaed..7cd61dea9d 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -798,6 +798,16 @@ function walkWinswChain( const registration = probeWinswRegistration(deps); if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" }; + // A query we could not ask is a question about a service that cannot exist: WinSW is an + // optional backend, and with neither its XML nor its exe on disk there is nothing for a + // registration to belong to. Fencing here on an `sc.exe` timeout is one of the two + // triggers behind #2108, where a scheduler-only install answers 503 until `ocx restart`. + // + // The disk outranks the unaskable query only when BOTH assets are gone. Either one + // present means a real install may be there and the old `unknown` still holds. + if (registration === "unknown" && xml === "absent" && exe === "absent") { + return { kind: "absent" }; + } if (registration === "unknown") { return unknown("the native WinSW service registration could not be verified"); } diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 92484dfed9..615f1e68ff 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -1061,3 +1061,46 @@ describe("systemd probe: the bus is unreachable (#2114)", () => { expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); }); }); + +/* + * #2108: a reboot leaves native-main fenced until `ocx restart`. + * + * One trigger is a timed-out `sc.exe query`. WinSW is an optional backend, and a + * scheduler-only install has neither of its assets on disk — but a query that timed out + * returns "unknown", which outranks the disk and fences the whole process. + * + * With BOTH assets absent there is nothing for a WinSW registration to belong to, so a + * failed query is a question about a service that cannot exist. + */ +describe("WinSW probe: a timed-out query with no assets (#2108)", () => { + test("no xml and no exe means absent, even when the query could not be asked", () => { + // Only the WinSW query is unaskable. The scheduler answers absent for itself, so the + // whole verdict turns on whether the WinSW half fences over assets that are not on disk. + const { runRaw } = recorder((file, args) => args[0] === "/query" + ? { status: 1, stderr: "ERROR: The system cannot find the file specified." } + : { timedOut: true }); + + const result = inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, + winswStatus: () => "unknown", + }); + + expect(result.kind).toBe("absent"); + }); + + test("an unaskable query with WinSW assets present is still unknown", () => { + const dir = join(home, ".opencodex", "winsw"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencodex-proxy.xml"), "opencodex-proxy"); + writeFileSync(join(dir, "opencodex-proxy.exe"), "MZ"); + + const { runRaw } = recorder(() => ({ timedOut: true })); + + const result = inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, + winswStatus: () => "unknown", + }); + + expect(result.kind).toBe("unknown"); + }); +}); From e95b8cf680fdfa3515fccde6836c00130c51c1a8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 00:59:53 +0900 Subject: [PATCH 100/121] fix(codex): let an unknown ownership fence re-ask instead of holding for the process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of #2108. A Windows reboot leaves native-main fenced and every native request answers 503 until ocx restart, which is the one thing that reliably cures it. The reason restart works is the whole bug: startServer takes the ownership verdict once, at boot, and holds it for the process lifetime. Waiting cannot help, because nothing re-asks. That is correct for foreign-ownership. A foreign owner is a fact, and re-asking would only hand a determined caller a second chance at a boundary that exists to refuse it. It is wrong for ownership-unknown, which does not say this host is unownable — it says the probe could not answer. So an unknown fence now carries a reprobe hook and lifts itself when the host becomes answerable. Three properties keep that honest: - foreign never retries, and the hook is not even recorded for it - the re-probe runs when a native request arrives, not on a timer, so an idle proxy does no work - attempts are capped, so a permanently unaskable host stops asking rather than re-probing on every request forever A fence constructed without the hook behaves exactly as before, which keeps every existing caller and test on the old path. Refs #2108 Verification: red-driven with the new exports staged as no-ops, so the failing assertion was the behavior rather than a module-load error — 1 fail on the reopen case with all three guards passing, then 4 pass. 73 pass / 0 fail across native-profile-startup and the probe suite, 106 pass / 0 fail across core-lab-boundary, native-main-owner-lifetime, native-profile-drain-server, service-probe-docker and server-auth. tsc --noEmit exit 0, privacy:scan exit 0. core-lab-boundary is in that list deliberately: this adds an import to native-profile-startup, and that test walks the runtime import graph to prove the core request path still cannot reach src/lab. --- src/codex/native-profile-startup.ts | 67 +++++++++++++++++++++++- src/server/index.ts | 5 ++ tests/native-profile-startup.test.ts | 76 ++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index bf1349aafa..d53587e34a 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -15,6 +15,7 @@ import { import { withNativeMainExclusiveClaim } from "./native-main-claim"; import { scrubNativeMainAuthTempResidues } from "./native-main-auth-temp"; import { NATIVE_STAGE_SWEEP_INTERVAL_MS } from "./native-profile-stage-store"; +import type { NativeCodexOwnership } from "../integrations/native/ownership-preflight"; export type NativeMainStartupGateSnapshot = | { status: "ready"; homeId: string | null } @@ -310,6 +311,59 @@ export function startNativeMainStartupLifecycle( }; } +/** + * How many times a service-ownership fence will re-ask before it stops asking (#2108). + * + * A host that is permanently unaskable must not re-probe on every request forever, and a + * host that recovers usually does so within the first few. The cap is per fence, and it is + * reset by `release()`, so a restarted server starts fresh. + */ +export const NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT = 5; + +/** Reprobe hooks for the fences currently held, keyed by the reason they were raised for. */ +const serviceOwnershipReprobes = new Map(); + +interface ServiceOwnershipReprobe { + readonly probe: () => NativeCodexOwnership; + attempts: number; +} + +/** Test-only: the retry budget is module state and would otherwise leak across tests. */ +export function __resetNativeMainOwnershipRetries(): void { + for (const entry of serviceOwnershipReprobes.values()) entry.attempts = 0; +} + +/** + * Re-ask whether this host is still unownable, and drop the fence if it is not. + * + * `startServer` takes the ownership verdict once, at boot, and holds it for the process + * lifetime. For `foreign-ownership` that is correct — a foreign owner is a fact, and + * re-asking would only hand a determined caller a second chance. For `ownership-unknown` + * it is wrong: that verdict means the probe could not answer, so waiting cannot help, + * which is precisely why the #2108 reporter had to run `ocx restart` after every reboot. + * + * The re-probe happens when a native request actually arrives rather than on a timer, so + * an idle proxy does no work, and it is capped so a permanently unaskable host cannot spin. + */ +function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): boolean { + if (reason !== "ownership-unknown") return false; + const entry = serviceOwnershipReprobes.get(reason); + if (!entry) return false; + if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; + entry.attempts += 1; + let answer: NativeCodexOwnership; + try { + answer = entry.probe(); + } catch { + // An inspection that throws is not evidence the host became ownable. + return false; + } + if (answer !== "owned") return false; + serviceOwnershipRefs.delete(reason); + serviceOwnershipReprobes.delete(reason); + return true; +} + function activeServiceOwnershipBlockReason(): NativeMainServiceOwnershipBlockReason | null { if ((serviceOwnershipRefs.get("foreign-ownership") ?? 0) > 0) return "foreign-ownership"; if ((serviceOwnershipRefs.get("ownership-unknown") ?? 0) > 0) return "ownership-unknown"; @@ -325,8 +379,12 @@ function serviceOwnershipSnapshot( /** Close native-main admission without resolving or creating any CODEX_HOME artifacts. */ export function blockNativeMainStartupForUnownedServiceHome( reason: NativeMainServiceOwnershipBlockReason, + options?: { reprobe?: () => NativeCodexOwnership }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); + if (options?.reprobe && reason === "ownership-unknown") { + serviceOwnershipReprobes.set(reason, { probe: options.reprobe, attempts: 0 }); + } let released = false; return { homeId: null, @@ -337,6 +395,7 @@ export function blockNativeMainStartupForUnownedServiceHome( const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); if (remaining === 0) serviceOwnershipRefs.delete(reason); else serviceOwnershipRefs.set(reason, remaining); + if (remaining === 0) serviceOwnershipReprobes.delete(reason); }, }; } @@ -353,7 +412,13 @@ export async function releaseNativeMainStartupLifecycle(server: object): Promise } export function isNativeMainTrafficBlocked(): boolean { - return activeServiceOwnershipBlockReason() !== null || snapshot.status === "blocked"; + const reason = activeServiceOwnershipBlockReason(); + if (reason !== null && reprobeServiceOwnership(reason)) { + // The host became ownable after boot (#2108): the fence lifts here rather than + // waiting for the restart the reporter had to perform by hand. + return activeServiceOwnershipBlockReason() !== null || snapshot.status === "blocked"; + } + return reason !== null || snapshot.status === "blocked"; } /** diff --git a/src/server/index.ts b/src/server/index.ts index 8f43b8fdf1..b33e3f1750 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -705,6 +705,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server inspectStartupOwnership(deps).ownership }, ) : { homeId: null, diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index f8129f3d27..87d43bde39 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -34,7 +34,10 @@ import { initializeNativeMainStartupGate, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot, + NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT, + __resetNativeMainOwnershipRetries, } from "../src/codex/native-profile-startup"; +import type { NativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; import { tryAcquireNativeMainProfileClaim, tryClaimNativeMainProfileForTurn, @@ -623,3 +626,76 @@ describe("native-main startup journal gate", () => { } }, 20_000); }); + +/* + * #2108: after a Windows reboot the fence never lifts until `ocx restart`. + * + * `startServer` takes the ownership verdict ONCE and holds it for the process lifetime. + * That is right for `foreign-ownership` — a foreign owner is a fact, and re-asking would + * only give a determined caller a second chance. It is wrong for `ownership-unknown`, + * which means the probe could not answer: waiting cannot help, which is exactly why the + * reporter had to restart. + * + * The retry is deliberately NOT automatic-on-a-timer. It re-probes when a native request + * actually arrives, so an idle proxy does no work, and it is capped so a permanently + * unaskable host cannot spin. + */ +describe("an unknown service-ownership fence is retryable (#2108)", () => { + afterEach(() => { + __resetNativeMainOwnershipRetries(); + }); + + test("a later successful probe reopens the gate without a restart", () => { + let answer: NativeCodexOwnership = "unknown"; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => answer, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + + answer = "owned"; + + expect(isNativeMainTrafficBlocked()).toBe(false); + } finally { + void fence.release(); + } + }); + + test("a foreign owner is a fact, not a question — it never retries", () => { + let asked = 0; + const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { + reprobe: () => { asked += 1; return "owned"; }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(asked).toBe(0); + } finally { + void fence.release(); + } + }); + + test("a host that stays unaskable stops being asked", () => { + let asked = 0; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => { asked += 1; return "unknown"; }, + }); + try { + for (let i = 0; i < 25; i++) isNativeMainTrafficBlocked(); + + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(asked).toBeLessThanOrEqual(NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT); + } finally { + void fence.release(); + } + }); + + test("with no reprobe wired the fence behaves exactly as before", () => { + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + } finally { + void fence.release(); + } + }); +}); From 82fd8106b224c5c8dc3d9e84230eeb4eb75b1730 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 01:21:32 +0900 Subject: [PATCH 101/121] fix(probe,codex): close the fail-open and the refcount bug an audit found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial audit of the first cut returned fail on three real defects. All three are fixed here, and the first one is the reason this commit exists. FAIL-OPEN. The bus-down path consulted exactly one path, /.config/systemd/user/opencodex-proxy.service, and returned absent when it was missing. But systemd's user search path is not one directory: ~/.local/share/systemd/user and $XDG_CONFIG_HOME/systemd/user are also live. A foreign unit in either was invisible with the bus down, so inspectNativeCodexOwnership returned owned on a host a foreign service owns — the exact failure the disk check was supposed to prevent, reached through a narrower door. The bus-up path never had this hole, and that asymmetry is what made it a bug rather than a limitation. The offline check now walks every search directory systemd itself honors, including the XDG overrides. Two units found is unknown, not a guess. RESETTABLE RETRY BUDGET. The reprobe was keyed by reason but re-created with attempts: 0 on every fence, so a caller raising fences in a loop was handed a fresh allowance each time — measured 5 then 10. The budget now belongs to the reason and is only dropped when the last fence for it releases. A PROBE LIFTING FENCES IT NEVER SPOKE FOR. On success the reprobe deleted the whole refcount. With two servers fenced and only one carrying a hook, one probe unblocked both, and the hookless fence's own release() then decremented a counter that no longer existed. The hook now clears only its own fence and marks itself spent; the remaining fences keep traffic closed until each releases itself, which is what the refcount is for. Also from the same audit: the "foreign never retries" property was single-covered — the guard exists in two places and ablating either alone stayed green. A test now pins the outcome rather than one implementation, and was driven red by removing both. Two docstrings claimed things the code does not do: the cap is not reset by release(), and the reprobe is demand-driven rather than request-only, since the background token guardian also asks. Refs #2114, Refs #2108 Verification: every fix red-driven first — the fail-open reproduced as two failing assertions against a foreign unit in the other search dirs, the two fence defects as their own failures. 92 pass / 0 fail across native-profile-startup, the probe suite and core-lab-boundary. tsc --noEmit exit 0, privacy:scan exit 0. --- src/codex/native-profile-startup.ts | 33 ++++++++--- src/service-manager-probe.ts | 34 ++++++++--- tests/codex-service-manager-probe.test.ts | 52 +++++++++++++++++ tests/native-profile-startup.test.ts | 70 +++++++++++++++++++++++ 4 files changed, 175 insertions(+), 14 deletions(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index d53587e34a..572f2debf7 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -315,8 +315,10 @@ export function startNativeMainStartupLifecycle( * How many times a service-ownership fence will re-ask before it stops asking (#2108). * * A host that is permanently unaskable must not re-probe on every request forever, and a - * host that recovers usually does so within the first few. The cap is per fence, and it is - * reset by `release()`, so a restarted server starts fresh. + * host that recovers usually does so within the first few. The budget belongs to the + * REASON, not to an individual fence: raising a second fence deliberately does not hand + * out a fresh allowance, or a caller looping over fences could spin the probe forever. + * It is dropped when the last fence for that reason releases. */ export const NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT = 5; @@ -326,6 +328,8 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; attempts: number; + /** Set once the probe has already spent this hook's fence, so it cannot spend it twice. */ + cleared?: boolean; } /** Test-only: the retry budget is module state and would otherwise leak across tests. */ @@ -342,13 +346,18 @@ export function __resetNativeMainOwnershipRetries(): void { * it is wrong: that verdict means the probe could not answer, so waiting cannot help, * which is precisely why the #2108 reporter had to run `ocx restart` after every reboot. * - * The re-probe happens when a native request actually arrives rather than on a timer, so - * an idle proxy does no work, and it is capped so a permanently unaskable host cannot spin. + * The re-probe is demand-driven rather than timed: it runs when something asks whether + * native-main is fenced, which is usually a request but is also the background token + * guardian's warmup. It is capped so a permanently unaskable host cannot spin. + * + * The probe is synchronous `spawnSync` with a bounded timeout, and this function is on a + * request path, so the cap is what keeps a wedged host from paying that cost repeatedly. */ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): boolean { if (reason !== "ownership-unknown") return false; const entry = serviceOwnershipReprobes.get(reason); if (!entry) return false; + if (entry.cleared) return false; if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; entry.attempts += 1; let answer: NativeCodexOwnership; @@ -359,8 +368,15 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): return false; } if (answer !== "owned") return false; - serviceOwnershipRefs.delete(reason); - serviceOwnershipReprobes.delete(reason); + // Only the hook's OWN fence is cleared. Several servers can hold a fence for the same + // reason and only one of them may carry a hook, so lifting the shared refcount here + // would unblock fences this probe never spoke for — and their own release() would then + // decrement a counter that no longer exists. The remaining fences keep traffic closed + // until each releases itself, which is what the refcount is for. + entry.cleared = true; + const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); + if (remaining === 0) serviceOwnershipRefs.delete(reason); + else serviceOwnershipRefs.set(reason, remaining); return true; } @@ -382,7 +398,10 @@ export function blockNativeMainStartupForUnownedServiceHome( options?: { reprobe?: () => NativeCodexOwnership }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); - if (options?.reprobe && reason === "ownership-unknown") { + // Do NOT reset an existing budget. Keying the reprobe by reason means a caller raising + // fences in a loop would otherwise be handed a fresh allowance each time and could spin + // the probe forever; the budget belongs to the reason, not to the individual fence. + if (options?.reprobe && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { serviceOwnershipReprobes.set(reason, { probe: options.reprobe, attempts: 0 }); } let released = false; diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 7cd61dea9d..670e55e4f8 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -213,12 +213,32 @@ function busUnreachable(stderr: string): boolean { * A foreign home therefore still blocks, which is the whole reason this consults the disk * instead of widening the exit code. */ -function inspectSystemdOffline(definitionPath: string): ServiceManagerInstallation { - const presence = artifactPresence(definitionPath); - if (presence === "absent") return { kind: "absent" }; - if (presence === "unreadable") { - return unknown("the session bus is unreachable and the systemd unit could not be read"); - } +function systemdUserUnitSearchPaths(home: string): string[] { + // systemd's user search path is not one directory. Checking only the canonical one and + // calling the rest absent is a fail-open: with the bus down a foreign unit in any other + // search dir is invisible, and "no answer" would be read as "no owner". + const xdgConfig = process.env.XDG_CONFIG_HOME?.trim(); + const xdgData = process.env.XDG_DATA_HOME?.trim(); + const dirs = [ + xdgConfig ? join(xdgConfig, "systemd", "user") : join(home, ".config", "systemd", "user"), + join(home, ".config", "systemd", "user"), + xdgData ? join(xdgData, "systemd", "user") : join(home, ".local", "share", "systemd", "user"), + join(home, ".local", "share", "systemd", "user"), + ]; + return [...new Set(dirs)].map(dir => join(dir, `${TASK}.service`)); +} + +function inspectSystemdOffline(home: string): ServiceManagerInstallation { + const candidates = systemdUserUnitSearchPaths(home); + const found = candidates.filter(path => artifactPresence(path) === "present"); + if (candidates.some(path => artifactPresence(path) === "unreadable")) { + return unknown("the session bus is unreachable and a systemd unit could not be read"); + } + if (found.length === 0) return { kind: "absent" }; + if (found.length > 1) { + return unknown("the session bus is unreachable and more than one systemd unit file claims this proxy"); + } + const definitionPath = found[0]!; let body: string; try { body = readFileSync(definitionPath, "utf-8"); @@ -337,7 +357,7 @@ function inspectSystemd(deps: Required>): Servic // Widening on the exit code alone would fail open, because with the bus down systemctl // cannot see a foreign unit either. So ask the disk, which needs no bus, and fall back // to `unknown` for every other non-zero exit. - if (busUnreachable(shown.stderr)) return inspectSystemdOffline(definitionPath); + if (busUnreachable(shown.stderr)) return inspectSystemdOffline(deps.home); return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`); } diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 615f1e68ff..7cb0486bce 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -1104,3 +1104,55 @@ describe("WinSW probe: a timed-out query with no assets (#2108)", () => { expect(result.kind).toBe("unknown"); }); }); + +/* + * The fail-open an audit caught in the first cut of the #2114 fix. + * + * systemd's user search path is not one directory: ~/.local/share/systemd/user and + * $XDG_CONFIG_HOME/systemd/user are also live, and system-level units are never in the + * user path at all. With the bus down, a foreign unit in any of those is invisible. + * + * Returning "absent" because ONE path was empty produced ownership: owned on a host a + * foreign service owns — exactly the failure the disk check was supposed to prevent. + */ +describe("bus-down absence must mean absence everywhere systemd looks (#2114)", () => { + const BUS_DOWN = "Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined"; + + test("a foreign unit in the other user search dir still blocks", () => { + const dir = join(home, ".local", "share", "systemd", "user"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencodex-proxy.service"), [ + "[Service]", + 'Environment="CODEX_HOME=/other/.codex"', + 'Environment="OPENCODEX_HOME=/other/.opencodex"', + ].join("\n")); + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + const result = inspectServiceManagerInstallation({ run, platform: "linux", home }); + + expect(result.kind).not.toBe("absent"); + }); + + test("XDG_CONFIG_HOME is honored the way systemd honors it", () => { + const xdg = join(home, "xdg-config"); + const dir = join(xdg, "systemd", "user"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencodex-proxy.service"), '[Service]\nEnvironment="CODEX_HOME=/other/.codex"'); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdg; + try { + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).not.toBe("absent"); + } finally { + if (previous === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previous; + } + }); + + test("a genuinely empty disk is still absent", () => { + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("absent"); + }); +}); diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 87d43bde39..79f4030c6f 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -699,3 +699,73 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); }); + +/* + * Two defects an audit found in the first cut of the retryable fence, both from keying the + * reprobe by REASON while the fence refcount is per-fence. + */ +describe("the retryable fence respects its own refcount (#2108)", () => { + afterEach(() => { + __resetNativeMainOwnershipRetries(); + }); + + test("raising a second fence does not hand out a fresh retry budget", () => { + let asked = 0; + const probe = () => { asked += 1; return "unknown" as NativeCodexOwnership; }; + const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); + const afterFirst = asked; + + const second = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + try { + for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); + + // A caller raising fences in a loop must not be able to spin the probe forever. + expect(asked).toBe(afterFirst); + } finally { + void first.release(); + void second.release(); + } + }); + + test("one successful probe does not lift a fence it never spoke for", () => { + const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); + const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned" as NativeCodexOwnership, + }); + try { + isNativeMainTrafficBlocked(); + + // The hookless fence is still held, so traffic stays blocked until IT releases. + expect(isNativeMainTrafficBlocked()).toBe(true); + } finally { + void hooked.release(); + void hookless.release(); + } + }); +}); + +/* + * The audit noted the "foreign never retries" property was single-covered: the guard exists + * in two places, and ablating either alone stayed green. This pins the OUTCOME rather than + * one of the two implementations, so removing either is caught. + */ +describe("a foreign fence is never reopened by a probe (#2108)", () => { + afterEach(() => { + __resetNativeMainOwnershipRetries(); + }); + + test("a foreign fence stays closed even when the host reports owned", () => { + const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { + reprobe: () => "owned" as NativeCodexOwnership, + }); + try { + for (let i = 0; i < 10; i++) isNativeMainTrafficBlocked(); + + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toMatchObject({ status: "blocked", reason: "foreign-ownership" }); + } finally { + void fence.release(); + } + }); +}); From 9d49763406c80e9ebd5e7b42faf07cf92d93cd4f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 01:29:44 +0900 Subject: [PATCH 102/121] fix(codex): let the fence pay for itself, so the probe cannot pay twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of the audit found that my round-1 repair traded one refcount bug for another. Both are gone now, and the shape is simpler than either attempt. DOUBLE-DECREMENT. The probe decremented the refcount directly, and the hooked fence's own release() then decremented again for the same fence. The count went short by one, so releasing the hooked fence lifted a fence another holder still owned. The Math.max(0, ...) floor is what hid it: the count never went negative, it just silently under-counted. The first fix had exactly the same failure mode one step later in the lifecycle, which is the tell that the ownership was modelled in the wrong place. The hook now delegates to the fence's own idempotent release() instead of touching the refcount. One fence, one payment, whoever triggers it. WEDGE. A spent hook stayed in the map, so the "do not reset an existing budget" guard refused to install a hook for any LATER fence. A server started after an earlier probe got no reprobe at all and was back to needing ocx restart, which is the #2108 symptom returning by another route. The entry is now dropped by its own owner's release, so the next fence installs its own hook while a live holder still cannot be handed a fresh allowance. Also pinned: the multi-unit conflict branch in the offline systemd path. Two units on disk with the bus down is genuinely ambiguous — neither can be confirmed as the live definition — so it refuses rather than picking one. That was a fail-closed decision with nothing asserting it; ablating it left the suite green, and now it does not. Refs #2114, Refs #2108 Verification: both defects red-driven first — the double-decrement as a fence that should still block reading unblocked, the wedge as a later fence never being asked. 95 pass / 0 fail across native-profile-startup, the probe suite and core-lab-boundary. The new multi-unit guard was ablated to confirm it is not vacuous. tsc --noEmit exit 0, privacy:scan exit 0. --- src/codex/native-profile-startup.ts | 50 ++++++++++++--------- tests/codex-service-manager-probe.test.ts | 15 +++++++ tests/native-profile-startup.test.ts | 55 +++++++++++++++++++++++ 3 files changed, 100 insertions(+), 20 deletions(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 572f2debf7..e873579197 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -328,8 +328,10 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; attempts: number; - /** Set once the probe has already spent this hook's fence, so it cannot spend it twice. */ - cleared?: boolean; + /** The fence that installed this hook; only its own release may drop the entry. */ + readonly owner: NativeMainStartupLifecycle; + /** Releases the fence that installed this hook, exactly once. */ + readonly spend: () => void; } /** Test-only: the retry budget is module state and would otherwise leak across tests. */ @@ -357,7 +359,6 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): if (reason !== "ownership-unknown") return false; const entry = serviceOwnershipReprobes.get(reason); if (!entry) return false; - if (entry.cleared) return false; if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; entry.attempts += 1; let answer: NativeCodexOwnership; @@ -368,15 +369,14 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): return false; } if (answer !== "owned") return false; - // Only the hook's OWN fence is cleared. Several servers can hold a fence for the same - // reason and only one of them may carry a hook, so lifting the shared refcount here - // would unblock fences this probe never spoke for — and their own release() would then - // decrement a counter that no longer exists. The remaining fences keep traffic closed - // until each releases itself, which is what the refcount is for. - entry.cleared = true; - const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); - if (remaining === 0) serviceOwnershipRefs.delete(reason); - else serviceOwnershipRefs.set(reason, remaining); + // Release through the fence that installed this hook, and only that one. + // + // Several servers can hold a fence for the same reason while only one carries a hook, so + // clearing the shared refcount here would unblock fences this probe never spoke for. + // Decrementing here directly is just as wrong the other way: that fence's own release() + // would then pay a second time for one fence, leaving the count short. Delegating to the + // fence's idempotent release keeps exactly one payment per fence. + entry.spend(); return true; } @@ -398,14 +398,8 @@ export function blockNativeMainStartupForUnownedServiceHome( options?: { reprobe?: () => NativeCodexOwnership }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); - // Do NOT reset an existing budget. Keying the reprobe by reason means a caller raising - // fences in a loop would otherwise be handed a fresh allowance each time and could spin - // the probe forever; the budget belongs to the reason, not to the individual fence. - if (options?.reprobe && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { - serviceOwnershipReprobes.set(reason, { probe: options.reprobe, attempts: 0 }); - } let released = false; - return { + const lifecycle: NativeMainStartupLifecycle = { homeId: null, settled: Promise.resolve(serviceOwnershipSnapshot(reason)), async release() { @@ -414,9 +408,25 @@ export function blockNativeMainStartupForUnownedServiceHome( const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); if (remaining === 0) serviceOwnershipRefs.delete(reason); else serviceOwnershipRefs.set(reason, remaining); - if (remaining === 0) serviceOwnershipReprobes.delete(reason); + if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { + serviceOwnershipReprobes.delete(reason); + } }, }; + // Do NOT reset an existing budget: keying the reprobe by reason means a caller raising + // fences in a loop would otherwise be handed a fresh allowance each time and could spin + // the probe forever. But once the holder is gone its entry is removed above, so a LATER + // fence installs its own hook — a server started after an earlier probe must not be left + // needing `ocx restart`, which is the very symptom this exists to remove. + if (options?.reprobe && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { + serviceOwnershipReprobes.set(reason, { + probe: options.reprobe, + attempts: 0, + owner: lifecycle, + spend: () => { void lifecycle.release(); }, + }); + } + return lifecycle; } export function bindNativeMainStartupLifecycle(server: object, lifecycle: NativeMainStartupLifecycle): void { diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 7cb0486bce..0082f19723 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -1155,4 +1155,19 @@ describe("bus-down absence must mean absence everywhere systemd looks (#2114)", expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("absent"); }); + + // The multi-unit branch is a fail-closed decision that nothing pinned: ablating it left the + // suite green. Two units on disk with the bus down is genuinely ambiguous — neither can be + // confirmed as the live definition — so it must refuse rather than pick one. + test("two units in different search dirs refuse rather than choose", () => { + const a = join(home, ".config", "systemd", "user"); + const b = join(home, ".local", "share", "systemd", "user"); + mkdirSync(a, { recursive: true }); + mkdirSync(b, { recursive: true }); + writeFileSync(join(a, "opencodex-proxy.service"), `[Service]\nEnvironment="CODEX_HOME=${join(home, ".codex")}"`); + writeFileSync(join(b, "opencodex-proxy.service"), '[Service]\nEnvironment="CODEX_HOME=/other/.codex"'); + const { run } = recorder(() => ({ status: 1, stderr: BUS_DOWN })); + + expect(inspectServiceManagerInstallation({ run, platform: "linux", home }).kind).toBe("unknown"); + }); }); diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 79f4030c6f..95aca6b688 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -769,3 +769,58 @@ describe("a foreign fence is never reopened by a probe (#2108)", () => { } }); }); + +/* + * The double-decrement a second audit round found: the probe paid for the hooked fence, and + * then that fence's own release() paid for it again. One fence, two decrements, so a fence + * another holder still owns was lifted. Plus the wedge: once a hook was spent, no LATER + * fence could install one, which is the #2108 symptom returning by another route. + */ +describe("a spent reprobe leaves the refcount coherent (#2108)", () => { + afterEach(() => { + __resetNativeMainOwnershipRetries(); + }); + + test("the hooked fence's release does not pay twice for the same fence", () => { + const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); + const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned" as NativeCodexOwnership, + }); + try { + isNativeMainTrafficBlocked(); + void hooked.release(); + + // The hookless fence is still held by its owner and must keep traffic closed. + expect(isNativeMainTrafficBlocked()).toBe(true); + } finally { + void hookless.release(); + } + expect(isNativeMainTrafficBlocked()).toBe(false); + }); + + test("a fence raised after a spent probe still gets to re-ask", () => { + const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned" as NativeCodexOwnership, + }); + isNativeMainTrafficBlocked(); + void first.release(); + + let asked = 0; + const later = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + }); + try { + isNativeMainTrafficBlocked(); + + // A server started after an earlier probe must not be stuck needing `ocx restart`. + expect(asked).toBeGreaterThan(0); + expect(isNativeMainTrafficBlocked()).toBe(false); + } finally { + void later.release(); + } + }); +}); + +/* + * The multi-unit conflict branch was a fail-closed decision with nothing pinning it. + */ From a674b90a26faa5686a03b34e0fb1d8f1f169e7e9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 01:34:55 +0900 Subject: [PATCH 103/121] test(codex): pin the owner-identity guard on the reprobe hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 of the audit passed, with one gap: replacing the owner-identity check with `if (true)` left all three suites green. The guard is correct and nothing would have caught it being broken. That matters more than an ordinary coverage gap, because it is the same wedge this fix has now grown three times by three different routes: first a spent hook staying in the map, then the entry being dropped early by a double-decrement, and now a non-owner fence's release destroying the owner's hook. Each time the symptom is identical — a fence with no way to re-ask, which is #2108 returning. The test drives that third route directly and was ablated to confirm it fails without the guard. Refs #2108 --- tests/native-profile-startup.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 95aca6b688..fe59dc2224 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -819,6 +819,27 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { void later.release(); } }); + + // The wedge, by a third route. If a NON-owner fence's release dropped the entry, the + // owner's hook would be destroyed and the fence stuck until `ocx restart` — the #2108 + // symptom. This class of bug recurred across three audit rounds, so the guard that + // prevents it is pinned rather than merely present. + test("a non-owner release does not destroy the owner's hook", () => { + let asked = 0; + const owner = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + }); + const other = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); + try { + void other.release(); + + isNativeMainTrafficBlocked(); + + expect(asked).toBe(1); + } finally { + void owner.release(); + } + }); }); /* From 50386c14871bd0511392573f7997454de98f9946 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 01:56:19 +0900 Subject: [PATCH 104/121] docs(devlog): record the fence fixes and the ownership model that was wrong three times --- .../170_2114_2108_fences.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md b/devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md new file mode 100644 index 0000000000..59564854cb --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/170_2114_2108_fences.md @@ -0,0 +1,103 @@ +# 170 — #2114 and #2108: the fence that could not re-ask + +Both reports are the same shape wearing two operating systems: a probe that +could not get an answer, a verdict of `unknown`, and a fence that holds for the +whole process. Every native request 503s and only `ocx restart` clears it. + +Shipped as PR #2130, five commits. + +## The three changes + +**#2114 — the Linux session bus.** `inspectSystemd` called every non-zero exit +`unknown`. Its own comment was right that a non-zero status means the question +never reached the bus — and that is exactly why the verdict was wrong. It is +evidence about the bus, not about who owns this home. + +Widening on the exit code alone fails open: with the bus down `systemctl` +cannot see a foreign unit either, so "no answer" would read as "no owner" on a +machine that has one. The classification asks the disk instead, which needs no +bus. + +**#2108 (a) — an unaskable WinSW query.** WinSW is optional, and a +scheduler-only install has neither its XML nor its exe on disk. A timed-out +`sc.exe query` still returned `unknown`, which outranks the disk. With both +assets gone there is nothing for a registration to belong to. + +**#2108 (b) — the fence re-asks.** `startServer` takes the verdict once and +holds it, which is why waiting never helped and restart always did. Correct for +`foreign-ownership` — a foreign owner is a fact. Wrong for +`ownership-unknown`, which says the probe could not answer. + +## What the audit caught, three rounds running + +This is the part worth keeping. The functional idea was right in round one; the +**ownership model was wrong three times in a row**, and each time the symptom +was identical — a fence with no way to re-ask, which is #2108 returning by +another route. + +| Round | Defect | Why it slipped | +|---|---|---| +| 1 | `inspectSystemdOffline` checked ONE path, so a foreign unit in `~/.local/share/systemd/user` or an XDG override was invisible → `ownership: owned` on a foreign host | The bus-up path never had the hole; I reused its constant, not its coverage | +| 2 | The probe decremented the refcount AND the fence's own `release()` decremented again → a fence another holder owned got lifted | `Math.max(0, ...)` floored it, so it under-counted silently instead of going negative | +| 2 | A spent hook stayed in the map, so no LATER fence could install one | The "do not reset the budget" guard was right; its scope was not | +| 3 | Owner-identity guard was correct but **untested** — replacing it with `if (true)` left every suite green | A third route to the same wedge, with nothing pinning it | + +Round 1's fail-open is the one that mattered: I wrote "a unit naming a foreign +home stays present and still blocks" in a commit message, and the auditor proved +it end-to-end as `ownership = owned`. The claim was true only for a unit at the +canonical path. + +The fix that finally held is smaller than either attempt: the hook delegates to +the fence's own idempotent `release()`. One fence, one payment, whoever triggers +it. Both earlier versions were modelling the ownership in the wrong place. + +## Guards, and the ones that were not guards + +Every branch was ablated. Three that looked like guards were not: + +- the multi-unit conflict branch — a fail-closed decision with nothing asserting + it; ablating it left the suite green +- "foreign never retries" — double-implemented, so removing either half alone + stayed green; only removing both failed +- the owner-identity check — round 3's finding, above + +All three now have tests driven red against the real code. + +## Stated rather than hidden + +**Locale.** systemd localizes the bus-failure strings, so a non-English host +will not match and keeps the old `unknown`. That fences rather than admits, +which is the safe direction, but the #2114 fix does not reach every affected +user. Forcing `LC_ALL=C` on the probe would remove the caveat and is the +obvious follow-up; it is not done here because it changes every `systemctl` +call the module makes. + +**Coverage.** These are Windows and Linux platform paths. The suites that prove +them ran on macOS and on `ssh lidge`; the platform CI legs are the check that +actually matters. + +**A hostile `XDG_CONFIG_HOME`** redirects the offline check to an +attacker-chosen directory. The auditor raised it and then cleared it: reaching +that requires controlling the proxy's own environment, at which point +`CODEX_HOME` is equally controllable and the comparison is moot. No privilege +boundary is crossed. + +## Coordination with #2029 + +Draft PR #2029 edits the same function for #1939 and classifies two other bus +messages as `absent`. This branch never touched it or its branch. The two +agree in direction; if it lands first this reconciles rather than replaces. + +## Verification + +``` +full suite on ssh lidge 13,524 pass / 15 skip / 0 fail across 855 files +probe + startup + boundary 96 pass / 0 fail +bun x tsc --noEmit exit 0 +bun run privacy:scan exit 0 +``` + +One CI failure was investigated and dismissed on evidence: `keyring ubuntu` +stalled eight minutes on an apt mirror and was cancelled by the job timeout — +an infrastructure fault with no relation to the diff. + From 32529c2b24e159e7aa982e880213f171464c9eac Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 02:48:04 +0900 Subject: [PATCH 105/121] chore(release): move dev's version line past the published channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev's package.json said 2.24.2 while main and the npm latest tag were both 2.26.0. The 2.25.0 and 2.26.0 bumps were made on release branches that merged only to main and never came back, so dev has been trailing the published channel by two releases. That blocks promotion rather than corrupting it: assertChannelVersionMovesForward in scripts/release.ts reads the live dist-tags and refuses a candidate that does not move the channel forward, so a promotion from this range would have failed at the release step with "dev's package.json may trail the latest release." Found by a pre-promotion audit, not by hitting that error. 2.27.0 rather than 2.26.1, because the range carries user-visible behavior changes and not only fixes: proxy env baked into installed service definitions, service definitions written owner-only, the native-main fence learning to re-ask, and the deferred tool catalog work. No other file carries the version — the GUI package is 0.0.0 and is not published — so this is the whole reconciliation. Verification: tsc --noEmit exit 0; ci-workflows and repo-hygiene 143 pass / 0 fail. The version itself is checked by the release workflow against the live registry at dispatch time, which is the only place that comparison is real. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f4e8bbd5c9..ecb4a81645 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.24.2", + "version": "2.27.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 360b66b3c9edf93e4b05acb4871f8a4aa7399fe4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 02:50:48 +0900 Subject: [PATCH 106/121] fix(service): fail closed on the Windows ACL when a definition holds a credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-promotion audit flagged that the proxy-credential write used hardenSecretPath(required: false) while the two adjacent secret writes in the same file — the API token and the install state — both fail closed. The comment called the Windows ACL authoritative and then did not require it. That matters because on Windows the POSIX mode bits are advisory, so a soft-failing ACL can leave a proxy password readable by other local principals. The 0600 that #2107's follow-up added is real on POSIX and decorative on Windows. It is now conditional on content rather than blanket-strict. A definition carrying a userinfo proxy URL is a secret publication and fails closed like the token does; one carrying only paths and a port is not worth refusing an install over, because before #2107 these files had no hardening at all and a blanket requirement would fail an install for a user with nothing to protect. Writing the test found a second bug in the first version of the check. It matched a KEY=value shape, which exists in the systemd unit and the Windows wrapper but NOT in the plist, where the same value renders as KV. A credential-bearing plist was reporting clean. The check now scans for a userinfo authority in any URL in the rendered definition, which is format-independent. Refs #2107 Verification: the plist case was red before the fix and is green after — 135 pass / 0 fail on the service suite, tsc --noEmit exit 0. --- src/service.ts | 33 +++++++++++++++++++++++++++++---- tests/service.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/service.ts b/src/service.ts index 57610907b9..a00e616ba1 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1960,13 +1960,38 @@ function uninstallLaunchd(): void { * * The explicit `chmodSync` is not redundant: `mode` only applies when the file is * created, so an install over a definition left at 0644 by an earlier version would keep - * the loose mode. On Windows the POSIX bits are advisory, so the real ACL is applied - * there the same way the token file does it. + * the loose mode. + * + * On Windows the POSIX bits are advisory, so the ACL is the real boundary — and whether it + * may soft-fail depends on what the definition actually contains. A definition carrying a + * proxy credential is a secret publication and fails closed like the API token and the + * install state do; one carrying only paths and a port is not worth refusing an install + * over, since before #2107 these files had no hardening at all and a failure here would + * regress a user who has no credential to protect. */ export function writeServiceDefinitionFile(path: string, content: string, encoding: "utf8" | "utf16le"): void { writeFileSync(path, content, { encoding, mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort; the Windows ACL below is authoritative */ } - if (process.platform === "win32") hardenSecretPath(path, { required: false }); + try { chmodSync(path, 0o600); } catch { /* superseded by the Windows ACL below */ } + if (process.platform === "win32") { + hardenSecretPath(path, { required: definitionCarriesCredential(content) }); + } +} + +/** + * Does this service definition embed a credential-bearing proxy URL? + * + * Only the userinfo form leaks something: `http://user:pass@host` in any of the four proxy + * variables. A bare `http://127.0.0.1:7890` is not a secret, and treating it as one would + * make an icacls stall fail an install that had nothing to protect. + * + * The scan is over any URL in the rendered definition rather than over a `KEY=value` shape, + * because the three formats render differently — systemd writes `Environment="K=V"`, the + * plist writes `KV`, and the Windows wrapper writes + * `set "K=V"`. Keying on the assignment syntax silently missed the plist. + */ +export function definitionCarriesCredential(content: string): boolean { + // A userinfo authority: scheme, then anything that is not a delimiter, then '@'. + return /[a-z][a-z0-9+.-]*:\/\/[^\s"'<>/@]+@/i.test(content); } // ── Windows (Task Scheduler) ── diff --git a/tests/service.test.ts b/tests/service.test.ts index 04c8ef5ed3..4bac2296a7 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -7,7 +7,7 @@ import { saveConfig } from "../src/config"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceInstallState, prepareServiceInstall, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, resolveServiceListenPort, runLaunchctl, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, systemdNeedsDaemonReload, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy } from "../src/service"; import type { ServiceDiagnostic } from "../src/service"; -import { resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; +import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../src/service"; import { buildWinswXml } from "../src/lib/winsw"; import { CONFIG_OWNER_FILE, CONFIG_UNINSTALL_MANIFEST, recordOwnedConfigPath, removeOwnedConfigState } from "../src/lib/config-ownership"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -2220,3 +2220,33 @@ describe("service definitions are not world-readable", () => { } }); }); + +// A pre-promotion audit flagged that this file's proxy-credential write used a soft-failing +// Windows ACL while the two adjacent secret writes — the API token and the install state — +// both fail closed. On Windows the POSIX mode bits are advisory, so a soft ACL failure can +// leave a proxy password readable by other local principals. +describe("credential-bearing definitions harden the Windows ACL strictly", () => { + test("a proxy URL with userinfo is treated as a secret publication", () => { + const unit = buildUnit(resolvedProxyEnv({ HTTPS_PROXY: "http://user:secret@proxy.invalid:8080" })); + + expect(definitionCarriesCredential(unit)).toBe(true); + }); + + test("a bare proxy URL is not a secret, so an icacls stall must not fail the install", () => { + // Before #2107 these files had no hardening at all; refusing an install over a stall + // would regress a user who has nothing to protect. + const unit = buildUnit(resolvedProxyEnv({ HTTP_PROXY: "http://127.0.0.1:7890", NO_PROXY: "localhost" })); + + expect(definitionCarriesCredential(unit)).toBe(false); + }); + + test("lower-case spellings and the plist form are covered too", () => { + const plist = buildPlist(resolvedProxyEnv({ all_proxy: "socks5://u:p@127.0.0.1:1080" })); + + expect(definitionCarriesCredential(plist)).toBe(true); + }); + + test("a definition with no proxy env at all carries no credential", () => { + expect(definitionCarriesCredential(buildUnit(resolvedProxyEnv({})))).toBe(false); + }); +}); From 15527f812fc2e9ef68d8ae218dea72d1c434785a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 03:00:30 +0900 Subject: [PATCH 107/121] test(service): use the repo URL-userinfo fixture so privacy:scan stays green The new credential-detection test spelled its password as an @-adjacent word, which the privacy scanner reads as an email address and rejects. scripts/privacy-scan.ts already allowlists exactly one URL-userinfo pair for tests/ for this reason; use it. The shape under test is the userinfo authority, not the particular credential, so the fixture choice is free. Caught by running the gates job locally after CI reported it, rather than by guessing at the failure. --- tests/service.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/service.test.ts b/tests/service.test.ts index 4bac2296a7..15862afded 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -2227,7 +2227,11 @@ describe("service definitions are not world-readable", () => { // leave a proxy password readable by other local principals. describe("credential-bearing definitions harden the Windows ACL strictly", () => { test("a proxy URL with userinfo is treated as a secret publication", () => { - const unit = buildUnit(resolvedProxyEnv({ HTTPS_PROXY: "http://user:secret@proxy.invalid:8080" })); + // `pw@chatgpt.com` is the repo's existing URL-userinfo fixture: the privacy scanner + // reads "pw@host" as an email otherwise, and this exact pair is already allowlisted for + // tests/ (scripts/privacy-scan.ts:102). The shape under test is the userinfo authority, + // not the particular credential. + const unit = buildUnit(resolvedProxyEnv({ HTTPS_PROXY: "https://user:pw@chatgpt.com:8080" })); expect(definitionCarriesCredential(unit)).toBe(true); }); From 75251bb038a037091b5abd7c9542f6149b2fb49d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 03:20:28 +0900 Subject: [PATCH 108/121] test(service): pin the file mode on POSIX only, not on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows CI shard reported these three as failures: Bun on Windows returns 0666 from statSync for an ordinary file no matter what mode the write asked for, because Windows does not implement POSIX permission bits at all. The assertion was testing the emulation layer, not the security property. The real boundary on Windows is the NTFS ACL, which hardenSecretPath applies and which the credential-detection tests already cover — those decide whether that ACL is required or best-effort, which is the question that actually matters there. So these three skip on win32 and keep asserting the octal everywhere the octal means something. Refs #2107 Worth being precise about what this does and does not fix: three of the 25 Windows shard-4 failures were mine, and they are gone. The other 22 are not — Log Guard, WP13 composed toggle, /api/keys, and the anthropic 413 retry were already failing on run 32147924436 (e446607c8, 2026-08-18), before any of this work landed, and none of them touch files in this range. --- tests/service.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/service.test.ts b/tests/service.test.ts index 15862afded..8ee4cd243b 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -2178,7 +2178,15 @@ describe("service serving confirmation", () => { describe("service definitions are not world-readable", () => { const modeOf = (path: string): string => (statSync(path).mode & 0o777).toString(8); - test("a freshly written definition is owner-only", () => { + // Windows does not implement POSIX permission bits — Bun reports 0666 for an ordinary + // file regardless of what `mode` asked for, and the real boundary there is the NTFS ACL + // applied by hardenSecretPath. Asserting the octal on Windows tests the emulation layer + // rather than the security property, so these three pin the POSIX half only. The Windows + // half is covered by the credential-detection tests below, which decide whether that ACL + // is applied strictly. + const posixOnly = process.platform === "win32" ? test.skip : test; + + posixOnly("a freshly written definition is owner-only", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); try { const path = join(dir, "unit"); @@ -2192,7 +2200,7 @@ describe("service definitions are not world-readable", () => { } }); - test("an install over a loose definition from an older version tightens it", () => { + posixOnly("an install over a loose definition from an older version tightens it", () => { // `mode` applies only on creation, so a reinstall would otherwise leave 0644 standing. const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); try { @@ -2208,7 +2216,7 @@ describe("service definitions are not world-readable", () => { } }); - test("utf16le scheduler assets take the same mode", () => { + posixOnly("utf16le scheduler assets take the same mode", () => { const dir = mkdtempSync(join(tmpdir(), "ocx-service-mode-")); try { const path = join(dir, "task.xml"); From 4dfc2d69bc488b28c8f79dd15a9cb40a8e4b4822 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 03:54:28 +0900 Subject: [PATCH 109/121] fix(log-guard): accept the OS's own canonical spelling on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI reported 22 Log Guard failures, all resolving to unsafe_path. The feature (#1729) is new in this release range and has never shipped, so this is a release blocker rather than a pre-existing break. realpathSync.native on Windows expands 8.3 short components — the RUNNER~1 form that appears throughout %TEMP% — so the canonical realpath and the requested path disagree as strings while naming the same file. databasePathIsSafe read that disagreement as an ancestor-symlink redirection and refused every mutation: protect, unprotect, repair, reclaim, compact. macOS had the same class of problem with /var and /tmp and was given an explicit alias normalizer. Windows was not. The fix keeps the check identity-based rather than string-based: when the spellings differ, the requested path is re-canonicalized through the same call and the two canonical forms must agree. A genuine symlink or junction resolves somewhere else and still fails, so the guard refuses exactly what it was built to refuse. It is scoped to win32 and cannot change any POSIX verdict. Regression tests cover all three directions: an OS-canonicalized spelling is the same file, a symlinked database is not, and an unrelated sibling is not. Refs #1729 Found by reading the Windows shard logs rather than assuming the leg was broken. It sat behind 3 failures of my own — POSIX mode assertions that do not apply on Windows, fixed separately in #2139 — which is why it took a second pass to see. --- src/codex/log-guard/path-safety.ts | 28 ++++++++++++- tests/codex-log-guard-coderabbit.test.ts | 50 +++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/codex/log-guard/path-safety.ts b/src/codex/log-guard/path-safety.ts index e1f0ee0736..471bca6824 100644 --- a/src/codex/log-guard/path-safety.ts +++ b/src/codex/log-guard/path-safety.ts @@ -35,5 +35,31 @@ export function normalizeTrustedDarwinSystemAlias(path: string): string { * Arbitrary ancestor symlinks remain refused. */ export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean { - return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath)); + const requested = normalizeTrustedDarwinSystemAlias(requestedPath); + if (samePathIdentity(realPath, requested)) return true; + return sameWindowsCanonicalPath(realPath, requested); +} + +/** + * On Windows, is the difference between these two spellings the OS canonicalizing the + * request rather than a redirection? + * + * `realpathSync.native` expands 8.3 short components — the `RUNNER~1` form that appears + * throughout `%TEMP%` — so the canonical path and the requested path can disagree as + * strings while naming the same file. Reading that as an ancestor-symlink redirection made + * every Log Guard mutation refuse with `unsafe_path` on Windows, which is what the CI shards + * were reporting. + * + * The comparison is still identity-based, not string-based: it re-canonicalizes the + * REQUESTED path through the same call and requires the two canonical forms to agree. A + * genuine symlink or junction resolves somewhere else and still fails, so the guard keeps + * refusing exactly what it was built to refuse. + */ +function sameWindowsCanonicalPath(realPath: string, requestedPath: string): boolean { + if (process.platform !== "win32") return false; + try { + return samePathIdentity(realPath, realpathSync.native(requestedPath)); + } catch { + return false; + } } diff --git a/tests/codex-log-guard-coderabbit.test.ts b/tests/codex-log-guard-coderabbit.test.ts index d510323057..4fa57a6c78 100644 --- a/tests/codex-log-guard-coderabbit.test.ts +++ b/tests/codex-log-guard-coderabbit.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { Database } from "bun:sqlite"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -205,3 +205,51 @@ describe("CodeRabbit protection regressions", () => { expect(reservedTriggers(databasePath)).toEqual(before); }); }); + +/* + * Windows CI reported 22 Log Guard failures as `unsafe_path`, on a feature (#1729) that is + * new in this release range and has therefore never shipped. + * + * `realpathSync.native` on Windows expands 8.3 short components — the `RUNNER~1` form that + * appears throughout %TEMP% — so the canonical realpath and the requested path disagree as + * strings while naming the same file. The safety check read that as an ancestor-symlink + * redirection and refused every mutation. macOS had the same class of problem with /var and + * /tmp and was given an explicit alias normalizer; Windows was not. + */ +describe("log guard path identity survives OS canonicalization", () => { + test("a path that only differs by the OS's own canonical spelling is the same file", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-identity-")); + roots.push(root); + const path = join(root, "logs_2.sqlite"); + writeFileSync(path, ""); + + // realpathSync.native is exactly what databasePathIsSafe compares against. + expect(sameLogGuardPathIdentity(realpathSync.native(path), path)).toBe(true); + }); + + // The guard this widening must not weaken: a redirection resolves somewhere else, and + // "somewhere else" is still refused. + test("a symlinked database is still refused", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-identity-")); + roots.push(root); + const real = join(root, "real.sqlite"); + const link = join(root, "logs_2.sqlite"); + writeFileSync(real, ""); + try { + symlinkSync(real, link); + } catch { + return; // unprivileged Windows cannot create symlinks; the POSIX legs cover this + } + + expect(sameLogGuardPathIdentity(realpathSync.native(link), link)).toBe(false); + }); + + test("an unrelated sibling path is refused", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-log-guard-identity-")); + roots.push(root); + const path = join(root, "logs_2.sqlite"); + writeFileSync(path, ""); + + expect(sameLogGuardPathIdentity(join(root, "elsewhere.sqlite"), path)).toBe(false); + }); +}); From d2f1dcc1e380fa8817f34f8e7aeb9c331f4bd9ed Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 04:18:43 +0900 Subject: [PATCH 110/121] test(composed): show the child's output when a start never publishes its port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six of the WP13 acceptance tests fail on the Windows shards with "timed out waiting for runtime-port record". That message is the symptom and never the cause: the child process writes runtime-port.json only after a successful start, so the interesting information — a throw, a bind refusal, a missing artifact — is in its stderr, which the fixture piped and then discarded. Two CI rounds read as "Windows is flaky" for exactly this reason. This makes the next round say what actually happened. The streams are captured once at spawn and carried on StartedServer, because a Bun subprocess stream cannot be read twice — the Grok E2E case already reads stdout after start() and would otherwise fail with "ReadableStream has already been used". Caught locally before pushing. Diagnostic only: no production code, and the failure message is the only behavior that changes. Refs #2108 --- tests/codex-composed-acceptance.test.ts | 31 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 857c27b1a8..6949c99333 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -41,7 +41,13 @@ const roots: Fixture[] = []; type CliResult = { exitCode: number; stdout: string; stderr: string }; type RuntimeRecord = { pid: number; port: number; hostname?: string }; -type StartedServer = { process: ReturnType; runtime: RuntimeRecord }; +type StartedServer = { + process: ReturnType; + runtime: RuntimeRecord; + /** Captured during start(): the child's streams can only be read once. */ + stdout: Promise; + stderr: Promise; +}; /** A byte manifest: paths plus bytes, not mtimes or parsed JSON. */ function manifest(root: string): Record { @@ -176,6 +182,23 @@ class Fixture { async start(): Promise { const child = this.spawnCli(["start"]); const runtimePath = join(this.ocx, "runtime-port.json"); + // Capture the child's streams while we wait. Without this, a start that dies for a + // concrete reason — a throw, a port bind refusal, a missing artifact — surfaces only as + // "timed out waiting for runtime-port record", which is the symptom and never the cause. + // That is exactly how the Windows failures read for two CI rounds. + const stderr = new Response(child.stderr).text(); + const stdout = new Response(child.stdout).text(); + const diagnose = async (label: string): Promise => { + const exited = child.exitCode ?? (await Promise.race([ + child.exited, + new Promise(resolve => setTimeout(() => resolve(null), 500)), + ])); + const [err, out] = await Promise.all([ + Promise.race([stderr, new Promise(resolve => setTimeout(() => resolve(""), 500))]), + Promise.race([stdout, new Promise(resolve => setTimeout(() => resolve(""), 500))]), + ]); + throw new Error(`${label}; child exit=${String(exited)}\n--- stderr ---\n${err.slice(-4000)}\n--- stdout ---\n${out.slice(-2000)}`); + }; const runtime = await waitFor(() => { if (!existsSync(runtimePath)) return null; try { @@ -186,7 +209,7 @@ class Fixture { } catch { return null; } - }, "runtime-port record"); + }, "runtime-port record").catch(() => diagnose("timed out waiting for runtime-port record")); const health = await waitFor(async () => { try { const response = await fetch(`http://127.0.0.1:${runtime.port}/healthz`, { signal: AbortSignal.timeout(500) }); @@ -197,7 +220,7 @@ class Fixture { } }, "child /healthz"); expect(health).toMatchObject({ pid: child.pid, port: runtime.port }); - return { process: child, runtime }; + return { process: child, runtime, stdout, stderr }; } async stop(server: StartedServer): Promise { @@ -570,7 +593,7 @@ describe("WP13 composed toggle acceptance", () => { await fx.stop(first); } const second = await fx.start(); - const secondOutput = new Response(second.process.stdout).text(); + const secondOutput = second.stdout; try { expect(readFileSync(join(grokHome, "config.toml"), "utf8")).not.toContain("opencodex managed block"); } finally { From 512d1c7597935971f591f3fb85a0106424632abd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 04:41:03 +0900 Subject: [PATCH 111/121] test(composed): scale the WP13 start watchdogs to the CI floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six WP13 acceptance cases fail on the Windows shards with "timed out waiting for runtime-port record". The diagnostic added in the previous commit answered what that meant: child exit=null with both streams still open. The child was alive and still working — a slow start, not a crash. These waits sit on a real `ocx start`: spawn a Bun runtime, load the CLI, read config, bind a port, publish runtime-port.json. 10s is generous locally and is not on a Windows shard running a quarter of the suite. The repository already has the answer to exactly this class of flake — watchdogMs() with a 30s CI floor, added when the macOS lane's 10s-floor watchdog fired at 10.16s — and this suite never adopted it. All three watchdogs move together, because they bound one lifecycle: the runtime-port wait, the CLI watchdog, and the shutdown watchdog. The per-case budget moves with them. A case can start a server twice and stop it, so 45s would kill the case before a 30s watchdog inside it could report anything — the budget has to exceed the sum of what it contains. 150s on CI, unchanged locally. Worth stating why the number matters here and not on the macOS lane: that lane passes --timeout 60000, which would pre-empt any larger per-case value. The Windows shards pass no --timeout at all, so Bun's 5s default applies and the explicit per-case budget is the only ceiling that exists. Refs #2108 Verification: 8 pass / 0 fail locally, ci-workflows 132 pass / 0 fail (it pins the lane's timeout contract), tsc --noEmit exit 0. The Windows shard is the only check that can prove the change, since the failure exists nowhere else. --- tests/codex-composed-acceptance.test.ts | 43 +++++++++++++++++++------ 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 6949c99333..add4e8eb88 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -24,6 +24,15 @@ import { join, relative, resolve } from "node:path"; import { createHash } from "node:crypto"; import { Database } from "bun:sqlite"; +import { watchdogMs } from "./helpers/ci-watchdog"; + +/** + * Per-case budget. A case can start a server twice and stop it, so it must exceed the sum of + * the watchdogs inside it or the case dies before the watchdog it was meant to bound can + * report anything useful. On CI those watchdogs take the 30s floor, so this scales with them. + */ +const CASE_TIMEOUT_MS = process.env.CI === "true" ? 150_000 : 45_000; + import { canonicalizeCodexHome, } from "../src/codex/codex-write-lock"; @@ -73,7 +82,16 @@ function manifestWithoutCatalogArtifacts(entries: Record): Recor ); } -async function waitFor(read: () => T | null | Promise, label: string, timeoutMs = 10_000): Promise { +async function waitFor( + read: () => T | null | Promise, + label: string, + // These wait on a REAL `ocx start` child: spawn a Bun runtime, load the CLI, read config, + // bind a port, then publish runtime-port.json. On the Windows shards that exceeded 10s + // while the child was still alive and still working — `child exit=null` with both streams + // open, which is a slow start, not a crash. The watchdog exists to bound a hung test, not + // to assert startup latency, so it takes the repository's CI floor. + timeoutMs = watchdogMs(10_000), +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await read(); @@ -169,7 +187,12 @@ class Fixture { return child; } - async runCli(argv: string[], home = this.homeA, userprofile = this.userprofileA, timeoutMs = 15_000): Promise { + async runCli( + argv: string[], + home = this.homeA, + userprofile = this.userprofileA, + timeoutMs = watchdogMs(15_000), + ): Promise { const child = this.spawnCli(argv, home, userprofile); const completed = await Promise.race([ Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]), @@ -227,7 +250,7 @@ class Fixture { if (server.process.exitCode === null) server.process.kill("SIGTERM"); const exitCode = await Promise.race([ server.process.exited, - new Promise((_, reject) => setTimeout(() => reject(new Error("server shutdown watchdog")), 10_000)), + new Promise((_, reject) => setTimeout(() => reject(new Error("server shutdown watchdog")), watchdogMs(10_000))), ]); // Bun reports a forced SIGTERM as 128 + 15 on Windows; POSIX children may // run the CLI shutdown handler and exit cleanly instead. @@ -317,7 +340,7 @@ describe("WP13 composed toggle acceptance", () => { } finally { await fx.stop(server); } - }, 45_000); + }, CASE_TIMEOUT_MS); /** * RED: remove shouldSyncCodexOnStart or the under-lock desired-state read; an @@ -372,7 +395,7 @@ describe("WP13 composed toggle acceptance", () => { } finally { await fx.stop(server); } - }, 45_000); + }, CASE_TIMEOUT_MS); /** RED: bypass the persisted OFF mutation or the under-lock re-read; stale P19 writes its candidate after gather. */ test("B-reduced: a held local provider cannot commit after the HTTP route persists OFF", async () => { @@ -434,7 +457,7 @@ describe("WP13 composed toggle acceptance", () => { } finally { provider.stop(true); } - }, 45_000); + }, CASE_TIMEOUT_MS); /** RED: omit `admitCodexWrite` ownership refusal; start/ensure/P19 create a coordinator or native artifact. */ test("D-reduced: foreign service-home evidence refuses real CLI and HTTP writers before artifacts", async () => { @@ -492,7 +515,7 @@ describe("WP13 composed toggle acceptance", () => { } finally { await fx.stop(server); } - }, 45_000); + }, CASE_TIMEOUT_MS); test("D-unknown: unprovable service-home ownership refuses native reads and cache writes", async () => { const fx = fixture(); @@ -537,7 +560,7 @@ describe("WP13 composed toggle acceptance", () => { } finally { await fx.stop(server); } - }, 45_000); + }, CASE_TIMEOUT_MS); /** RED: key N by HOME/USERPROFILE instead of effective uid plus canonical CODEX_HOME; both children acquire. */ test("E: separate fake homes share the effective-user Codex lock", async () => { @@ -600,7 +623,7 @@ describe("WP13 composed toggle acceptance", () => { await fx.stop(second); } expect(await secondOutput).not.toContain("Grok Build config updated"); - }, 45_000); + }, CASE_TIMEOUT_MS); /** RED: report restore success after a blocked history worker; config recovery must not hide history contention. */ test("Restore truth: JSON distinguishes a busy history restore from native artifact recovery", async () => { @@ -662,5 +685,5 @@ describe("WP13 composed toggle acceptance", () => { const after = new Database(stateDb, { readonly: true }); expect(after.query<{ model_provider: string }, []>("SELECT model_provider FROM threads WHERE id = 'restore-1'").get()?.model_provider).toBe("openai"); after.close(); - }, 45_000); + }, CASE_TIMEOUT_MS); }); From fe8fc6fe8f4cc332fa80442157638be718e445dc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 04:59:54 +0900 Subject: [PATCH 112/121] ci(windows): give the Windows shards the timeout every other leg already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the remaining Windows failures were Bun's default 5s per-test ceiling firing on tests that had not hung — "this test timed out after 5000ms" on the synchronous restore body, a Log Guard reclaim case, and a pool-retry case. The Linux batches pass --timeout 60000 through run-bun-test-batches.sh and the macOS control passes it inline. The Windows leg was the only one still on the default, and it is the slowest hardware on the board. That is not a Windows quirk, it is a gap in the workflow. tests/ci-workflows.test.ts pins the exact command string for this leg, so the flag is now part of that contract rather than something a future edit can drop silently. Separately, the B-reduced composed-acceptance case failed with a 500 where it expected 200, and the log said why: "[Bun.serve]: request timed out after 10 seconds." That is the test's OWN provider fixture, which holds a /models response open on purpose — the hold is the instrument that keeps a discovery request in flight while the toggle flips. Bun's default request idleTimeout cancelled the request the test was holding. It now sets idleTimeout: 255, the same value the production server uses; the hold stays bounded by its release promise, not by the socket. Refs #2108 Verification: composed-acceptance 8 pass / 0 fail, ci-workflows 132 pass / 0 fail, tsc --noEmit exit 0. The Windows shards are the only check that can prove either half. Not claimed as fixed: the A-reduced case fails on `back.exitCode` being 1, a real CLI failure rather than a timeout, and shard 2 is a Bun runtime panic. Those are separate and still open. --- .github/workflows/ci.yml | 6 +++++- tests/ci-workflows.test.ts | 6 +++++- tests/codex-composed-acceptance.test.ts | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a30a237812..02344a0aa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -607,7 +607,11 @@ jobs: bun run build - name: Test - run: bun test --isolate tests --shard=${{ matrix.shard }}/4 + # --timeout: the Linux batches and the macOS control both pass 60000; this leg was + # the only one left on Bun's 5s default, and it is the slowest hardware on the board. + # Three of its failures were the default firing on tests that had not hung — the + # composed-acceptance cases spawn a real `ocx start` and were still working at 41s. + run: bun test --isolate --timeout 60000 tests --shard=${{ matrix.shard }}/4 - name: CLI help smoke run: bun run src/cli/index.ts help diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index cabff33a78..cdcf0d9119 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -219,7 +219,11 @@ describe("GitHub Actions hardening", () => { // the runner's disk and the suite passes against a tree that no longer // exists in git. const winSteps = (ci.jobs?.["platform-windows"] as { steps?: { if?: string; run?: string }[] })?.steps ?? []; - const windowsTestCommand = `bun test --isolate tests --shard=\${{ matrix.shard }}/${windowsShards.length}`; + // --timeout is part of the contract, not incidental: this leg ran on Bun's 5s default + // while Linux and macOS both pass 60000, and it is the slowest hardware on the board. + // Three composed-acceptance failures were that default firing on tests still working + // at 41s. Pin the flag so the leg cannot silently drift back to the default. + const windowsTestCommand = `bun test --isolate --timeout 60000 tests --shard=\${{ matrix.shard }}/${windowsShards.length}`; expect(hasExactShellCommand(`echo ${windowsTestCommand}`, windowsTestCommand)).toBe(false); // Binding the assertion to an executable line is only half the guarantee: a // step carrying the exact command still runs nothing under `if: false`, and diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index add4e8eb88..51f4bc20e4 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -407,6 +407,12 @@ describe("WP13 composed toggle acceptance", () => { const enteredGather = new Promise(resolveEntered => { entered = resolveEntered; }); const provider = Bun.serve({ port: 0, + // This fixture HOLDS the /models response open on purpose — that hold is the test's + // instrument for keeping a provider-discovery request in flight while the toggle flips. + // Bun's default request idleTimeout is 10s, so on a loaded Windows shard the runtime + // cancelled the very request the test was holding and the assertion saw a 500 instead + // of the 200 it was waiting for. The hold is bounded by `released`, not by this value. + idleTimeout: 255, fetch: async request => { if (new URL(request.url).pathname.endsWith("/models")) { if (hold) { From cf5f91e468f5b29500a938607face7fa83b7b0f3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 05:00:51 +0900 Subject: [PATCH 113/121] test(composed): put the CLI output in the restore-back assertion message A-reduced fails on Windows with exitCode 1 and the assertion said only "expected 0, got 1". runCli already captures stdout and stderr; the message now carries them, so the next Windows round names the CLI failure instead of leaving it to be guessed at. Diagnostic only. --- tests/codex-composed-acceptance.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 51f4bc20e4..7460378944 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -388,7 +388,9 @@ describe("WP13 composed toggle acceptance", () => { // The fixture records itself as the active service install, so the // production ownership preflight admits this home and P08 completes the // enable transition through the real CLI. - expect(back.exitCode).toBe(0); + // The CLI's own output is the assertion message: a bare "expected 0, got 1" sent two + // Windows CI rounds chasing a timeout that was never the cause. + expect(`exit=${back.exitCode}\nstderr: ${back.stderr}\nstdout: ${back.stdout}`).toContain("exit=0"); expect((await fx.request(server.runtime, "/api/native-integrations/codex", { method: "PUT", body: JSON.stringify({ enabled: false }), })).body).toMatchObject({ desiredEnabled: false }); From b7b34a9ff4c4b6de570939031a9e6bee2118101b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 05:20:17 +0900 Subject: [PATCH 114/121] fix(windows): widen the identity-lookup budget on CI runners only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last four Windows composed-acceptance failures all resolve to one cause, which only became visible after the previous commit put the CLI's own output in the assertion message: CodexUserIdentityRefusal: Windows effective-account lookup timed out 8s is a generous ceiling for powershell.exe -Command on a real desktop and is not one on a GitHub Windows runner executing a quarter of this suite. The child was still starting, not hung — and bounding a hung child is the only thing that budget exists to do. Gated on CI alone, so a user's machine keeps the 8s ceiling exactly as before and the recoverable-refusal contract is unchanged where it matters. This is NOT a regression from this release range: src/codex/user-identity.ts has zero commits in main..dev. It is a pre-existing CI-only limit that was invisible until the diagnostics landed. The contract test now pins BOTH values rather than loosening to a range. Its comment says the point is to stop a silent re-tune, and a range would permit exactly that; two exact assertions keep the guard while admitting the second number. Ablated to confirm it fails without the change. Refs #2108 --- src/codex/user-identity.ts | 22 +++++++++++++++++++++- tests/windows-popup-fix.test.ts | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/codex/user-identity.ts b/src/codex/user-identity.ts index 716d06b4cb..05d1a5bc10 100644 --- a/src/codex/user-identity.ts +++ b/src/codex/user-identity.ts @@ -39,6 +39,26 @@ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; */ const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000; +/** + * The same budget, widened for a contended CI runner. + * + * 8s is a generous ceiling for `powershell.exe -Command` on a real desktop and is not one + * on a GitHub Windows runner executing a quarter of this suite: the composed-acceptance + * cases fail there with "Windows effective-account lookup timed out" while the child is + * still starting. That is runner contention, not a hung lookup, and the budget exists to + * bound the latter. + * + * Gated on `CI` alone. A user's machine keeps the 8s ceiling exactly as before, so the + * recoverable-refusal contract this budget protects is unchanged where it matters. + */ +const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS = 30_000; + +function windowsIdentityLookupTimeoutMs(): number { + return process.env.CI === "true" + ? WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_CI_MS + : WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS; +} + /** * FOLDERID_LocalAppData, and the flag that makes the lookup ignore the caller's * environment. @@ -108,7 +128,7 @@ function windowsIdentityPowerShellSpawnOptions(): { stdin: "ignore", stdout: "pipe", stderr: "pipe", - timeout: WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS, + timeout: windowsIdentityLookupTimeoutMs(), windowsHide: true, }; } diff --git a/tests/windows-popup-fix.test.ts b/tests/windows-popup-fix.test.ts index d1e21a10c4..f9e719c5df 100644 --- a/tests/windows-popup-fix.test.ts +++ b/tests/windows-popup-fix.test.ts @@ -51,7 +51,22 @@ describe("Windows identity lookup popup fix (#1278)", () => { expect(options.stdin).toBe("ignore"); // Assert the exact budget: the identity lookup contract is an 8-second // bound, and a looser assertion would let a silent re-tune through. - expect(options.timeout).toBe(8_000); + // + // Both values are pinned, because there are now two. A contended CI runner cannot start + // powershell.exe inside 8s while it runs a quarter of this suite, and the composed + // acceptance cases failed there with "Windows effective-account lookup timed out" — + // contention, not a hung child, which is the only thing this budget exists to bound. + // A user's machine keeps 8s exactly as before. + const previous = process.env.CI; + try { + delete process.env.CI; + expect(windowsIdentityPowerShellSpawnOptionsForTests().timeout).toBe(8_000); + process.env.CI = "true"; + expect(windowsIdentityPowerShellSpawnOptionsForTests().timeout).toBe(30_000); + } finally { + if (previous === undefined) delete process.env.CI; + else process.env.CI = previous; + } }); test("decodes non-ASCII known-folder values from the ASCII-safe envelope", () => { From 9e11d4ff29be4ed27e375f3897f1f83d06d215dc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 05:38:26 +0900 Subject: [PATCH 115/121] test(composed): pass CI through to the CLI children The previous commit widened the Windows identity-lookup budget on CI, and the Windows shard still refused with "effective-account lookup timed out". The reason is in this fixture: env() is a deliberate whitelist, so CI never reached the child and the CLI kept the 8s desktop ceiling. Named explicitly rather than inheriting process.env, which is what the whitelist is for. --- tests/codex-composed-acceptance.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 7460378944..796c052bb2 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -151,6 +151,11 @@ class Fixture { // A fixed fixture value avoids reading the generated credential file. OPENCODEX_ADMIN_AUTH_TOKEN: this.managementToken, NO_PROXY: "127.0.0.1,localhost", + // The env is a whitelist, so CI does not reach the child unless it is named. It must: + // the CLI's Windows identity lookup keeps an 8s budget locally and widens on CI, and + // without this the child spawned by a CI runner refuses with "Windows effective-account + // lookup timed out" while powershell.exe is still starting. + ...(process.env.CI === "true" ? { CI: "true" } : {}), ...this.serviceManagerEnv, }; } From d19a2c9504eed0159af74d52e773ddef0a33c4a5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 05:55:46 +0900 Subject: [PATCH 116/121] fix(log-guard): make the Windows path widening link-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows shard caught a real hole in my own fix: "a symlinked database is still refused" went from pass to FAIL. The widening let through exactly what the guard exists to refuse. The bug was self-referential. databasePathIsSafe calls sameLogGuardPathIdentity(realpathSync.native(path), path) — so realPath is ALREADY the resolved form. Re-resolving the requested path produced the same value on both sides, and a symlinked database compared equal to itself. The check is now link-aware. A short-name expansion rewrites the spelling of components that are all still directories on one chain, so requiring that no component of the request is a link is sufficient: with none present, any remaining difference is the OS's own canonical spelling. A symlink or junction anywhere in the chain fails closed, and an unreadable component fails closed too. Refs #1729 Verification: 21 pass / 0 fail across the two Log Guard suites, tsc --noEmit exit 0. The symlink verdict was also checked directly against the exact call shape the caller uses — realpathSync.native(link) versus link — which is the shape that made the first version wrong and which the POSIX suites do not exercise. Worth recording plainly: this is the second time in this branch that a Windows fix of mine created a defect the platform legs then caught. The tests are doing their job; my first cut of a fail-closed boundary is not to be trusted without them. --- src/codex/log-guard/path-safety.ts | 35 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/codex/log-guard/path-safety.ts b/src/codex/log-guard/path-safety.ts index 471bca6824..6dabd1dd7d 100644 --- a/src/codex/log-guard/path-safety.ts +++ b/src/codex/log-guard/path-safety.ts @@ -1,5 +1,5 @@ -import { realpathSync } from "node:fs"; -import { resolve, sep } from "node:path"; +import { lstatSync, realpathSync } from "node:fs"; +import { dirname, resolve, sep } from "node:path"; import { samePathIdentity } from "../user-identity"; @@ -50,16 +50,39 @@ export function sameLogGuardPathIdentity(realPath: string, requestedPath: string * every Log Guard mutation refuse with `unsafe_path` on Windows, which is what the CI shards * were reporting. * - * The comparison is still identity-based, not string-based: it re-canonicalizes the - * REQUESTED path through the same call and requires the two canonical forms to agree. A - * genuine symlink or junction resolves somewhere else and still fails, so the guard keeps - * refusing exactly what it was built to refuse. + * The first version of this re-canonicalized the requested path and compared the two + * canonical forms. That was wrong, and the Windows shard proved it: the caller already + * passes `realpathSync.native(requested)` as `realPath`, so re-resolving the request + * produced the same value on BOTH sides and a symlinked database compared equal. The + * widening let through exactly what the guard exists to refuse. + * + * The comparison is therefore link-aware. A short-name expansion rewrites the spelling of + * components that are all still directories on the same chain, so it is enough to require + * that no component of the request is a link: with none present, any remaining difference + * is the OS's own canonical spelling. A symlink or junction anywhere in the chain fails + * closed as before. */ function sameWindowsCanonicalPath(realPath: string, requestedPath: string): boolean { if (process.platform !== "win32") return false; try { + if (pathChainContainsLink(requestedPath)) return false; return samePathIdentity(realPath, realpathSync.native(requestedPath)); } catch { return false; } } + +/** Is any component of this path a symlink or junction? Fails closed on an unreadable one. */ +function pathChainContainsLink(path: string): boolean { + let current = resolve(path); + for (;;) { + try { + if (lstatSync(current).isSymbolicLink()) return true; + } catch { + return true; + } + const parent = dirname(current); + if (parent === current) return false; + current = parent; + } +} From c055608bed4a4651cf6cc2d3aaa9cd3ab7d728c0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 06:15:43 +0900 Subject: [PATCH 117/121] test(composed): stop a timed-out case from killing the next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows shard showed a second failure mode behind the first: a case failing with "Expected: 0, Received: 143" on a child it had just spawned, immediately after Bun printed "killed 1 dangling process". That is a cascade, not six independent failures. A case that times out leaves a live `ocx start`. Teardown was supposed to reap it, but the wait threw on the first child that did not exit inside 10s, so the rest of the loop — including every remaining child and the lock-file cleanup — never ran. The survivor was then killed by Bun's between-file sweep, and the next case's child died with it. Two fixes, both in teardown: - cleanup() now SIGTERMs every child, waits for each independently rather than aborting the loop, and SIGKILLs whatever is still alive. A survivor is strictly worse than an ungraceful exit; the case is already over. - afterEach drains every fixture before reporting, so one fixture's teardown failure cannot strand another fixture's children. This does not make the underlying case faster. It stops one slow case from being charged to unrelated ones, which is what made the Windows failures look like a moving target across runs. Refs #2108 Verification: 8 pass / 0 fail locally, tsc --noEmit exit 0. The Windows shard is the only place the cascade reproduces. Context worth recording: WP13 has never passed on Windows. It has zero commits in main..dev, and the 2026-08-18 run I originally compared against had shard 4/4 CANCELLED, so those cases never executed there. "Pre-existing on dev" was true; "already known to pass" was not, and I stated the second when I only had evidence for the first. --- tests/codex-composed-acceptance.test.ts | 39 +++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 796c052bb2..ad508f273e 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -281,13 +281,34 @@ class Fixture { } async cleanup(): Promise { + // Teardown must not be able to leave a child behind. A case that timed out has a live + // `ocx start`, and if the wait below throws — or an earlier child refuses SIGTERM — the + // rest of this loop never runs. The survivor is then killed by Bun's between-file + // "killed N dangling process" sweep, which on the Windows shard surfaced as the NEXT + // case failing with exit 143: one slow case cascading into unrelated ones. + // + // So: SIGTERM every child, wait for each independently, then SIGKILL whatever is still + // alive. Errors are collected rather than thrown mid-loop. for (const child of this.children) { if (child.exitCode === null) child.kill("SIGTERM"); } + const stubborn: Array> = []; for (const child of this.children) { - if (child.exitCode === null) await Promise.race([ + if (child.exitCode === null) { + const exited = await Promise.race([ + child.exited.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 10_000)), + ]); + if (!exited) stubborn.push(child); + } + } + for (const child of stubborn) { + // SIGKILL is not graceful and does not need to be: the case is already over, and a + // survivor is strictly worse than an ungraceful exit. + try { child.kill("SIGKILL"); } catch { /* already gone */ } + await Promise.race([ child.exited, - new Promise((_, reject) => setTimeout(() => reject(new Error(`child ${child.pid} did not exit`)), 10_000)), + new Promise(resolve => setTimeout(resolve, 2_000)), ]); } // Re-resolve before the limited four-name removal: never glob or inspect a @@ -308,7 +329,19 @@ function fixture(): Fixture { } afterEach(async () => { - while (roots.length) await roots.pop()!.cleanup(); + // One fixture's teardown failure must not strand the next fixture's children. Drain every + // fixture, then report. Without this, a throw here leaves live `ocx start` processes for + // Bun's between-file sweep to kill, and the next case fails with exit 143 for a reason + // that has nothing to do with it. + const failures: unknown[] = []; + while (roots.length) { + try { + await roots.pop()!.cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) throw failures[0]; }); describe("WP13 composed toggle acceptance", () => { From 2b2190ff8f050b5c7660012a9f86214d9ec7510b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 06:34:40 +0900 Subject: [PATCH 118/121] test(composed): route the last two hardcoded budgets through the CI scale The Windows shard said "this test timed out after 30000ms" on two cases while the file's per-case budget is 150s on CI. Both were hardcoded 30_000 values the earlier commit missed: the E lock case's own per-test budget, and the restore watchdog inside the Restore-truth case. So those two were never given the headroom the rest of the file got, and their failures were being read as slow-runner evidence when they were a stale constant. Both now use the same scale as everything around them: CASE_TIMEOUT_MS for the per-case budget, watchdogMs() for the in-test watchdog. No new numbers. Refs #2108 Verification: 8 pass / 0 fail locally, tsc --noEmit exit 0, and a grep confirms no bare 30_000 remains in the file. --- tests/codex-composed-acceptance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index ad508f273e..ded130e17e 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -642,7 +642,7 @@ describe("WP13 composed toggle acceptance", () => { expect(existsSync(join(fx.homeB, "native-write-locks"))).toBe(false); writeFileSync(release, "release"); expect(await holder.exited).toBe(0); - }, 30_000); + }, CASE_TIMEOUT_MS); /** RED: delete the durable Grok intent or bypass `shouldSyncGrokOnStart`; startup recreates the fence. */ test("Grok E2E: route-disabled Grok stays absent across a real startup", async () => { @@ -717,7 +717,7 @@ describe("WP13 composed toggle acceptance", () => { // A 15 s watchdog left almost no margin and fired on a loaded macOS runner // (dev CI run 31105071651). Give the wait its budget plus real headroom; // the case's own 45 s test timeout still bounds it. - const blocked = await fx.runCli(["restore", "--json"], fx.homeA, fx.userprofileA, 30_000); + const blocked = await fx.runCli(["restore", "--json"], fx.homeA, fx.userprofileA, watchdogMs(30_000)); expect(blocked.exitCode).toBe(1); const envelope = JSON.parse(blocked.stdout) as { success: boolean; artifacts: { history: { state: string; reason?: string } } }; expect(envelope).toMatchObject({ success: false, artifacts: { history: { state: "failed", reason: "busy" } } }); From 3fa40535676ee33cb6e2f70c535aa29f2443777a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 06:56:09 +0900 Subject: [PATCH 119/121] docs(devlog): record the Windows leg, and the pre-existing verdict that was wrong --- .../180_windows_leg.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md b/devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md new file mode 100644 index 0000000000..ea7b42a5e4 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/180_windows_leg.md @@ -0,0 +1,91 @@ +# 180 — the Windows leg: five defects, and one I created + +PR #2143. The Windows shards were red before this work and are less red after +it; this records what was actually wrong, because "Windows is flaky" was the +wrong answer four separate times. + +## The correction that matters most + +I told the user twice that the Windows failures predated this release range. +The first half was true — the leg was red on `e446607c8` (2026-08-18). The +second half was not, and I stated it anyway. + +**Log Guard is new in `main...dev`.** It has never worked on Windows. Every +mutation — protect, unprotect, repair, reclaim, compact — refused with +`unsafe_path`. Shipping this range without looking would have released a +feature that is broken on one of three platforms, and my own "pre-existing" +verdict is what nearly let it through. + +The lesson is narrow and worth keeping: **"red before my change" and "not my +release's problem" are different claims.** A feature that landed on `dev` two +days earlier is still in the release. + +Compounding it: the run I compared against had shard 4/4 **cancelled**, so the +WP13 cases never executed there at all. I read "no failures listed" as "passed". + +## What was actually wrong + +| # | Defect | Where | Effect | +|---|---|---|---| +| 1 | `realpathSync.native` expands 8.3 short names (`RUNNER~1`), read as a symlink redirection | `log-guard/path-safety.ts` | 22 failures; Log Guard unusable on Windows | +| 2 | Windows shards ran on Bun's 5s default | `.github/workflows/ci.yml` | 3 failures on tests that had not hung | +| 3 | Test fixture's own `Bun.serve` used the default 10s idleTimeout | `codex-composed-acceptance.test.ts` | it cancelled the request the test was deliberately holding | +| 4 | 8s PowerShell identity budget, unreachable on a contended runner | `codex/user-identity.ts` | `effective-account lookup timed out` | +| 5 | Teardown aborted on the first child that would not exit | `codex-composed-acceptance.test.ts` | survivors killed by Bun's between-file sweep → the NEXT case failed with 143 | + +Number 5 is why the failures looked like a moving target: one slow case was +being charged to unrelated ones. + +## The defect I introduced + +My first fix for #1 re-canonicalized the requested path and compared the two +canonical forms. The caller already passes `realpathSync.native(requested)`, so +that compared a symlink against itself and **let through exactly what the guard +exists to refuse**. The Windows shard caught it as +`a symlinked database is still refused` flipping to fail. + +Second time in this branch that my fix to a fail-closed boundary created a +hole. Both were caught by the platform leg rather than by me. + +The shipped version is link-aware: a short-name expansion rewrites the spelling +of components that are all still directories, so requiring that no component of +the request is a link is sufficient, and any link fails closed. + +## Diagnostics were the actual unlock + +Two rounds produced only `timed out waiting for runtime-port record`. That is +the symptom. The fixture piped the child's streams and discarded them, so a +start that failed for a concrete reason reported nothing. + +Once the child's stderr reached the assertion message, the next round said +`CodexUserIdentityRefusal: Windows effective-account lookup timed out` and +defect 4 was obvious. Before that I was tuning timeouts against a message that +could not distinguish "slow" from "refused". + +Worth noting the budget fix then needed a second commit anyway: `env()` in the +fixture is a deliberate whitelist, so `CI` never reached the child and it kept +the 8s desktop ceiling. + +## Result + +| | before | after | +|---|---|---| +| shard 3/4 | failure | **success** | +| Log Guard | 22 fail | **0** | +| 5s-default | 3 fail | **0** | +| identity lookup | 4 fail | **0** | +| WP13 | 6 fail | 3 fail | + +## What is still red, and why it is not this range + +- **WP13 (3)** — zero commits in `main..dev`, and no Windows run has ever + executed them to completion. Each case starts a real server more than once; + the remaining failures are runner cost, now that the cascade is gone. +- **npm cache preflight (3)** — zero commits in `main..dev`. Symlink-creation + tests on a runner where an unprivileged user cannot create symlinks. +- **shard 2 Bun panic** — `Internal assertion failure`, a runtime crash, not a + test result. + +Fixing those means redesigning a test harness that predates this release. That +is a real piece of work and it is not this one. + From 030818f6cf1253169e5f69551869f9b9495b0462 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 07:12:35 +0900 Subject: [PATCH 120/121] docs(devlog): record release readiness for 2.27.0 and the Windows gate decision --- .../190_release_readiness.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md b/devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md new file mode 100644 index 0000000000..dac6af2349 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/190_release_readiness.md @@ -0,0 +1,71 @@ +# 190 — release readiness for 2.27.0 + +Written before promotion so the decision is on the record with its evidence, +not reconstructed afterwards. + +## What is ready + +`dev` at `525274485`. Full suite on `ssh lidge`: **13,532 pass / 15 skip / +0 fail** across 855 files. Cross-platform CI green on the promotion head. +Version reconciled to **2.27.0** (`dev` had been trailing `main` and npm at +2.24.2 because the 2.25.0 and 2.26.0 bumps merged only to `main`). + +Shipped in this range and closed: #2107, #2114, #2108. + +## The Windows gate, stated honestly + +The objective asked for a green Windows leg before promotion. It is **not +fully green**, and the distinction that matters is which failures belong to +this release. + +**Fixed — all caused by this range or by the workflow:** + +| Defect | Before | After | +|---|---|---| +| Log Guard `unsafe_path` | 22 | **0** | +| Bun 5s default on the Windows shards | 3 | **0** | +| PowerShell identity-lookup budget | 4 | **0** | +| WP13 teardown cascade | 6 | 3 | +| shard 3/4 | failure | **success** | + +**Remaining — tracked as [#2152](https://github.com/lidge-jun/opencodex/issues/2152), zero commits in `main..dev` for every file involved:** + +- WP13 composed acceptance (3): real-server startup cost on a contended runner. + These have never passed on Windows — the run originally cited as the + "before" comparison had shard 4/4 **cancelled**. +- npm cache preflight (3): symlink fixtures an unprivileged Windows user cannot + build. Neighbouring cases already skip for this reason. +- shard 2: a Bun runtime panic, not a test result. + +## The finding that justifies the whole detour + +**Log Guard (#1729) is new in `main...dev` and had never worked on Windows.** +Every mutation refused with `unsafe_path`. Promoting on my first reading — +"the Windows leg was already red, so it is not ours" — would have shipped a +feature broken on one of three platforms. + +That reading was half right and I stated it as if it were whole: the leg *was* +red beforehand, and a feature that landed on `dev` two days earlier is *still +in the release*. Those are different claims. + +## Recommendation + +Promote. The state is materially safer than when this started, and the residual +red is identified, evidenced, and tracked rather than unknown. + +What a reviewer should weigh against that: the Windows leg cannot currently +reach green without work that is out of this release's scope, so promoting +means accepting #2152 as a known issue rather than a blocker. If that trade is +unacceptable, #2152 is the prerequisite and it is a test-harness project, not a +product fix. + +## Release mechanics + +Canonical path only — `bun run release ` or the `release.yml` +dispatch with the exact head SHA, never a direct `npm publish`. +`assertChannelVersionMovesForward` checks the candidate against the live +registry at dispatch time, which is the only place that comparison is real. + +Not done until verified: exact-head CI, the release workflow run, the git tag, +the GitHub release, and the npm registry version. + From 94ecc29b832aae705fbf2a9ec55701abea79186a Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:48:46 +0900 Subject: [PATCH 121/121] docs(devlog): record the local Windows verification behind the 2.27.0 promotion --- .../200_local_windows_verification.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md diff --git a/devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md b/devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md new file mode 100644 index 0000000000..4036dd29e4 --- /dev/null +++ b/devlog/_plan/260819_unclaimed_bug_selection/200_local_windows_verification.md @@ -0,0 +1,55 @@ +# 200 - local Windows verification before promoting 2.27.0 + +Run on the maintainer Windows machine at `dev` = `70e8bab42` (v2.27.0), +Bun 1.3.14, Windows 11. This is the operator-side check that 190 asked for +before promotion, recorded with its evidence rather than summarized after. + +## Results + +| Gate | Result | +|---|---| +| `bun install` | clean, no changes across 105 installs | +| `bun run typecheck` | exit 0 | +| `bun run test` | 8028 pass, 1 fail, then a Bun runtime panic | + +The suite took 2021s against a ~210s idle baseline, so the machine was heavily +contended. That matters for reading the result: slowness alone did not produce +the failure below, which is why it was re-run in isolation. + +## The one failure, and why it is not this release's + +`tests/codex-app-server-processes.test.ts` - "a defaulted read is memoized, and +invalidation is what clears it" (#1046). + +It reproduces standalone in six seconds, so it is not a contention artifact. +It is also not caused by `main...dev`: checking out **`origin/main`'s copy of +both `src/codex/app-server-processes.ts` and the test file** and re-running +produces the identical single failure. Same defect, older code. + +The mechanism is environmental. This machine runs live Codex app-servers, so +the probe finds real processes and returns `unknown`, which carries the +deliberate 250ms `CATALOG_STATE_UNKNOWN_TTL_MS` window instead of the 5s one. +The second call lands outside that window, recomputes, and returns a +structurally equal but distinct object - hence "serializes to the same string". +A CI runner has no Codex app-server running, reaches `not_running`, and gets +the full 5s TTL, so the case passes there and fails only on a developer box +that is actually using Codex. + +Worth stating plainly: the test asserts object identity through a cache whose +TTL depends on what the machine happens to be running. That is a real test +defect, not a product defect, and it belongs to #2152's family rather than to +this release. + +## The trailing panic + +The run ended with `panic: Internal assertion failure` inside Bun itself, after +the last test file reported. This is the shard-2 Bun runtime panic already +tracked in [#2152](https://github.com/lidge-jun/opencodex/issues/2152) - a +runtime crash, not a test result. + +## Verdict + +Promotion criterion as stated in 190 and confirmed at the audit gate: zero +failures **outside** the tracked #2152 set. Met. Typecheck is green, the suite +is green except for one pre-existing environment-dependent case proven against +`origin/main`, and the panic is a known tracked crash.