From b933aa29231a24b41056a17e24c05893bdeab6e1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:48:26 -0700 Subject: [PATCH 01/21] fix(cursor): bound local installer manifests (#6319) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/integrations/cursor-local-installer.ts | 31 ++++++- structure/clients/integrations.md | 3 +- .../cursor/cursor-local-installer.test.ts | 85 +++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/integrations/cursor-local-installer.ts b/src/integrations/cursor-local-installer.ts index 996da4b61d5..c454504da7d 100644 --- a/src/integrations/cursor-local-installer.ts +++ b/src/integrations/cursor-local-installer.ts @@ -17,6 +17,9 @@ const DEFAULT_UPDATE_HOST = "https://api2.cursor.sh"; const UPDATE_MANIFEST_TIMEOUT_MS = 4_000; +const UPDATE_MANIFEST_MAX_BYTES = 64 * 1024; +const UPDATE_MANIFEST_MAX_VERSION_LENGTH = 256; +const UPDATE_MANIFEST_MAX_URL_LENGTH = 4 * 1024; const CACHE_TTL_OK_MS = 30 * 60_000; const CACHE_TTL_FAILED_MS = 5 * 60_000; @@ -63,7 +66,29 @@ export function realCursorLocalHintDeps(): CursorLocalHintDeps { fetchJson: async (url, timeoutMs) => { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); + if (!response.body) throw new Error("Missing response body"); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > UPDATE_MANIFEST_MAX_BYTES) { + // Best-effort discard: awaiting cancel() could hang on a stalled source + // even though AbortSignal.timeout already bounds the request itself. + void reader.cancel().catch(() => {}); + throw new Error("Update manifest exceeds byte limit"); + } + chunks.push(value); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); }, }; } @@ -86,7 +111,8 @@ export function platformForHost(os: string, arch: string): CursorLocalPlatform | function parseManifest(raw: unknown): { version: string; url: string } | null { if (!raw || typeof raw !== "object") return null; const record = raw as CursorLocalManifest; - if (typeof record.url !== "string" || !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) { + if (typeof record.url !== "string" || record.url.length > UPDATE_MANIFEST_MAX_URL_LENGTH + || !/^https:\/\/downloads\.cursor\.com\/local-mode\//.test(record.url)) { return null; } // Windows and Linux manifests carry `version`/`productVersion`; the Darwin ones return only @@ -94,6 +120,7 @@ function parseManifest(raw: unknown): { version: string; url: string } | null { const candidate = typeof record.version === "string" ? record.version : typeof record.productVersion === "string" ? record.productVersion : typeof record.name === "string" ? record.name : ""; + if (candidate.length > UPDATE_MANIFEST_MAX_VERSION_LENGTH) return null; const version = candidate.trim(); if (version === "") return null; // The Linux channel advertises the AppImage's zsync delta metadata (what the in-app updater diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 117a1fc8b67..790b0b5796b 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -78,7 +78,8 @@ Cursor's `cursor-local` update channel advertises for the host platform and arch `x64` and `arm64` on Windows, macOS and Linux map; any other host resolves to `unsupported-platform` with no request) (`/updates/api/update//cursor-local/0.0.0/manual-check/stable`, 4 s timeout). -Only a `https://downloads.cursor.com/local-mode/` URL with a version is accepted (a Linux +The decoded manifest is capped at 64 KiB before JSON parsing. Only a bounded +`https://downloads.cursor.com/local-mode/` URL with a bounded version is accepted (a Linux `.AppImage.zsync` delta-metadata URL is mapped to its sibling `.AppImage`); anything else resolves to `available: false` with reason `unreachable` or `unusable-response`, and nothing is requested when Private Inference is already installed or no regular install exists. The module never diff --git a/tests/providers/cursor/cursor-local-installer.test.ts b/tests/providers/cursor/cursor-local-installer.test.ts index 3d3578a3e2a..5178aeed01d 100644 --- a/tests/providers/cursor/cursor-local-installer.test.ts +++ b/tests/providers/cursor/cursor-local-installer.test.ts @@ -59,6 +59,91 @@ describe("buildCursorLocalInstallerHint", () => { expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" }); }); + test("oversized manifest fields are rejected before they can be cached", async () => { + for (const manifest of [ + { version: "v".repeat(257), url: REPORTED_INSTALLER }, + { version: "3.21.18", url: `https://downloads.cursor.com/local-mode/${"x".repeat(4096)}` }, + ]) { + resetCursorLocalInstallerCacheForTests(); + const hint = await buildCursorLocalInstallerHint( + { regularInstalled: true, privateInferenceInstalled: false }, + depsWith(manifest), + ); + expect(hint).toEqual({ available: false, url: null, version: null, reason: "unusable-response" }); + } + }); + + test("real deps stop reading a decoded response above the manifest byte limit", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => Promise.resolve(new Response(JSON.stringify({ + version: "v".repeat(70 * 1024), + url: REPORTED_INSTALLER, + })))) as typeof fetch; + try { + const hint = await buildCursorLocalInstallerHint( + { regularInstalled: true, privateInferenceInstalled: false }, + { ...realCursorLocalHintDeps(), platform: "win32", arch: "x64" }, + ); + expect(hint).toEqual({ available: false, url: null, version: null, reason: "unreachable" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("oversize rejection does not await a never-settling stream cancellation", async () => { + const originalFetch = globalThis.fetch; + let cancelled = 0; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array(32 * 1024 + 1)); + }, + cancel() { + cancelled += 1; + return new Promise(() => {}); + }, + }); + globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch; + let timer: ReturnType | undefined; + try { + const outcome = await Promise.race([ + realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000).then( + () => ({ kind: "resolved" }), + error => ({ kind: "rejected", message: String(error.message) }), + ), + new Promise(resolve => { timer = setTimeout(() => resolve({ kind: "timeout" }), 1_000); }), + ]); + expect(outcome).toEqual({ kind: "rejected", message: "Update manifest exceeds byte limit" }); + expect(cancelled).toBe(1); + } finally { + clearTimeout(timer); + globalThis.fetch = originalFetch; + } + }); + + test.each([1_024, 64 * 1024])("valid multichunk manifest at %i bytes is decoded", async size => { + const originalFetch = globalThis.fetch; + const manifest = { version: "3.21.18", url: REPORTED_INSTALLER, padding: "" }; + const encoder = new TextEncoder(); + manifest.padding = "x".repeat(size - encoder.encode(JSON.stringify(manifest)).byteLength); + const encoded = encoder.encode(JSON.stringify(manifest)); + expect(encoded.byteLength).toBe(size); + const body = new ReadableStream({ + start(controller) { + for (let offset = 0; offset < encoded.byteLength; offset += 512) { + controller.enqueue(encoded.slice(offset, offset + 512)); + } + controller.close(); + }, + }); + globalThis.fetch = (() => Promise.resolve(new Response(body))) as typeof fetch; + try { + expect(await realCursorLocalHintDeps().fetchJson(MANIFEST_URL, 4_000)).toEqual(manifest); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("no regular install resolves nothing without any network call", async () => { const hint = await buildCursorLocalInstallerHint( { regularInstalled: false, privateInferenceInstalled: false }, From 2713b60e32966bbe37094b03e1df7ba5e91ecc5e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:55:10 -0700 Subject: [PATCH 02/21] fix(codex): preserve TOML values and routing marker ownership (#6308) Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../troubleshooting/codex-cannot-sign-in.md | 17 + scripts/test-layout/layout.json | 1 + src/codex/inject/config-toml.ts | 427 ++++++++---------- src/codex/inject/provider-table.ts | 7 +- src/codex/inject/remove.ts | 31 +- src/codex/injected-marker.ts | 51 ++- src/codex/toml-source-lines.ts | 94 +++- structure/codex-home.md | 2 +- .../codex-config-preservation.test.ts | 292 ++++++++++++ .../codex-provider-table-retention.test.ts | 128 ++++++ tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 768 insertions(+), 283 deletions(-) create mode 100644 tests/codex-integration/codex-config-preservation.test.ts diff --git a/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md index a5971443b34..9bff3814fdb 100644 --- a/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md +++ b/docs-site/src/content/docs/troubleshooting/codex-cannot-sign-in.md @@ -75,6 +75,23 @@ comment above other keys it manages, such as an injected `developer_instructions`, and deleting those will not help you sign in while costing you configuration you may want back. +Repeated standalone ownership comments do not change TOML values or routing. +Provider-table reinjection and removal consume the exact bare marker or its +`(undo: ocx restore)` form immediately above `[model_providers.opencodex]`. +Marker text inside another user comment or a multiline string is preserved; +existing comments separated from the table are not claimed by that transform. +Root routing ownership uses an exact adjacent marker at a structural root +assignment. A matching injected value recorded in the journal also proves +ownership after a Codex app rewrite drops comments. Quoting the marker in a +user comment or a multiline string does not prove ownership. Managed +`web_search` replacements record the complete original assignment, including +multiline values, so switching it back on or fallback restore can put it back. +Removal and capture validate complete assignments without converting a valid +64-bit integer into a JavaScript number. Value-based ownership still requires +successful value decoding; invalid or incomplete assignments are refused. +Fallback removal compacts document +blank lines outside multiline values, preserving blank lines within those values. + Delete the `model_catalog_json` line **with** the routing, not on its own. A `model_catalog_json` that names a file which no longer exists makes Codex fail to load its configuration at all, which looks like the same lockout for a diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8ffeb8454e..f41417d755e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,7 @@ "version": 1, "root": "tests", "explicit": { + "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index 99d8d0437c4..36c31719f6f 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -3,23 +3,22 @@ import { existsSync, readFileSync } from "node:fs"; import { contextCompatibleBaseLine } from "../context-compat"; import { resolveEffectiveProjectModelProvider } from "../project-config-warnings"; import { - OCX_SECTION_MARKER, OCX_ROUTING_MARKER_LINE, REALTIME_WS_BASE_URL_KEY, + isOcxRoutingMarkerLine, isRootOpenaiBaseUrlLine, isRootRealtimeWsBaseUrlLine, providerTableStart, providerTableString, rootTomlString, - tomlStringPattern, } from "../injected-marker"; import { CODEX_CONFIG_PATH, DEFAULT_CATALOG_PATH, - parseTomlString, resolveCodexConfigPath, tomlString, } from "../paths"; +import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceAssignmentSpan, sourceText, type SourceLine } from "../toml-source-lines"; import { readBoundedCodexConfig } from "./bounded-config-reader"; import { type CodexRoutingTarget, @@ -67,10 +66,9 @@ export function observedExternalCodexModelProvider(): string | null { } /** - * Detect the file's dominant line ending. Every transform in this module is LF-pure - * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are - * normalized to LF at the pipeline boundary and converted back on write — otherwise a - * single inject would leave a mixed-EOL file. + * Detect the file's dominant line ending. Structural edits retain physical line endings; + * the full injection/removal pipeline still normalizes to LF at its boundary and converts + * back on write so generated blocks do not leave a mixed-EOL file. */ export function dominantEol(content: string): "\r\n" | "\n" { const crlf = (content.match(/\r\n/g) ?? []).length; @@ -234,66 +232,43 @@ export function setRootOpenaiBaseUrl( if (typeof portOrTarget !== "number") { return setRootOpenaiBaseUrlForTarget(content, validateCodexRoutingTarget(portOrTarget)); } - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname)); + return setRootUrl(content, contextCompatibleBaseLine(content, buildOpenaiBaseUrlLine(portOrTarget, hostname))); +} - for (let i = 0; i < rootEnd; i++) { - if (!isRootOpenaiBaseUrlLine(lines[i])) continue; - const markerOwned = i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserBaseUrl: true }; - // Refresh the marker too, so a config injected by a build that predates the recovery - // hint gains it on the next `ocx start` instead of keeping a bare marker forever. - lines[i - 1] = OCX_ROUTING_MARKER_LINE; - lines[i] = key; - return { content: lines.join("\n"), keptUserBaseUrl: false }; - } +function ownedRootUrl(lines: SourceLine[], index: number, key: string): boolean { + return lines[index]!.structural && rootTomlString(lines[index]!.text, key) !== null + && index > 0 && lines[index - 1]!.structural && isOcxRoutingMarkerLine(lines[index - 1]!.text); +} - if (firstTable === -1) { - return { - content: - content.replace(/\n+$/, "") + - "\n" + - OCX_ROUTING_MARKER_LINE + - "\n" + - key + - "\n", - keptUserBaseUrl: false, - }; +function insertRootSourceLines(content: string, added: string[]): string { + const { bom, lines, rootEnd } = rootSourceLines(content); + const eol = dominantEol(content); + if (rootEnd === lines.length) { + return content.replace(/(?:\r?\n)+$/, "") + eol + added.join(eol) + eol; + } + let at = rootEnd; + while (at > 0 && lines[at - 1]!.structural && lines[at - 1]!.text.trim() === "") at -= 1; + lines.splice(at, 0, ...added.map(text => ({ text, eol, structural: true }))); + return bom + sourceText(lines); +} + +function setRootUrl(content: string, key: string): { content: string; keptUserBaseUrl: boolean } { + const { bom, lines, rootEnd } = rootSourceLines(content); + for (let index = 0; index < rootEnd; index++) { + if (!lines[index]!.structural || !isRootOpenaiBaseUrlLine(lines[index]!.text)) continue; + if (!ownedRootUrl(lines, index, "openai_base_url")) return { content, keptUserBaseUrl: true }; + lines[index - 1]!.text = OCX_ROUTING_MARKER_LINE; + lines[index]!.text = key; + return { content: bom + sourceText(lines), keptUserBaseUrl: false }; } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key); - return { content: lines.join("\n"), keptUserBaseUrl: false }; + return { content: insertRootSourceLines(content, [OCX_ROUTING_MARKER_LINE, key]), keptUserBaseUrl: false }; } export function setRootOpenaiBaseUrlForTarget( content: string, target: CodexRoutingTarget, ): { content: string; keptUserBaseUrl: boolean } { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const key = contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target)); - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootOpenaiBaseUrlLine(lines[index])) continue; - const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserBaseUrl: true }; - lines[index - 1] = OCX_ROUTING_MARKER_LINE; - lines[index] = key; - return { content: lines.join("\n"), keptUserBaseUrl: false }; - } - if (firstTable === -1) { - return { - content: `${content.replace(/\n+$/, "")}\n${OCX_ROUTING_MARKER_LINE}\n${key}\n`, - keptUserBaseUrl: false, - }; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt -= 1; - lines.splice(insertAt, 0, OCX_ROUTING_MARKER_LINE, key); - return { content: lines.join("\n"), keptUserBaseUrl: false }; + return setRootUrl(content, contextCompatibleBaseLine(content, buildOpenaiBaseUrlLineForTarget(target))); } /** @@ -310,25 +285,22 @@ export function setRootRealtimeWsBaseUrl( content: string, target: CodexRoutingTarget, ): { content: string; keptUserRealtimeWsBaseUrl: boolean } { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const { bom, lines, rootEnd } = rootSourceLines(content); const key = buildRealtimeWsBaseUrlLine(validateCodexRoutingTarget(target)); - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootRealtimeWsBaseUrlLine(lines[index])) continue; - const markerOwned = index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER); - if (!markerOwned) return { content, keptUserRealtimeWsBaseUrl: true }; - lines[index - 1] = OCX_ROUTING_MARKER_LINE; - lines[index] = key; - return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; + for (let index = 0; index < rootEnd; index++) { + if (!lines[index]!.structural || !isRootRealtimeWsBaseUrlLine(lines[index]!.text)) continue; + if (!ownedRootUrl(lines, index, REALTIME_WS_BASE_URL_KEY)) return { content, keptUserRealtimeWsBaseUrl: true }; + lines[index - 1]!.text = OCX_ROUTING_MARKER_LINE; + lines[index]!.text = key; + return { content: bom + sourceText(lines), keptUserRealtimeWsBaseUrl: false }; } - for (let index = 0; index < rootEnd; index += 1) { - if (!isRootOpenaiBaseUrlLine(lines[index])) continue; - if (!(index > 0 && lines[index - 1].includes(OCX_SECTION_MARKER))) continue; - lines.splice(index + 1, 0, OCX_ROUTING_MARKER_LINE, key); - return { content: lines.join("\n"), keptUserRealtimeWsBaseUrl: false }; + for (let index = 0; index < rootEnd; index++) { + if (!isRootOpenaiBaseUrlLine(lines[index]!.text) || !ownedRootUrl(lines, index, "openai_base_url")) continue; + const eol = dominantEol(content); + lines[index]!.eol ||= eol; + lines.splice(index + 1, 0, ...[OCX_ROUTING_MARKER_LINE, key].map(text => ({ text, eol, structural: true }))); + return { content: bom + sourceText(lines), keptUserRealtimeWsBaseUrl: false }; } - // No marker-owned routing override to attach to: the override has no owner, so inject nothing. return { content, keptUserRealtimeWsBaseUrl: false }; } @@ -345,40 +317,30 @@ export const ROOT_WEB_SEARCH_DISABLED_LINE = 'web_search = "disabled"'; export const ROOT_WEB_SEARCH_DISABLED_VALUE = "disabled"; export function isRootWebSearchLine(line: string): boolean { - // The quoted spellings are the same key to TOML, and `tomlStringPattern` -- which the value - // evidence below goes through -- already reads them. A predicate that matched only the bare + // Quoted spellings (including escaped basic keys) name the same key to TOML. + // A predicate that matched only the bare // spelling would leave `"web_search" = "live"` in place while inserting our own line, and two // root keys of the same name stop Codex from loading the file at all. - return /^\s*(?:"web_search"|'web_search'|web_search)\s*=/.test(line); + return rootAssignmentKey(line) === ROOT_WEB_SEARCH_KEY; } /** What an earlier injection recorded about this key, read back from the journal. */ export interface RootWebSearchJournal { /** The value that injection wrote, or null when it wrote none. */ injectedValue?: string | null; - /** The user-owned line that injection had to remove, or null when there was none. */ + /** The complete user-owned assignment removed (possibly multiline), or null. */ replacedUserLine?: string | null; } /** What one pass of {@link ensureRootWebSearchDisabled} did, for the journal to record. */ export interface RootWebSearchOutcome { content: string; - /** The user-owned root line this pass removed, or null when there was none. */ + /** The complete user-owned root assignment removed (possibly multiline), or null. */ replacedUserLine: string | null; /** The value this pass wrote, or null when it wrote none. */ wroteValue: string | null; } -/** Insert root-level lines ahead of the first table; TOML root keys may not follow one. */ -function insertRootLines(lines: string[], text: string): string { - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - if (firstTable === -1) return `${lines.join("\n").replace(/\n+$/, "")}\n${text}\n`; - let at = firstTable; - while (at > 0 && lines[at - 1].trim() === "") at -= 1; - lines.splice(at, 0, ...text.split("\n")); - return lines.join("\n"); -} - /** * Ensure the root `web_search` key follows the web-search sidecar's master switch. * @@ -406,14 +368,13 @@ export function ensureRootWebSearchDisabled( disabled: boolean, journal: RootWebSearchJournal = {}, ): RootWebSearchOutcome { - const lines = stripInjectedRootWebSearch(content, journal.injectedValue).split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const stripped = stripInjectedRootWebSearch(content, journal.injectedValue); + const { bom, lines, rootEnd } = rootSourceLines(stripped); if (!disabled) { - const restore = journal.replacedUserLine?.trim(); - const owned = lines.slice(0, rootEnd).some(isRootWebSearchLine); + const restore = journal.replacedUserLine; + const owned = lines.slice(0, rootEnd).some(line => line.structural && isRootWebSearchLine(line.text)); return { - content: restore && !owned ? insertRootLines(lines, restore) : lines.join("\n"), + content: restore?.trim() && !owned ? insertRootSourceLines(stripped, [applyEol(restore, dominantEol(stripped))]) : stripped, replacedUserLine: null, wroteValue: null, }; @@ -424,14 +385,21 @@ export function ensureRootWebSearchDisabled( // line keeps the one an earlier off pass recorded — the ordinary way to reach that state is a // second injection while the switch is still off (a model change), and the operator's mode must // not evaporate because the line it came from is already gone. - const replacedUserLine = lines - .slice(0, rootEnd) - .find((line) => isRootWebSearchLine(line)) - ?.replace(/\r$/, "") ?? journal.replacedUserLine?.trim() ?? null; + const drop = new Set(); + let replacedUserLine = journal.replacedUserLine ?? null; + let foundUserAssignment = false; + for (let index = 0; index < rootEnd; index++) { + if (!lines[index]!.structural || !isRootWebSearchLine(lines[index]!.text)) continue; + const assignment = sourceAssignmentSpan(lines, index, rootEnd); + if (!assignment) throw new Error("Cannot safely rewrite an incomplete root web_search assignment."); + if (!foundUserAssignment) replacedUserLine = assignment.text; + foundUserAssignment = true; + for (let at = index; at < assignment.end; at++) drop.add(at); + } return { - content: insertRootLines( - lines.filter((line, index) => index >= rootEnd || !isRootWebSearchLine(line)), - `${OCX_ROUTING_MARKER_LINE}\n${ROOT_WEB_SEARCH_DISABLED_LINE}`, + content: insertRootSourceLines( + bom + sourceText(lines.filter((_, index) => !drop.has(index))), + [OCX_ROUTING_MARKER_LINE, ROOT_WEB_SEARCH_DISABLED_LINE], ), replacedUserLine, wroteValue: ROOT_WEB_SEARCH_DISABLED_VALUE, @@ -446,26 +414,20 @@ export function ensureRootWebSearchDisabled( * comment a Codex app reserialize dropped is still recognized as ours. */ export function stripInjectedRootWebSearch(content: string, injectedValue?: string | null): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const { bom, lines, rootEnd } = rootSourceLines(content); const drop = new Set(); - for (let i = 0; i + 1 < rootEnd; i += 1) { - if (lines[i].includes(OCX_SECTION_MARKER) && isRootWebSearchLine(lines[i + 1])) { - drop.add(i); - drop.add(i + 1); - } + for (let index = 0; index < rootEnd; index++) { + const line = lines[index]!; + if (!line.structural || !isRootWebSearchLine(line.text)) continue; + const assignment = sourceAssignment(lines, index, rootEnd); + if (!assignment || typeof assignment.value !== "string") continue; + const value = assignment.value; + const markerOwned = index > 0 && lines[index - 1]!.structural && isOcxRoutingMarkerLine(lines[index - 1]!.text); + if (!markerOwned && (!injectedValue || value !== injectedValue)) continue; + for (let at = index; at < assignment.end; at++) drop.add(at); + if (markerOwned) drop.add(index - 1); } - if (injectedValue) { - for (let i = 0; i < rootEnd; i += 1) { - if (!isRootWebSearchLine(lines[i])) continue; - if (rootTomlString(lines[i], ROOT_WEB_SEARCH_KEY) !== injectedValue) continue; - drop.add(i); - if (i > 0 && lines[i - 1].includes(OCX_SECTION_MARKER)) drop.add(i - 1); - } - } - if (drop.size === 0) return content; - return lines.filter((_, index) => !drop.has(index)).join("\n"); + return drop.size ? bom + sourceText(lines.filter((_, index) => !drop.has(index))) : content; } /** @@ -475,21 +437,23 @@ export function stripInjectedRootWebSearch(content: string, injectedValue?: stri * A marker-owned `experimental_realtime_ws_base_url` pair is removed by the same rule. */ export function stripInjectedOpenaiBaseUrl(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const { bom, lines, rootEnd } = rootSourceLines(content); const drop = new Set(); - for (let i = 0; i < rootEnd; i++) { - if (!lines[i].includes(OCX_SECTION_MARKER)) continue; - if (i + 1 < rootEnd && (isRootOpenaiBaseUrlLine(lines[i + 1]) || isRootRealtimeWsBaseUrlLine(lines[i + 1]))) { - drop.add(i); - drop.add(i + 1); - } else if (i + 1 >= rootEnd || lines[i + 1].trim() === "") { - drop.add(i); // orphaned marker at root + for (let index = 0; index < rootEnd; index++) { + const line = lines[index]!; + if (!line.structural || !isOcxRoutingMarkerLine(line.text)) continue; + const next = lines[index + 1]; + if (index + 1 < rootEnd && next?.structural + && (isRootOpenaiBaseUrlLine(next.text) || isRootRealtimeWsBaseUrlLine(next.text))) { + const key = rootAssignmentKey(next.text)!; + if (rootTomlString(next.text, key) === null) continue; + drop.add(index); + drop.add(index + 1); + } else if (index + 1 >= rootEnd || (next?.structural && next.text.trim() === "")) { + drop.add(index); } } - if (drop.size === 0) return content; - return lines.filter((_, i) => !drop.has(i)).join("\n"); + return drop.size ? bom + sourceText(lines.filter((_, index) => !drop.has(index))) : content; } /** @@ -500,18 +464,19 @@ export function stripInjectedOpenaiBaseUrl(content: string): string { * untouched. */ export function stripExistingModelProvider(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const out: string[] = []; - lines.forEach((line, i) => { - if (/^\s*model_provider\s*=/.test(line)) { - const isOurs = /^\s*model_provider\s*=\s*"opencodex"\s*$/.test(line); - const isRoot = firstTable === -1 || i < firstTable; - if (isOurs || isRoot) return; // drop it + const { bom, lines, rootEnd } = rootSourceLines(content); + const drop = new Set(); + for (let index = 0; index < lines.length; index++) { + if (!lines[index]!.structural || rootAssignmentKey(lines[index]!.text) !== "model_provider") continue; + const assignment = sourceAssignmentSpan(lines, index, index < rootEnd ? rootEnd : lines.length); + if (!assignment) throw new Error("Cannot safely rewrite an incomplete model_provider assignment."); + if (index >= rootEnd) { + const decoded = sourceAssignment(lines, index); + if (typeof decoded?.value !== "string" || decoded.value.trim() !== "opencodex") continue; } - out.push(line); - }); - return out.join("\n"); + for (let at = index; at < assignment.end; at++) drop.add(at); + } + return bom + sourceText(lines.filter((_, index) => !drop.has(index))); } /** @@ -521,29 +486,21 @@ export function stripExistingModelProvider(content: string): string { * compaction limits do not alter the advertised context window and must survive reinjection. */ export function stripRootContextWindowOverrides(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - return lines - .filter((line, i) => { - const isRoot = firstTable === -1 || i < firstTable; - return !isRoot || !/^\s*model_context_window\s*=/.test(line); - }) - .join("\n"); + const { bom, lines, rootEnd } = rootSourceLines(content); + const drop = new Set(); + for (let index = 0; index < rootEnd; index++) { + if (!lines[index]!.structural || rootAssignmentKey(lines[index]!.text) !== "model_context_window") continue; + const assignment = sourceAssignmentSpan(lines, index, rootEnd); + if (!assignment) throw new Error("Cannot safely rewrite an incomplete model_context_window assignment."); + for (let at = index; at < assignment.end; at++) drop.add(at); + } + return bom + sourceText(lines.filter((_, index) => !drop.has(index))); } export function stripRootRoutedModel(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - return lines - .filter((line, i) => { - const isRoot = firstTable === -1 || i < firstTable; - if (!isRoot) return true; - const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*$/); - if (!m) return true; - const model = parseTomlString(m[1]); - return !model?.includes("/"); - }) - .join("\n"); + const { bom, lines, rootEnd } = rootSourceLines(content); + return bom + sourceText(lines.filter((line, index) => index >= rootEnd || !line.structural + || !rootTomlString(line.text, "model")?.includes("/"))); } /** @@ -551,28 +508,16 @@ export function stripRootRoutedModel(content: string): string { * header (TOML root keys must precede all tables). If there are no tables, append it to the root body. */ export function setRootModelProvider(content: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); - const key = 'model_provider = "opencodex"'; - if (firstTable === -1) { - return content.replace(/\n+$/, "") + "\n" + key + "\n"; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, key); - return lines.join("\n"); + return insertRootSourceLines(content, ['model_provider = "opencodex"']); } function readRootModelCatalogPath(content: string): string | null { - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); + const { lines, rootEnd } = rootSourceLines(content); let ownedCatalogPath: string | null = null; for (let index = 0; index < rootEnd; index += 1) { - const match = modelCatalogAssignment.exec(lines[index]); - if (!match) continue; - const catalogPath = parseTomlString(match[1]); + if (!lines[index].structural) continue; + const catalogPath = rootTomlString(lines[index].text, "model_catalog_json"); + if (catalogPath === null) continue; if (!isOpencodexCatalogPath(catalogPath)) return catalogPath; ownedCatalogPath ??= catalogPath; } @@ -580,18 +525,14 @@ function readRootModelCatalogPath(content: string): string | null { } export function setRootModelCatalogPath(content: string, catalogPath: string): string { - const lines = content.split("\n"); - const firstTable = lines.findIndex((l) => /^\s*\[/.test(l)); + const { bom, lines, rootEnd } = rootSourceLines(content); const key = `model_catalog_json = ${tomlString(catalogPath)}`; - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); - const rootEnd = firstTable === -1 ? lines.length : firstTable; const ownedAssignments: number[] = []; let hasUserAssignment = false; for (let i = 0; i < rootEnd; i++) { - const m = modelCatalogAssignment.exec(lines[i]); - if (!m) continue; - const existing = parseTomlString(m[1]); - if (isOpencodexCatalogPath(existing)) { + if (!lines[i].structural || rootAssignmentKey(lines[i].text) !== "model_catalog_json") continue; + const existing = rootTomlString(lines[i].text, "model_catalog_json"); + if (existing !== null && isOpencodexCatalogPath(existing)) { ownedAssignments.push(i); } else { hasUserAssignment = true; @@ -599,53 +540,38 @@ export function setRootModelCatalogPath(content: string, catalogPath: string): s } if (hasUserAssignment) { const owned = new Set(ownedAssignments); - return lines.filter((_, index) => !owned.has(index)).join("\n"); + return bom + sourceText(lines.filter((_, index) => !owned.has(index))); } if (ownedAssignments.length > 0) { - lines[ownedAssignments[0]] = key; + lines[ownedAssignments[0]].text = key; const duplicates = new Set(ownedAssignments.slice(1)); - return lines.filter((_, index) => !duplicates.has(index)).join("\n"); + return bom + sourceText(lines.filter((_, index) => !duplicates.has(index))); } - if (firstTable === -1) { - return content.replace(/\n+$/, "") + "\n" + key + "\n"; - } - let insertAt = firstTable; - while (insertAt > 0 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, key); - return lines.join("\n"); + return insertRootSourceLines(content, [key]); } export function removeProfileSection(content: string): string { - const lines = content.split("\n"); - const filtered: string[] = []; + const { bom, lines } = rootSourceLines(content); let inProfile = false; - for (const line of lines) { - if (line.trim() === "[profiles.opencodex]") { - inProfile = true; - continue; - } - if (inProfile) { - if (/^\s*\[/.test(line) && line.trim() !== "[profiles.opencodex]") { - inProfile = false; - filtered.push(line); - } - continue; + const kept = lines.filter(line => { + if (line.structural && /^\s*\[/.test(line.text)) { + try { + const parsed = Bun.TOML.parse(line.text) as Record; + inProfile = typeof parsed.profiles === "object" && parsed.profiles !== null + && Object.hasOwn(parsed.profiles, "opencodex"); + } catch { inProfile = false; } } - filtered.push(line); - } - return ( - filtered - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd() + "\n" - ); + return !inProfile; + }); + return normalizeStructuralWhitespace(bom + sourceText(kept)); } export function normalizeServiceTier(content: string): string { - return content.replace( - /^(\s*service_tier\s*=\s*)["']priority["']\s*$/gm, - '$1"fast"', - ); + const { bom, lines, rootEnd } = rootSourceLines(content); + for (const line of lines.slice(0, rootEnd)) { + if (line.structural) line.text = line.text.replace(/^(\s*service_tier\s*=\s*)["']priority["']\s*$/, '$1"fast"'); + } + return bom + sourceText(lines); } export function ensureFastModeFeature(content: string, fastMode?: boolean): string { @@ -654,32 +580,41 @@ export function ensureFastModeFeature(content: string, fastMode?: boolean): stri // untouched (no [features] table is added and an existing fast_mode line is // preserved as-is). Table and key matching accept the valid TOML spellings // `[features] # comment`, `["features"]` / `['features']`, and quoted keys. - const lines = content.split("\n"); - const featuresHeader = /^\s*\[(["']?)\s*features\s*\1\]\s*(?:#.*)?$/; - const fastModeKey = /^\s*(?:"fast_mode"|'fast_mode'|fast_mode)\s*=/; - const featuresStart = lines.findIndex(line => featuresHeader.test(line)); + if (fastMode === undefined) return content; + const { bom, lines } = rootSourceLines(content); + const eol = lines.find(line => line.eol)?.eol || "\n"; + const featuresStart = lines.findIndex(line => { + if (!line.structural || !/^\s*\[/.test(line.text)) return false; + try { + const parsed = Bun.TOML.parse(line.text) as Record; + return Object.keys(parsed).length === 1 && typeof parsed.features === "object" + && parsed.features !== null && !Array.isArray(parsed.features) + && Object.keys(parsed.features).length === 0; + } catch { return false; } + }); if (featuresStart === -1) { - if (fastMode === undefined) return content; - return content.trimEnd() + "\n\n[features]\nfast_mode = " + (fastMode ? "true" : "false") + "\n"; + return content.replace(/[\r\n]+$/, "") + eol + eol + "[features]" + eol + + "fast_mode = " + (fastMode ? "true" : "false") + eol; } const nextTable = lines.findIndex( - (line, index) => index > featuresStart && /^\s*\[/.test(line), + (line, index) => index > featuresStart && line.structural && /^\s*\[/.test(line.text), ); const featuresEnd = nextTable === -1 ? lines.length : nextTable; for (let i = featuresStart + 1; i < featuresEnd; i++) { - if (fastModeKey.test(lines[i])) { - if (fastMode === undefined) return lines.join("\n"); - lines[i] = lines[i].replace(/^(\s*)(?:"fast_mode"|'fast_mode'|fast_mode)\s*=.*$/, `$1fast_mode = ${fastMode ? "true" : "false"}`); - return lines.join("\n"); + if (lines[i].structural && rootAssignmentKey(lines[i].text) === "fast_mode") { + // Refuse to rewrite an incomplete physical view of a collection/string value. + try { Bun.TOML.parse(lines[i].text); } catch { return content; } + lines[i].text = `${/^\s*/.exec(lines[i].text)![0]}fast_mode = ${fastMode ? "true" : "false"}`; + return bom + sourceText(lines); } } - if (fastMode === undefined) return lines.join("\n"); let insertAt = featuresEnd; - while (insertAt > featuresStart + 1 && lines[insertAt - 1].trim() === "") insertAt--; - lines.splice(insertAt, 0, `fast_mode = ${fastMode ? "true" : "false"}`); - return lines.join("\n"); + while (insertAt > featuresStart + 1 && lines[insertAt - 1].structural && lines[insertAt - 1].text.trim() === "") insertAt--; + if (insertAt > 0 && !lines[insertAt - 1].eol) lines[insertAt - 1].eol = eol; + lines.splice(insertAt, 0, { text: `fast_mode = ${fastMode ? "true" : "false"}`, eol, structural: true }); + return bom + sourceText(lines); } function isOpencodexCatalogPath(path: string): boolean { @@ -687,17 +622,13 @@ function isOpencodexCatalogPath(path: string): boolean { } export function stripOpencodexCatalogPath(content: string): string { - const modelCatalogAssignment = tomlStringPattern("model_catalog_json"); - const lines = content.split("\n"); - const firstTable = lines.findIndex((line) => /^\s*\[/.test(line)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; - return lines + const { bom, lines, rootEnd } = rootSourceLines(content); + return bom + sourceText(lines .filter((line, index) => { - if (index >= rootEnd) return true; - const m = modelCatalogAssignment.exec(line); - return !m || !isOpencodexCatalogPath(parseTomlString(m[1])); - }) - .join("\n"); + if (index >= rootEnd || !line.structural) return true; + const path = rootTomlString(line.text, "model_catalog_json"); + return path === null || !isOpencodexCatalogPath(path); + })); } export function buildProfileFile(port: number, catalogPath?: string | null, supportsWebsockets?: boolean, includeApiAuthHeader?: boolean, hostname?: string, fastMode?: boolean): string; diff --git a/src/codex/inject/provider-table.ts b/src/codex/inject/provider-table.ts index 8d86b7151ea..b7c2a3d4a9f 100644 --- a/src/codex/inject/provider-table.ts +++ b/src/codex/inject/provider-table.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from "node:util"; -import { OCX_SECTION_MARKER } from "../injected-marker"; +import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER } from "../injected-marker"; import { splitSourceLines, type SourceLine } from "../toml-source-lines"; function record(value: unknown): Record | null { @@ -61,7 +61,10 @@ function providerLines(content: string): { bom: string; lines: SourceLine[]; own section = headerKind(text); if (section === "provider") { const previous = lines[index - 1]; - if (previous?.structural && previous.text.trim() === OCX_SECTION_MARKER) owned.add(index - 1); + const marker = previous?.text.trim(); + if (previous?.structural && (marker === OCX_SECTION_MARKER || marker === OCX_ROUTING_MARKER_LINE)) { + owned.add(index - 1); + } } } else if (line.structural && unsupportedProviderAssignment(text, section)) { // These forms have no independently removable table span. Do not append a diff --git a/src/codex/inject/remove.ts b/src/codex/inject/remove.ts index fac225b3f0e..d41e2dbb09f 100644 --- a/src/codex/inject/remove.ts +++ b/src/codex/inject/remove.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines"; import { atomicWriteFile } from "../../config"; import { REALTIME_WS_BASE_URL_KEY, @@ -13,7 +14,7 @@ import { journaledInjectedRootWebSearch, journaledReplacedRootWebSearch, } from "../journal"; -import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, readRootTomlString } from "../paths"; +import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths"; import { transformManagedSubagentDefaults } from "../subagent-defaults"; import { applyEol, @@ -59,6 +60,15 @@ interface StripOpencodexConfigResult { managedDefaultsError: string | null; } +function hasRootOcxProvider(content: string): boolean { + const { lines, rootEnd } = rootSourceLines(content); + return lines.slice(0, rootEnd).some((line, index) => { + if (!line.structural || rootAssignmentKey(line.text) !== "model_provider") return false; + const value = sourceAssignment(lines, index, rootEnd)?.value; + return typeof value === "string" && value.trim() === "opencodex"; + }); +} + /** * Detailed form used by the on-disk restore path. A damaged ownership marker is * ambiguous: keep the associated value, but return the transform error so the @@ -73,7 +83,7 @@ function stripOpencodexConfigResult( ): StripOpencodexConfigResult { let out = content; const hadRootOcxProvider = - readRootTomlString(out, "model_provider") === "opencodex"; + hasRootOcxProvider(out); // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded // writing -- so an app-rewritten config is still recognized as ours. @@ -94,10 +104,15 @@ function stripOpencodexConfigResult( out = removeProfileSection(out); // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too — // must match the detection regex above, or a detected line could survive un-removed. - out = out - .split("\n") - .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)) - .join("\n"); + const routingSource = rootSourceLines(out); + const providerLines = new Set(); + routingSource.lines.forEach((line, index) => { + if (!line.structural || rootAssignmentKey(line.text) !== "model_provider") return; + const assignment = sourceAssignment(routingSource.lines, index); + if (!assignment || typeof assignment.value !== "string" || assignment.value.trim() !== "opencodex") return; + for (let at = index; at < assignment.end; at++) providerLines.add(at); + }); + out = routingSource.bom + sourceText(routingSource.lines.filter((_, index) => !providerLines.has(index))); // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves // them — strip on both the legacy re-tag form and the Design B injected-base-url form. if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out); @@ -105,7 +120,7 @@ function stripOpencodexConfigResult( if (managedDefaults.ok) out = managedDefaults.content; out = stripOpencodexCatalogPath(out); return { - content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n", + content: normalizeStructuralWhitespace(out), managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null, }; } @@ -118,7 +133,7 @@ export function stripOpencodexConfig(content: string): string { function hasOpencodexRouting(content: string): boolean { return ( hasOcxProviderTable(content) || - /^\s*model_provider\s*=\s*"opencodex"/m.test(content) || + hasRootOcxProvider(content) || hasInjectedOpenaiBaseUrl(content) ); } diff --git a/src/codex/injected-marker.ts b/src/codex/injected-marker.ts index bf27f8185aa..e1bb40a1440 100644 --- a/src/codex/injected-marker.ts +++ b/src/codex/injected-marker.ts @@ -8,6 +8,7 @@ * two public predicates, so external callers see no change. */ import { parseTomlString } from "./paths"; +import { rootAssignmentKey, rootSourceLines, sourceText } from "./toml-source-lines"; export const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; @@ -20,17 +21,22 @@ export const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; * and it said nothing but "Auto-injected by opencodex" — so the recovery they found was to * hand-delete lines and the catalog file, which is strictly worse than `ocx restore`. * - * Every ownership predicate below matches on {@link OCX_SECTION_MARKER} as a SUBSTRING, never by - * equality, so this longer line is recognized by them and by any opencodex old enough to predate - * it. That is the whole reason the hint is appended to the marker instead of occupying a line of - * its own: a separate comment line would survive removal as orphaned text. + * Root routing predicates and lossless provider table capture/removal accept either exact marker + * line directly above structural routing syntax; they do not claim user comments or opaque value + * lines that merely contain the marker. Keeping the hint on the same + * line lets those transforms remove it together with the routing they own. * * Scope is routing only. Prompt layers keep the bare marker: `ocx restore` is not their undo. */ export const OCX_ROUTING_MARKER_LINE = `${OCX_SECTION_MARKER} (undo: ocx restore)`; +export function isOcxRoutingMarkerLine(line: string): boolean { + const text = line.trim(); + return text === OCX_SECTION_MARKER || text === OCX_ROUTING_MARKER_LINE; +} + export function isRootOpenaiBaseUrlLine(line: string): boolean { - return /^\s*openai_base_url\s*=/.test(line); + return rootAssignmentKey(line) === "openai_base_url"; } /** @@ -44,7 +50,7 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean { export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url"; export function isRootRealtimeWsBaseUrlLine(line: string): boolean { - return /^\s*experimental_realtime_ws_base_url\s*=/.test(line); + return rootAssignmentKey(line) === REALTIME_WS_BASE_URL_KEY; } export function tomlStringPattern(key: string): RegExp { @@ -58,13 +64,13 @@ export function tomlStringPattern(key: string): RegExp { } export function rootTomlString(content: string, key: string): string | null { - const lines = content.split("\n"); - const firstTable = lines.findIndex(line => /^\s*\[/.test(line)); - const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable); - const pattern = tomlStringPattern(key); - for (const line of rootLines) { - const match = pattern.exec(line); - if (match?.[1]) return parseTomlString(match[1]).trim(); + const { lines, rootEnd } = rootSourceLines(content); + for (const line of lines.slice(0, rootEnd)) { + if (!line.structural || rootAssignmentKey(line.text) !== key) continue; + try { + const value = (Bun.TOML.parse(line.text) as Record)[key]; + if (typeof value === "string") return value.trim(); + } catch { /* Incomplete multiline assignments are not single-line ownership evidence. */ } } return null; } @@ -103,12 +109,11 @@ export function stripJournaledOpenaiBaseUrl( injectedRealtimeWsUrl: string | null = null, ): string { if (!injectedUrl && !injectedRealtimeWsUrl) return content; - const lines = content.split(String.fromCharCode(10)); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const { bom, lines, rootEnd } = rootSourceLines(content); const drop = new Set(); for (let i = 0; i < rootEnd; i++) { - const line = lines[i]!; + if (!lines[i]!.structural) continue; + const line = lines[i]!.text; // Each key is matched against ITS OWN recorded value. The realtime override is // journaled separately so a user-owned override that happens to equal the proxy // URL is never mistaken for ours. @@ -122,18 +127,18 @@ export function stripJournaledOpenaiBaseUrl( drop.add(i); // Take an ownership marker directly above it too, so repeated cycles cannot // accumulate orphaned comments. - if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER)) drop.add(i - 1); + if (i > 0 && lines[i - 1]!.structural && isOcxRoutingMarkerLine(lines[i - 1]!.text)) drop.add(i - 1); } if (drop.size === 0) return content; - return lines.filter((_, i) => !drop.has(i)).join(String.fromCharCode(10)); + return bom + sourceText(lines.filter((_, i) => !drop.has(i))); } export function hasInjectedOpenaiBaseUrl(content: string): boolean { - const lines = content.split("\n"); - const firstTable = lines.findIndex(l => /^\s*\[/.test(l)); - const rootEnd = firstTable === -1 ? lines.length : firstTable; + const { lines, rootEnd } = rootSourceLines(content); for (let i = 1; i < rootEnd; i++) { - if (isRootOpenaiBaseUrlLine(lines[i]!) && lines[i - 1]!.includes(OCX_SECTION_MARKER)) return true; + if (lines[i]!.structural && lines[i - 1]!.structural + && isRootOpenaiBaseUrlLine(lines[i]!.text) && rootTomlString(lines[i]!.text, "openai_base_url") !== null + && isOcxRoutingMarkerLine(lines[i - 1]!.text)) return true; } return false; } diff --git a/src/codex/toml-source-lines.ts b/src/codex/toml-source-lines.ts index e85dd8b187d..1085bd1e3be 100644 --- a/src/codex/toml-source-lines.ts +++ b/src/codex/toml-source-lines.ts @@ -2,7 +2,7 @@ export interface SourceLine { text: string; eol: "\r\n" | "\n" | ""; - /** False when this physical line began inside a TOML multiline string. */ + /** False when this physical line began inside a multiline string or collection. */ structural: boolean; } @@ -28,6 +28,98 @@ export function splitSourceLines(content: string): SourceLine[] { return lines; } +/** Root boundaries exclude headers and assignments that are merely multiline/array data. */ +export function rootSourceLines(content: string): { bom: string; lines: SourceLine[]; rootEnd: number } { + const bom = content.startsWith("\uFEFF") ? "\uFEFF" : ""; + const lines = splitSourceLines(content.slice(bom.length)); + const firstTable = lines.findIndex(line => line.structural && /^\s*\[/.test(line.text)); + return { bom, lines, rootEnd: firstTable === -1 ? lines.length : firstTable }; +} + +export function sourceText(lines: readonly SourceLine[]): string { + return lines.map(line => line.text + line.eol).join(""); +} + +const assignmentKeyToken = /^[ \t]*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')[ \t]*=/; + +/** Decode only a single key token, without parsing unrelated values or dotted paths. */ +export function rootAssignmentKey(text: string): string | null { + const match = assignmentKeyToken.exec(text); + if (!match) return null; + try { + return Object.keys(Bun.TOML.parse(`${match[1]} = true\n`))[0] ?? null; + } catch { + return null; + } +} + +interface AssignmentSpan { key: string; start: number; end: number; text: string } + +function lexicalAssignmentSpan(lines: readonly SourceLine[], start: number, limit: number): AssignmentSpan | null { + const line = lines[start]; + if (!line?.structural) return null; + const key = rootAssignmentKey(line.text); + if (key === null) return null; + let end = start + 1; + while (end < limit && !lines[end]!.structural) end += 1; + // Store the original assignment bytes, excluding only its final document separator. + const text = sourceText(lines.slice(start, end)).replace(/\r?\n$/, ""); + return { key, start, end, text }; +} + +/** A complete assignment whose value can be decoded without losing precision. */ +export function sourceAssignment(lines: readonly SourceLine[], start: number, limit = lines.length): + (AssignmentSpan & { value: unknown }) | null { + const span = lexicalAssignmentSpan(lines, start, limit); + if (!span) return null; + try { + const parsed = Bun.TOML.parse(span.text) as Record; + return Object.keys(parsed).length === 1 && Object.hasOwn(parsed, span.key) + ? { ...span, value: parsed[span.key] } : null; + } catch { return null; } +} + +/** Validate complete syntax for removal/capture without requiring a JavaScript value. */ +export function sourceAssignmentSpan(lines: readonly SourceLine[], start: number, limit = lines.length): AssignmentSpan | null { + const decoded = sourceAssignment(lines, start, limit); + if (decoded) return decoded; + const span = lexicalAssignmentSpan(lines, start, limit); + // Bun rejects valid TOML integers outside the JS safe-integer range. Only a complete + // scalar integer gets this exception: collections, strings and other parse failures do not. + if (!span || span.end !== start + 1) return null; + const keyToken = assignmentKeyToken.exec(span.text)!; + const scalar = /^([ \t]*)([+-]?(?:0|[1-9](?:_?[0-9])*)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*)([ \t]*(?:#[^\r\n]*)?)$/.exec(span.text.slice(keyToken[0].length)); + if (!scalar) return null; + const integer = BigInt(scalar[2]!.replaceAll("_", "")); + if (integer < -(1n << 63n) || integer >= (1n << 63n)) return null; + // Keep the parser responsible for the key, separator and comment grammar. Replacing + // this already validated integer for validation never edits or decodes the source value. + try { + const parsed = Bun.TOML.parse(`${keyToken[0]}${scalar[1]}0${scalar[3]}`); + return Object.keys(parsed).length === 1 && Object.hasOwn(parsed, span.key) ? span : null; + } catch { return null; } +} + +/** Cosmetic document whitespace only; opaque value lines retain every byte. */ +export function normalizeStructuralWhitespace(content: string): string { + const { bom, lines } = rootSourceLines(content); + const out: SourceLine[] = []; + let blankRun = 0; + for (const line of lines) { + if (line.structural && line.text === "") { + blankRun += 1; + if (blankRun > 1) continue; + } else { + blankRun = 0; + } + out.push(line); + } + while (out.at(-1)?.structural && out.at(-1)!.text.trim() === "") out.pop(); + const text = bom + sourceText(out); + const eol = lines.find(line => line.eol)?.eol || "\n"; + return text.endsWith("\n") ? text : text + eol; +} + type MultilineStringKind = "basic" | "literal" | null; /** diff --git a/structure/codex-home.md b/structure/codex-home.md index 90ff9cf3912..26a215b59fc 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -430,7 +430,7 @@ On apply, that reason retires the relabel unit and the config/profile/journal wr `history_paginated_openai_requires_native_writer` is the one apply-side reason that is neither of those. It means a provider-table transition found an `openai`-tagged row already paginated, and the transition as planned would take the root `openai_base_url` out from under it. `applyPaginatedOpenaiCompat` in `src/codex/inject/paginated-openai-compat.ts` resolves it in the same window as the provider-table retention above, before the witness: it keeps the marker-owned root override beside the table and downgrades the reason to the stand-down constant, so the relabel unit never starts and the paginated row is neither read nor written. The retained line is journaled as OpenCodex's own, which is what lets restore remove it later; a line the user owns is left in place and journaled as theirs. Only an admission-token form still refuses, because Codex's built-in `openai` entry cannot carry `x-opencodex-api-key`, and that refusal names the configuration that resolves it rather than telling the operator not to retry (#5321). -On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. If an exact journal restore brings back a same-named provider table, retention accepts it only when the parsed provider values match. `src/codex/inject/provider-table.ts` captures lossless table spans using the shared lexical lines in `src/codex/toml-source-lines.ts`, also consumed by the native defaults editor. Header-shaped text and blank lines inside multiline values stay intact; only the isolated provider block is parsed so unrelated large integers do not prevent retention. Cosmetic spacing and comments do not change equality, but multiline string contents do. Invalid, duplicate, array-root or unsupported inline provider definitions refuse; retained bytes are never regenerated from parsed values. A different table fails the restore and compensation reinstates the pre-restore files rather than rebinding tagged threads to another destination. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. +On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. If an exact journal restore brings back a same-named provider table, retention accepts it only when the parsed provider values match. `src/codex/inject/provider-table.ts` captures lossless table spans using the shared lexical lines in `src/codex/toml-source-lines.ts`, also consumed by the native defaults editor. Header-shaped text and blank lines inside multiline values stay intact; only the isolated provider block is parsed so unrelated large integers do not prevent retention. Capture and removal own only an adjacent structural comment whose trimmed text is exactly the bare ownership marker or its routing undo-hint form; marker substrings in user comments and multiline values remain untouched. Root URL and web-search ownership likewise requires an exact adjacent structural marker and a complete root assignment, with quoted keys decoded as TOML keys. Journal-recorded injected values are separate ownership evidence for marker-less app rewrites. Managed root assignment edits consume their complete validated lexical spans; value-free removal and capture accept scalar TOML integers throughout the signed 64-bit range without JavaScript number decoding, while value-based ownership still requires decoded values and malformed assignments refuse; the web-search journal field retains the whole replaced assignment, including multiline values, for re-enabling or fallback restore. Catalog and feature edits exclude header/key-shaped multiline data. Fallback blank-line normalization applies only to structural document lines, preserving opaque multiline values. Cosmetic spacing and comments do not change equality, but multiline string contents do. Invalid, duplicate, array-root or unsupported inline provider definitions refuse; retained bytes are never regenerated from parsed values. A different table fails the restore and compensation reinstates the pre-restore files rather than rebinding tagged threads to another destination. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. It no longer costs the home its ability to be uninstalled: synchronous and asynchronous restore, inline journal restore, and direct config removal all take routing down on that reason while keeping the provider table, so an already-paginated home can be stopped and uninstalled and plain `codex` returns to the built-in provider. Rows naming `opencodex` still resolve through the retained table; their requests reach a proxy that is gone and fail with an ordinary connection error, which is a per-conversation failure rather than a broken config. `ocx restore --remove-codex-provider-table` removes the table for a user who accepts that those conversations stop opening; nothing selects it implicitly. diff --git a/tests/codex-integration/codex-config-preservation.test.ts b/tests/codex-integration/codex-config-preservation.test.ts new file mode 100644 index 00000000000..cc465baab33 --- /dev/null +++ b/tests/codex-integration/codex-config-preservation.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { + setRootOpenaiBaseUrlForTarget, + setRootRealtimeWsBaseUrl, + stripInjectedOpenaiBaseUrl, + stripInjectedRootWebSearch, + removeProfileSection, + ensureRootWebSearchDisabled, ensureFastModeFeature, stripOpencodexCatalogPath, + setRootModelCatalogPath, + stripExistingModelProvider, stripRootContextWindowOverrides, +} from "../../src/codex/inject/config-toml"; +import { stripOpencodexConfig } from "../../src/codex/inject/remove"; +import { deriveCodexInjectionPlan } from "../../src/codex/inject/plan"; +import { rootSourceLines, sourceAssignment, sourceAssignmentSpan } from "../../src/codex/toml-source-lines"; +import { + hasInjectedOpenaiBaseUrl, OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER, + stripJournaledOpenaiBaseUrl, +} from "../../src/codex/injected-marker"; + +const target = { baseUrl: "http://127.0.0.1:10100/v1", requiresAdmissionToken: false, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const }; +const userUrl = "https://fixture.invalid/v1"; +const parse = (text: string) => Bun.TOML.parse(text.replace(/^\uFEFF/, "")); + +describe("structural root ownership and multiline preservation", () => { + test.each(["openai_base_url", '"openai_base_url"', "'openai_base_url'", '"openai_base_\\u0075rl"'])("user marker quotations never own the root URL (%s)", key => { + const content = `# user quote: ${OCX_ROUTING_MARKER_LINE}\n${key} = "${userUrl}"\nmodel = "user/model"\n`; + expect(hasInjectedOpenaiBaseUrl(content)).toBe(false); + expect(stripInjectedOpenaiBaseUrl(content)).toBe(content); + expect(parse(stripOpencodexConfig(content))).toEqual(parse(content)); + expect(setRootOpenaiBaseUrlForTarget(content, target)).toEqual({ content, keptUserBaseUrl: true }); + }); + + test.each([OCX_SECTION_MARKER, OCX_ROUTING_MARKER_LINE])("exact adjacent markers own quoted root keys (%s)", marker => { + const content = `${marker}\n"openai_base_url" = "${target.baseUrl}"\n${marker}\n'experimental_realtime_ws_base_url' = "${target.baseUrl}"\nmodel = "fixture-model"\n`; + expect(hasInjectedOpenaiBaseUrl(content)).toBe(true); + expect(stripInjectedOpenaiBaseUrl(content)).toBe('model = "fixture-model"\n'); + }); + + test("journal value evidence removes its own URL but preserves a user marker quotation", () => { + const comment = `# user quote: ${OCX_ROUTING_MARKER_LINE}\n`; + const content = `${comment}"openai_base_url" = "${target.baseUrl}"\nmodel = "fixture-model"\n`; + expect(stripJournaledOpenaiBaseUrl(content, target.baseUrl)).toBe(comment + 'model = "fixture-model"\n'); + expect(stripJournaledOpenaiBaseUrl(content, userUrl)).toBe(content); + }); + + test("an exact marker above a different root setting remains user data", () => { + const content = `${OCX_ROUTING_MARKER_LINE}\nmodel = "fixture-model"\nopenai_base_url = "${userUrl}"\n`; + expect(stripInjectedOpenaiBaseUrl(content)).toBe(content); + expect(hasInjectedOpenaiBaseUrl(content)).toBe(false); + }); + + const variants = ["\n", "\r\n"].flatMap(eol => ["", "\uFEFF"].flatMap(bom => + ['"""', "'''"].map(delimiter => ({ eol, bom, delimiter })))); + test.each(variants)("actual multiline web-search assignments survive an Off/On rollback (%j)", ({ eol, bom, delimiter }) => { + const assignment = ` 'web_search' = ${delimiter}\nlive${delimiter} # user mode`.replaceAll("\n", eol); + const content = bom + assignment + eol + `model = "fixture-model"${eol}[user]${eol}value = "keep"${eol}`; + const off = ensureRootWebSearchDisabled(content, true); + expect(parse(off.content).web_search).toBe("disabled"); + expect(off.replacedUserLine).toBe(assignment); + const journal = { injectedValue: off.wroteValue, replacedUserLine: off.replacedUserLine }; + expect(ensureRootWebSearchDisabled(off.content, true, journal).replacedUserLine).toBe(assignment); + const on = ensureRootWebSearchDisabled(off.content, false, journal).content; + expect(parse(on)).toEqual(parse(content)); + expect(on).toContain(assignment); + expect(on.startsWith(bom)).toBe(true); + }); + + test.each(variants)("actual multiline provider assignments are removed as complete spans (%j)", ({ eol, bom, delimiter }) => { + const content = bom + `"model_provider" = ${delimiter}\nopenai${delimiter}\nmodel = "fixture-model"\n[user]\nvalue = "keep"\n`.replaceAll("\n", eol); + expect(parse(stripExistingModelProvider(content))).toEqual({ model: "fixture-model", user: { value: "keep" } }); + for (const requiresAdmissionToken of [false, true]) { + const plan = deriveCodexInjectionPlan(content, { config: undefined, routingTarget: { ...target, requiresAdmissionToken }, catalogPathOption: null, journalReadOnly: true }); + expect(plan.kind).toBe("ok"); + if (plan.kind !== "ok") throw new Error(plan.message); + expect(parse(plan.content).user).toEqual({ value: "keep" }); + expect(parse(plan.baselineContent)).toEqual(parse(content)); + } + }); + + test.each(['"""', "'''"])("journal value evidence strips a complete marker-less multiline value (%s)", delimiter => { + const content = `web_search = ${delimiter}\ndisabled${delimiter}\nmodel = "fixture-model"\n`; + expect(stripInjectedRootWebSearch(content)).toBe(content); + expect(stripInjectedRootWebSearch(content, "disabled")).toBe('model = "fixture-model"\n'); + expect(stripInjectedRootWebSearch(content, "live")).toBe(content); + const spaced = content.replace("disabled", " disabled "); + expect(stripInjectedRootWebSearch(spaced, "disabled")).toBe(spaced); + const child = `[user]\nmodel_provider = ${delimiter}\nexternal${delimiter}\n`; + expect(stripExistingModelProvider(child)).toBe(child); + const owned = child.replace("external", "opencodex"); + expect(parse(stripOpencodexConfig(owned))).toEqual({ user: {} }); + }); + + test("context override removal consumes the complete collection without touching its neighbor", () => { + const content = 'model_context_window = [\n1,\n2\n]\nmodel = "fixture-model"\n'; + expect(parse(stripRootContextWindowOverrides(content))).toEqual({ model: "fixture-model" }); + }); + + test.each([ + "9007199254740992", "+9007199254740992", "-9007199254740992", + "9223372036854775807", "-9223372036854775808", "9_223_372_036_854_775_807", + "-9_223_372_036_854_775_808", "0x7fff_ffff_ffff_ffff", "0o777777777777777777777", + "0b111111111111111111111111111111111111111111111111111111111111111", + ])("value-free removal accepts a complete signed 64-bit integer (%s)", numeric => { + for (const eol of ["\n", "\r\n"]) for (const bom of ["", "\uFEFF"]) { + const neighbor = `model = "fixture-model"${eol}# user comment${eol}[child]${eol}value = "keep"${eol}`; + const key = numeric === "9007199254740992" || numeric === "9223372036854775807" + ? "model_context_window" : "'model_context_window'"; + const assignment = ` ${key} = ${numeric} # fixture integer`; + const content = bom + assignment + eol + neighbor; + const source = rootSourceLines(content); + expect(sourceAssignment(source.lines, 0, source.rootEnd)).toBeNull(); + expect(sourceAssignmentSpan(source.lines, 0, source.rootEnd)?.text).toBe(assignment); + expect(stripRootContextWindowOverrides(content)).toBe(bom + neighbor); + for (const requiresAdmissionToken of [false, true]) { + const plan = deriveCodexInjectionPlan(content, { config: undefined, routingTarget: { ...target, requiresAdmissionToken }, catalogPathOption: null, journalReadOnly: true }); + expect(plan.kind).toBe("ok"); + if (plan.kind !== "ok") throw new Error(plan.message); + expect(parse(plan.content).model).toBe("fixture-model"); + expect(plan.baselineContent).toBe(content); + } + expect(stripExistingModelProvider(content.replace("model_context_window", "model_provider"))).toBe(bom + neighbor); + const webSearch = content.replace("model_context_window", "web_search"); + const off = ensureRootWebSearchDisabled(webSearch, true); + expect(off.replacedUserLine).toBe(assignment.replace("model_context_window", "web_search")); + const journal = { injectedValue: off.wroteValue, replacedUserLine: off.replacedUserLine }; + expect(ensureRootWebSearchDisabled(off.content, false, journal).content).toContain(off.replacedUserLine!); + const child = bom + neighbor + `model_provider = ${numeric}${eol}`; + expect(stripExistingModelProvider(child)).toBe(child); + } + }); + + test.each([ + "9223372036854775808", "-9223372036854775809", "0x8000000000000000", + "0o1000000000000000000000", "+0x7fffffffffffffff", "-0x7fffffffffffffff", + "09007199254740992", "9__007199254740992", "9007199254740992_", "_9007199254740992", + "9007199254740992 garbage", "9007199254740992 1", "9007199254740992 # invalid\u0001comment", + "[9007199254740992", '"""unclosed', "'''unclosed", "", "0x", "0b2", + ])("value-free removal rejects invalid or incomplete integer assignments (%s)", value => { + const content = `model_context_window = ${value}\nmodel = "fixture-model"\n`; + expect(() => stripRootContextWindowOverrides(content)).toThrow(); + expect(() => stripExistingModelProvider(content.replace("model_context_window", "model_provider"))).toThrow(); + expect(() => ensureRootWebSearchDisabled(content.replace("model_context_window", "web_search"), true)).toThrow(); + }); + test.each(variants)("restore and injection preserve opaque multiline bytes (%j)", ({ eol, bom, delimiter }) => { + const value = `a\n${OCX_ROUTING_MARKER_LINE}\n\n\nb\n# user quote: ${OCX_SECTION_MARKER}\nopenai_base_url = "https://data.invalid/v1"\n[profiles.opencodex]\n[not_a_table]\nmodel_provider = "opencodex"\nservice_tier = "priority"\n${OCX_ROUTING_MARKER_LINE}\nweb_search = "disabled"\nz`; + const content = bom + `instructions = ${delimiter}${value}${delimiter}\n"openai_base_url" = "${userUrl}"\n'web_search' = "live"\n[child]\nnote = ${delimiter}first\n\n\nlast${delimiter}\n[child.headers]\n"x-fixture" = "keep"\n`.replaceAll("\n", eol); + expect(stripInjectedOpenaiBaseUrl(content)).toBe(content); + expect(stripInjectedRootWebSearch(content)).toBe(content); + expect(removeProfileSection(content)).toBe(content); + expect(hasInjectedOpenaiBaseUrl(content)).toBe(false); + expect(setRootOpenaiBaseUrlForTarget(content, target)).toEqual({ content, keptUserBaseUrl: true }); + expect(setRootRealtimeWsBaseUrl(content, target).content).toBe(content); + expect(parse(stripOpencodexConfig(content))).toEqual(parse(content)); + const plan = deriveCodexInjectionPlan(content, { config: undefined, routingTarget: target, catalogPathOption: null, journalReadOnly: true }); + expect(plan.kind).toBe("ok"); + if (plan.kind !== "ok") throw new Error(plan.message); + const next = parse(plan.content); + const original = parse(content); + expect(next.instructions).toBe(original.instructions); + expect(next.child).toEqual(original.child); + expect(next.openai_base_url).toBe(userUrl); + expect(next.web_search).toBe("live"); + expect(plan.content.startsWith(bom)).toBe(true); + }); + + test.each(["\n", "\r\n"])("structural whitespace cleanup leaves multiline blank lines and child tables intact (%j)", eol => { + const content = `# user comment\n\n\n\nmodel = "fixture-model"\n[child]\nbasic = """a\n\n\nb"""\nliteral = '''c\n\n\nd'''\n[child.headers]\n"x-fixture" = "keep"\n\n\n`.replaceAll("\n", eol); + const result = stripOpencodexConfig(content); + expect(parse(result)).toEqual(parse(content)); + expect(result).toContain(`a${eol}${eol}${eol}b`); + expect(result).toContain(`c${eol}${eol}${eol}d`); + expect(result).toContain('[child.headers]'); + }); + + test.each([ + { name: "strip catalog", transform: stripOpencodexCatalogPath }, + { name: "set catalog", transform: (content: string) => setRootModelCatalogPath(content, "fixture-catalog.json") }, + { name: "feature toggle", transform: (content: string) => ensureFastModeFeature(content, true) }, + { name: "web-search toggle", transform: (content: string) => ensureRootWebSearchDisabled(content, true).content }, + ])("key-shaped string data remains opaque (%j)", ({ transform }) => { + for (const { eol, bom, delimiter } of variants) { + const content = bom + `instructions = ${delimiter}a\nmodel_catalog_json = "opencodex-catalog.json"\n[features]\nfast_mode = false\nweb_search = "live"\nz${delimiter}\n[child]\nvalue = "keep"\n`.replaceAll("\n", eol); + const result = transform(content); + expect(parse(result).instructions).toBe(parse(content).instructions); + expect(parse(result).child).toEqual(parse(content).child); + expect(result.startsWith(bom)).toBe(true); + if (eol === "\r\n") expect(result.replaceAll("\r\n", "")).not.toContain("\n"); + } + }); + + test("provider-shaped string data cannot own a user's routed root model", () => { + const content = 'instructions = """a\nmodel_provider = "opencodex"\nz"""\nmodel = "user/model"\n'; + expect(parse(stripOpencodexConfig(content))).toEqual(parse(content)); + }); + + test.each(['["features"]', "['features']", '["f\\u0065atures"]'])("real quoted feature keys are updated outside opaque values (%s)", header => { + const content = `\uFEFF${header}\r\nnote = '''a\r\nfast_mode = false\r\n\r\n\r\nb'''\r\n'fast_mode' = false\r\n[features.child]\r\nfast_mode = false\r\n`; + const result = ensureFastModeFeature(content, true); + const original = parse(content); + const next = parse(result); + expect((next.features as Record).fast_mode).toBe(true); + expect((next.features as Record).note).toBe((original.features as Record).note); + expect((next.features as Record).child).toEqual((original.features as Record).child); + expect(result.startsWith("\uFEFF")).toBe(true); + expect(result.replaceAll("\r\n", "")).not.toContain("\n"); + }); + + test("owned marker adjacency does not overwrite an incomplete single-line view of a multiline URL", () => { + const content = `${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = """https://fixture.invalid/v1"""\ninstructions = """a\\\n \n b"""\n`; + const multiline = content.replace('"""https://fixture.invalid/v1"""', '"""\nhttps://fixture.invalid/v1\n"""'); + expect(stripInjectedOpenaiBaseUrl(multiline)).toBe(multiline); + expect(setRootOpenaiBaseUrlForTarget(multiline, target)).toEqual({ content: multiline, keptUserBaseUrl: true }); + expect(parse(stripOpencodexConfig(multiline))).toEqual(parse(multiline)); + }); + + test.each(['"""', "'''"])("artifact failure rollback and journal fallback preserve actual multiline settings (%s)", delimiter => { + const fixture = mkdtempSync(join(tmpdir(), "ocx-managed-span-")); + const codexHome = join(fixture, "codex"); + const ocxHome = join(fixture, "opencodex"); + mkdirSync(codexHome); + mkdirSync(ocxHome); + const assignment = ` 'web_search' = ${delimiter}\nlive${delimiter} # user mode`; + const native = `${assignment}\nmodel_provider = ${delimiter}\nopenai${delimiter}\nmodel = "fixture-model"\n[user]\nvalue = "keep"\n`; + writeFileSync(join(codexHome, "config.toml"), native); + const script = ` + import assert from "node:assert/strict"; + import { readFileSync, appendFileSync, existsSync } from "node:fs"; + import { createHash } from "node:crypto"; + import { join } from "node:path"; + import { injectCodexConfig, restoreNativeCodex, setHistoryArtifactStageForTests } from "./src/codex/inject"; + const configPath = join(process.env.CODEX_HOME, "config.toml"); + const profilePath = join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = join(process.env.CODEX_HOME, "opencodex-journal.json"); + const original = readFileSync(configPath, "utf8"); + const hash = text => createHash("sha256").update(text).digest("hex"); + let hit = false; + setHistoryArtifactStageForTests(stage => { + if (stage === "after-config") { hit = true; throw new Error("synthetic artifact failure"); } + }); + let failed; + try { failed = await injectCodexConfig(10100, { webSearchSidecar: { enabled: false } }); } + catch (error) { assert.match(String(error), /synthetic artifact failure/); } + assert.equal(hit, true); + assert.notEqual(failed?.success, true); + assert.equal(readFileSync(configPath, "utf8"), original); + assert.equal(existsSync(profilePath), false); + assert.equal(existsSync(journalPath), false); + setHistoryArtifactStageForTests(undefined); + const injected = await injectCodexConfig(10100, { webSearchSidecar: { enabled: false } }); + assert.equal(injected.success, true); + const firstJournal = JSON.parse(readFileSync(journalPath, "utf8")); + assert.equal(firstJournal.replacedRootWebSearch, process.env.TEST_ORIGINAL_WEB_SEARCH); + assert.equal(firstJournal.injectedConfigHash, hash(readFileSync(configPath, "utf8"))); + const exact = restoreNativeCodex({ skipHistory: true, removeProviderTable: true }); + assert.equal(exact.success, true); + assert.equal(exact.artifacts.config.action, "journal-restored"); + assert.equal(readFileSync(configPath, "utf8"), original); + const reinjected = await injectCodexConfig(10100, { webSearchSidecar: { enabled: false } }); + assert.equal(reinjected.success, true); + const journal = JSON.parse(readFileSync(journalPath, "utf8")); + assert.equal(journal.replacedRootWebSearch, process.env.TEST_ORIGINAL_WEB_SEARCH); + assert.equal(journal.injectedConfigHash, hash(readFileSync(configPath, "utf8"))); + appendFileSync(configPath, '\\n[user_edit]\\nvalue = "keep edit"\\n'); + assert.notEqual(journal.injectedConfigHash, hash(readFileSync(configPath, "utf8"))); + const fallback = restoreNativeCodex({ skipHistory: true, removeProviderTable: true }); + assert.equal(fallback.success, true); + assert.equal(fallback.artifacts.config.action, "owned-fields-stripped"); + const after = readFileSync(configPath, "utf8"); + const parsed = Bun.TOML.parse(after); + assert.equal(parsed.web_search, "live"); + assert.deepEqual(parsed.user, { value: "keep" }); + assert.deepEqual(parsed.user_edit, { value: "keep edit" }); + assert.ok(after.includes(process.env.TEST_ORIGINAL_WEB_SEARCH)); + console.log(JSON.stringify({ rollback: true, exact: true, fallback: true })); + `; + try { + const child = spawnSync(process.execPath, ["--no-env-file", "--eval", script], { + cwd: repoRoot(), env: { ...process.env, CODEX_HOME: codexHome, CODEX_SQLITE_HOME: "", OPENCODEX_HOME: ocxHome, TEST_ORIGINAL_WEB_SEARCH: assignment }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS, + }); + expect(child.status, child.error?.message ?? child.stderr).toBe(0); + expect(JSON.parse(child.stdout.trim())).toEqual({ rollback: true, exact: true, fallback: true }); + } finally { removeTreeWithRetry(fixture); } + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/codex-integration/codex-provider-table-retention.test.ts b/tests/codex-integration/codex-provider-table-retention.test.ts index 1a42a8e12f0..b39067bd3e1 100644 --- a/tests/codex-integration/codex-provider-table-retention.test.ts +++ b/tests/codex-integration/codex-provider-table-retention.test.ts @@ -1,4 +1,13 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { deriveCodexInjectionPlan } from "../../src/codex/inject/plan"; +import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER } from "../../src/codex/injected-marker"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { appendOcxProviderTableBlock, extractOcxProviderTableBlock, @@ -104,3 +113,122 @@ describe("lossless retained provider tables", () => { expect(() => appendOcxProviderTableBlock(content, header + 'name = "retained"\n')).toThrow(); }); }); + +describe("provider table ownership markers", () => { + const variants = ["\n", "\r\n"].flatMap(eol => ["", "\uFEFF"].flatMap(bom => + [OCX_SECTION_MARKER, OCX_ROUTING_MARKER_LINE].map(marker => ({ eol, bom, marker })))); + + test.each(variants)("capture and removal own only the adjacent exact marker (%j)", ({ eol, bom, marker }) => { + const prefix = '# user comment\nmodel = "fixture-model"\n'.replaceAll("\n", eol); + const block = `${marker}\n${header}name = "fixture"\n`.replaceAll("\n", eol); + const foreign = '[user]\nnote = "keep"\n'.replaceAll("\n", eol); + expect(extractOcxProviderTableBlock(bom + prefix + block + foreign)).toBe(block); + expect(removeOcxSection(bom + prefix + block + foreign)).toBe(bom + prefix + foreign); + expect(appendOcxProviderTableBlock(bom + prefix + block + foreign, block)).toBe(bom + prefix + block + foreign); + }); + + test.each(["\n", "\r\n"])("real reinjection plans are byte idempotent (%j)", eol => { + for (const bom of ["", "\uFEFF"]) { + const ctx = { + config: undefined, + routingTarget: { baseUrl: "http://127.0.0.1:10100/v1", requiresAdmissionToken: true, tokenEnv: "OPENCODEX_API_AUTH_TOKEN" as const }, + catalogPathOption: null, + journalReadOnly: true, + }; + // A foreign table before the provider prevents root-marker cleanup from masking this bug. + const native = bom + '# user comment\nmodel = "fixture-model"\n[user]\nnote = "fixture"\n'.replaceAll("\n", eol); + const first = deriveCodexInjectionPlan(native, ctx); + expect(first.kind).toBe("ok"); + if (first.kind !== "ok") throw new Error(first.message); + let current = first.content; + for (let cycle = 0; cycle < 5; cycle++) { + const next = deriveCodexInjectionPlan(current, ctx); + expect(next.kind).toBe("ok"); + if (next.kind !== "ok") throw new Error(next.message); + expect(next.content).toBe(first.content); + expect(next.content.split(eol).filter(line => line.trim() === OCX_ROUTING_MARKER_LINE)).toHaveLength(1); + current = next.content; + } + expect(current.startsWith(bom + "# user comment")).toBe(true); + } + }); + + test.each([ + `${OCX_SECTION_MARKER} user note`, + `${OCX_ROUTING_MARKER_LINE} user note`, + `# user quoted ${OCX_ROUTING_MARKER_LINE}`, + `${OCX_ROUTING_MARKER_LINE}\n`, + ])("user comments and nonadjacent orphan markers remain untouched (%s)", comment => { + const prefix = `${comment}\n`; + const block = header + 'name = "fixture"\n'; + expect(extractOcxProviderTableBlock(prefix + block)).toBe(block); + expect(removeOcxSection(prefix + block)).toBe(prefix); + }); + + test.each(['"""', "'''"])("multiline marker data and separated child tables stay lossless (%s)", delimiter => { + const foreign = `[user]\nnote = ${delimiter}start\n${OCX_ROUTING_MARKER_LINE}\n[model_providers.opencodex]\nend${delimiter}\n`; + const root = `${OCX_ROUTING_MARKER_LINE}\n${header}name = "fixture"\n`; + const child = `${OCX_SECTION_MARKER}\n[model_providers.opencodex.http_headers]\n"x-fixture" = "value"\n`; + expect(extractOcxProviderTableBlock(root + foreign + child)).toBe(root + child); + expect(removeOcxSection(root + foreign + child)).toBe(foreign); + expect(removeOcxSection(foreign)).toBe(foreign); + }); + + test("provider ownership leaves root routing marker pairs intact", () => { + const root = `${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "https://fixture.invalid/v1"\n`; + const block = `${OCX_ROUTING_MARKER_LINE}\n${header}name = "fixture"\n`; + expect(removeOcxSection(root + block)).toBe(root); + expect(extractOcxProviderTableBlock(root + block)).toBe(block); + }); + + test("a journal byte mismatch takes fallback restore without orphaning the provider marker", () => { + const fixture = mkdtempSync(join(tmpdir(), "ocx-provider-marker-")); + const codexHome = join(fixture, "codex"); + const ocxHome = join(fixture, "opencodex"); + mkdirSync(codexHome); + mkdirSync(ocxHome); + writeFileSync(join(codexHome, "config.toml"), '# user comment\nmodel = "fixture-model"\n[user]\nnote = "fixture"\n'); + const script = ` + import { readFileSync, appendFileSync } from "node:fs"; + import { createHash } from "node:crypto"; + import { join } from "node:path"; + import { injectCodexConfig, restoreNativeCodex } from "./src/codex/inject"; + const configPath = join(process.env.CODEX_HOME, "config.toml"); + await injectCodexConfig(10100, { + port: 10100, providers: {}, defaultProvider: "openai", + codexDesktopAuthless: true, + }, { catalogPath: null }); + const before = readFileSync(configPath, "utf8"); + const journal = JSON.parse(readFileSync(join(process.env.CODEX_HOME, "opencodex-journal.json"), "utf8")); + const hash = content => createHash("sha256").update(content).digest("hex"); + if (!journal.injectedConfigHash || journal.injectedConfigHash !== hash(before)) { + throw new Error("fixture must have a journal matching the injected bytes"); + } + appendFileSync(configPath, '\\n[user_edit]\\nvalue = "keep"\\n'); + if (journal.injectedConfigHash === hash(readFileSync(configPath, "utf8"))) { + throw new Error("fixture must force a journal hash mismatch"); + } + const result = restoreNativeCodex({ skipHistory: true, removeProviderTable: true }); + console.log(JSON.stringify({ before, after: readFileSync(configPath, "utf8"), success: result.success, action: result.artifacts.config.action })); + `; + try { + const child = spawnSync(process.execPath, ["--no-env-file", "--eval", script], { + cwd: repoRoot(), + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS, + }); + if (child.error || child.status !== 0) throw new Error(`fixture child failed: ${child.error?.message ?? child.stderr}`); + const result = JSON.parse(child.stdout.trim()); + expect(result.before).toContain(OCX_ROUTING_MARKER_LINE); + expect(result.before).toContain("[model_providers.opencodex]"); + expect(result.success).toBe(true); + expect(result.action).toBe("owned-fields-stripped"); + expect(result.after).not.toContain(OCX_ROUTING_MARKER_LINE); + expect(result.after).not.toContain("[model_providers.opencodex]"); + expect(result.after).toContain("# user comment"); + expect(Bun.TOML.parse(result.after)).toEqual({ model: "fixture-model", user: { note: "fixture" }, user_edit: { value: "keep" } }); + } finally { + removeTreeWithRetry(fixture); + } + }, 2 * SPAWN_BUDGET_MS); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 15d64cefde8..a74fbe1897e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", "codex-credits-probes.test.ts": "codex-integration", From 57fc9cc57efcb4da2e73b676d09fad1bc3067798 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:55:22 -0700 Subject: [PATCH 03/21] fix(devin): bound held signature-type payloads (#6315) * fix(devin): bound held signature type payloads Carry source commit 3a257a6b72169ced57d4ef44faa11bbfdb9997cf. Count signatureType in the held UTF-16 payload budget and cap its wire bytes before UTF-8 decoding. Preserve the signed retry path below the budget, with exact byte-boundary and multibyte regression coverage. * test(devin): verify oversized-type legacy signature replay --------- Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/adapters/devin.ts | 2 +- src/adapters/devin/cloud-direct/chat.ts | 4 ++- structure/providers-and-adapters.md | 2 +- ...devin-anthropic-signature-fallback.test.ts | 20 ++++++++++- .../devin-reasoning-continuation.test.ts | 36 +++++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 0cafb1161fb..00c08d10363 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -836,7 +836,7 @@ export function createDevinAdapter( refusedUsage = refusedUsage ? mergeDevinUsage(refusedUsage, next) : next; } if (event.kind === "reasoning") heldPayloadBytes += event.text.length * 2; - if (event.kind === "reasoning_signature") heldPayloadBytes += event.signature.length * 2; + if (event.kind === "reasoning_signature") heldPayloadBytes += (event.signature.length + (event.signatureType?.length ?? 0)) * 2; if (held.length > HELD_REASONING_MAX_EVENTS || heldPayloadBytes > HELD_REASONING_MAX_PAYLOAD_BYTES) { visible = true; clearInterval(heartbeatTimer); diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 61a07b329ae..1c24fa69fad 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -77,6 +77,8 @@ function cloudStreamHeadersMs(): number { export const cloudStreamHeadersMsForTests = cloudStreamHeadersMs; /** Maximum acceptable Connect-RPC frame length (16 MB). */ const MAX_FRAME_LEN = 16 * 1024 * 1024; +/** Bound the wire field before allocating its decoded UTF-8 string. */ +const MAX_SIGNATURE_TYPE_BYTES = 4 * 1024; /** * PromptCacheOptions.type = EPHEMERAL. Marks the system prefix as a cache entry @@ -819,7 +821,7 @@ export function* decodeChatFrame(proto: Buffer): Generator { if (authoritativeUsage) yield authoritativeUsage; let signatureType: string | undefined; for (const f of iterFields(proto)) { - if (f.num === 21 && f.wire === 2 && Buffer.isBuffer(f.value)) signatureType = (f.value as Buffer).toString('utf8') || undefined; + if (f.num === 21 && f.wire === 2 && Buffer.isBuffer(f.value) && f.value.length <= MAX_SIGNATURE_TYPE_BYTES) signatureType = f.value.toString('utf8') || undefined; } for (const f of iterFields(proto)) { if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 8f26f84df8d..a2a7e3fd8b9 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -190,7 +190,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | -Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. Held usage frames merge per cumulative field, and the refused attempt's usage is added to the retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning, signature, and signature-type payload; signature types are individually capped at 4 KiB before UTF-8 decoding, and crossing either queue cap releases the events and disables that retry. Held usage frames merge per cumulative field, and the refused attempt's usage is added to the retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. Inline document admission shares one encoding predicate between its scanner and parser in `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 47bf34571fb..29bf4c69848 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -26,7 +26,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "split-usage-then-refuse" | "usage-ok" | "many-reasoning-then-refuse" | "large-reasoning-then-refuse"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "split-usage-then-refuse" | "usage-ok" | "many-reasoning-then-refuse" | "large-reasoning-then-refuse" | "large-signature-types-then-refuse" | "signature-types-below-cap-then-refuse"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -85,6 +85,7 @@ describe("Devin Anthropic signature fallback", () => { : next === "usage-ok" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1100), encodeVarintField(3, 20)])), encodeString(3, "ok"), encodeVarintField(5, 2)])), frame(Buffer.from("{}"), 2)]) : next === "many-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x")), ...Array.from({ length: 1_024 }, () => frame(encodeString(9, "x"))), refusal]) : next === "large-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x".repeat(524_289))), refusal]) + : (next === "large-signature-types-then-refuse" || next === "signature-types-below-cap-then-refuse") ? Buffer.concat([...Array.from({ length: next === "large-signature-types-then-refuse" ? 129 : 127 }, () => frame(Buffer.concat([encodeString(10, "s"), encodeString(21, "x".repeat(4_096))]))), refusal]) : ok; return new Response(body, { headers: { "content-type": "application/connect+proto" } }); }) as typeof fetch; @@ -270,6 +271,23 @@ describe("Devin Anthropic signature fallback", () => { expect(events.some(e => e.type === "error")).toBe(true); }); + test("held signature types below the payload cap still allow unsigned retry", async () => { + responses = ["signature-types-below-cap-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(2); + expect(events.some(e => e.type === "thinking_signature")).toBe(false); + expect(events.some(e => e.type === "error")).toBe(false); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + }); + + test("held signature types count toward the payload cap", async () => { + responses = ["large-signature-types-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + expect(events.some(e => e.type === "thinking_signature")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(true); + }); + test("an accepted signed Claude turn is sent once, signature included", async () => { responses = ["ok"]; await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); diff --git a/tests/providers/devin-reasoning-continuation.test.ts b/tests/providers/devin-reasoning-continuation.test.ts index 32dbc1548b0..62c04fea57c 100644 --- a/tests/providers/devin-reasoning-continuation.test.ts +++ b/tests/providers/devin-reasoning-continuation.test.ts @@ -26,6 +26,42 @@ describe("Devin reasoning continuation across turns", () => { const frame = Buffer.concat([encodeString(10, SEALED), encodeString(21, "sealed")]); expect([...decodeChatFrame(frame)]).toContainEqual({ kind: "reasoning_signature", signature: SEALED, signatureType: "sealed" }); expect([...decodeChatFrame(encodeString(10, SEALED))]).toContainEqual({ kind: "reasoning_signature", signature: SEALED }); + expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, "x".repeat(4_097))]))]) + .toContainEqual({ kind: "reasoning_signature", signature: SEALED }); + }); + + test.each(["x".repeat(4_096), "é".repeat(2_048)])("signature types at the byte cap remain available", signatureType => { + expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, signatureType)]))]) + .toContainEqual({ kind: "reasoning_signature", signature: SEALED, signatureType }); + }); + + test("the signature type cap counts UTF-8 bytes", () => { + expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, "é".repeat(2_049))]))]) + .toContainEqual({ kind: "reasoning_signature", signature: SEALED }); + }); + + test("an oversized wire type retains the signature through stored-envelope replay", () => { + const event = [...decodeChatFrame(Buffer.concat([ + encodeString(10, SEALED), encodeString(21, "x".repeat(4_097)), + ]))].find(event => event.kind === "reasoning_signature"); + if (!event || event.kind !== "reasoning_signature") throw new Error("Missing signature event"); + expect(event.signatureType).toBeUndefined(); + const stored = encodeDevinSignature(event.signature, event.signatureType); + expect(stored).toBe(SEALED); + const history = mapOcxMessagesToDevin(parseRequest({ + model: "devin/swe-2", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: stored }) }, + { role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + })); + const assistant = history.find(message => message.role === "assistant"); + expect(assistant?.signature).toBe(SEALED); + expect(assistant?.signature_type).toBeUndefined(); + const wire = assistantPrompt(history); + expect(wire.get(12)).toBe(SEALED); + expect(wire.has(18)).toBe(false); }); test("the stored signature carries its type and an older stored signature still replays", () => { From 8326838e1dd7f69ca1c91a00ff12ae19607429a4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:55:33 -0700 Subject: [PATCH 04/21] fix(devin): score family axes sparsely with own-key targets (#6324) --- src/adapters/devin/live-models.ts | 36 +++++++-- structure/providers-and-adapters.md | 2 + .../providers/devin-family-resolution.test.ts | 81 ++++++++++++++++++- 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts index 5b72bd56ce2..9d0eead0037 100644 --- a/src/adapters/devin/live-models.ts +++ b/src/adapters/devin/live-models.ts @@ -207,6 +207,22 @@ function effortIndex(rung: string | undefined): number { return rung === undefined ? -1 : FAMILY_EFFORT_LADDER.indexOf(rung); } +function axisMismatchCount( + axes: ModelCatalogEntry["familyAxes"], + targets: Record, + baseline: number, +): number { + let mismatches = baseline; + for (const [axis, value] of Object.entries(axes ?? {})) { + if (isEffortAxis(axis)) continue; + const target = Object.hasOwn(targets, axis) ? targets[axis]! : 0; + if (target !== 0) { + if (value.order === target) mismatches--; + } else if (value.order !== 0) mismatches++; + } + return mismatches; +} + /** * The member closest to `targets` on the non-effort axes, then the lowest rung * at or above `effort`, falling back to the highest rung below it. A missing @@ -227,11 +243,15 @@ function closestMember( effort: string | undefined, ): ModelCatalogEntry | undefined { const want = effortIndex(effort); + // The baseline counts only `targets`, so scan it once: recomputing it per member + // made a wide anchor (many exposed axes) quadratic in members x axes. + let mismatchBaseline = 0; + for (const order of Object.values(targets)) if (order !== 0) mismatchBaseline++; let best: ModelCatalogEntry | undefined; let bestScore: number[] | undefined; for (const member of members) { const axes = member.familyAxes ?? {}; - const mismatches = Object.entries(targets).filter(([axis, order]) => (axes[axis]?.order ?? 0) !== order).length; + const mismatches = axisMismatchCount(axes, targets, mismatchBaseline); const have = effortIndex(devinFamilyEffortOf(member)); const lowered = want > 0 && have < want ? 1 : 0; const distance = want < 0 ? 0 @@ -263,18 +283,18 @@ export function selectDevinFamilyMember( anchor?: ModelCatalogEntry, options: { includeDisabled?: boolean } = {}, ): ModelCatalogEntry | undefined { - const axisNames = new Set(members.flatMap((m) => Object.keys(m.familyAxes ?? {})).filter((a) => !isEffortAxis(a))); const base = anchor ?? members.find((m) => m.isFamilyDefault) - ?? closestMember(members, Object.fromEntries([...axisNames].map((a) => [a, 0])), "medium"); + ?? closestMember(members, {}, "medium"); if (!base) return undefined; - const targets: Record = {}; - for (const axis of axisNames) targets[axis] = base.familyAxes?.[axis]?.order ?? 0; - if (request.fast && axisNames.has(FAST_AXIS)) targets[FAST_AXIS] = 1; - if (request.longContext && axisNames.has(LONG_CONTEXT_AXIS)) targets[LONG_CONTEXT_AXIS] = 1; + const targets = Object.fromEntries(Object.entries(base.familyAxes ?? {}) + .filter(([axis]) => !isEffortAxis(axis)) + .map(([axis, value]) => [axis, value.order])); + if (request.fast) targets[FAST_AXIS] = 1; + if (request.longContext) targets[LONG_CONTEXT_AXIS] = 1; // `none` means no reasoning. Families like Claude Sonnet 4.6 express that as // Thinking off rather than as an effort rung. - if (request.effort === "none" && axisNames.has(THINKING_AXIS)) targets[THINKING_AXIS] = 0; + if (request.effort === "none") targets[THINKING_AXIS] = 0; const enabled = options.includeDisabled ? members : members.filter((m) => !m.disabled); return closestMember(enabled.length > 0 ? enabled : members, targets, request.effort ?? devinFamilyEffortOf(base)); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a2a7e3fd8b9..158795c0823 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -192,6 +192,8 @@ rewrite rules and the routed-id settlement. Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning, signature, and signature-type payload; signature types are individually capped at 4 KiB before UTF-8 decoding, and crossing either queue cap releases the events and disables that retry. Held usage frames merge per cumulative field, and the refused attempt's usage is added to the retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. +Devin family selection in `src/adapters/devin/live-models.ts` counts the anchor's nonzero targets once and visits each candidate's own axes. Missing axes have order zero; extra nonzero axes, including prototype names such as `toString`, count as mismatches. Effort precedence and family confinement remain unchanged; `tests/providers/devin-family-resolution.test.ts` covers the sparse-scoring contract. + Inline document admission shares one encoding predicate between its scanner and parser in `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, and valid padded or unpadded payloads pass unchanged without a decoding allocation. diff --git a/tests/providers/devin-family-resolution.test.ts b/tests/providers/devin-family-resolution.test.ts index 53d3c3f1331..b78d1cb7f4d 100644 --- a/tests/providers/devin-family-resolution.test.ts +++ b/tests/providers/devin-family-resolution.test.ts @@ -10,7 +10,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { resolveWireModelUidForTests } from "../../src/adapters/devin"; -import { fetchDevinUsableModels } from "../../src/adapters/devin/live-models"; +import { fetchDevinUsableModels, selectDevinFamilyMember } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer, setCachedCatalogForTests, type CacheEntry } from "../../src/adapters/devin/cloud-direct/catalog"; import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; @@ -259,6 +259,85 @@ describe("family-based wire model resolution", () => { expect(await resolve(catalogOf(rows), "devin-test-think", "none")).toBe("think-off"); expect(await resolve(catalogOf(rows), "devin-test-think", "high")).toBe("think-high"); }); + + test("scores distinct family axes without a members-by-axes cross-product", () => { + // The anchor row carries the wide axis set: the mismatch baseline scans `targets`, + // which the anchor fills. With per-member baseline recomputation this case is + // members x axes (~144M iterations); hoisted, it is anchor axes + member axes. + const wideAxes = Object.fromEntries( + Array.from({ length: 12_000 }, (_, index) => [`Anchor ${index}`, { order: 1 }]), + ); + const members = [ + { + modelUid: "wide-anchor", + displayName: "wide-anchor", + familyUid: "wide", + familyAxes: wideAxes, + isFamilyDefault: true, + }, + ...Array.from({ length: 12_000 }, (_, index) => ({ + modelUid: `wide-${index}`, + displayName: `wide-${index}`, + familyUid: "wide", + familyAxes: { [`Axis ${index}`]: { order: 1 } }, + })), + ]; + const started = performance.now(); + expect(selectDevinFamilyMember(members, {})?.modelUid).toBe("wide-anchor"); + // Wall-clock bound, generous on purpose: the hoist makes this ~36k axis touches + // (ms even on a contended runner), while the per-member rescan needs ~144M — the + // bound only has to distinguish those two orders, not measure fast hardware. + expect(performance.now() - started).toBeLessThan(5_000); + }); + + test.each(["toString", "constructor", "__proto__"])("counts an extra own %s axis instead of inheriting a target", axis => { + const member = (modelUid: string, familyAxes: Record) => ({ + modelUid, displayName: modelUid, familyUid: "own-axes", familyAxes, + }); + const anchor = member("anchor", { A: { order: 1 } }); + const exact = member("exact", { A: { order: 1 } }); + const extra = member("extra", Object.fromEntries([["A", { order: 1 }], [axis, { order: 1 }]])); + expect(selectDevinFamilyMember([extra, exact], {}, anchor)?.modelUid).toBe("exact"); + const namedAnchor = member("named-anchor", Object.fromEntries([[axis, { order: 2 }]])); + const namedExact = member("named-exact", Object.fromEntries([[axis, { order: 2 }]])); + expect(selectDevinFamilyMember([member("missing", {}), namedExact], {}, namedAnchor)?.modelUid).toBe("named-exact"); + }); + + test("ranks an exact axis match ahead of missing and extra axes", () => { + const anchor = { + modelUid: "axis-anchor", + displayName: "axis-anchor", + familyUid: "wide", + familyAxes: { A: { order: 1 }, B: { order: 2 } }, + isFamilyDefault: true, + }; + const exact = { + modelUid: "axis-exact", + displayName: "axis-exact", + familyUid: "wide", + familyAxes: { A: { order: 1 }, B: { order: 2 } }, + }; + const missing = { + modelUid: "axis-missing", + displayName: "axis-missing", + familyUid: "wide", + familyAxes: { A: { order: 1 } }, + }; + const extra = { + modelUid: "axis-extra", + displayName: "axis-extra", + familyUid: "wide", + familyAxes: { A: { order: 1 }, B: { order: 2 }, C: { order: 1 }, D: { order: 3 } }, + }; + // The anchor itself always wins while listed: the ranking claim is about the + // members it cannot see, so exclude it and pass it explicitly. + const members = [missing, extra, exact]; + expect(selectDevinFamilyMember(members, {}, anchor)?.modelUid).toBe("axis-exact"); + // Without the exact row, a member missing one target axis scores baseline - 1, + // while a member matching every target but exposing two extra axes scores + // 0 + 2 — unequal mismatch counts, so the ordering is proven rather than a tie. + expect(selectDevinFamilyMember([missing, extra], {}, anchor)?.modelUid).toBe("axis-missing"); + }); }); describe("suffix fallback without family metadata never crosses families", () => { From f5e9fdabaa8782311f331e317f5c8835f14b1a9c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:57:27 -0700 Subject: [PATCH 05/21] fix(redaction): bound XML identifying-attribute scans (#6325) * fix(redaction): bound XML identifying-attribute scans Carry luvs01/opencodex#691 at f42e713c58745e1d0788ea7307b8a95785fbcd8c. Replace wall-clock regression with delimiter-work accounting and preserve malformed/escaped credential coverage. * fix(redaction): recognize quoted XML tag delimiters Scan tag terminators outside quoted values, cover credential masking after embedded delimiters, and count manual character work in linear-scaling regressions. --- src/lib/redact.ts | 62 ++++++++++++++++++++--- structure/transports/byte-accounting.md | 8 +++ tests/lib/redact.test.ts | 66 ++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 2206a9baa9b..482192b85d4 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -100,13 +100,6 @@ const OTHER_FRAMED_CREDENTIALS: Array<[RegExp, string]> = [ ), "element", ], - [ - new RegExp( - `(<[^\\S\\r\\n]*(?:[A-Za-z_][\\w.-]*:)?[A-Za-z_][\\w:.-]*)(?=[^>]*?(?]))[\\s\\S]*`, - "gi", - ), - "element", - ], // Multipart part: everything from a credential-named part header to the end // of the input. Part-based, not line-based — a body can span lines and the // blank line is often missing in a malformed echo. @@ -136,7 +129,60 @@ const OTHER_FRAMED_CREDENTIALS: Array<[RegExp, string]> = [ function maskOtherFramings(value: string): string { // Same union rule as the header pass: decoding may add coverage, never // remove it. - return maskOtherFramingsOnce(maskOtherFramingsOnce(value, true), false); + const decoded = maskOtherFramingsOnce(value, true); + const decodedXml = maskXmlIdentifyingAttribute(decoded, true); + const plain = maskOtherFramingsOnce(decodedXml, false); + return maskXmlIdentifyingAttribute(plain, false); +} + +const XML_TAG_NAME = /<[^\S\r\n]*(?:[A-Za-z_][\w.-]*:)?[A-Za-z_][\w:.-]*/y; +const XML_CREDENTIAL_ATTRIBUTE = new RegExp( + `(?])`, + "i", +); + +/** Find a tag terminator without treating a quoted attribute's `>` as syntax. */ +function findXmlTagClose(text: string, from: number): number { + let quote = ""; + for (let index = from; index < text.length; index += 1) { + const character = text.charAt(index); + if (quote) { + if (character === quote) quote = ""; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + return -1; +} + +/** Scan each tag once; a suffix-searching lookahead here makes unterminated tags quadratic. */ +function maskXmlIdentifyingAttribute(value: string, decodeEscapes: boolean): string { + const { folded, map } = foldForMatching(value, decodeEscapes); + let searchFrom = 0; + while (searchFrom < folded.length) { + const relativeStart = folded.indexOf("<", searchFrom); + if (relativeStart === -1) return value; + const tagStart = relativeStart; + XML_TAG_NAME.lastIndex = tagStart; + const tagName = XML_TAG_NAME.exec(folded); + if (!tagName) { + searchFrom = tagStart + 1; + continue; + } + const close = findXmlTagClose(folded, tagStart + tagName[0].length); + const tagEnd = close === -1 ? folded.length : close + 1; + const attributes = folded.slice(tagStart + tagName[0].length, tagEnd); + if (XML_CREDENTIAL_ATTRIBUTE.test(attributes)) { + const start = map[tagStart] ?? value.length; + const headEnd = map[tagStart + tagName[0].length] ?? value.length; + return value.slice(0, start) + value.slice(start, headEnd) + REDACTED_SECRET; + } + if (close === -1) return value; + searchFrom = tagEnd; + } + return value; } function maskOtherFramingsOnce(value: string, decodeEscapes: boolean): string { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index f514bcdcb5e..72f3945eb10 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -104,6 +104,14 @@ Canonical Responses identity sanitation and narrowly scoped pre-output combo rec ## Response-log inspection +`src/lib/redact.ts` scans XML identifying attributes over disjoint tag spans rather than +searching the remaining suffix from each opening delimiter. Tag terminators are quote-aware, +so `>` inside a single- or double-quoted attribute cannot hide later credential attributes. +The decoded and raw passes +retain the original-offset mapping and mask a credential-bearing element through the rest +of the input. `tests/lib/redact.test.ts` counts delimiter searches and scanned characters across doubled inputs +without a machine-speed deadline, as well as malformed and escaped credential coverage. + `src/server/response-log-body.ts` forwards raw response chunks on downstream demand. Diagnostic retention is limited to 32 MiB for JSON and an 8 KiB prefix for other HTTP error bodies. Fixed 64 KiB blocks also bound per-chunk bookkeeping. These diff --git a/tests/lib/redact.test.ts b/tests/lib/redact.test.ts index f15e4aa30db..0f8f1a5ec94 100644 --- a/tests/lib/redact.test.ts +++ b/tests/lib/redact.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { readFileSync } from "node:fs"; import { foldForMatching } from "../../src/lib/redact-folding"; import { repoPath } from "../helpers/repo-root"; @@ -335,6 +335,70 @@ describe("redactSecretString", () => { .toBe('public-status'); }); + test("XML delimiter search work scales linearly without a wall-clock deadline", () => { + for (const tag of ["", '', '") { + searched += (found < 0 ? this.length : found + 1) - (start ?? 0); + } + return found; + }); + try { + expect(redactSecretString(input)).toBe(input); + } finally { + scan.mockRestore(); + characters.mockRestore(); + } + expect(searched).toBeGreaterThan(0); + expect(searched).toBeLessThanOrEqual(4 * input.length); + work.push(searched); + } + expect(work[1]!).toBeLessThanOrEqual(2 * work[0]! + 8); + } + }); + + test("XML attribute scanning keeps credential coverage after malformed and escaped prefixes", () => { + const prefix = "π ratio∶1\n"; + for (const input of [ + 'synthetic-xml-canary', + ''.repeat(4_000) + 'synthetic-xml-canary', + 'synthetic-xml-canary', + '', + ]) { + const result = redactSecretString(prefix + input); + expect(result.startsWith(prefix)).toBe(true); + expect(result).toContain(REDACTED_SECRET); + expect(result).not.toContain("synthetic-xml-canary"); + } + }); + + test("XML quoted tag delimiters cannot hide a later credential attribute", () => { + for (const tag of [ + '', + "", + 'd\' id="password">', + '', + ]) { + const result = redactSecretString("diagnostic: safe\n" + tag + "synthetic-xml-canary"); + expect(result).toBe("diagnostic: safe\nsynthetic-xml-canary')) + .toBe(harmless + " { // Line-based masking left a multi-line body and the no-blank-line shape // partly intact, and stopping at the first `--` trusted an attacker-chosen From b0d275dc79e8ce89de3495a60cbad6bb8b7ee5e2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 07:57:36 -0700 Subject: [PATCH 06/21] fix(codex): require live credential evidence for pool quota policy (#6326) Preserve display snapshots and legacy login observations while gating pool policy changes on the captured writer. Cover missing writers in compact and WebSocket paths. --- .../content/docs/guides/codex-integration.md | 2 + src/codex/auth-api/pool-quota-probe.ts | 3 +- src/codex/quota-auto-refresh.ts | 2 +- src/codex/quota.ts | 16 +- src/server/responses/compact.ts | 5 +- src/server/responses/core-codex-account.ts | 6 +- src/server/responses/passthrough-delivery.ts | 3 +- structure/providers/openai-accounts.md | 2 +- .../low-quota-protection.test.ts | 175 +++++++++++++++++- 9 files changed, 197 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 27881bc9c5d..3ca45b32568 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -940,6 +940,8 @@ threshold. The Desktop/main account keeps its separate 98% hard lock. Set `codexPool.lowQuotaProtection` in configuration to pause accounts, record a log-and-API alert, or both; see [routing configuration](/reference/configuration/routing/#codex-pool-low-quota-protection). A pause takes effect for the next selection immediately, while saving it to disk is deferred. +Pool quota responses can authorize a pause only with valid usage and a captured credential +that is still current. A response without that proof may refresh displayed usage without pausing an account. Check this server’s authenticated `GET /api/codex-auth/low-quota-events` history for `logged` alerts or save failures. Manual resume remains in force for the current quota episode. The default alert reaches only the log and API; it does not produce a desktop or OS notification. diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index b95c4afb8e6..d267f00540d 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -373,7 +373,8 @@ export async function commitPoolQuotaResponse( } const validPolicyObservation = isValidWhamHistoryObservation(data); setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, validPolicyObservation ? quota : null, - ctx.poolWriter && validPolicyObservation ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined); + ctx.poolWriter && validPolicyObservation ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined, + true); return { quota: getAccountQuota(accountId), needsReauth: false, diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index 54cd196aac8..42fa040ee53 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -226,7 +226,7 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { if (isCodexAccountGenerationLive(accountId, token.generation)) { - applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter }); + applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter, poolResponse: true }); } } }); } catch (error) { diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 7720c33c678..49c761110d9 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -288,6 +288,10 @@ export function setAccountQuotaFromParsed( mainWriter?: MainQuotaWriter, policyQuota: MainPolicyQuotaObservation | null = accountId === MAIN_CODEX_ACCOUNT_ID ? quota : null, historyEvidence?: QuotaObservationEvidence, + // Pool responses carry their credential provenance separately from the optional history + // writer: when capture failed before dispatch, an absent writer must fail closed instead + // of reading as a writer-free legacy/login observation. + poolRequest = false, ): void { quota = withoutRetiredCodexQuota(quota); policyQuota = withoutRetiredCodexQuota(policyQuota); @@ -298,7 +302,10 @@ export function setAccountQuotaFromParsed( hydrateAccountQuotasFromDisk(); const legacyExisting = accountQuota.get(accountId); const updatedAt = Date.now(); - if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) { + const livePoolEvidence = historyEvidence + ? historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer) + : !poolRequest; + if (historyEvidence && livePoolEvidence) { quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source, credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw), }, updatedAt); @@ -323,7 +330,7 @@ export function setAccountQuotaFromParsed( schedulePersistAccountQuotas(); // Credits carry the previous usage tuple; they must not refresh its observation clock. if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) { - if (!isMain && policyQuota) observeCodexLowQuota(accountId, policyQuota); + if (!isMain && policyQuota && livePoolEvidence) observeCodexLowQuota(accountId, policyQuota); notifyCodexQuotaSnapshot(accountId, next); } } @@ -581,7 +588,7 @@ export function applyAccountQuotaFromUpstreamHeaders( headers: Headers, writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, - options?: { modelId?: string; poolWriter?: PoolQuotaWriter }, + options?: { modelId?: string; poolWriter?: PoolQuotaWriter; poolResponse?: boolean }, ): void { const quota = parseUpstreamQuotaHeaders(headers, options); if (!quota) return; @@ -591,7 +598,8 @@ export function applyAccountQuotaFromUpstreamHeaders( const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"] .some(name => isInvalidPolicyUsagePercent(headers.get(name))); setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota, - options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined); + options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined, + options?.poolResponse === true); } export function updateAccountQuota( diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 4371aa8d732..526a8caeaa0 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -1259,7 +1259,8 @@ export async function handleResponsesCompact( upstream.headers, authCtx.writerGeneration, authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }, + { modelId: route.modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined, + poolResponse: authCtx.kind === "pool" }, ); } recordCompactPoolOutcome(authCtx, upstream.status, { @@ -1328,7 +1329,7 @@ export async function handleResponsesCompact( if (outcomeCtx.kind === "pool") { const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota"); applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration, - undefined, { modelId: route.modelId, poolWriter: outcomeCtx.poolQuotaWriter }); + undefined, { modelId: route.modelId, poolWriter: outcomeCtx.poolQuotaWriter, poolResponse: true }); } const retryAfter = upstream.headers.get("retry-after"); const resetAt = [ diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 1f6f2992581..2f2c7413368 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -127,7 +127,8 @@ export function codexWsQuotaObserver(authCtx: CodexAuthContext, provider: OcxPro const mainWriter = authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined; return headers => { if (credentialGeneration !== undefined && !isCodexAccountGenerationLive(accountId, credentialGeneration)) return; - applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined }); + applyCapturedCodexQuota(accountId, headers, writerGeneration, mainWriter, { modelId, poolWriter: authCtx.kind === "pool" ? authCtx.poolQuotaWriter : undefined, + poolResponse: authCtx.kind === "pool" }); }; } @@ -717,7 +718,8 @@ export async function retryCodexPoolOnAlternateAccount( firstResponse.headers, firstAuthCtx.writerGeneration, firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined, - { modelId: route.modelId, poolWriter: firstAuthCtx.kind === "pool" ? firstAuthCtx.poolQuotaWriter : undefined }, + { modelId: route.modelId, poolWriter: firstAuthCtx.kind === "pool" ? firstAuthCtx.poolQuotaWriter : undefined, + poolResponse: firstAuthCtx.kind === "pool" }, ); } const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown( diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index c3d55b1f445..eae2a726175 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -487,7 +487,8 @@ export async function deliverPassthroughResponse( if (!isCodexWsQuotaObservedResponse(upstreamResponse)) { applyAccountQuotaFromUpstreamHeaders(admissionState.authCtx.accountId, upstreamResponse.headers, admissionState.authCtx.writerGeneration, admissionState.authCtx.kind === "main-pool" ? admissionState.authCtx.mainQuotaWriter : undefined, - { modelId: route.modelId, poolWriter: admissionState.authCtx.kind === "pool" ? admissionState.authCtx.poolQuotaWriter : undefined }); + { modelId: route.modelId, poolWriter: admissionState.authCtx.kind === "pool" ? admissionState.authCtx.poolQuotaWriter : undefined, + poolResponse: admissionState.authCtx.kind === "pool" }); } if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 7c719ce7b92..ab490244b89 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -291,7 +291,7 @@ The credits module beside `src/codex/quota-types.ts` retains validated WHAM cred ## Low-quota protection -`src/codex/quota.ts` sends accepted usage observations through `src/codex/low-quota-observer.ts`; credits-only updates never replay carried usage into protection, and pool WHAM/header observations reach the policy only after raw percentages pass validation; clamped display bars cannot authorize a pause. The pool-account-only [configuration policy](../config.md#codex-pool-low-quota-protection) pauses live selection immediately, defers a bounded config save, and deduplicates account/window notices. Manual resume is respected across every qualifying window already active for that account until recovery or a new reset episode. Native main keeps its separate 98% hard lock. Each server registration owns a bounded status ledger; its authenticated management route exposes only its own account ids. The default alert is a log line plus a `logged` event, with no OS notification. +`src/codex/quota.ts` sends accepted usage observations through `src/codex/low-quota-observer.ts`; credits-only updates never replay carried usage into protection, and pool WHAM/header observations reach the policy only after raw percentages pass validation and the captured credential writer is still live; clamped display bars and responses from retired credentials cannot authorize a pause. The pool-account-only [configuration policy](../config.md#codex-pool-low-quota-protection) pauses live selection immediately, defers a bounded config save, and deduplicates account/window notices. Manual resume is respected across every qualifying window already active for that account until recovery or a new reset episode. Native main keeps its separate 98% hard lock. Each server registration owns a bounded status ledger; its authenticated management route exposes only its own account ids. The default alert is a log line plus a `logged` event, with no OS notification. A pool response that could not capture a writer at dispatch (credential already replaced, or the store unreadable) fails closed the same way: its snapshot still refreshes the display cache, but it never authorizes a policy pause. Writer-free legacy and login paths are explicitly distinct from that pool contract and keep their existing observation behavior. ## Bounded pool quota observations diff --git a/tests/codex-integration/low-quota-protection.test.ts b/tests/codex-integration/low-quota-protection.test.ts index ff4261c251e..c09c98bfd46 100644 --- a/tests/codex-integration/low-quota-protection.test.ts +++ b/tests/codex-integration/low-quota-protection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,8 +9,13 @@ import { createLowQuotaEventLedger } from "../../src/codex/low-quota-events"; import { registerCodexLowQuotaProtection, type LowQuotaRegistration } from "../../src/codex/low-quota-protection"; import { setCodexAccountPaused } from "../../src/codex/account-pause"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/account-id"; -import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { capturePoolQuotaWriter, removeCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe"; +import * as authContextModule from "../../src/codex/auth-context"; +import { handleResponsesCompact } from "../../src/server/responses"; +import { baseCompactionBody, compactionRequest, completedPayload, drainCompactionResponseState } from "../helpers/compaction-routing-fixtures"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { codexWsQuotaObserver } from "../../src/server/responses/core-codex-account"; import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, setAccountQuotaFromParsed, updateAccountQuota } from "../../src/codex/quota"; import type { CodexAccount } from "../../src/types/accounts"; @@ -275,16 +280,19 @@ describe("low quota protection", () => { test("invalid raw WHAM usage stays display-only while valid usage pauses", async () => { const config = configWith(protection({ actions: { pause: true, notify: false } })); const registration = register(config, { persist: () => {} }); - const generation = saveCodexAccountCredential(ACCOUNT_A, { + const credential = { accessToken: "fixture-access", refreshToken: "fixture-refresh", expiresAt: Date.now() + 60_000, chatgptAccountId: "fixture-chatgpt-account", - }); + }; + const generation = saveCodexAccountCredential(ACCOUNT_A, credential); + const poolWriter = capturePoolQuotaWriter(ACCOUNT_A, { ...credential, generation }); + expect(poolWriter).toBeDefined(); const publish = (usedPercent: number) => commitPoolQuotaResponse( new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: usedPercent, limit_window_seconds: 604_800, } } }), { status: 200 }), { accountId: ACCOUNT_A, existing: null, configuredPlan: "plus", generation, - writerGeneration: captureConfigGeneration() }, + writerGeneration: captureConfigGeneration(), poolWriter }, ); await publish(150); expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(100); @@ -309,6 +317,163 @@ describe("low quota protection", () => { await registration.flush(); }); + test("a stale pool credential response cannot pause its replacement", () => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + const registration = register(config, { persist: () => {} }); + const first = { accessToken: "first-access", refreshToken: "first-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "first-chatgpt" }; + const generation = saveCodexAccountCredential(ACCOUNT_A, first); + const writer = capturePoolQuotaWriter(ACCOUNT_A, { ...first, generation }); + expect(writer).toBeDefined(); + removeCodexAccountCredential(ACCOUNT_A); + saveCodexAccountCredential(ACCOUNT_A, { ...first, accessToken: "replacement-access" }); + + applyAccountQuotaFromUpstreamHeaders(ACCOUNT_A, new Headers({ + "x-codex-primary-used-percent": "90", "x-codex-primary-window-minutes": "10080", + }), captureConfigGeneration(), undefined, { poolWriter: writer }); + + expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(90); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(registration.hasPendingSave()).toBe(false); + }); + + test("a pool response whose writer capture failed cannot pause its replacement", () => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + const registration = register(config, { persist: () => {} }); + const first = { accessToken: "first-access", refreshToken: "first-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "first-chatgpt" }; + saveCodexAccountCredential(ACCOUNT_A, first); + // The credential is replaced before the delayed response lands, exactly when a + // dispatch-time capture could no longer produce a writer for the retired credential. + removeCodexAccountCredential(ACCOUNT_A); + saveCodexAccountCredential(ACCOUNT_A, { ...first, accessToken: "replacement-access" }); + + applyAccountQuotaFromUpstreamHeaders(ACCOUNT_A, new Headers({ + "x-codex-primary-used-percent": "90", "x-codex-primary-window-minutes": "10080", + }), captureConfigGeneration(), undefined, { poolResponse: true }); + + // The display snapshot still commits; only the policy observation fails closed. + expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(90); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(registration.hasPendingSave()).toBe(false); + }); + + test("valid WHAM usage without a captured pool writer remains display-only", async () => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + const registration = register(config, { persist: () => {} }); + const generation = saveCodexAccountCredential(ACCOUNT_A, { + accessToken: "fixture-access", refreshToken: "fixture-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "fixture-chatgpt-account", + }); + await commitPoolQuotaResponse(new Response(JSON.stringify({ rate_limit: { primary_window: { + used_percent: 90, limit_window_seconds: 604_800, + } } }), { status: 200 }), { + accountId: ACCOUNT_A, existing: null, configuredPlan: "plus", generation, + writerGeneration: captureConfigGeneration(), + }); + expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(90); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(registration.hasPendingSave()).toBe(false); + }); + + test.each([false, true])("WS pool metadata requires a captured live writer (captured=%s)", captured => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + const registration = register(config, { persist: () => {} }); + const credential = { accessToken: "fixture-access", refreshToken: "fixture-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "fixture-chatgpt-account" }; + const generation = saveCodexAccountCredential(ACCOUNT_A, credential); + const writer = capturePoolQuotaWriter(ACCOUNT_A, { ...credential, generation }); + expect(writer).toBeDefined(); + const observer = codexWsQuotaObserver({ + kind: "pool", accountId: ACCOUNT_A, generation, ...credential, + writerGeneration: captureConfigGeneration(), poolQuotaWriter: captured ? writer : undefined, + }, { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(observer).toBeDefined(); + observer!(new Headers({ "x-codex-primary-used-percent": "90", "x-codex-primary-window-minutes": "10080" })); + expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(90); + expect(config.pausedCodexAccountIds).toEqual(captured ? [ACCOUNT_A] : undefined); + expect(registration.hasPendingSave()).toBe(captured); + }); + + test("WS metadata from a retired generation cannot refresh or pause its replacement", () => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + const registration = register(config, { persist: () => {} }); + const credential = { accessToken: "fixture-access", refreshToken: "fixture-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "fixture-chatgpt-account" }; + const generation = saveCodexAccountCredential(ACCOUNT_A, credential); + const writer = capturePoolQuotaWriter(ACCOUNT_A, { ...credential, generation }); + expect(writer).toBeDefined(); + const observer = codexWsQuotaObserver({ + kind: "pool", accountId: ACCOUNT_A, generation, ...credential, + writerGeneration: captureConfigGeneration(), poolQuotaWriter: writer, + }, { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(observer).toBeDefined(); + removeCodexAccountCredential(ACCOUNT_A); + saveCodexAccountCredential(ACCOUNT_A, { ...credential, accessToken: "replacement-access" }); + observer!(new Headers({ "x-codex-primary-used-percent": "90", "x-codex-primary-window-minutes": "10080" })); + expect(getAccountQuota(ACCOUNT_A)).toBeNull(); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(registration.hasPendingSave()).toBe(false); + }); + + test.each([false, true])("compact pool headers require a captured live writer (captured=%s)", async captured => { + const config = configWith(protection({ actions: { pause: true, notify: false } })); + config.defaultProvider = "openai"; + config.activeCodexAccountId = ACCOUNT_A; + config.providers = { openai: { adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", codexAccountMode: "pool" } }; + saveCodexAccountCredential(ACCOUNT_A, { accessToken: "fixture-access", refreshToken: "fixture-refresh", + expiresAt: Date.now() + 600_000, chatgptAccountId: "fixture-chatgpt-account" }); + updateAccountQuota(ACCOUNT_A, 10); + const registration = register(config, { persist: () => {} }); + const originalResolve = authContextModule.resolveCodexAuthContext; + let poolContexts = 0; + // The resolver still chooses a real fixture credential. Omit only its optional writer + // to exercise dispatch-time capture failure through the actual compact handler. + const resolve = spyOn(authContextModule, "resolveCodexAuthContext").mockImplementation(async (...args) => { + const ctx = await originalResolve(...args); + if (ctx.kind !== "pool") return ctx; + expect(ctx.poolQuotaWriter).toBeDefined(); + poolContexts++; + return captured ? ctx : { ...ctx, poolQuotaWriter: undefined }; + }); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + const releaseSpendHome = acquireOwnedSpendHome(); + const originalFetch = globalThis.fetch; + let compactSends = 0; + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (request.method === "GET" && url.pathname.endsWith("/models")) { + return Response.json({ models: [{ slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list" }] }); + } + expect(url.pathname.endsWith("/responses/compact")).toBe(true); + compactSends++; + return Response.json(completedPayload("fixture compact result"), { headers: { + "x-codex-primary-used-percent": "90", "x-codex-primary-window-minutes": "10080", + } }); + }) as typeof fetch; + try { + const response = await handleResponsesCompact(compactionRequest(baseCompactionBody({ model: "gpt-5.6-sol" })), + config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(compactSends).toBe(1); + expect(poolContexts).toBeGreaterThan(0); + expect(getAccountQuota(ACCOUNT_A)?.weeklyPercent).toBe(90); + expect(config.pausedCodexAccountIds).toEqual(captured ? [ACCOUNT_A] : undefined); + expect(registration.hasPendingSave()).toBe(captured); + } finally { + resolve.mockRestore(); + globalThis.fetch = originalFetch; + try { await drainCompactionResponseState(); } finally { + releaseSpendHome(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + } + }); + test("invalid raw legacy weekly usage stays display-only while valid usage pauses", async () => { const config = configWith(protection({ actions: { pause: true, notify: false } })); const registration = register(config, { persist: () => {} }); From 662dfe170e166c9a12b2b01022b495679d9e5ce8 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:01:15 +0900 Subject: [PATCH 07/21] fix(devin): apply Cognition blocklist rewrites to the system prompt (#6328) Carries #6281 unchanged plus maintainer review fixes; the contributor gate re-drafted the original after the maintainer push. Co-authored-by: lonefisher <132996955+lonefisher@users.noreply.github.com> --- src/adapters/devin/cloud-direct/chat.ts | 40 +++++++++++-- structure/providers-and-adapters.md | 2 +- tests/providers/devin-adapter.test.ts | 79 ++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 1c24fa69fad..c910e6db4d9 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -619,6 +619,26 @@ const MAX_TOOL_DESC_LEN = 6998; * symptom was the adapter's own blocklist message pointing back at this * table, which is why they are named here rather than left to the next person * to re-bisect. + * + * The fourth entry is not a tool description at all: it is a sentence from + * Codex's `` escalation boilerplate, which Codex + * injects into the system prompt. Binary-search against a live account + * isolated the trigger to the clause "asking the user if they want to allow + * the action in `justification` parameter" — the whole bullet was required + * (every sub-phrase passed alone), matching flexibly on whitespace and case + * like the other Codex entries. The rewrite swaps "if they want to allow" + * for "whether to allow", verified live to clear the filter while preserving + * the instruction's meaning. + * + * Scope note: the sanitizer only ever touches *instruction surfaces* — + * tool descriptions and the #2 system prompt, where a meaning-preserving + * reword loses nothing. It deliberately does NOT touch data fields: + * message text (#3), replayed thinking (#11), and tool-call arguments + * (#6.3) carry literal content (patches, exact needles, quoted file bytes) + * where a rewrite would silently change what the model did or sees. If a + * blocklisted phrase reaches the cloud inside one of those, the request is + * refused and the caller sees the upstream `permission_denied` — which is + * the correct failure, better than corrupting the data. */ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], @@ -630,10 +650,14 @@ const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ /\bWrites\s+characters\s+to\s+an\s+existing\s+unified\s+exec\s+session\s+and\s+returns\s+recent\s+output\b/gi, "Sends characters to an existing unified exec session and returns recent output", ], + [ + /\basking\s+the\s+user\s+if\s+they\s+want\s+to\s+allow\s+the\s+action\s+in\s+`?justification`?\s+parameter\b/gi, + "asking the user whether to allow the action in the `justification` parameter", + ], ]; -function sanitizeToolDescriptionForCognition(description: string): string { - let out = description; +function sanitizeTextForCognition(text: string): string { + let out = text; for (const [pattern, replacement] of COGNITION_BLOCKLIST_REWRITES) { out = out.replace(pattern, replacement); } @@ -642,12 +666,17 @@ function sanitizeToolDescriptionForCognition(description: string): string { /** Test-only: exercise the Cognition blocklist rewrite directly. */ export function sanitizeToolDescriptionForCognitionForTests(description: string): string { - return sanitizeToolDescriptionForCognition(description); + return sanitizeTextForCognition(description); +} + +/** Test-only: exercise the sanitizer used for system text and tool descriptions. */ +export function sanitizeTextForCognitionForTests(text: string): string { + return sanitizeTextForCognition(text); } /** Description as transmitted on the Cognition wire, also used by overflow estimation. */ export function prepareToolDescriptionForCognition(description: string): string { - const rawDesc = sanitizeToolDescriptionForCognition(description); + const rawDesc = sanitizeTextForCognition(description); return rawDesc.length > MAX_TOOL_DESC_LEN ? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + '\n…(truncated for cloud)' : rawDesc; @@ -692,6 +721,7 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { .map((p) => p.text).join('\n')) .filter(Boolean) .join('\n\n'); + const sanitizedSystemPrompt = sanitizeTextForCognition(systemPrompt); const collapsed = collapseSystemIntoUser(args.messages.slice(leadingSystem.length)); const promptParts = collapsed.map((m) => encodeMessage( @@ -736,7 +766,7 @@ function buildGetChatMessageRequest(args: BuildArgs): Buffer { return Buffer.concat([ encodeMessage(1, metadata), // #2 system_prompt is always written, empty when the caller had none. - encodeString(2, systemPrompt), + encodeString(2, sanitizedSystemPrompt), ...promptParts, encodeVarintField(7, args.requestType ?? 5), encodeMessage(8, completion), diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 158795c0823..9e9b284e68b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -183,7 +183,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 7bb18ce3974..0482c353e98 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -3,10 +3,10 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; -import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; +import { buildGetChatMessageRequestForTests, sanitizeTextForCognitionForTests, sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; -import { encodeMessage, encodeString, encodeVarintField } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { DEPRECATED_OAUTH_PROVIDER_ALIASES, OAUTH_PROVIDERS, resolveRefreshPolicy } from "../../src/oauth"; import { DEVIN_DEFAULT_API_SERVER } from "../../src/oauth/devin"; import { saveCredential } from "../../src/oauth/store"; @@ -304,6 +304,81 @@ describe("devin adapter", () => { expect(sanitizeToolDescriptionForCognitionForTests(nearMiss)).toBe(nearMiss); }); + test("rewrites the Codex escalation phrase on instruction surfaces and preserves data", () => { + // This trigger lives in Codex's boilerplate, + // which Codex sends as system prompt — not in a tool description. The + // clause was isolated live: the full sentence is refused while every + // sub-phrase passes, so the rewrite edits the verb phrase only. + const trigger = "asking the user if they want to allow the action in `justification` parameter"; + const rewritten = sanitizeTextForCognitionForTests( + `Include a short question ${trigger}. e.g. "Do you want to run it?"`, + ); + expect(rewritten).toContain("asking the user whether to allow the action in the `justification` parameter"); + expect(rewritten).not.toContain("if they want to allow the action"); + // Flexible whitespace/case, same as the other Codex entries. + expect(sanitizeTextForCognitionForTests(trigger.toUpperCase())) + .toContain("whether to allow the action"); + expect(sanitizeTextForCognitionForTests(trigger.replaceAll(" ", "\n "))).toBe( + "asking the user whether to allow the action in the `justification` parameter", + ); + + // The phrase is rewritten on instruction surfaces — #2 (system prompt) + // and tool descriptions — but data fields must stay byte-exact: a user + // message, replayed thinking, or tool-call arguments quoting the phrase + // are literal content (patches, exact needles) where a rewrite would + // silently change what the model did. If the cloud still refuses such a + // request, the caller sees the upstream permission_denied. + const sys = `\n- Include a short question ${trigger}. e.g. "Do it?"\n`; + const req = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + messages: [ + { role: "system", content: sys }, + { role: "user", content: `here is a quote: ${trigger}` }, + { + role: "assistant", + content: "ok", + thinking: `the prompt says: ${trigger}`, + tool_calls: [{ id: "c1", name: "apply_patch", arguments: `{"patch":"${trigger}"}` }], + }, + { role: "tool", tool_call_id: "c1", content: `file bytes: ${trigger}` }, + { role: "user", content: "hello" }, + ], + tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], + }); + // Decode #2 independently: a rewritten tool description cannot mask an + // unsanitized system prompt in a whole-buffer substring assertion. + const systemField = [...iterFields(req)].find((field) => field.num === 2); + expect(systemField?.value).toEqual(Buffer.from( + `\n- Include a short question asking the user whether to allow the action in the \`justification\` parameter. e.g. "Do it?"\n`, + )); + // ...but data fields preserve the literal bytes (user text, thinking, + // tool-call arguments all still carry the verbatim trigger). + expect(req.includes(Buffer.from(`here is a quote: ${trigger}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`the prompt says: ${trigger}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`{"patch":"${trigger}"}`, "utf8"))).toBe(true); + expect(req.includes(Buffer.from(`file bytes: ${trigger}`, "utf8"))).toBe(true); + + // A request where the ONLY trigger carrier is a tool description must + // come out clean — pins encodeToolDef specifically. + const toolOnly = buildGetChatMessageRequestForTests({ + apiKey: "k", + modelUid: "swe-2", + cascadeId: "c", + sessionId: "s", + requestId: 1n, + triggerId: "t", + messages: [{ role: "user", content: "hi" }], + tools: [{ name: "codex_escalation", description: trigger, parameters: { type: "object" } }], + }); + expect(toolOnly.includes(Buffer.from(trigger, "utf8"))).toBe(false); + expect(toolOnly.includes(Buffer.from("whether to allow the action", "utf8"))).toBe(true); + }); + test("the catalog parser reads the per-account context window", () => { // ClientModelConfig #18 is the max input tokens, and it is the only // first-party context-window figure Cognition exposes: the Devin CLI and From 6c32c4da36868afd87a603476b7c9b9f65a7588d Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:01:29 +0900 Subject: [PATCH 08/21] fix(anthropic): give each Claude model its real 128K output maximum (#6329) Carries #6316 unchanged plus maintainer review fixes; the contributor gate re-drafted the original after the maintainer push. Co-authored-by: vadymhimself <11277453+vadymhimself@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/providers/registry/entries-core.ts | 3 + src/providers/registry/model-seeds.ts | 33 +++++++++- .../anthropic/anthropic-output-maxima.test.ts | 61 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/adapters/anthropic/anthropic-output-maxima.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f41417d755e..9454aec7f0f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -132,6 +132,7 @@ "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-sonnet-5-5-contract.test.ts": "adapters/anthropic", + "anthropic-output-maxima.test.ts": "adapters/anthropic", "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 51369022bf1..bbbb0cb1c1d 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -20,6 +20,7 @@ import { ANTHROPIC_MODEL_CONTEXT_WINDOWS, ANTHROPIC_MODEL_INPUT_MODALITIES, ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS, ANTHROPIC_MODEL_REASONING_EFFORTS, ZAI_GLM_52_REASONING_EFFORTS, ZAI_GLM_53_REASONING_EFFORTS, @@ -489,6 +490,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Codex omits max_output_tokens; without a provider budget the Anthropic adapter // falls back to 8192, which truncates long answers with stop_reason=max_tokens. defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS }, defaultModel: "claude-sonnet-5", // Claude fast mode on the subscription lane (Claude Code `/fast`): the OAuth route accepts // `speed` and gates it on account entitlement (usage credits / org enablement), probed live @@ -516,6 +518,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS }, defaultModel: "claude-sonnet-5", fastWire: ANTHROPIC_FAST_WIRE, modelSupportsServiceTier: { ...ANTHROPIC_FAST_MODELS }, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index b2a8ce82ed9..9b480828a4a 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -19,9 +19,38 @@ export const ANTHROPIC_MODEL_INPUT_MODALITIES: Record = Object ANTHROPIC_MODELS.map(id => [id, ["text", "image"]]), ); // Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x -// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a -// larger request never over-allocates; it only stops the 8192 truncation. +// through Opus 5 and Fable 5), so this stays the floor for a model id not listed in +// ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS below. export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; +/** + * Per-model synchronous Messages API output maxima, each read from that model's own overview + * page on platform.claude.com (`/docs/en/models//overview`), which states the number in + * its header line and again in the Capabilities table. Deliberately NOT the 300K Message + * Batches extended-output beta, which needs `output-300k-2026-03-24` and does not apply to the + * synchronous route these providers use. + * + * Every seeded Claude model except Haiku 4.5 is 128K. Haiku 4.5 is genuinely 64K and is left + * out so it keeps inheriting ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS. + * + * Without this the 64000 default stood in for all of them, which understates the real ceiling + * by half on every Opus, Sonnet and Fable model. `resolveOutputCeiling` is the value the + * adapter budgets against when a caller declares no `max_tokens`, so a 128K-capable model + * stopped at 64000 with `stop_reason: max_tokens`; the same figure feeds the combo admission + * reserve in `checkComboTargetInputAdmission`, where understating a target's output makes its + * headroom arithmetic wrong. + */ +export const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record = { + "claude-fable-5-1": 128_000, + "claude-fable-5": 128_000, + "claude-sonnet-5-5": 128_000, + "claude-sonnet-5": 128_000, + "claude-opus-5-5": 128_000, + "claude-opus-5": 128_000, + "claude-opus-4-8": 128_000, + "claude-opus-4-7": 128_000, + "claude-opus-4-6": 128_000, + "claude-sonnet-4-6": 128_000, +}; /** * The effort rungs opencodex exposes for native Anthropic models. Without this the * providers advertised no ladder at all, so every client that keys its effort control off diff --git a/tests/adapters/anthropic/anthropic-output-maxima.test.ts b/tests/adapters/anthropic/anthropic-output-maxima.test.ts new file mode 100644 index 00000000000..1f423102b78 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-output-maxima.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_MODELS, + ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS, +} from "../../../src/providers/registry/model-seeds"; +import { resolveModelPolicy } from "../../../src/providers/resolved-model-policy"; +import { resolveOutputCeiling } from "../../../src/server/responses/input-admission"; + +// Anthropic's per-model overview pages (platform.claude.com/docs/en/models//overview, +// read 2026-09-30) put the synchronous Messages API maximum at 128K for every seeded Claude +// model except Haiku 4.5, which is 64K. The 300K figure on those pages belongs to the Message +// Batches extended-output beta and does not apply to the synchronous route. +const HIGH_OUTPUT = 128_000; +const HAIKU = "claude-haiku-4-5"; +const PROVIDERS = ["anthropic", "anthropic-apikey"] as const; + +const entry = (name: string) => getProviderRegistryEntry(name)!; + +function policy(providerName: string, modelId: string) { + const registryEntry = entry(providerName); + const authMode = providerName === "anthropic" ? "oauth" : "key"; + return resolveModelPolicy({ + providerName, + modelId, + provider: { adapter: registryEntry.adapter, baseUrl: registryEntry.baseUrl, authMode }, + registryEntry, + transportMatchedRegistry: true, + effectiveAuth: { authMode }, + }); +} + +describe("Anthropic per-model output maxima", () => { + test("the table covers every seeded model except the one that really is 64K", () => { + expect(Object.keys(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS).sort()) + .toEqual(ANTHROPIC_MODELS.filter(id => id !== HAIKU).sort()); + for (const maximum of Object.values(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS)) { + expect(maximum).toBe(HIGH_OUTPUT); + } + // Haiku 4.5 carries no entry on purpose, so it must still resolve the default and not 128K. + expect(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS[HAIKU]).toBeUndefined(); + expect(ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS).toBe(64_000); + }); + + for (const providerName of PROVIDERS) { + test(`${providerName} resolves each model's real ceiling rather than the 64K default`, () => { + expect(entry(providerName).modelMaxOutputTokens).toEqual(ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS); + const provider = providerConfigSeed(entry(providerName)); + for (const modelId of ANTHROPIC_MODELS) { + const expected = modelId === HAIKU ? ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS : HIGH_OUTPUT; + expect(policy(providerName, modelId).model.maxOutputTokens, modelId).toBe(expected); + // The admission path reads the ceiling through its own resolver, so assert that too: + // it is what the adapter budgets against when a caller declares no max_tokens, and what + // the combo reserve is computed from. + expect(resolveOutputCeiling(provider, providerName, modelId)).toBe(expected); + } + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a74fbe1897e..ac3ad1f2837 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -138,6 +138,7 @@ "anthropic-image-retry-e2e.test.ts": "adapters/anthropic", "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-sonnet-5-5-contract.test.ts": "adapters/anthropic", + "anthropic-output-maxima.test.ts": "adapters/anthropic", "anthropic-parallel-tool-disable.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", From 87e182f110b3ade4d2d5b2e6a7b0fff0e3f394fd Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:04:05 +0900 Subject: [PATCH 09/21] fix(claude): record why a native passthrough failed in its request-log row (#6330) Carries #6295 with a maintainer privacy rework: the stored reason is limited to the HTTP status plus an allowlisted Anthropic error type, so upstream message text (which can echo account identifiers or request content) never reaches the request log or usage history. Client responses are unchanged. Co-authored-by: sh940701 --- src/server/claude-messages.ts | 43 ++- structure/clients/claude-desktop.md | 2 + .../claude-native-passthrough.test.ts | 314 ++++++++++++++++++ 3 files changed, 349 insertions(+), 10 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 45eb39e9585..d4b0fe54178 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -555,6 +555,14 @@ async function anthropicNativePassthrough( logCtx.provider = "anthropic-native"; logCtx.requestedModel = model; const finalize = createFinalRequestLog(logIds, logCtx).finish; + // Locally generated diagnostics are fixed text plus validated guard limits. Runtime + // fetch errors retain their existing client response but use a fixed stored reason. + const fail = (status: number, closeReason: PassthroughCloseReason | "non_stream", message: string, type: string, storedReason = message) => { + const safeMessage = redactSecretString(message); + logCtx.upstreamError = storedReason.slice(0, 500); + finalize(status, { closeReason }); + return anthropicErrorResponse(status, safeMessage, type); + }; const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, ""); const search = new URL(req.url).search; @@ -581,13 +589,11 @@ async function anthropicNativePassthrough( req.signal, ); if (result.kind === "timeout") { - finalize(504, { closeReason: "non_stream" }); - return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error"); + return fail(504, "non_stream", "anthropic passthrough timed out waiting for response headers", "timeout_error"); } if (result.kind === "error") { const err = result.error; - finalize(502, { closeReason: "non_stream" }); - return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error"); + return fail(502, "non_stream", `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error", "anthropic passthrough failed: upstream connection error"); } const upstream = result.upstream; @@ -607,16 +613,13 @@ async function anthropicNativePassthrough( // idle/size bounds — headers are NOT yet sent here, so real statuses are available. const bodyResult = await readBoundedPassthroughBody(upstream, bodyGuard); if (bodyResult.kind === "client_cancel") { - finalize(499, { closeReason: "client_cancel" }); - return anthropicErrorResponse(499, "client closed request during anthropic passthrough", "api_error"); + return fail(499, "client_cancel", "client closed request during anthropic passthrough", "api_error"); } if (bodyResult.kind === "stall") { - finalize(504, { closeReason: "body_stall" }); - return anthropicErrorResponse(504, `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error"); + return fail(504, "body_stall", `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error"); } if (bodyResult.kind === "overflow") { - finalize(502, { closeReason: "body_overflow" }); - return anthropicErrorResponse(502, `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error"); + return fail(502, "body_overflow", `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error"); } const text = bodyResult.text; if (upstream.ok) { @@ -624,6 +627,21 @@ async function anthropicNativePassthrough( const parsed = JSON.parse(text) as { usage?: Rec }; if (isRec(parsed?.usage)) logCtx.usage = anthropicUsageToOcx(parsed.usage); } catch { /* count_tokens etc. */ } + } else if (upstream.status >= 400) { + // Upstream text is untrusted: retain only a closed type vocabulary in history. + // Bound parsing work independently of the larger response-relay body limit. + const statusReason = `Provider error ${upstream.status}`; + logCtx.upstreamError = statusReason; + if (text.length <= PASSTHROUGH_ERROR_DETAIL_MAX_CHARS) { + try { + const parsed: unknown = JSON.parse(text); + if (isRec(parsed) && parsed.type === "error" && isRec(parsed.error)) { + const upstreamType = parsed.error.type; + const knownType = PASSTHROUGH_ERROR_TYPES.find(type => type === upstreamType); + if (knownType) logCtx.upstreamError = `${statusReason}: ${knownType}`; + } + } catch { /* Malformed errors keep the fixed status-only diagnostic. */ } + } } finalize(upstream.status, { closeReason: "non_stream" }); const retryAfter = upstream.headers.get("retry-after"); @@ -633,6 +651,11 @@ async function anthropicNativePassthrough( }); } +const PASSTHROUGH_ERROR_TYPES = [ + "invalid_request_error", "authentication_error", "permission_error", "not_found_error", + "rate_limit_error", "api_error", "overloaded_error", "request_too_large", +] as const; +const PASSTHROUGH_ERROR_DETAIL_MAX_CHARS = 64 * 1024; const DEFAULT_BODY_STALL_SEC = 90; const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e256089a2a8..97d24db4803 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -466,4 +466,6 @@ Native Anthropic passthrough in `src/server/claude-messages.ts` forwards the cal `tapAnthropicSseForLog` in `src/server/claude-messages.ts` relays the streamed body of both the native passthrough and the managed native Messages lane (`src/server/messages-native.ts`). The response headers are already sent, so a stall, a byte-cap overflow, or an upstream read failure ends the body with an Anthropic `event: error` frame after a blank-line boundary and a clean close: `timeout_error` for an idle stall, `api_error` for the byte cap, and `api_error` when an upstream read fails mid-stream (a socket reset). The mid-stream reset is logged like the Responses relay's read error: status 502, `terminalStatus: "failed"`, `closeReason: "terminal"`, `transportPhase: "mid_stream"`, a synthetic terminal source, the attempt marked `streamAborted`, the redacted reason in `upstreamError`, and the usage seen before the reset. The non-streaming fold in the managed lane closes its row with the tap's meta for a reset, a stall or the byte cap, so its row matches the streaming lane's. The request is not replayed. The logged status is not uniform across these frames: a stall and the byte cap keep status 200 with `closeReason` `body_stall` or `body_overflow`, which classify as `incomplete`, while a reset is a 502 that classifies as `failed`. Some read failures are not upstream failures. When the cancel signal is already aborted, the rejection is a `499` client cancel, because Bun can reject the read before it dispatches the abort listener. The managed lane passes its upstream controller's signal, so shutdown and turn release count as cancels too. When `message_stop` or an upstream `error` event has been seen, including one still in the buffer without its blank-line delimiter, the turn is complete: it logs 200 and closes with no error frame. A terminal found only in that unterminated tail gets its blank line restored, because an SSE parser drops an event that EOF cuts off. A translator budget overflow is a local cap, so it still errors the stream, and the non-streaming fold answers it with 413. `tests/claude-integration/claude-native-passthrough.test.ts` and `tests/claude-integration/messages-native.test.ts` cover both lanes against an upstream that resets after a partial or a complete body, plus both cancel paths. +A native passthrough answered without a stream records its reason in `upstreamError`, so the row and the failure diagnostics in usage.jsonl name the cause. An upstream error response (status 400 or above) is relayed verbatim; the stored diagnostic is `Provider error : ` only for a valid Anthropic error envelope with one of the closed types `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `rate_limit_error`, `api_error`, `overloaded_error` or `request_too_large`. Upstream messages and arbitrary type strings never enter this diagnostic. Unknown or malformed envelopes, non-JSON bodies and bodies over 64 Ki characters log `Provider error `. Local header timeout, body stall, byte-cap overflow and cancel diagnostics contain fixed text plus validated guard limits. Fetch failures log the fixed reason `anthropic passthrough failed: upstream connection error`; the existing redacted client response is preserved. Classification uses these stored reasons and HTTP status, so a `permission_error` at 403 is `permission_denied` regardless of upstream message wording. `tests/claude-integration/claude-native-passthrough.test.ts` covers these cases and checks that echoed account identifiers and request content are absent from both history sinks. + Linked-machine data uses the [connection-bound relay contract](../remote-link.md#connection-bound-relay-authentication); client-local credentials and routing policy remain unchanged. diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index c01425f744e..c15106a57e2 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -12,6 +12,7 @@ import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { startTruncatedSseUpstream } from "../helpers/truncated-sse-upstream"; +import { readRecentUsageEntries } from "../../src/usage/log"; import { tapAnthropicSseForLog } from "../../src/server/claude-messages"; import type { RequestLogContext } from "../../src/server/request-log"; import { TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; @@ -968,3 +969,316 @@ test("a terminal frame still in the buffer when the read fails counts as seen", expect(calls).toEqual([{ status: 200, closeReason: "terminal" }]); expect(logCtx.usage).toEqual(expect.objectContaining({ inputTokens: 12, outputTokens: 5 })); }); + +// --- A failed passthrough keeps its reason in the request log --- + +async function nativeFailureRow(upstreamBase: string, extraClaude?: Record, overrides?: Partial, requestBody?: Record) { + const { clearRequestLogsForTests } = await import("../../src/server/request-log"); + clearRequestLogsForTests(); + saveConfig({ ...cfg(upstreamBase, extraClaude), ...overrides }); + const server = startServer(0); + try { + const res = await fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), stream: false, ...requestBody }), + }); + const text = await res.text(); + const logs = logsFromApiBody<{ status?: number; closeReason?: string; upstreamError?: string; errorCode?: string }>( + await (await fetch(new URL("/api/logs?tail=1", server.url))).json(), + ); + expect(logs).toHaveLength(1); + return { res, text, row: logs[0]! }; + } finally { + await server.stop(true); + } +} + +test("an upstream error response is relayed verbatim and its reason reaches the request log and usage.jsonl", async () => { + const errorBody = { type: "error", error: { type: "invalid_request_error", message: "prompt is too long: 213000 tokens > 200000 maximum" }, request_id: "req_fixture" }; + const upstream = Bun.serve({ port: 0, fetch: () => Response.json(errorBody, { status: 400 }) }); + try { + const { res, text, row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(res.status).toBe(400); + expect(JSON.parse(text)).toEqual(errorBody); + expect(row.status).toBe(400); + expect(row.upstreamError).toBe("Provider error 400: invalid_request_error"); + // usage.jsonl carries failure diagnostics for >= 400 rows; this row used to persist none. + expect(readRecentUsageEntries(1)[0]?.upstreamError).toBe(row.upstreamError); + } finally { + upstream.stop(true); + } +}); + +test("a non-JSON upstream error still logs its status as the reason", async () => { + const upstream = Bun.serve({ port: 0, fetch: () => new Response("bad gateway", { status: 502, headers: { "content-type": "text/html" } }) }); + try { + const { res, row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(res.status).toBe(502); + expect(row.upstreamError).toBe("Provider error 502"); + } finally { + upstream.stop(true); + } +}); + +test("a credential echoed in an upstream error message is excluded from persistent diagnostics", async () => { + const leaked = "sk-ant-api03-" + "Z".repeat(40); + const upstream = Bun.serve({ port: 0, fetch: () => Response.json({ type: "error", error: { type: "authentication_error", message: `invalid x-api-key ${leaked}` } }, { status: 401 }) }); + try { + const { row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(row.status).toBe(401); + expect(row.upstreamError).toBe("Provider error 401: authentication_error"); + expect(readRecentUsageEntries(1)[0]?.upstreamError).toBe(row.upstreamError); + expect(row.upstreamError).not.toContain(leaked); + } finally { + upstream.stop(true); + } +}); + +test.each(["account", "request body"] as const)("an upstream message cannot persist %s", async scenario => { + const privateValue = scenario === "account" ? "review-fixture@example.test" : "synthetic-private-request-content-6295"; + const errorType = scenario === "account" ? "permission_error" : "invalid_request_error"; + let responseText = ""; + const upstream = Bun.serve({ port: 0, async fetch(req) { + const body = await req.json() as { messages: { content: string }[] }; + responseText = JSON.stringify({ type: "error", error: { + type: errorType, + message: scenario === "account" ? `Account ${privateValue} is not authorized` : `Invalid message: ${JSON.stringify(body.messages)}`, + } }); + return new Response(responseText, { status: 400, headers: { "content-type": "application/json", "retry-after": "7" } }); + } }); + try { + const { res, text, row } = await nativeFailureRow(upstream.url.origin, undefined, undefined, { + messages: [{ role: "user", content: privateValue }], + }); + expect(res.status).toBe(400); + expect(res.headers.get("retry-after")).toBe("7"); + expect(text).toBe(responseText); + expect(text).toContain(privateValue); + const usage = readRecentUsageEntries(1); + expect(usage).toHaveLength(1); + for (const record of [row, usage[0]]) { + expect(record?.upstreamError).toBe(`Provider error 400: ${errorType}`); + expect(JSON.stringify(record)).not.toContain(privateValue); + } + } finally { + upstream.stop(true); + } +}); + +test.each([ + "invalid_request_error", "authentication_error", "permission_error", "not_found_error", + "rate_limit_error", "api_error", "overloaded_error", "request_too_large", +])("a known error type %s uses only its closed diagnostic", async errorType => { + const privateValue = "opaque-private-value-6295"; + const upstream = Bun.serve({ port: 0, fetch: () => Response.json({ type: "error", error: { type: errorType, message: privateValue } }, { status: 500 }) }); + try { + const { row } = await nativeFailureRow(upstream.url.origin); + expect(row.upstreamError).toBe(`Provider error 500: ${errorType}`); + expect(readRecentUsageEntries(1)[0]?.upstreamError).toBe(row.upstreamError); + expect(JSON.stringify(row)).not.toContain(privateValue); + } finally { + upstream.stop(true); + } +}); + +test.each([ + { type: "error", error: { type: "account-fixture@example.test", message: "private-content" } }, + { type: "error", error: { type: "toString", message: "private-content" } }, + { type: "error", error: { type: "constructor", message: "private-content" } }, + { type: "error", error: { type: "__proto__", message: "private-content" } }, + { type: "error", error: { type: { name: "api_error" }, message: "private-content" } }, + { type: "error", error: { message: "private-content" } }, + { type: "message", error: { type: "api_error", message: "private-content" } }, + { type: "error", error: ["api_error", "private-content"] }, + ["private-content"], null, +])("an unknown or malformed envelope %# records only HTTP status", async errorBody => { + const upstream = Bun.serve({ port: 0, fetch: () => Response.json(errorBody, { status: 502 }) }); + try { + const { text, row } = await nativeFailureRow(upstream.url.origin); + expect(JSON.parse(text)).toEqual(errorBody); + expect(row.upstreamError).toBe("Provider error 502"); + expect(readRecentUsageEntries(1)[0]?.upstreamError).toBe("Provider error 502"); + } finally { + upstream.stop(true); + } +}); + +test("an unreachable upstream logs the fetch failure as the reason", async () => { + // Stubbed like the reject-path activation test in claude-messages-endpoint.test.ts: a real + // refusal can take longer than cfg()'s 250 ms header deadline on some platforms. + const refusedOrigin = "http://127.0.0.1:1"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + if (url.origin === refusedOrigin) throw Object.assign(new TypeError("connect ECONNREFUSED"), { code: "ECONNREFUSED" }); + return originalFetch(input, init); + }) as typeof globalThis.fetch; + try { + const { res, text, row } = await nativeFailureRow(refusedOrigin); + expect(res.status).toBe(502); + expect(row.status).toBe(502); + expect(row.upstreamError).toBe("anthropic passthrough failed: upstream connection error"); + expect(JSON.parse(text).error.message).toBe("anthropic passthrough failed: connect ECONNREFUSED"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a credential in a local failure message is redacted for the client as well as the log", async () => { + // Runtime failures can include identity or opaque data that secret redaction misses. + const refusedOrigin = "http://127.0.0.1:1"; + const leaked = "sk-ant-api03-" + "Q".repeat(40); + const privateValue = "runtime-fixture@example.test opaque-runtime-content-6295"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + if (url.origin === refusedOrigin) throw new TypeError(`proxy rejected key ${leaked}: ${privateValue}`); + return originalFetch(input, init); + }) as typeof globalThis.fetch; + try { + const { res, text, row } = await nativeFailureRow(refusedOrigin); + expect(res.status).toBe(502); + expect(text).not.toContain(leaked); + expect(row.upstreamError).toBe("anthropic passthrough failed: upstream connection error"); + expect(row.upstreamError).not.toContain(leaked); + expect(JSON.parse(text).error.message).toStartWith("anthropic passthrough failed: proxy rejected key "); + const usage = readRecentUsageEntries(1); + expect(usage).toHaveLength(1); + expect(usage[0]?.upstreamError).toBe(row.upstreamError); + for (const record of [row, usage[0]]) { + expect(JSON.stringify(record)).not.toContain(privateValue); + expect(JSON.stringify(record)).not.toContain(leaked); + } + expect(JSON.parse(text).error.message).toContain(privateValue); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an upstream that never sends headers logs the header timeout as the reason", async () => { + const silent = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() { /* accept, never answer */ } } }); + try { + const { res, row } = await nativeFailureRow(`http://127.0.0.1:${silent.port}`); + expect(res.status).toBe(504); + expect(row.status).toBe(504); + expect(row.upstreamError).toBe("anthropic passthrough timed out waiting for response headers"); + } finally { + silent.stop(true); + } +}); + +test("a non-stream body over the byte cap logs the cap as the reason", async () => { + const upstream = Bun.serve({ port: 0, fetch: () => Response.json({ type: "message", content: [{ type: "text", text: "x".repeat(4096) }] }) }); + try { + const { res, row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, ""), { bodyMaxBytes: 1024 }); + expect(res.status).toBe(502); + expect(row).toMatchObject({ status: 502, closeReason: "body_overflow" }); + expect(row.upstreamError).toBe("anthropic passthrough body exceeded 1024 bytes"); + } finally { + upstream.stop(true); + } +}); + +test("an oversized upstream error body is relayed verbatim but not parsed for the log reason", async () => { + // 64 Ki characters: the order of the managed native lane's 64 KiB error read bound. + const errorBody = { type: "error", error: { type: "invalid_request_error", message: "x".repeat(70 * 1024) } }; + const upstream = Bun.serve({ port: 0, fetch: () => Response.json(errorBody, { status: 400 }) }); + try { + const { res, text, row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(res.status).toBe(400); + expect(JSON.parse(text)).toEqual(errorBody); + expect(row.upstreamError).toBe("Provider error 400"); + } finally { + upstream.stop(true); + } +}); + +test("a 3xx answer is not logged with an error reason", async () => { + const upstream = Bun.serve({ port: 0, fetch: () => new Response("choose", { status: 300 }) }); + try { + const { res, row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(res.status).toBe(300); + expect(row.status).toBe(300); + expect(row.upstreamError).toBeUndefined(); + } finally { + upstream.stop(true); + } +}); + +test("a 403 permission error is classified without consulting its upstream message", async () => { + const upstream = Bun.serve({ port: 0, fetch: () => Response.json({ type: "error", error: { type: "permission_error", message: "OAuth authentication is currently not supported." } }, { status: 403 }) }); + try { + const { row } = await nativeFailureRow(upstream.url.toString().replace(/\/$/, "")); + expect(row.status).toBe(403); + expect(row.upstreamError).toBe("Provider error 403: permission_error"); + expect(row.errorCode).toBe("permission_denied"); + } finally { + upstream.stop(true); + } +}); + +/** + * Sends a 200 status line and headers promising a JSON body, then nothing. Bun.serve holds the + * headers of a streamed body until its first chunk, which would trip the header deadline instead. + */ +function startHeadersOnlyUpstream(onRequest?: () => void): { port: number; stop: () => void } { + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + onRequest?.(); + socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n"); + }, + }, + }); + return { port: listener.port, stop: () => listener.stop(true) }; +} + +test("a non-stream body that stalls logs the stall as the reason", async () => { + const upstream = startHeadersOnlyUpstream(); + try { + const { res, row } = await nativeFailureRow(`http://127.0.0.1:${upstream.port}`, { bodyStallSec: 1 }); + expect(res.status).toBe(504); + expect(row).toMatchObject({ status: 504, closeReason: "body_stall" }); + expect(row.upstreamError).toBe("anthropic passthrough body stalled: no upstream bytes for 1s"); + } finally { + upstream.stop(); + } +}); + +test("a client that leaves while a non-stream body is pending logs the cancel as the reason", async () => { + const { clearRequestLogsForTests } = await import("../../src/server/request-log"); + clearRequestLogsForTests(); + let reached!: () => void; + const upstreamReached = new Promise(resolve => { reached = resolve; }); + const upstream = startHeadersOnlyUpstream(() => reached()); + saveConfig(cfg(`http://127.0.0.1:${upstream.port}`)); + const server = startServer(0); + try { + const client = new AbortController(); + const pending = fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", + headers: OAUTH_HEADERS, + body: JSON.stringify({ ...claudeBody(), stream: false }), + signal: client.signal, + }).catch(() => undefined); + await upstreamReached; + await Bun.sleep(50); + client.abort(); + await pending; + let row: { status?: number; closeReason?: string; upstreamError?: string } | undefined; + for (let i = 0; i < 100 && !row; i++) { + row = logsFromApiBody<{ status?: number; closeReason?: string; upstreamError?: string }>( + await (await fetch(new URL("/api/logs?tail=1", server.url))).json(), + )[0]; + if (!row) await Bun.sleep(20); + } + expect(row).toMatchObject({ status: 499, closeReason: "client_cancel" }); + expect(row!.upstreamError).toBe("client closed request during anthropic passthrough"); + } finally { + await server.stop(true); + upstream.stop(); + } +}); From f9bfca0d0a1235257173c586feeab6d5f9f1f258 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:05:44 +0900 Subject: [PATCH 10/21] fix(models): apply new-model policy before discovery, sync and export publication (#6331) Carries #6260 with a maintainer fix: a file-loaded config keeps its file provenance after discovery inventory drift, so stale discovery is refused under the mutation coordinator instead of disabling a model in memory that a later unrelated save would persist over the operator's choice. Co-authored-by: colthreepv <2657230+colthreepv@users.noreply.github.com> --- .../src/content/docs/guides/model-routing.md | 3 + .../content/docs/ja/guides/model-routing.md | 9 + .../content/docs/ko/guides/model-routing.md | 9 + .../content/docs/ru/guides/model-routing.md | 9 + .../docs/zh-cn/guides/model-routing.md | 7 + scripts/test-layout/layout.json | 4 + src/codex/catalog/retained-sync.ts | 9 + src/config.ts | 7 +- src/config/live-reconcile.ts | 18 ++ src/config/rebase-provenance.ts | 12 + src/providers/new-model-policy-runtime.ts | 72 ++++++ src/providers/new-model-policy.ts | 25 ++ src/server/management/model-rows.ts | 36 ++- src/server/management/shared.ts | 11 +- structure/catalog.md | 2 +- structure/clients/integrations.md | 8 +- structure/config.md | 4 +- structure/gui-and-management-api.md | 2 +- structure/providers-and-adapters.md | 1 + structure/runtime.md | 2 +- .../codex-sync-new-model-policy.test.ts | 236 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 4 + .../new-model-policy-runtime.test.ts | 203 +++++++++++++++ tests/providers/new-model-policy.test.ts | 154 +++++++++++- .../model-export-new-model-policy.test.ts | 236 ++++++++++++++++++ .../server-new-model-policy-arrival.test.ts | 154 ++++++++++++ 26 files changed, 1220 insertions(+), 17 deletions(-) create mode 100644 src/providers/new-model-policy-runtime.ts create mode 100644 tests/codex-integration/codex-sync-new-model-policy.test.ts create mode 100644 tests/providers/new-model-policy-runtime.test.ts create mode 100644 tests/server/model-export-new-model-policy.test.ts create mode 100644 tests/server/server-new-model-policy-arrival.test.ts diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index 74f08b67ec0..f8e66dee0f3 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -85,6 +85,9 @@ Routing and catalog visibility are separate controls: - Fresh installs set `modelDiscovery.newModelPolicy` to `"off"`. After the first successful live fetch establishes a baseline, later arrivals are appended to `disabledModels` and carry a **NEW** dashboard badge until enabled or acknowledged. Existing installs remain `"on"` until opted in. + The policy applies before publishing newly discovered models through `/v1/models`, the dashboard, + client configuration exports, or a Codex catalog sync (including service startup). + Enabling an arrival manually keeps it enabled on later refreshes and exports. Use `ocx models new-policy off` globally, add `--provider ` for an override, and inspect `ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is diff --git a/docs-site/src/content/docs/ja/guides/model-routing.md b/docs-site/src/content/docs/ja/guides/model-routing.md index 7dda1864c0a..900185715a3 100644 --- a/docs-site/src/content/docs/ja/guides/model-routing.md +++ b/docs-site/src/content/docs/ja/guides/model-routing.md @@ -83,6 +83,15 @@ model ID は変更しません。`openai-apikey/` は API key transport ブロックしません。 - プロバイダーの `selectedModels` が空でなければカタログ許可リストとして動作します。ライブモデル探索と 直接ルーティングはそのままに、カタログと `/v1/models` に公開するモデルだけ絞ります。 +- `modelDiscovery.newModelPolicy` が `"off"` の場合、最初の成功したライブ探索で基準を確立し、 + その後に追加されたモデルを `disabledModels` に入れます。これは `/v1/models`、ダッシュボード、 + クライアント設定のエクスポート、サービス起動時を含む Codex カタログ同期で公開する前に + 適用されます。手動で有効化したモデルは、その後の更新やエクスポートでも有効なままです。 + 新規インストールの既定値は `"off"`、既存のインストールは変更するまで `"on"` です。 + `ocx models new-policy off` で全体に適用し、`--provider ` で個別に上書きします。 + `ocx models new-arrivals [--json]` で追加モデルを確認できます。失敗または縮退した探索では + 基準は変わりません。`selectedModels` が空でないプロバイダーはすでに選別済みのため、 + このポリシーによる自動無効化は行いません。 - `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは 失敗し、`defaultModel` / `models[]` 検査でもスキップします。 - `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。 diff --git a/docs-site/src/content/docs/ko/guides/model-routing.md b/docs-site/src/content/docs/ko/guides/model-routing.md index ac6425f3423..d20f8320c7d 100644 --- a/docs-site/src/content/docs/ko/guides/model-routing.md +++ b/docs-site/src/content/docs/ko/guides/model-routing.md @@ -82,6 +82,15 @@ fallback하지 않습니다. 막지는 않습니다. - 프로바이더의 `selectedModels`가 비어 있지 않으면 카탈로그 허용 목록으로 동작합니다. 실시간 모델 탐색과 직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다. +- `modelDiscovery.newModelPolicy`가 `"off"`이면 첫 실시간 탐색 성공 시 기준을 만들고, + 이후 추가된 모델을 `disabledModels`에 넣습니다. 이 정책은 `/v1/models`, 대시보드, + 클라이언트 설정 내보내기, 서비스 시작을 포함한 Codex 카탈로그 동기화에서 새 모델을 공개하기 + 전에 적용됩니다. 수동으로 활성화한 모델은 이후 새로고침과 내보내기에서도 활성 상태를 유지합니다. + 새 설치의 기본값은 `"off"`이며, 기존 설치는 직접 바꾸기 전까지 `"on"`을 유지합니다. + `ocx models new-policy off`로 전역 정책을 설정하고, `--provider `으로 프로바이더별 + 정책을 지정합니다. `ocx models new-arrivals [--json]`으로 새 모델을 확인할 수 있습니다. + 실패하거나 제한된 탐색은 기준을 바꾸지 않습니다. `selectedModels`가 비어 있지 않은 + 프로바이더는 이미 선택 목록을 사용하므로 이 정책으로 모델을 자동 비활성화하지 않습니다. - `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은 실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다. - `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다. diff --git a/docs-site/src/content/docs/ru/guides/model-routing.md b/docs-site/src/content/docs/ru/guides/model-routing.md index ce96fc95b8d..117802629dc 100644 --- a/docs-site/src/content/docs/ru/guides/model-routing.md +++ b/docs-site/src/content/docs/ru/guides/model-routing.md @@ -88,6 +88,15 @@ description: Как opencodex решает, какой провайдер буд - Непустой `selectedModels` провайдера — ещё один список разрешённых для каталога. Живое обнаружение и прямая маршрутизация продолжают работать; сужается только вывод в каталог и `/v1/models`. +- При `modelDiscovery.newModelPolicy: "off"` первое успешное живое обнаружение задаёт базовый + список; последующие новые модели добавляются в `disabledModels` до публикации в `/v1/models`, + дашборде, экспорте конфигурации клиентов и синхронизации каталога Codex, включая запуск сервиса. + Модель, включённая вручную, остаётся включённой при последующих обновлениях и экспортах. + Новые установки используют `"off"`; существующие сохраняют `"on"`, пока пользователь не изменит + политику. `ocx models new-policy off` задаёт общую политику; `--provider ` переопределяет + её для провайдера. `ocx models new-arrivals [--json]` показывает новые модели. Неудачное или + деградировавшее обнаружение не меняет базовый список. Для провайдеров с непустым `selectedModels` + автоматическое отключение по этой политике не применяется: их список уже отобран. - `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы `provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают. - `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-routing.md b/docs-site/src/content/docs/zh-cn/guides/model-routing.md index 5bfe9d28bcb..1b51859ec35 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-routing.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-routing.md @@ -72,6 +72,13 @@ transport;这些凭证路径互不 fallback。 仍保留在目录中,但会改为 `visibility: "hide"`。它**不会**拒绝对该模型的直接请求。 - 提供商的非空 `selectedModels` 是另一层目录 allowlist。实时发现和直接路由仍然有效;它只会缩小 目录和 `/v1/models` 输出的模型范围。 +- 当 `modelDiscovery.newModelPolicy` 为 `"off"` 时,首次成功的实时发现建立基准,后续新增 + 模型会加入 `disabledModels`。此策略会在通过 `/v1/models`、仪表盘、客户端配置导出或 + Codex 目录同步(包括服务启动)发布新模型之前生效。手动启用的模型在后续刷新和导出中仍保持 + 启用。新安装默认使用 `"off"`,已有安装在主动更改前保持 `"on"`。使用 + `ocx models new-policy off` 设置全局策略,添加 `--provider ` 覆盖单个提供商的设置; + 使用 `ocx models new-arrivals [--json]` 查看新增模型。失败或降级的发现不会改变基准。 + 非空 `selectedModels` 已经限定了提供商的模型列表,因此此策略不会再自动禁用模型。 - `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败, `defaultModel` / `models[]` 扫描也会跳过它。 - `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9454aec7f0f..2c11df1cbbd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -598,6 +598,7 @@ "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", + "codex-sync-new-model-policy.test.ts": "codex-integration", "codex-sync-response.test.ts": "codex-integration", "codex-tool-mode.test.ts": "codex-integration", "codex-transition-state-adoption.test.ts": "codex-integration", @@ -1080,6 +1081,7 @@ "management-metrics-export.test.ts": "server", "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", + "model-export-new-model-policy.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-compat-carry.test.ts": "server", "management-provider-hide-raw-reasoning.test.ts": "server", @@ -1112,6 +1114,7 @@ "model-cache.test.ts": "codex-integration", "model-costs-management-api.test.ts": "server", "model-discovery-management-api.test.ts": "server", + "server-new-model-policy-arrival.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", "model-pinned-effort-config.test.ts": "config", @@ -1159,6 +1162,7 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "new-model-policy-runtime.test.ts": "providers", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 9e2ab48c989..3078e4d8a60 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -7,6 +7,7 @@ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration import { getCodexHome } from "../paths"; import type { OcxConfig } from "../../types"; import { pendingModelSelectionProviders } from "../../providers/initial-model-selection"; +import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { providerCodexAccountMode } from "../../providers/registry"; import { COMBO_NAMESPACE } from "../../combos"; @@ -602,10 +603,13 @@ export async function syncCatalogModels( evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog), processEvidence: retainedCatalogProcessEvidence(), }; + const discoveryBaseline = captureModelDiscoveryBaseline(config); + const providerContentRevisions = new Map(); const [goModels, modelEntitlements] = await Promise.all([ gatherRoutedModels(config, { comboOmissions, providerModelOutcomes, + providerContentRevisions, }), resolveAdmittedCodexModelEntitlements(config), ]); @@ -629,6 +633,11 @@ export async function syncCatalogModels( const current = revalidateRetainedCatalogSync(config, prepared); if (current === null) return null; if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null; + // Revalidate before adopting discovery: its scoped config mutation would otherwise + // invalidate our own evidence. K -> C is the catalog/config lock order. + if (!finalizeModelDiscovery(config, discoveryBaseline, goModels, + providerModelOutcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), + providerContentRevisions)) return null; return writeRetainedCatalogSync({ config, goModels, diff --git a/src/config.ts b/src/config.ts index 37191f58c72..566a85be8b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ import { import { refreshConfigDerivedRegistries } from "./config/derived-registries"; import { clearPendingConfigDeletions, + recordFileBackedConfig, projectConfigRebaseProvenance, } from "./config/rebase-provenance"; import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; @@ -249,7 +250,7 @@ export function loadConfig(): OcxConfig { warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); + return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath); } // Only object-shaped configs are repairable. Spreading another JSON value // into defaults can manufacture a valid config and bypass the invalid-file @@ -285,7 +286,7 @@ export function loadConfig(): OcxConfig { warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); + return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath); } // Still failing, but if every complaint is about one or more named entries // in an independent section, drop exactly those and keep the rest. Falling @@ -315,7 +316,7 @@ export function loadConfig(): OcxConfig { warnDegradedCatalogAutoRefresh(parsed); warnDegradedCodexPool(parsed); warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); + return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath); } } // Merge couldn't fix it — truly broken config diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts index 085b2529a8f..d33aa9746b0 100644 --- a/src/config/live-reconcile.ts +++ b/src/config/live-reconcile.ts @@ -41,6 +41,24 @@ const claudeCodeBaseline = new WeakMap(); * reconciliation paths below. */ const liveConfigBaseline = new WeakMap(); + +/** + * Adopt a committed discovery decision and its merge baseline as one synchronous step. + * Visibility writers assign and save synchronously; any future writer yielding between + * those steps must preserve pending edits through a three-way merge here. + */ +export function adoptPersistedModelDiscovery( + config: OcxConfig, + persisted: Pick, +): void { + config.modelDiscovery = structuredClone(persisted.modelDiscovery); + config.disabledModels = structuredClone(persisted.disabledModels); + const baseline = liveConfigBaseline.get(config); + if (baseline) { + baseline.modelDiscovery = structuredClone(persisted.modelDiscovery); + baseline.disabledModels = structuredClone(persisted.disabledModels); + } +} /** * The live config retains the address of the socket Bun actually opened, while * this map retains the operator's desired address for the next process start. diff --git a/src/config/rebase-provenance.ts b/src/config/rebase-provenance.ts index b9a28eca0ce..96a35fd181b 100644 --- a/src/config/rebase-provenance.ts +++ b/src/config/rebase-provenance.ts @@ -1,5 +1,17 @@ import type { OcxConfig } from "../types"; +// Object provenance survives disk drift without adding serializable config fields. +const fileBackedConfigs = new WeakMap(); + +export function recordFileBackedConfig(config: OcxConfig, path: string): OcxConfig { + fileBackedConfigs.set(config, path); + return config; +} + +export function fileBackedConfigPath(config: OcxConfig): string | undefined { + return fileBackedConfigs.get(config); +} + const pendingTopLevelDeletions = new WeakMap>(); const pendingObjectChildDeletions = new WeakMap>>(); export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance"; diff --git a/src/providers/new-model-policy-runtime.ts b/src/providers/new-model-policy-runtime.ts new file mode 100644 index 00000000000..57e1c86d45a --- /dev/null +++ b/src/providers/new-model-policy-runtime.ts @@ -0,0 +1,72 @@ +import { isDeepStrictEqual } from "node:util"; +import { mutatePersistedConfig, readConfigDiagnostics, validateConfigCandidate } from "../config"; +import { fileBackedConfigPath } from "../config/rebase-provenance"; +import { getConfigPath } from "../config/paths"; +import { observeModelCacheRevision } from "../codex/model-cache"; +import { adoptPersistedModelDiscovery } from "../config/live-reconcile"; +import type { OcxConfig } from "../types"; +import { reconcileSuccessfulModelDiscoveries } from "./new-model-policy"; + +function inventory(config: OcxConfig): unknown { + const validated = validateConfigCandidate(config); + if (!validated.ok) return null; + const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, { + ...provider, + // Initial selection may finish during this discovery, but a re-registration is distinct. + initialModelSelection: provider.initialModelSelection?.registrationId, + }])); + // Compare normalized persisted data, not injected fetch functions. Never log this identity. + return JSON.parse(JSON.stringify({ providers, combos: validated.config.combos, + policy: validated.config.modelDiscovery?.newModelPolicy })); +} + +export function captureModelDiscoveryBaseline(config: OcxConfig) { + const identity = inventory(config); + const disk = readConfigDiagnostics(); + return { identity, persisted: identity !== null && disk.source === "file" + && isDeepStrictEqual(identity, inventory(disk.config)) }; +} + +/** Publish new-arrival choices before returning discovery rows to any visibility consumer. */ +export function finalizeModelDiscovery( + config: OcxConfig, + baseline: ReturnType, + models: Iterable<{ provider: string; id: string; custom?: boolean }>, + authoritativeProviders: string[], + revisions: Map, +): boolean { + const filePath = fileBackedConfigPath(config); + const rows = [...models]; + const now = new Date().toISOString(); + const current = () => (filePath === undefined || filePath === getConfigPath()) + && isDeepStrictEqual(baseline.identity, inventory(config)) + && [...revisions].every(([provider, revision]) => observeModelCacheRevision(provider) === revision); + if (!current()) return false; + const reconcile = (target: OcxConfig) => reconcileSuccessfulModelDiscoveries({ + config: target, models: rows, authoritativeProviders, now, mode: "discovery", + }); + if (!baseline.persisted && filePath === undefined) { + // Standalone/synthetic callers may project their own config, never overwrite another home. + reconcile(config); + return true; + } + // A file-loaded instance remains file-backed after inventory drift. The locked + // freshness gate below refuses stale rows without creating unsaved live disables. + try { + const result = mutatePersistedConfig(fresh => { + if (!current() || !isDeepStrictEqual(baseline.identity, inventory(fresh))) { + return { changed: false, value: null }; + } + // Re-evaluate against the latest baseline and manual choices under the mutation lock. + // A second caller then adopts a first caller's result rather than disabling an ID twice. + const changed = reconcile(fresh); + return { changed, value: { modelDiscovery: fresh.modelDiscovery, disabledModels: fresh.disabledModels } }; + }); + if (result.status === "unavailable" || !result.value) return false; + adoptPersistedModelDiscovery(config, result.value); + return true; + } catch { + // Do not publish an unrecorded arrival when persistence failed. The caller retries discovery. + return false; + } +} diff --git a/src/providers/new-model-policy.ts b/src/providers/new-model-policy.ts index b9ac16af377..f1a07f5311e 100644 --- a/src/providers/new-model-policy.ts +++ b/src/providers/new-model-policy.ts @@ -8,6 +8,17 @@ export const MAX_RECENT_ARRIVALS_PER_PROVIDER = 50; export type KnownModelBaseline = NonNullable["knownModels"]>[string]; export type NewModelPolicy = "on" | "off"; +/** + * Which side of the reconciliation is running. + * + * `converge` (the default) is the catalog convergence cycle: it advances the removal grace and + * retires ids missing for {@link MODEL_REMOVAL_GRACE_FETCHES} cycles. `discovery` is the HTTP read + * side (GET /v1/models), which is polled far more often than convergence runs; it absorbs genuine + * arrivals and reappearances but never advances removal accounting, so a transiently absent id + * cannot be retired by repeated polls. + */ +export type NewModelPolicyMode = "converge" | "discovery"; + export interface NewModelPolicyResult { newIds: string[]; nextBaseline: KnownModelBaseline; @@ -44,6 +55,8 @@ export function applyNewModelPolicy(options: { policy: NewModelPolicy; hasSelectedModels?: boolean; now: string; + /** See {@link NewModelPolicyMode}; defaults to `converge`. */ + mode?: NewModelPolicyMode; }): NewModelPolicyResult { const discovered = [...new Set(options.discoveredIds)].sort(); const prior = options.baseline; @@ -60,8 +73,17 @@ export function applyNewModelPolicy(options: { const seen = new Set(discovered); const newIds = discovered.filter(id => !active.has(id) && !removed.has(id)); const missing: Record = {}; + // Read-side discovery must not advance the removal grace: a per-poll increment would retire a + // transiently absent id after three polls. Absence keeps its recorded count (and a reappearance + // clears it) instead of moving toward `removed`. + const discovery = options.mode === "discovery"; for (const id of active) { if (seen.has(id)) continue; + if (discovery) { + const priorCount = prior.missing?.[id]; + if (priorCount !== undefined) missing[id] = priorCount; + continue; + } const count = (prior.missing?.[id] ?? 0) + 1; if (count >= MODEL_REMOVAL_GRACE_FETCHES) { active.delete(id); @@ -99,6 +121,8 @@ export function reconcileSuccessfulModelDiscoveries(options: { models: Iterable<{ provider: string; id: string; custom?: boolean }>; authoritativeProviders: Iterable; now: string; + /** See {@link NewModelPolicyMode}; defaults to `converge`. */ + mode?: NewModelPolicyMode; }): boolean { const byProvider = new Map(); for (const model of options.models) { @@ -118,6 +142,7 @@ export function reconcileSuccessfulModelDiscoveries(options: { policy: effectiveNewModelPolicy(options.config, provider), hasSelectedModels: (configured.selectedModels?.length ?? 0) > 0, now: options.now, + mode: options.mode, }); if (result.overflow) continue; const priorBaseline = known[provider]; diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 28a24d4690a..971b91fc72a 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -12,6 +12,7 @@ import type { CatalogModel } from "../../codex/catalog"; import { observeModelCacheRevision } from "../../codex/model-cache"; import { captureExportConfigAdmission, + detachedConfigSnapshot, isExportConfigAdmissionCurrent, type ExportConfigAdmission, } from "../../config/admitted-identity"; @@ -37,6 +38,9 @@ import type { OcxConfig } from "../../types"; import { enrichProviderFromRegistry } from "../../providers/derive"; import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements"; import { fetchAllModels } from "./shared"; +import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather"; +import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime"; +import { reconcileSuccessfulModelDiscoveries } from "../../providers/new-model-policy"; import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection"; import { catalogFastRowEligible, fastRowId } from "../fast-row"; import { knownEffortRowIds } from "../effort-row"; @@ -471,8 +475,10 @@ export async function loadExportModels( // The configuration this pass will use from beginning to end, proved to be the one on disk. // Without it there is nothing that may be retained, and the caller still gets its rows: only the // preview authority is withheld. - const admission = captureExportConfigAdmission(config); - const admitted = admission?.config ?? config; + let admission = captureExportConfigAdmission(config); + let admitted = admission?.config ?? config; + const discoveryBaseline = models === undefined ? captureModelDiscoveryBaseline(admitted) : null; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; // The gather stamps each provider as it chooses its rows, so the roster and the revisions that // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's // publication be recorded against rows it never produced. @@ -483,10 +489,34 @@ export async function loadExportModels( // had, and runs alongside as it did inside the projection. const roster = models === undefined ? (await Promise.all([ - (await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }), + (await import("../../codex/catalog")).gatherRoutedModels(admitted, { + providerContentRevisions: gathered, providerModelOutcomes: outcomes, + }), ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }), ]))[0] : models; + if (discoveryBaseline !== null) { + const authoritative = outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider); + if (admission !== null && isExportConfigAdmissionCurrent(admission, config) + && [...gathered].every(([provider, revision]) => observeModelCacheRevision(provider) === revision)) { + if (!finalizeModelDiscovery(config, discoveryBaseline, roster, authoritative, gathered)) { + lastExportSnapshot = null; + throw new CatalogGatherBusyError(); + } + // Only our scoped discovery decision moved: bind the projection to the committed state. + admission = captureExportConfigAdmission(config); + admitted = admission?.config ?? config; + } else { + // Preserve the old-response contract after a race, without persisting stale evidence or + // retaining it as a current preview. Apply visibility policy only to a detached projection. + const projection = detachedConfigSnapshot(admitted); + if (projection === null) throw new CatalogGatherBusyError(); + reconcileSuccessfulModelDiscoveries({ config: projection, models: roster, + authoritativeProviders: authoritative, now: new Date().toISOString(), mode: "discovery" }); + admitted = projection; + admission = null; + } + } const rows = await listManagementModelRows(admitted, { models: roster }); // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index d69adc83d47..2244d510beb 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "../../providers/initial-model-selection-runtime"; +import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime"; +import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather"; import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -269,14 +271,19 @@ export async function fetchAllModels( ): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); const baseline = captureInitialSelectionBaseline(config); - if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined); + const discoveryBaseline = captureModelDiscoveryBaseline(config); + const revisions = providerContentRevisions ?? new Map(); const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes, - ...(providerContentRevisions ? { providerContentRevisions } : {}), + providerContentRevisions: revisions, }); finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); + if (!finalizeModelDiscovery(config, discoveryBaseline, models, + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions)) { + throw new CatalogGatherBusyError(); + } return models; } diff --git a/structure/catalog.md b/structure/catalog.md index 2ce2a2f73fa..f26cad389c5 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -91,7 +91,7 @@ this rule, and uninstall keeps the [manifest validation and residual reporting c Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors. -`src/codex/catalog/model-visibility.ts` also excludes models owned by disabled providers, including custom rows. `src/codex/catalog/routed-gather.ts` does not inherit provider configuration into custom rows while that provider is disabled. +`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. File-backed configs retain their load provenance when their inventory diverges from disk; the locked fresh-config comparison refuses stale publication without mutating live policy or its merge baseline. A reload or explicit adoption is required before that inventory can publish again. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency). On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 790b0b5796b..a1c31f165cb 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -132,9 +132,11 @@ writes the provider cache. With no usable snapshot the request is refused, which process and equally a snapshot retired because the configuration or the provider cache moved. The roster is gathered and projected from a detached copy of the configuration (`src/config/admitted-identity.ts`) taken before the gather, so an edit that lands mid-load changes -neither half of the result; the same admission is revalidated against the resident object and the -configuration file before anything is retained, so such an edit leaves no snapshot rather than one -recorded under a state its rows never had. The identity a caller carries between a preview and the +neither half of the result. `src/server/management/model-rows.ts` revalidates the admission and gathered +cache revisions before committing the [new-arrival policy](../catalog.md#shared-catalog), then captures +the resulting configuration for projection and retention. A superseded gather applies policy only +to its detached projection and retains no snapshot; a failed discovery commit refuses the export. +The identity a caller carries between a preview and the mutation that confirms it is process-local and opaque, and describes nothing about the configuration. The Integrations collection read populates one when discovery succeeds and the configuration can be diff --git a/structure/config.md b/structure/config.md index 21af76a0260..41b5a4e4d74 100644 --- a/structure/config.md +++ b/structure/config.md @@ -57,7 +57,7 @@ and fresh-location examples. Diagnostics do not introduce a fallback or alter fi `src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. It rechecks the file before committing, retries a changed snapshot up to three times, and returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot -test seam and the mutation types remain re-exported through `src/config.ts`. +test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery caller, and changing the resolved config home refuses publication. `src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes the config directory and `config.json` path and retains the existing cache rule: a relative home is @@ -75,7 +75,7 @@ process-wide temp sequence, symlink target resolution, no-follow directory-entry externally writable integration directories, real-home test guard, owner manifest, Windows ACL hardening, scrub-before-unlink failure path, and explicit residual-temp errors. A caller must not replace it with a local temp-and-rename shortcut. Publication failures in -`src/config/persist-unlocked.ts` and `src/config/live-reconcile.ts` follow the [publication-aware rollback contract](gui-and-management-api.md#durable-provider-patch). +`src/config/persist-unlocked.ts` and `src/config/live-reconcile.ts` follow the [publication-aware rollback contract](gui-and-management-api.md#durable-provider-patch). `src/config/live-reconcile.ts` adopts committed model discovery and disabled selectors together with their scoped live merge baselines. A later manual enable therefore removes the automatic disable instead of having a stale three-way merge restore it. Unrelated live config baselines are not advanced by that adoption. Windows hardening there is applied once per write, not once per harden call. Both calls stay `required: true` and still fail the write closed, but the pre-rename call resolves through the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 13da2971e76..019e62da729 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -254,7 +254,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the eight operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` accepts a boolean or `null` to clear it. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | -| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | +| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 9e9b284e68b..7099a226f97 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -215,6 +215,7 @@ The image/video loop bounds each hidden iteration before replay or fulfillment; [media iteration retention](transports/inventory.md#media-iteration-retention). Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. +Ordinary discovery records the new-arrival policy before exposing its rows through the shared management fetcher; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes. Custom providers keep the conventional `${baseUrl}/models` request, normalized by `providerModelsUrl` the same way `openaiChatCompletionsUrl` normalizes the send path: outer whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a diff --git a/structure/runtime.md b/structure/runtime.md index aba5f201412..b9681900ad1 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -28,7 +28,7 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. -Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that differs from disk refuses publication without adding unsaved live disables; its load provenance follows the [config contract](config.md). OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/tests/codex-integration/codex-sync-new-model-policy.test.ts b/tests/codex-integration/codex-sync-new-model-policy.test.ts new file mode 100644 index 00000000000..5dcc9b481ad --- /dev/null +++ b/tests/codex-integration/codex-sync-new-model-policy.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { readCodexCatalogPath } from "../../src/codex/catalog/parsing"; +import { refreshCodexModelCatalog } from "../../src/codex/refresh"; +import { syncModelsToCodex } from "../../src/codex/sync"; +import { syncCodexOnStartIfEnabled } from "../../src/codex/desired-state"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { createTestCaseLifecycle } from "../helpers/test-sandbox-cleanup"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +/** + * Startup and explicit `syncModelsToCodex` reach the catalog writer through + * `refreshCodexModelCatalog -> syncCatalogModels` rather than the `fetchAllModels` refresh. + * These cases pin the contract for that path: a sync-discovered arrival obeys the persisted + * `newModelPolicy`, is absorbed into the persisted `knownModels` / `recentArrivals` / + * `disabledModels`, the provider roster is refetched on every sync, a manual re-enable + * survives because the baseline absorbed the arrival, and a failed arrival-persistence + * commit publishes no catalog change. + * + * The real startup wrapper, the real `syncModelsToCodex`, and the real gathering/catalog + * writer run here; only the unrelated injection and admission seams are stubbed, as the + * sibling `codex-sync-api` suite already does. + */ +const provider = "sync-fixture"; +const existing = ["model-a", "model-b"]; +const arrival = "model-c"; +const now = "2026-01-01T00:00:00Z"; + +let home: TempHome; +let lifecycle: ReturnType; +let ids: string[] = []; +let discoveries: string[][] = []; +let upstream: ReturnType; + +beforeEach(() => { + home = createTempHome("ocx-sync-policy-"); + mkdirSync(home.codexHome, { recursive: true }); + lifecycle = createTestCaseLifecycle(); + clearModelCache(provider); +}); + +afterEach(async () => { + try { + await lifecycle.close(); + } finally { + setPersistedConfigMutationBeforeCommitForTests(null); + clearModelCache(provider); + home.remove(); + } +}); + +function startUpstream(): void { + ids = [...existing]; + discoveries = []; + upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => { + if (request.method !== "GET" || new URL(request.url).pathname !== "/v1/models") { + return new Response("Unexpected upstream request", { status: 404 }); + } + discoveries.push([...ids]); + return Response.json({ data: ids.map(id => ({ id })) }); + } }); + lifecycle.ownStop(() => upstream.stop(true)); +} + +/** + * Give the real writer a Codex catalog to merge into. + * + * Without a readable source `syncCatalogModels` refuses before it gathers, and the + * case would fail for a fixture reason rather than the behavior under test. A `config.toml` + * pointing at a non-default catalog mirrors the hardened sync suite and keeps the + * seeded file authoritative. + */ +function seedCodexCatalog(): void { + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "sync-catalog.json"\n', "utf8"); + writeFileSync(join(home.codexHome, "sync-catalog.json"), `${JSON.stringify({ models: [{ + slug: "gpt-5.5", display_name: "GPT-5.5", description: "native", priority: 0, + visibility: "list", shell_type: "shell_command", comp_hash: "native-comp-hash", + model_messages: { instructions_template: "You are Codex." }, + base_instructions: "You are Codex, a coding agent based on GPT-5.", + supported_reasoning_levels: [{ effort: "medium", description: "m" }], + }] }, null, 2)}\n`); +} + +function persistedConfig(policy: { global: "on" | "off"; local?: "on" | "off" }): OcxConfig { + return { + port: 0, defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, models: [...existing], + ...(policy.local === undefined ? {} : { newModelPolicy: policy.local }), + } }, + modelDiscovery: { newModelPolicy: policy.global, knownModels: { + [provider]: { ids: [...existing], removed: [], updatedAt: now }, + } }, + }; +} + +const deps = { + // `deps` REPLACES the module defaults (no partial merge), so the real catalog + // refresh must be listed explicitly or the sync silently swallows an undefined + // call and writes no catalog. + refreshCodexModelCatalog, + admitCodexWrite: () => ({ kind: "admitted" as const }), + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, +}; + +/** Real startup gate -> real `syncModelsToCodex` -> real catalog writer. */ +async function syncOnStartup(config: OcxConfig): Promise { + const outcome = await syncCodexOnStartIfEnabled( + 10_100, + config, + port => syncModelsToCodex(port, config, null, deps), + ); + expect(outcome.ran).toBe(true); +} + +function catalogSlugs(): string[] { + const catalog = JSON.parse(readFileSync(readCodexCatalogPath(), "utf8")) as { + models?: Array<{ slug?: string }>; + }; + return (catalog.models ?? []).flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []); +} + +test("policy off: a sync-discovered arrival is hidden, disabled, and recorded", () => lifecycle.run(async () => { + startUpstream(); + seedCodexCatalog(); + const config = persistedConfig({ global: "off" }); + saveConfig(config); + await syncOnStartup(config); + // Fixture sanity: the real writer published the two known rows before the arrival. + expect(catalogSlugs()).toContain(`${provider}/model-a`); + expect(catalogSlugs()).toContain(`${provider}/model-b`); + expect(discoveries.at(-1)).toEqual(existing); + + ids = [...existing, arrival]; + clearModelCache(provider); + const beforeRefetch = discoveries.length; + await syncOnStartup(config); + // The sync refetch proves the arrival came from a fresh roster read, not a stale cache. + expect(discoveries.length).toBeGreaterThan(beforeRefetch); + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + + const after = catalogSlugs(); + expect(after).toContain(`${provider}/model-a`); + expect(after).not.toContain(`${provider}/model-c`); + + const persisted = loadConfig(); + expect(persisted.disabledModels ?? []).toContain(`${provider}/model-c`); + expect(persisted.modelDiscovery?.knownModels?.[provider]?.ids).toEqual([...existing, arrival]); + expect(persisted.modelDiscovery?.recentArrivals?.[provider] ?? []) + .toContainEqual(expect.objectContaining({ id: arrival })); +}), SERVER_BUDGET_MS); + +test("policy on: the same sync-discovered arrival stays visible", () => lifecycle.run(async () => { + startUpstream(); + seedCodexCatalog(); + const config = persistedConfig({ global: "on" }); + saveConfig(config); + + ids = [...existing, arrival]; + clearModelCache(provider); + await syncOnStartup(config); + + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + expect(catalogSlugs()).toContain(`${provider}/model-c`); + const persisted = loadConfig(); + expect(persisted.disabledModels ?? []).not.toContain(`${provider}/model-c`); + expect(persisted.modelDiscovery?.recentArrivals?.[provider] ?? []) + .toContainEqual(expect.objectContaining({ id: arrival })); +}), SERVER_BUDGET_MS); + +test("a manual re-enable holds because the sync absorbed the arrival into the baseline", () => lifecycle.run(async () => { + startUpstream(); + seedCodexCatalog(); + const config = persistedConfig({ global: "off" }); + saveConfig(config); + await syncOnStartup(config); + + ids = [...existing, arrival]; + clearModelCache(provider); + const beforeRefetch = discoveries.length; + await syncOnStartup(config); + expect(discoveries.length).toBeGreaterThan(beforeRefetch); + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + expect(catalogSlugs()).not.toContain(`${provider}/model-c`); + + // Manual re-enable: drop the auto-added disable and sync again. The baseline already + // absorbed model-c, so it is no longer a new arrival and must stay visible. + const reenabled = loadConfig(); + reenabled.disabledModels = (reenabled.disabledModels ?? []).filter(slug => slug !== `${provider}/model-c`); + saveConfig(reenabled); + clearModelCache(provider); + const beforeReenableRefetch = discoveries.length; + await syncOnStartup(reenabled); + + expect(discoveries.length).toBeGreaterThan(beforeReenableRefetch); + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + expect(catalogSlugs()).toContain(`${provider}/model-c`); + expect(loadConfig().disabledModels ?? []).not.toContain(`${provider}/model-c`); +}), SERVER_BUDGET_MS); + +test("an arrival whose persistence fails publishes no catalog change", () => lifecycle.run(async () => { + startUpstream(); + seedCodexCatalog(); + const config = persistedConfig({ global: "off" }); + saveConfig(config); + await syncOnStartup(config); + const catalogBefore = readFileSync(readCodexCatalogPath(), "utf8"); + expect(catalogSlugs()).toContain(`${provider}/model-b`); + + ids = [...existing, arrival]; + clearModelCache(provider); + // The arrival decision commits through the persisted-mutation coordinator. This one-shot + // seam corrupts config.json after that decision but before the freshness revalidation, so + // the commit is unavailable and the catalog must not be republished with the arrival. + let hookRan = false; + const corruptConfig = "{ not-json"; + setPersistedConfigMutationBeforeCommitForTests(() => { + hookRan = true; + writeFileSync(home.path("config.json"), corruptConfig, "utf8"); + }); + await syncOnStartup(config); + + // The seam must have fired, and the refused commit must have left the corrupt bytes in + // place rather than rewriting config.json; a vacuous pass would miss both. + expect(hookRan).toBe(true); + expect(readFileSync(home.path("config.json"), "utf8")).toBe(corruptConfig); + expect(readFileSync(readCodexCatalogPath(), "utf8")).toBe(catalogBefore); + expect(catalogSlugs()).not.toContain(`${provider}/model-c`); +}), SERVER_BUDGET_MS); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ac3ad1f2837..3bf189236ef 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -607,6 +607,7 @@ "codex-spark-visibility.test.ts": "codex-integration", "codex-sqlite-home.test.ts": "codex-integration", "codex-sync-api.test.ts": "codex-integration", + "codex-sync-new-model-policy.test.ts": "codex-integration", "codex-sync-response.test.ts": "codex-integration", "codex-tool-mode.test.ts": "codex-integration", "codex-transition-state-adoption.test.ts": "codex-integration", @@ -1091,6 +1092,7 @@ "management-metrics-export.test.ts": "server", "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", + "model-export-new-model-policy.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-compat-carry.test.ts": "server", "management-provider-hide-raw-reasoning.test.ts": "server", @@ -1123,6 +1125,7 @@ "model-cache.test.ts": "codex-integration", "model-costs-management-api.test.ts": "server", "model-discovery-management-api.test.ts": "server", + "server-new-model-policy-arrival.test.ts": "server", "model-display-names-management-api.test.ts": "codex-integration", "model-metadata-sync.test.ts": "codex-integration", "model-pinned-effort-config.test.ts": "config", @@ -1170,6 +1173,7 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "new-model-policy-runtime.test.ts": "providers", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/tests/providers/new-model-policy-runtime.test.ts b/tests/providers/new-model-policy-runtime.test.ts new file mode 100644 index 00000000000..65ceb74f9b4 --- /dev/null +++ b/tests/providers/new-model-policy-runtime.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { loadConfig, saveConfig } from "../../src/config"; +import { armClaudeCodeBaseline, saveConfigPreservingClaudeCode } from "../../src/config/live-reconcile"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { clearModelCache, observeModelCacheRevision, setCached } from "../../src/codex/model-cache"; +import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../src/providers/new-model-policy-runtime"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let home: TempHome; +const rows = ["a", "b", "c"].map(id => ({ provider: "vendor", id })); +const stamp = "2026-01-01T00:00:00Z"; +function fixture(): OcxConfig { + return { port: 10100, defaultProvider: "vendor", providers: { vendor: { + adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", models: ["a", "b"], newModelPolicy: "off", + } }, disabledModels: ["vendor/b"], modelDiscovery: { + knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: stamp } }, + } }; +} +const bytes = () => readFileSync(home.path("config.json"), "utf8"); +function complete(config: OcxConfig, providers = ["vendor"]) { + return finalizeModelDiscovery(config, captureModelDiscoveryBaseline(config), rows, providers, new Map()); +} +beforeEach(() => { home = createTempHome("ocx-arrival-persistence-"); }); +afterEach(() => { + setPersistedConfigMutationBeforeCommitForTests(null); + clearModelCache("vendor"); + home.remove(); +}); + +test("discovery persists arrival and disable together, then preserves a manual enable without rewriting", () => { + saveConfig(fixture()); + const config = loadConfig(); + expect(complete(config)).toBe(true); + expect(loadConfig().disabledModels).toEqual(["vendor/b", "vendor/c"]); + expect(loadConfig().modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b", "c"]); + const enabled = loadConfig(); + enabled.disabledModels = ["vendor/b"]; + saveConfig(enabled); + const before = bytes(); + expect(complete(config)).toBe(true); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(bytes()).toBe(before); +}); + +test("a concurrent writer's manual enable and unrelated edit survive mutation rebasing", () => { + saveConfig(fixture()); + const config = loadConfig(); + setPersistedConfigMutationBeforeCommitForTests(() => { + const concurrent = loadConfig(); + concurrent.modelDiscovery!.knownModels!.vendor.ids.push("c"); + concurrent.disabledModels = ["vendor/b"]; + concurrent.defaultModel = "a"; + writeFileSync(home.path("config.json"), JSON.stringify(concurrent)); + }); + expect(complete(config)).toBe(true); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(loadConfig().defaultModel).toBe("a"); +}); + +test("stale discovery cannot update a replaced provider or expose an uncommitted result", () => { + saveConfig(fixture()); + const config = loadConfig(); + const baseline = captureModelDiscoveryBaseline(config); + const replacement = loadConfig(); + replacement.providers.vendor.baseUrl = "https://replacement.invalid/v1"; + saveConfig(replacement); + const before = bytes(); + expect(finalizeModelDiscovery(config, baseline, rows, ["vendor"], new Map())).toBe(false); + expect(bytes()).toBe(before); + expect(config.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b"]); +}); + +test("a superseded cache snapshot is refused before advancing the baseline", () => { + saveConfig(fixture()); + const config = loadConfig(); + const baseline = captureModelDiscoveryBaseline(config); + const revisions = new Map([["vendor", observeModelCacheRevision("vendor")]]); + setCached("vendor", rows); + const before = bytes(); + expect(finalizeModelDiscovery(config, baseline, rows, ["vendor"], revisions)).toBe(false); + expect(bytes()).toBe(before); +}); + +test("degraded discovery never advances the baseline or records arrivals", () => { + saveConfig(fixture()); + const config = loadConfig(); + const before = bytes(); + expect(complete(config, [])).toBe(true); + expect(bytes()).toBe(before); + expect(config.modelDiscovery?.recentArrivals).toBeUndefined(); +}); + +test("a first authoritative discovery bootstraps without hiding the existing catalog", () => { + const initial = fixture(); + delete initial.modelDiscovery; + saveConfig(initial); + const config = loadConfig(); + expect(complete(config)).toBe(true); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(loadConfig().modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b", "c"]); +}); + +test("standalone and unrelated synthetic configurations never replace persisted provider state", () => { + const standalone = fixture(); + expect(complete(standalone)).toBe(true); + expect(standalone.disabledModels).toContain("vendor/c"); + const persisted = fixture(); + persisted.providers.vendor.baseUrl = "https://other.invalid/v1"; + saveConfig(persisted); + const before = bytes(); + expect(complete(fixture())).toBe(true); + expect(bytes()).toBe(before); +}); + +test("failed persistence refuses publication and does not advance in-memory discovery", () => { + saveConfig(fixture()); + const config = loadConfig(); + setPersistedConfigMutationBeforeCommitForTests(() => writeFileSync(home.path("config.json"), "invalid")); + expect(complete(config)).toBe(false); + expect(config.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b"]); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(bytes()).toBe("invalid"); +}); + +test("repeated discovery of a partial roster does not write or advance removal grace", () => { + saveConfig(fixture()); + const config = loadConfig(); + const before = bytes(); + for (let poll = 0; poll < 4; poll++) { + expect(finalizeModelDiscovery(config, captureModelDiscoveryBaseline(config), + [{ provider: "vendor", id: "a" }], ["vendor"], new Map())).toBe(true); + } + expect(bytes()).toBe(before); + expect(config.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b"]); + expect(config.modelDiscovery?.knownModels?.vendor.missing).toBeUndefined(); +}); + +test("a stale file-backed discovery preserves another writer's enabled model through an unrelated save", () => { + saveConfig(fixture()); + const config = loadConfig(); + armClaudeCodeBaseline(config); + const concurrent = loadConfig(); + concurrent.providers.vendor.models!.push("c"); + concurrent.modelDiscovery!.knownModels!.vendor.ids.push("c"); + saveConfig(concurrent); + const before = bytes(); + + const published = complete(config); + expect(bytes()).toBe(before); + config.contextCapValue = 320000; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().disabledModels).toEqual(["vendor/b"]); + expect(loadConfig().providers.vendor.models).toEqual(["a", "b", "c"]); + expect(loadConfig().contextCapValue).toBe(320000); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(published).toBe(false); +}); + + +test.each([false, true])("an unarmed file-backed caller rejects stale discovery (detached baseline: %s)", detached => { + saveConfig(fixture()); + const config = loadConfig(); + const concurrent = loadConfig(); + concurrent.providers.vendor.models!.push("c"); + concurrent.modelDiscovery!.knownModels!.vendor.ids.push("c"); + saveConfig(concurrent); + const before = bytes(); + const input = detached ? structuredClone(config) : config; + const baseline = captureModelDiscoveryBaseline(input); + expect(finalizeModelDiscovery(config, baseline, rows, ["vendor"], new Map())).toBe(false); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(config.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b"]); + expect(bytes()).toBe(before); +}); + +test("a file-backed provider replaced before discovery cannot be treated as synthetic", () => { + saveConfig(fixture()); + const config = loadConfig(); + const replacement = loadConfig(); + replacement.providers.vendor.baseUrl = "https://replacement.invalid/v1"; + saveConfig(replacement); + const before = bytes(); + expect(complete(config)).toBe(false); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(bytes()).toBe(before); +}); + +test("file-backed discovery cannot publish to a different config home", () => { + saveConfig(fixture()); + const config = loadConfig(); + const other = createTempHome("ocx-arrival-other-home-"); + try { + saveConfig(fixture()); + const before = readFileSync(other.path("config.json"), "utf8"); + expect(complete(config)).toBe(false); + expect(config.disabledModels).toEqual(["vendor/b"]); + expect(readFileSync(other.path("config.json"), "utf8")).toBe(before); + } finally { + other.remove(); + } +}); diff --git a/tests/providers/new-model-policy.test.ts b/tests/providers/new-model-policy.test.ts index dbf591bec38..06b8fabd927 100644 --- a/tests/providers/new-model-policy.test.ts +++ b/tests/providers/new-model-policy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { applyNewModelPolicy, reconcileSuccessfulModelDiscoveries } from "../../src/providers/new-model-policy"; +import { + applyNewModelPolicy, + effectiveNewModelPolicy, + MODEL_REMOVAL_GRACE_FETCHES, + reconcileSuccessfulModelDiscoveries, +} from "../../src/providers/new-model-policy"; const now = "2026-08-24T02:11:00Z"; @@ -79,4 +84,151 @@ describe("new-model policy", () => { expect(config.disabledModels).toEqual(["vendor/b"]); expect(config.modelDiscovery!.knownModels!.vendor!.updatedAt).toBe(now); }); + + /** + * GET /v1/models is polled far more often than convergence runs, so the read side reconciles + * from the same roster repeatedly. Advancing the removal grace once per poll would retire a + * transiently absent id after three polls and write config.json each time. Only convergence may + * move an id toward `removed`. + */ + describe("removal grace is advanced by convergence only", () => { + const seedBaseline = { ids: ["a", "b", "c"], removed: [] as string[], updatedAt: "2026-01-01T00:00:00Z" }; + const roster = [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }]; + + test("read-side reconciliation never retires an id that disappears from the roster", () => { + let baseline = seedBaseline; + for (let poll = 0; poll < MODEL_REMOVAL_GRACE_FETCHES * 4; poll++) { + const result = applyNewModelPolicy({ + provider: "vendor", discoveredIds: ["a", "b"], baseline, policy: "off", now, mode: "discovery", + }); + expect(result.newIds).toEqual([]); + expect(result.nextBaseline.ids).toEqual(["a", "b", "c"]); + expect(result.nextBaseline.removed).toEqual([]); + expect(result.nextBaseline.missing).toBeUndefined(); + baseline = result.nextBaseline; + } + }); + + test("convergence still retires the same id after the grace fetches (control)", () => { + let baseline = seedBaseline; + for (let cycle = 0; cycle < MODEL_REMOVAL_GRACE_FETCHES; cycle++) { + baseline = applyNewModelPolicy({ + provider: "vendor", discoveredIds: ["a", "b"], baseline, policy: "off", now, mode: "converge", + }).nextBaseline; + } + expect(baseline.ids).toEqual(["a", "b"]); + expect(baseline.removed).toEqual(["c"]); + }); + + test("read-side reconciliation still hides a genuine arrival but a reappearance clears its pending removal", () => { + const arrival = applyNewModelPolicy({ + provider: "vendor", discoveredIds: ["a", "b"], baseline: { ids: ["a"], removed: [], updatedAt: now }, + policy: "off", now, mode: "discovery", + }); + expect(arrival.newIds).toEqual(["b"]); + expect(arrival.slugsToDisable).toEqual(["vendor/b"]); + + const resumed = applyNewModelPolicy({ + provider: "vendor", discoveredIds: ["a", "c"], + baseline: { ids: ["a", "c"], removed: [], missing: { c: MODEL_REMOVAL_GRACE_FETCHES - 1 }, updatedAt: now }, + policy: "off", now, mode: "discovery", + }); + expect(resumed.nextBaseline.missing).toBeUndefined(); + expect(resumed.nextBaseline.removed).toEqual([]); + }); + + test("repeated read-side reconciliation of the same roster never shrinks the baseline or reports a change", () => { + const config = { + port: 10100, defaultProvider: "vendor", providers: { vendor: {} }, + modelDiscovery: { newModelPolicy: "off" as const, knownModels: { vendor: structuredClone(seedBaseline) } }, + } as Parameters[0]["config"]; + for (let poll = 0; poll < MODEL_REMOVAL_GRACE_FETCHES * 3; poll++) { + const changed = reconcileSuccessfulModelDiscoveries({ + config, models: roster, authoritativeProviders: ["vendor"], now, mode: "discovery", + }); + expect(changed).toBe(false); + } + const known = config.modelDiscovery!.knownModels!.vendor!; + expect(known.ids).toEqual(["a", "b", "c"]); + expect(known.removed).toEqual([]); + expect(known.missing).toBeUndefined(); + }); + + test("repeated convergence of the same shrinking roster retires the id (control)", () => { + const config = { + port: 10100, defaultProvider: "vendor", providers: { vendor: {} }, + modelDiscovery: { newModelPolicy: "off" as const, knownModels: { vendor: structuredClone(seedBaseline) } }, + } as Parameters[0]["config"]; + for (let cycle = 0; cycle < MODEL_REMOVAL_GRACE_FETCHES; cycle++) { + reconcileSuccessfulModelDiscoveries({ + config, models: roster, authoritativeProviders: ["vendor"], now, + }); + } + expect(config.modelDiscovery!.knownModels!.vendor!.removed).toEqual(["c"]); + }); + }); + + describe("effective per-provider policy override", () => { + /** Minimal convergence config: one known id, an optional provider-level override. */ + const convergenceConfig = (global: "on" | "off", local?: "on" | "off") => ({ + port: 10100, defaultProvider: "vendor", + providers: { vendor: local ? { newModelPolicy: local } : {} }, + modelDiscovery: { + newModelPolicy: global, + knownModels: { vendor: { ids: ["a"], removed: [], updatedAt: "2026-01-01T00:00:00Z" } }, + }, + } as Parameters[0]["config"]); + + test("the provider value wins, and an inherited/absent value falls back to the global flag", () => { + const config = { + port: 10100, defaultProvider: "alpha", + providers: { + alpha: { newModelPolicy: "off" as const }, beta: { newModelPolicy: "on" as const }, + gamma: { newModelPolicy: "inherit" as const }, delta: {}, + }, + modelDiscovery: { newModelPolicy: "on" as const }, + } as Parameters[0]; + expect(effectiveNewModelPolicy(config, "alpha")).toBe("off"); + expect(effectiveNewModelPolicy(config, "beta")).toBe("on"); + expect(effectiveNewModelPolicy(config, "gamma")).toBe("on"); + expect(effectiveNewModelPolicy(config, "delta")).toBe("on"); + const globalOff = { ...config, modelDiscovery: { newModelPolicy: "off" as const } } as typeof config; + expect(effectiveNewModelPolicy(globalOff, "delta")).toBe("off"); + expect(effectiveNewModelPolicy(globalOff, "beta")).toBe("on"); + }); + + test("a provider off override hides a genuine arrival under a global on", () => { + const config = convergenceConfig("on", "off"); + const changed = reconcileSuccessfulModelDiscoveries({ + config, + models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], + authoritativeProviders: ["vendor"], now, + }); + expect(changed).toBe(true); + expect(config.disabledModels).toEqual(["vendor/b"]); + }); + + test("a provider on override lets a genuine arrival through under a global off", () => { + const config = convergenceConfig("off", "on"); + const changed = reconcileSuccessfulModelDiscoveries({ + config, + models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], + authoritativeProviders: ["vendor"], now, + }); + expect(changed).toBe(true); + expect(config.disabledModels).toBeUndefined(); + expect(config.modelDiscovery!.recentArrivals!.vendor).toEqual([{ id: "b", at: now }]); + }); + + test("an explicit disabled choice survives a provider on override", () => { + const config = convergenceConfig("off", "on"); + config.disabledModels = ["vendor/b"]; + reconcileSuccessfulModelDiscoveries({ + config, + models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], + authoritativeProviders: ["vendor"], now, + }); + expect(config.disabledModels).toEqual(["vendor/b"]); + }); + }); }); diff --git a/tests/server/model-export-new-model-policy.test.ts b/tests/server/model-export-new-model-policy.test.ts new file mode 100644 index 00000000000..55960454b04 --- /dev/null +++ b/tests/server/model-export-new-model-policy.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { loadConfig, saveConfig } from "../../src/config"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { CatalogGatherBusyError } from "../../src/codex/catalog/routed-gather"; +import { + loadExportModels, + previewExportModels, + resetExportSnapshotForTests, +} from "../../src/server/management/model-rows"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { createTestCaseLifecycle } from "../helpers/test-sandbox-cleanup"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +/** + * The export roster (`loadExportModels`) is the ONE loader behind `/api/client-config`, the + * Integrations picker and the CLI model list. It reaches providers through `gatherRoutedModels` + * rather than `fetchAllModels`, so when it is the FIRST consumer of a discovery the effective + * `newModelPolicy` must still apply before the arrival can be published to a client (#6260). + * + * These cases drive the real loader against a fake local upstream and check the observable + * contract: a policy-disabled arrival is absent from the export and persisted, the baseline is + * absorbed, a manual re-enable survives the next export, a preview never gathers or writes, and a + * failed persistence publishes nothing. + */ +const provider = "export-fixture"; +const existing = ["model-a", "model-b"]; +const arrival = "model-c"; +const now = "2026-01-01T00:00:00Z"; + +let home: TempHome; +let lifecycle: ReturnType; +let ids: string[] = []; +let discoveries: string[][] = []; +let upstream: ReturnType; + +beforeEach(() => { + home = createTempHome("ocx-model-export-"); + mkdirSync(home.codexHome, { recursive: true }); + lifecycle = createTestCaseLifecycle(); + // `lastExportSnapshot` is module state shared by every case in this process. + resetExportSnapshotForTests(); + clearModelCache(provider); +}); + +afterEach(async () => { + setPersistedConfigMutationBeforeCommitForTests(null); + try { + await lifecycle.close(); + } finally { + resetExportSnapshotForTests(); + clearModelCache(provider); + home.remove(); + } +}); + +function startUpstream(beforeRespond?: () => void): void { + ids = [...existing]; + discoveries = []; + upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => { + if (request.method !== "GET" || new URL(request.url).pathname !== "/v1/models") { + return new Response("Unexpected upstream request", { status: 404 }); + } + discoveries.push([...ids]); + beforeRespond?.(); + return Response.json({ data: ids.map(id => ({ id })) }); + } }); + lifecycle.ownStop(() => upstream.stop(true)); +} + +/** Persist a baseline whose known roster is already A/B, so C is a genuine new arrival. */ +function seedConfig(policy: { global: "on" | "off"; local?: "on" | "off" }): void { + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, models: [...existing], + ...(policy.local === undefined ? {} : { newModelPolicy: policy.local }), + } }, + modelDiscovery: { newModelPolicy: policy.global, knownModels: { + [provider]: { ids: [...existing], removed: [], updatedAt: now }, + } }, + }; + saveConfig(config); +} + +/** The export roster as a client sees it, restricted to this fixture's provider. */ +async function exportIds(): Promise { + const models = await loadExportModels(loadConfig()); + return models.filter(model => model.provider === provider).map(model => model.namespaced).sort(); +} + +const cases: Array<{ name: string; global: "on" | "off"; local?: "on" | "off"; visible: boolean }> = [ + { name: "provider override off beats global on", global: "on", local: "off", visible: false }, + { name: "inherited global off", global: "off", visible: false }, + { name: "control: inherited global on keeps the arrival", global: "on", visible: true }, +]; + +test.each(cases)("export as first consumer: $name", policy => lifecycle.run(async () => { + startUpstream(); + seedConfig(policy); + // The seeded baseline already resolves to A/B on both sides, so nothing arrives yet. + expect(await exportIds()).toEqual([`${provider}/model-a`, `${provider}/model-b`]); + + ids = [...existing, arrival]; + clearModelCache(provider); + const before = discoveries.length; + const after = await exportIds(); + // Fresh roster read, not a stale cache: the arrival came from a new upstream fetch. + expect(discoveries.length).toBeGreaterThan(before); + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + expect(after).toContain(`${provider}/model-a`); + expect(after).toContain(`${provider}/model-b`); + // Direct reason, before any persistence claim: the published roster must already agree with + // the effective policy on the very first export that discovers the arrival. + if (policy.visible) expect(after).toContain(`${provider}/model-c`); + else expect(after).not.toContain(`${provider}/model-c`); + + const persisted = loadConfig(); + expect(persisted.modelDiscovery?.knownModels?.[provider]?.ids).toEqual([...existing, arrival]); + expect(persisted.modelDiscovery?.recentArrivals?.[provider] ?? []) + .toContainEqual(expect.objectContaining({ id: arrival })); + if (policy.visible) { + expect(persisted.disabledModels ?? []).not.toContain(`${provider}/model-c`); + } else { + expect(persisted.disabledModels ?? []).toContain(`${provider}/model-c`); + } +}), SERVER_BUDGET_MS); + +test("a manual re-enable survives the next export because the first export absorbed the arrival", + () => lifecycle.run(async () => { + startUpstream(); + seedConfig({ global: "off" }); + + ids = [...existing, arrival]; + clearModelCache(provider); + expect(await exportIds()).not.toContain(`${provider}/model-c`); + expect(loadConfig().disabledModels ?? []).toContain(`${provider}/model-c`); + + // Drop the auto-added disable — the operator re-enabling the arrival in the Models tab. + const reenabled = loadConfig(); + reenabled.disabledModels = (reenabled.disabledModels ?? []).filter(slug => slug !== `${provider}/model-c`); + saveConfig(reenabled); + clearModelCache(provider); + const before = discoveries.length; + const after = await exportIds(); + // The baseline absorbed C on the first export, so it is no longer new and must stay visible. + expect(discoveries.length).toBeGreaterThan(before); + expect(after).toContain(`${provider}/model-c`); + expect(loadConfig().disabledModels ?? []).not.toContain(`${provider}/model-c`); + }), SERVER_BUDGET_MS); + +test("a mid-gather configuration change falls back to the pre-gather projection", + () => lifecycle.run(async () => { + // Land the operator's manual disable while the discovery request is open: after the export + // captured its admission, before the gather resolves. The decision is then stale. + let mutated = false; + startUpstream(() => { + if (mutated) return; + mutated = true; + const live = loadConfig(); + live.disabledModels = [...(live.disabledModels ?? []), `${provider}/model-b`]; + saveConfig(live); + }); + seedConfig({ global: "off" }); + ids = [...existing, arrival]; + clearModelCache(provider); + + const after = await exportIds(); + expect(mutated).toBe(true); + // Pre-gather projection: B was not disabled when the roster was admitted, so A and B survive, + // and the stale Off decision still hides C because the policy applies to the projection in + // memory without committing anything. + expect(after).toContain(`${provider}/model-a`); + expect(after).toContain(`${provider}/model-b`); + expect(after).not.toContain(`${provider}/model-c`); + + // The stale decision never commits: the live/disk manual disable stands, the arrival is not + // absorbed into the persisted baseline, and no snapshot is retained for a preview. + const persisted = loadConfig(); + expect(persisted.disabledModels ?? []).toContain(`${provider}/model-b`); + expect(persisted.disabledModels ?? []).not.toContain(`${provider}/model-c`); + expect(persisted.modelDiscovery?.knownModels?.[provider]?.ids).toEqual([...existing]); + expect(previewExportModels(loadConfig())).toBeNull(); + }), SERVER_BUDGET_MS); + +test("previewExportModels serves the retained roster without gathering or writing", + () => lifecycle.run(async () => { + startUpstream(); + seedConfig({ global: "off" }); + ids = [...existing, arrival]; + clearModelCache(provider); + + // The FIRST export is already the authority: it absorbs and persists the arrival, and the + // retained snapshot must be usable from that load without a second steady-state pass. + const first = await exportIds(); + expect(first).not.toContain(`${provider}/model-c`); + + const configBefore = readFileSync(home.path("config.json"), "utf8"); + const before = discoveries.length; + const preview = previewExportModels(loadConfig()); + expect(preview).not.toBeNull(); + // Read-only in both directions: no upstream fetch and no rewrite of the configuration file. + expect(discoveries.length).toBe(before); + expect(readFileSync(home.path("config.json"), "utf8")).toBe(configBefore); + expect(preview!.filter(model => model.provider === provider).map(model => model.namespaced).sort()) + .toEqual(first); + // A repeated preview is stable: still no gather, still no write. + expect(previewExportModels(loadConfig())).toEqual(preview); + expect(discoveries.length).toBe(before); + expect(readFileSync(home.path("config.json"), "utf8")).toBe(configBefore); + }), SERVER_BUDGET_MS); + +test("a persistence failure raises CatalogGatherBusyError and publishes nothing", + () => lifecycle.run(async () => { + startUpstream(); + seedConfig({ global: "off" }); + ids = [...existing, arrival]; + clearModelCache(provider); + + // Corrupt the configuration at the commit seam so the arrival decision cannot persist. + const corrupt = "{ not-json"; + let hookRan = false; + setPersistedConfigMutationBeforeCommitForTests(() => { + hookRan = true; + writeFileSync(home.path("config.json"), corrupt, "utf8"); + }); + + await expect(loadExportModels(loadConfig())).rejects.toThrow(CatalogGatherBusyError); + + // The seam fired, and the refused commit left the corrupt bytes rather than publishing. + expect(hookRan).toBe(true); + expect(readFileSync(home.path("config.json"), "utf8")).toBe(corrupt); + }), SERVER_BUDGET_MS); diff --git a/tests/server/server-new-model-policy-arrival.test.ts b/tests/server/server-new-model-policy-arrival.test.ts new file mode 100644 index 00000000000..62240d76474 --- /dev/null +++ b/tests/server/server-new-model-policy-arrival.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { loadConfig, saveConfig } from "../../src/config"; +import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation"; +import { clearModelCache } from "../../src/codex/model-cache"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { createTestCaseLifecycle } from "../helpers/test-sandbox-cleanup"; + +const provider = "fixture-catalog"; +const existing = ["model-a", "model-b"]; +const arrival = "model-c"; +const now = "2026-01-01T00:00:00Z"; +let home: TempHome; +let lifecycle: ReturnType; + +beforeEach(() => { + home = createTempHome("ocx-model-arrival-"); + mkdirSync(home.codexHome, { recursive: true }); + lifecycle = createTestCaseLifecycle(); + clearModelCache(provider); +}); + +afterEach(async () => { + setPersistedConfigMutationBeforeCommitForTests(null); + try { + await lifecycle.close(); + } finally { + clearModelCache(provider); + home.remove(); + } +}); + +const cases: Array<{ + name: string; + global: "on" | "off"; + local?: "on" | "off"; + visible: boolean; +}> = [ + { name: "provider off overrides global on", global: "on", local: "off", visible: false }, + { name: "inherited global off", global: "off", visible: false }, + { name: "provider on overrides global off", global: "off", local: "on", visible: true }, + { name: "inherited global on", global: "on", visible: true }, +]; + +// Discovery is driven through the real HTTP route, never by calling policy reconciliation, so the +// assertions below check the persisted contract under test (#6260): an absorbed baseline, a +// recorded arrival, a surviving manual block, and a manual re-enable that holds across refresh. +test.each(cases)("new arrival before catalog convergence: $name", policy => lifecycle.run(async () => { + let ids = [...existing]; + const discoveries: string[][] = []; + const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => { + if (request.method !== "GET" || new URL(request.url).pathname !== "/v1/models") { + return new Response("Unexpected upstream request", { status: 404 }); + } + discoveries.push([...ids]); + return Response.json({ data: ids.map(id => ({ id })) }); + } }); + lifecycle.ownStop(() => upstream.stop(true)); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, models: [...existing], + ...(policy.local === undefined ? {} : { newModelPolicy: policy.local }), + } }, + disabledModels: [`${provider}/model-b`], + modelDiscovery: { newModelPolicy: policy.global, knownModels: { + [provider]: { ids: [...existing], removed: [], updatedAt: now }, + } }, + }; + saveConfig(config); + expect(loadConfig().modelDiscovery?.knownModels?.[provider]?.ids).toEqual(existing); + const { startServer } = await import("../../src/server"); + lifecycle.abort.signal.throwIfAborted(); + const server = startServer(0); + lifecycle.ownStop(() => server.stop(true)); + const read = async () => { + const response = await fetch(new URL("/v1/models", server.url), { signal: lifecycle.abort.signal }); + expect(response.status).toBe(200); + return (await response.json() as { data: Array<{ id: string }> }).data + .map(model => model.id).filter(id => id.startsWith(`${provider}/`)).sort(); + }; + expect(await read()).toEqual([`${provider}/model-a`]); + expect(discoveries.at(-1)).toEqual(existing); + const previousDiscoveries = discoveries.length; + ids = [...existing, arrival]; + // Exercise a fresh discovery without waiting for the production cache TTL to expire. + clearModelCache(provider); + const after = await read(); + expect(discoveries.length).toBeGreaterThan(previousDiscoveries); + expect(discoveries.at(-1)).toEqual([...existing, arrival]); + expect(after).toContain(`${provider}/model-a`); + expect(after).not.toContain(`${provider}/model-b`); + expect(after).toEqual(policy.visible + ? [`${provider}/model-a`, `${provider}/model-c`] + : [`${provider}/model-a`]); + + // A successful discovery must persist, not only shape this response. The baseline absorbs the + // arrival (so the next discovery does not re-flag it), the arrival is recorded for the operator, + // and the manual block on model-b survives the same convergence that disabled model-c. + const persisted = loadConfig(); + expect(persisted.modelDiscovery?.knownModels?.[provider]?.ids).toEqual([...existing, arrival]); + expect(persisted.modelDiscovery?.recentArrivals?.[provider] ?? []) + .toContainEqual(expect.objectContaining({ id: arrival })); + expect(persisted.disabledModels ?? []).toContain(`${provider}/model-b`); + if (policy.visible) expect(persisted.disabledModels ?? []).not.toContain(`${provider}/model-c`); + else expect(persisted.disabledModels ?? []).toContain(`${provider}/model-c`); + + // Manual re-enable through the management route the CLI uses. It only sticks because the baseline + // above absorbed model-c; a later discovery that still saw it as new would re-flag and re-hide it. + const adminToken = readFileSync(home.path("admin-api-token"), "utf8").trim(); + const enable = await fetch(new URL("/api/model-visibility", server.url), { + method: "PUT", + headers: { "content-type": "application/json", "x-opencodex-api-key": adminToken }, + body: JSON.stringify({ scope: "models", provider, enabled: true, targets: [{ id: arrival }] }), + signal: lifecycle.abort.signal, + }); + expect(enable.status).toBe(200); + expect(loadConfig().disabledModels ?? []).not.toContain(`${provider}/model-c`); + + clearModelCache(provider); + expect(await read()).toEqual([`${provider}/model-a`, `${provider}/model-c`]); +}), SERVER_BUDGET_MS); + +test("failed arrival persistence returns retryable HTTP 503 without publishing models", () => lifecycle.run(async () => { + const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => + Response.json({ data: [...existing, arrival].map(id => ({ id })) }), + }); + lifecycle.ownStop(() => upstream.stop(true)); + saveConfig({ + port: 0, hostname: "127.0.0.1", defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, models: [...existing], + newModelPolicy: "off", + } }, + modelDiscovery: { knownModels: { + [provider]: { ids: [...existing], removed: [], updatedAt: now }, + } }, + }); + const { startServer } = await import("../../src/server"); + lifecycle.abort.signal.throwIfAborted(); + const server = startServer(0); + lifecycle.ownStop(() => server.stop(true)); + setPersistedConfigMutationBeforeCommitForTests(() => writeFileSync(home.path("config.json"), "invalid")); + const response = await fetch(new URL("/v1/models", server.url), { signal: lifecycle.abort.signal }); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + const body = await response.json() as { error: { code: string }; data?: unknown }; + expect(body.error.code).toBe("catalog_busy"); + expect(body.data).toBeUndefined(); +}), SERVER_BUDGET_MS); From 4f3182292d6c22f53feb2ff5915d25e4bfaaa451 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:07:32 +0900 Subject: [PATCH 11/21] docs(structure): keep providers-and-adapters.md within its 600-line budget (#6332) --- structure/providers-and-adapters.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7099a226f97..3cc742e6821 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -214,8 +214,7 @@ changing its capability or adapter wire mapping. The image/video loop bounds each hidden iteration before replay or fulfillment; see [media iteration retention](transports/inventory.md#media-iteration-retention). -Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. -Ordinary discovery records the new-arrival policy before exposing its rows through the shared management fetcher; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes. +Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. Ordinary discovery records the new-arrival policy before exposing its rows through the shared management fetcher; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes. Custom providers keep the conventional `${baseUrl}/models` request, normalized by `providerModelsUrl` the same way `openaiChatCompletionsUrl` normalizes the send path: outer whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a From 349588e2f1df38bf7e43eac08284c0dd829a78ad Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 00:29:20 +0900 Subject: [PATCH 12/21] fix(models): project drifted discovery reads without mutating live policy (#6333) File-backed inventory drift rejected ordinary model reads with catalog_busy. Identical synthetic inventory succeeds on the same cached roster, ruling out row replacement and cache revision churn in the reproduction. Allow read callers to receive a detached new-arrival policy projection. Management renders disabled arrivals; other shared-fetch consumers receive only visible projected rows. Keep retained-sync and direct stale-writer refusal contracts, inventory/revision checks, and persisted merge baselines intact. Verification: eight authorized files, 80 passed / 0 failed; after adding save-safety coverage, runtime-policy and ratchet files, 24 passed / 0 failed. Typecheck and structure checks pass. Full suite and docs build were excluded by the delegated command restrictions. --- .../src/content/docs/guides/model-routing.md | 2 + src/providers/new-model-policy-runtime.ts | 11 ++++ src/server/management/model-rows.ts | 2 +- src/server/management/shared.ts | 11 +++- structure/catalog.md | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/runtime.md | 2 +- .../model-visibility-management-api.test.ts | 53 ++++++++++++++++++- .../new-model-policy-runtime.test.ts | 24 +++++++++ 11 files changed, 105 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index f8e66dee0f3..acb84ddd43e 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -88,6 +88,8 @@ Routing and catalog visibility are separate controls: The policy applies before publishing newly discovered models through `/v1/models`, the dashboard, client configuration exports, or a Codex catalog sync (including service startup). Enabling an arrival manually keeps it enabled on later refreshes and exports. + If the running provider configuration differs from disk, model lists still apply the policy + using a temporary projection; they leave the running configuration and saved choices untouched. Use `ocx models new-policy off` globally, add `--provider ` for an override, and inspect `ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is diff --git a/src/providers/new-model-policy-runtime.ts b/src/providers/new-model-policy-runtime.ts index 57e1c86d45a..c9b0f37ca59 100644 --- a/src/providers/new-model-policy-runtime.ts +++ b/src/providers/new-model-policy-runtime.ts @@ -34,6 +34,8 @@ export function finalizeModelDiscovery( models: Iterable<{ provider: string; id: string; custom?: boolean }>, authoritativeProviders: string[], revisions: Map, + /** Read consumers may publish detached policy state; writers require persisted decisions. */ + publishProjection?: (projection: OcxConfig) => void, ): boolean { const filePath = fileBackedConfigPath(config); const rows = [...models]; @@ -50,6 +52,15 @@ export function finalizeModelDiscovery( reconcile(config); return true; } + if (!baseline.persisted && publishProjection) { + // Drift already existed at admission. Do not add unsaved disables or advance the live + // merge baseline: a later rebase-save must preserve the operator's on-disk choices. + const projection = { ...config, modelDiscovery: structuredClone(config.modelDiscovery), + disabledModels: config.disabledModels === undefined ? undefined : [...config.disabledModels] }; + reconcile(projection); + publishProjection(projection); + return true; + } // A file-loaded instance remains file-backed after inventory drift. The locked // freshness gate below refuses stale rows without creating unsaved live disables. try { diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 971b91fc72a..78cdc0416fe 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -279,7 +279,7 @@ export async function listManagementModelRows( */ const models = options.models === undefined ? (await Promise.all([ - fetchAllModels(config, options.providerContentRevisions), + fetchAllModels(config, options.providerContentRevisions, projection => { config = projection; }), ensureCodexEntitlementFreshness(config, { waitMs: options.entitlementWaitMs ?? 3_000, }), diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 2244d510beb..2e1c88a374a 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -268,6 +268,8 @@ export async function fetchAllModels( config: OcxConfig, /** Filled with each provider's content revision as of the moment its rows were chosen. */ providerContentRevisions?: Map, + /** Management renders disabled rows using this detached state after inventory drift. */ + publishProjection?: (projection: OcxConfig) => void, ): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); const baseline = captureInitialSelectionBaseline(config); @@ -280,10 +282,17 @@ export async function fetchAllModels( }); finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); + let publicationConfig = config; if (!finalizeModelDiscovery(config, discoveryBaseline, models, - outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions)) { + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions, + projection => { publicationConfig = projection; publishProjection?.(projection); })) { throw new CatalogGatherBusyError(); } + if (publicationConfig !== config && !publishProjection) { + // Other consumers keep their live config, so only return rows visible under the projection. + const { filterCatalogVisibleModels } = await import("../../codex/catalog"); + return filterCatalogVisibleModels(models, publicationConfig); + } return models; } diff --git a/structure/catalog.md b/structure/catalog.md index f26cad389c5..cf1faa8b4fa 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -91,7 +91,7 @@ this rule, and uninstall keeps the [manifest validation and residual reporting c Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors. -`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. File-backed configs retain their load provenance when their inventory diverges from disk; the locked fresh-config comparison refuses stale publication without mutating live policy or its merge baseline. A reload or explicit adoption is required before that inventory can publish again. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency). +`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. File-backed configs retain their load provenance when their inventory diverges from disk. Read callers can opt into a detached policy projection when drift predates discovery: management renders its disabled rows, while other shared-fetch consumers receive only its visible rows. The projection copies discovery state and disables, leaving live config, disk, and the live merge baseline untouched. Writer callers still refuse stale publication; reload or explicit adoption is required before retained sync can commit. Inventory changes during discovery, superseded cache revisions, changed config homes, and failed persistence still refuse publication. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency), and `tests/codex-integration/model-visibility-management-api.test.ts` (drifted read projection). On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies diff --git a/structure/config.md b/structure/config.md index 41b5a4e4d74..2b92b202384 100644 --- a/structure/config.md +++ b/structure/config.md @@ -57,7 +57,7 @@ and fresh-location examples. Diagnostics do not introduce a fallback or alter fi `src/config/persisted-mutation.ts` owns schema-valid on-disk mutations under the shared lock. It rechecks the file before committing, retries a changed snapshot up to three times, and returns unavailable for missing, invalid, or persistently conflicting config. Its one-shot -test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery caller, and changing the resolved config home refuses publication. +test seam and the mutation types remain re-exported through `src/config.ts`. Successful, repaired, and salvaged file loads record their source path in the private WeakMap owned by `src/config/rebase-provenance.ts`; defaults and synthetic objects do not acquire it. This metadata is never serialized. Inventory drift cannot turn a file-backed instance into a synthetic discovery writer. Read publication may use the detached policy projection described by the [catalog contract](catalog.md#shared-catalog), without changing the live merge baseline; changing the resolved config home refuses publication. `src/config/paths.ts` is the single owner of `OPENCODEX_HOME` expansion and resolution. It exposes the config directory and `config.json` path and retains the existing cache rule: a relative home is diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 019e62da729..675755e3af3 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -254,7 +254,7 @@ per-request first-party callback reads that live object; a failed write leaves i | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/check` and `POST /api/update/run` await one per-channel asynchronous registry lookup and write successful results through to the package cache; run passes that result to the job starter. `GET /api/update/badge` only reads the cache and reports unknown after 40 hours, on missing cache, or on channel mismatch. The badge links to the update surface rather than gating other actions. `GET /api/update/badge?surface=desktop&session=` projects only that process-local Tauri session; missing or expired state is unknown and never falls back to the package cache. `POST /api/update/desktop-snapshot` is a 1 KiB bounded display-state mutation with no install permission. It accepts the existing admin-token principal or a dedicated single-use, ten-second snapshot capability bound to nonce, method, path, PID, port and the SHA-256 digest of the exact body bytes; that capability cannot authorize another route. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. A `POST /api/providers` overwrite of an existing name keeps the eight operator compatibility settings (`PROVIDER_COMPAT_CARRY_FIELDS` in `src/server/management/provider-overwrite-carry.ts`) and the stored key pool only while the destination (adapter, normalized base URL, auth mode when named) is unchanged. It keeps omitted `hideRawReasoning` on every overwrite of the same name, including destination moves, because it is a display policy; a submitted boolean wins. It never merges the rest of the old row. `PATCH` is a field mask and keeps every field it does not name; `hideRawReasoning` accepts a boolean or `null` to clear it. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | -| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. | +| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. Ordinary model reads also apply the [new-arrival catalog contract](catalog.md#shared-catalog) before returning models. With a matching persisted configuration, discovery records the baseline, NEW badges, and automatic disables together; HTTP listing does not wait for a separate Codex sync. Inventory already drifted from disk uses a detached policy projection: arrivals remain disabled in management rows without changing live config or disk. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `PUT /api/oauth/accounts/pause`, and `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, pause/resume a generic OAuth account, and remove one. Paused generic OAuth accounts are excluded from request selection, 429 failover, and proactive token refresh; pausing an active account selects the next usable account when available, and resuming an account restores an active selection if the current one remains paused. With no unpaused account, requests return 403 rather than a login error. Kiro account-list rows include the current automatic-selection projection and closed exclusion reason; an active singleton may still send. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login start and manual-code continuation require the server-resolved `gui-session` principal before credential acquisition or code submission (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Machine links live in `src/server/management/link-routes.ts`: dashboard sessions reach `GET /api/link/candidates`, `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply` and `POST /api/link/join`, meaning a paired session or, on a standalone runtime, the current loopback-issued session on trusted loopback ingress; join also refuses a runtime that is not standalone (`409 standalone_required`) or not listening on its configured port (`409 join_port_mismatch`) before any SSH, then restarts this runtime as a client; `GET /api/link/status` reports `joinAvailable` to GUI-session callers so the dashboard enables the Child role only when a join can succeed; `GET /api/link/status` and `DELETE /api/link/{id}` also accept the admin token on a trusted loopback ingress; `POST /api/link/issue` accepts only that admin token. Tailscale-identity sessions are refused on every link route. See [Remote Link](remote-link.md). Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 3cc742e6821..991a3723faa 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -214,7 +214,7 @@ changing its capability or adapter wire mapping. The image/video loop bounds each hidden iteration before replay or fulfillment; see [media iteration retention](transports/inventory.md#media-iteration-retention). -Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. Ordinary discovery records the new-arrival policy before exposing its rows through the shared management fetcher; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes. +Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. Ordinary discovery applies the new-arrival policy before exposing its rows through the shared management fetcher, persisting matching inventory or projecting drifted reads; see the [catalog contract](catalog.md#shared-catalog). Only authoritative provider results advance the baseline, and manual choices survive subsequent refreshes. Custom providers keep the conventional `${baseUrl}/models` request, normalized by `providerModelsUrl` the same way `openaiChatCompletionsUrl` normalizes the send path: outer whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a diff --git a/structure/runtime.md b/structure/runtime.md index b9681900ad1..85e0a73cb16 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -28,7 +28,7 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. -Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that differs from disk refuses publication without adding unsaved live disables; its load provenance follows the [config contract](config.md). +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that already differs from disk can publish HTTP rows through a detached policy projection without adding unsaved live disables; retained catalog writes still refuse stale inventory, and its load provenance follows the [config contract](config.md). OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index e730b144bc8..709779f5722 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { nativeModelRows } from "../../src/codex/catalog"; import { loadConfig, saveConfig } from "../../src/config"; @@ -10,6 +10,8 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { ManagementRequest as Request } from "../helpers/management-auth"; import { listManagementModelRows, type ManagementModelRow } from "../../src/server/management/model-rows"; import { routedSlug } from "../../src/providers/slug-codec"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { fetchAllModels } from "../../src/server/management/shared"; const TEST_DIR = join(import.meta.dir, `.tmp-model-visibility-management-${process.pid}`); const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -488,6 +490,55 @@ test("manual models replace management rows with the same provider/id and deleti ]); }); +test("drifted file-backed model reads project arrival disables without changing live config or disk", async () => { + const provider = "fixture-drift"; + const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => + Response.json({ data: ["a", "b", "c"].map(id => ({ id })) }), + }); + clearModelCache(provider); + try { + saveConfig({ + port: 0, defaultProvider: provider, + providers: { [provider]: { + adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href, + apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, + models: ["a", "b"], newModelPolicy: "off", + } }, + disabledModels: [`${provider}/b`], + modelDiscovery: { knownModels: { + [provider]: { ids: ["a", "b"], removed: [], updatedAt: "2026-01-01T00:00:00Z" }, + } }, + }); + const config = loadConfig(); + config.providers[provider]!.models!.push("local-extra"); + const before = structuredClone(config); + const diskBefore = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + // The same inventory is publishable as a synthetic caller: provenance is the difference. + const syntheticRows = await listManagementModelRows(structuredClone(config), { entitlementWaitMs: 0 }); + expect(syntheticRows.find(row => row.namespaced === `${provider}/c`)?.disabled).toBe(true); + + for (let read = 0; read < 2; read++) { + const url = new URL("http://localhost/api/models"); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(200); + const rows = await response!.json() as ManagementModelRow[]; + expect(rows.find(row => row.namespaced === `${provider}/a`)?.disabled).toBe(false); + expect(rows.find(row => row.namespaced === `${provider}/b`)?.disabled).toBe(true); + expect(rows.find(row => row.namespaced === `${provider}/c`)).toMatchObject({ disabled: true, fastRowAvailable: false }); + expect(config).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBefore); + } + // Shared fetch consumers that cannot render the projection must never expose the arrival. + const publicRows = await fetchAllModels(config); + expect(publicRows.filter(row => row.provider === provider).map(row => row.id)).toEqual(["a"]); + expect(config).toEqual(before); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(diskBefore); + } finally { + clearModelCache(provider); + upstream.stop(true); + } +}); + test("manual OpenAI visibility preserves the pending-selection error contract", async () => { const config = loadConfig(); config.providers.openai = { diff --git a/tests/providers/new-model-policy-runtime.test.ts b/tests/providers/new-model-policy-runtime.test.ts index 65ceb74f9b4..fbc3fca4eb1 100644 --- a/tests/providers/new-model-policy-runtime.test.ts +++ b/tests/providers/new-model-policy-runtime.test.ts @@ -158,6 +158,30 @@ test("a stale file-backed discovery preserves another writer's enabled model thr expect(published).toBe(false); }); +test("a read projection preserves a concurrent manual enable through the live writer's later save", () => { + saveConfig(fixture()); + const config = loadConfig(); + armClaudeCodeBaseline(config); + const concurrent = loadConfig(); + concurrent.providers.vendor.models!.push("c"); + concurrent.modelDiscovery!.knownModels!.vendor.ids.push("c"); + saveConfig(concurrent); + const before = bytes(); + const liveBefore = structuredClone(config); + let projection: OcxConfig | undefined; + expect(finalizeModelDiscovery(config, captureModelDiscoveryBaseline(config), rows, ["vendor"], + new Map(), projected => { projection = projected; })).toBe(true); + expect(projection?.disabledModels).toEqual(["vendor/b", "vendor/c"]); + expect(projection?.modelDiscovery?.knownModels?.vendor.ids).toEqual(["a", "b", "c"]); + expect(config).toEqual(liveBefore); + expect(bytes()).toBe(before); + config.contextCapValue = 320000; + saveConfigPreservingClaudeCode(config); + expect(loadConfig().disabledModels).toEqual(["vendor/b"]); + expect(loadConfig().providers.vendor.models).toEqual(["a", "b", "c"]); + expect(loadConfig().contextCapValue).toBe(320000); +}); + test.each([false, true])("an unarmed file-backed caller rejects stale discovery (detached baseline: %s)", detached => { saveConfig(fixture()); From 6f2f6ae9cf3b7cf11120dabeb08fcb3b0517d685 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 11:08:20 +0900 Subject: [PATCH 13/21] fix(xai): report a current Grok CLI version on the OAuth path (#6341) * fix(xai): report a current Grok CLI version on the OAuth path xAI now answers Grok OAuth requests that report a client version below 1.0.13 with HTTP 426 ("Your Grok CLI version (0.2.93) is outdated"), so every SuperGrok OAuth request failed. Report 1.0.25, the current stable Grok CLI, in x-grok-client-version and the User-Agent. Carries #6339. Co-authored-by: unsafe9 <24631203+unsafe9@users.noreply.github.com> * test(xai): pin Grok compatibility header regression --------- Co-authored-by: unsafe9 <24631203+unsafe9@users.noreply.github.com> --- src/providers/xai-transport.ts | 4 ++-- tests/providers/xai/xai-transport.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index 76582b07298..cc96f67a75b 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -28,8 +28,8 @@ export function isXaiResponsesDestination(provider: Pick { - test("OAuth snapshot is exact", async () => { + test("OAuth snapshot pins Grok CLI compatibility to 1.0.25", async () => { const { effective, seen } = await capture("oauth"); expect(effective.baseUrl).toBe(XAI_GROK_CLI_BASE_URL); expect(lower(seen[0])).toEqual({ authorization: "Bearer oauth-token", "content-type": "application/json", - "user-agent": `opencodex-grok/${XAI_GROK_CLIENT_VERSION}`, + "user-agent": "opencodex-grok/1.0.25", "x-authenticateresponse": "authenticate-response", "x-grok-client-identifier": "opencodex", - "x-grok-client-version": XAI_GROK_CLIENT_VERSION, + "x-grok-client-version": "1.0.25", "x-grok-conv-id": deriveXaiConvId("codex-session-abc"), "x-grok-req-id": expect.stringMatching(UUID_V4), "x-grok-session-id": deriveXaiConvId("codex-session-abc"), @@ -557,7 +557,7 @@ describe("xAI outbound compatibility headers", () => { expect(lower(seen[0])).toEqual({ authorization: "Bearer xai-api-key", "content-type": "application/json", - "user-agent": `opencodex-grok/${XAI_GROK_CLIENT_VERSION}`, + "user-agent": "opencodex-grok/1.0.25", "x-grok-conv-id": deriveXaiConvId("codex-session-abc"), "x-grok-req-id": expect.stringMatching(UUID_V4), "x-grok-session-id": deriveXaiConvId("codex-session-abc"), From a8c6c3f0d07fef5879e0d900fc54fc86c0e79044 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 11:14:50 +0900 Subject: [PATCH 14/21] fix(xai): report the current stable Grok CLI 1.0.46 (#6343) --- src/providers/xai-transport.ts | 4 ++-- tests/providers/xai/xai-transport.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index cc96f67a75b..b58519b1ccd 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -28,8 +28,8 @@ export function isXaiResponsesDestination(provider: Pick { - test("OAuth snapshot pins Grok CLI compatibility to 1.0.25", async () => { + test("OAuth snapshot pins Grok CLI compatibility to 1.0.46", async () => { const { effective, seen } = await capture("oauth"); expect(effective.baseUrl).toBe(XAI_GROK_CLI_BASE_URL); expect(lower(seen[0])).toEqual({ authorization: "Bearer oauth-token", "content-type": "application/json", - "user-agent": "opencodex-grok/1.0.25", + "user-agent": "opencodex-grok/1.0.46", "x-authenticateresponse": "authenticate-response", "x-grok-client-identifier": "opencodex", - "x-grok-client-version": "1.0.25", + "x-grok-client-version": "1.0.46", "x-grok-conv-id": deriveXaiConvId("codex-session-abc"), "x-grok-req-id": expect.stringMatching(UUID_V4), "x-grok-session-id": deriveXaiConvId("codex-session-abc"), @@ -557,7 +557,7 @@ describe("xAI outbound compatibility headers", () => { expect(lower(seen[0])).toEqual({ authorization: "Bearer xai-api-key", "content-type": "application/json", - "user-agent": "opencodex-grok/1.0.25", + "user-agent": "opencodex-grok/1.0.46", "x-grok-conv-id": deriveXaiConvId("codex-session-abc"), "x-grok-req-id": expect.stringMatching(UUID_V4), "x-grok-session-id": deriveXaiConvId("codex-session-abc"), From 7ea77aaaf5dfc110da7f0391cf6239a6deb46f8e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 11:15:00 +0900 Subject: [PATCH 15/21] docs(providers): link the TokenLab OpenCodex integration guide (#6342) --- docs-site/src/content/docs/guides/providers.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0e98269a586..3608bea9e46 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -569,6 +569,8 @@ OpenAI-compatible API gateway at [tokenlab.sh](https://tokenlab.sh/r/OPENCODEX), operated by TOKENLAB AI INC. Create a workspace [API key](https://tokenlab.sh/dashboard/api?tab=keys), then run `ocx provider add tokenlab` or select **TokenLab** in the dashboard's **Add provider** picker. +TokenLab maintains a step-by-step [OpenCodex integration guide](https://docs.tokenlab.sh/integrations/opencodex) +([한국어](https://docs.tokenlab.sh/ko/integrations/opencodex)) covering setup and per-model routing. The preset uses [Chat Completions](https://docs.tokenlab.sh/quickstart) and discovers models at `GET /v1/models?category=chat`, keeping only entries that declare `tool-use` capability. Image, video, audio, embedding and decision models are excluded from this chat preset. From ee3845fb10d2e81b2ba2978c13e0f9cc325299e4 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 11:16:21 +0900 Subject: [PATCH 16/21] fix(claude): preserve Devin answers with late reasoning signatures (#6344) Carries #6299 with the buffering note translated into the ja, ko, ru and zh-cn Claude Code guides. Co-authored-by: foxytanuki <45069709+foxytanuki@users.noreply.github.com> --- .../src/content/docs/guides/claude-code.md | 7 + .../src/content/docs/ja/guides/claude-code.md | 7 + .../src/content/docs/ko/guides/claude-code.md | 7 + .../src/content/docs/ru/guides/claude-code.md | 8 + .../content/docs/zh-cn/guides/claude-code.md | 7 + scripts/test-layout/layout.json | 1 + src/claude/devin-output-order.ts | 98 ++++ src/server/responses/run-turn-execution.ts | 30 +- structure/clients/claude-desktop.md | 23 + structure/providers-and-adapters.md | 2 +- structure/transports/byte-accounting.md | 4 + .../claude-devin-output-order.test.ts | 422 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 13 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 src/claude/devin-output-order.ts create mode 100644 tests/claude-integration/claude-devin-output-order.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 1d3394163fe..8ba8e33f281 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -7,6 +7,13 @@ opencodex serves `POST /v1/messages` (plus `count_tokens`) alongside `/v1/respon Code can use every routed provider — OAuth logins, account pools, key failover and sidecars included — with zero extra auth work. +On Devin routes (including SWE-2) reached through the Messages API, text and tool calls wait +for the upstream turn to complete so its late reasoning signature can precede the answer. This prevents Claude Code's final +result from becoming empty; reasoning and keepalive progress still flow during generation. +The buffer shares the request's 32 MiB translation limit and cancellation stops the producer. +This output-order fix does not resolve Cognition's separate refusal of some generated system +text. It preserves system instructions and safety constraints. + For an Anthropic route on stored OAuth or an Anthropic API key, native Fast is available on `claude-opus-5-5`, `claude-opus-5`, and `claude-opus-4-8`: pick the model's `--fast` row (listed when Fast rows are enabled) or set `fastMode: true`. Claude Code's own `/fast` toggle is not diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index b657396615f..609ff3c85c9 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -7,6 +7,13 @@ opencodex は `/v1/responses` と共に `POST /v1/messages`(`count_tokens` も) Code から OAuth ログイン、アカウントプール、キーフェイルオーバー、サイドカーを含むすべてのルーティングプロバイダーを別途の 認証作業なしで使えます。 +Messages API 経由の Devin ルート(SWE-2 を含む)では、テキストとツール呼び出しの送信を +上流のターンが完了するまで待ち、遅れて届く推論署名を回答より先に送ります。これにより Claude Code の +最終結果が空になるのを防ぎます。生成中も推論と keepalive による進行通知は送信されます。 +バッファはリクエストの 32 MiB の変換上限を共有し、キャンセルすると生成処理も停止します。 +この出力順序の修正は、生成された一部のシステムテキストを Cognition が拒否する別の問題を解決するものではありません。 +システム指示と安全上の制約は保持されます。 + ## クイックスタート ```bash diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index d45ed65f471..c33e412dc3b 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -7,6 +7,13 @@ opencodex는 `/v1/responses`와 함께 `POST /v1/messages`(및 `count_tokens`) Code에서 OAuth 로그인, 계정 풀, 키 장애 조치, 사이드카를 포함한 모든 라우팅 제공자를 별도의 인증 작업 없이 사용할 수 있어요. +Messages API를 통해 사용하는 Devin 경로(SWE-2 포함)에서는 늦게 도착하는 추론 서명을 답변보다 먼저 +보낼 수 있도록, 상위 제공자의 턴이 완료될 때까지 텍스트와 도구 호출 전송을 기다려요. 이렇게 하면 Claude Code의 +최종 결과가 비어 버리는 것을 막을 수 있어요. 생성 중에도 추론과 keepalive 진행 알림은 계속 전달돼요. +버퍼에는 요청의 32 MiB 변환 한도가 함께 적용되며, 요청을 취소하면 생성 작업도 중단돼요. +이 출력 순서 수정으로 Cognition이 생성된 일부 시스템 텍스트를 거부하는 별개의 문제가 해결되지는 않아요. +시스템 지시와 안전 제약은 그대로 유지해요. + ## 빠른 시작 ```bash diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 7df5ba2bbec..7f0ef98641b 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -8,6 +8,14 @@ Claude Code может использовать все маршрутизиру аккаунтов, отказоустойчивое переключение (failover) ключей и сайдкары — без какой-либо дополнительной работы по аутентификации. +На маршрутах Devin (включая SWE-2), доступных через Messages API, отправка текста и вызовов +инструментов откладывается до завершения хода у провайдера, чтобы поздно поступившая подпись +рассуждений могла предшествовать ответу. Это предотвращает пустой итоговый результат в Claude Code; +рассуждения и уведомления keepalive продолжают поступать во время генерации. +Буфер использует общий с запросом лимит преобразования в 32 MiB, а отмена запроса останавливает генерацию. +Это исправление порядка вывода не решает отдельную проблему отказа Cognition принимать некоторые +сгенерированные системные тексты. Системные инструкции и ограничения безопасности сохраняются. + ## Быстрый старт ```bash diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 1695a8a6ad7..efd959d4cc5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -7,6 +7,13 @@ opencodex 在 `/v1/responses` 之外还提供 `POST /v1/messages`(以及 `coun Code 可以使用每一个已路由的提供商——包括 OAuth 登录、账户池、密钥故障转移和 sidecar—— 而无需进行任何额外的身份验证配置。 +通过 Messages API 使用 Devin 路由(包括 SWE-2)时,文本和工具调用会等到上游轮次完成后再发送, +以便将晚到的推理签名放在答案之前。这样可以避免 Claude Code 的最终结果为空; +生成期间,推理内容和 keepalive 进度通知仍会持续传输。 +缓冲区与请求共用 32 MiB 的转换上限,取消请求会停止生成过程。 +这项输出顺序修复不会解决 Cognition 拒绝部分生成的系统文本这一独立问题。 +系统指令和安全约束会保持不变。 + ## 快速开始 ```bash diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2c11df1cbbd..b4d27b9425e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,7 @@ "version": 1, "root": "tests", "explicit": { + "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", diff --git a/src/claude/devin-output-order.ts b/src/claude/devin-output-order.ts new file mode 100644 index 00000000000..ef4ee7e2939 --- /dev/null +++ b/src/claude/devin-output-order.ts @@ -0,0 +1,98 @@ +import type { AdapterEvent } from "../types"; +import { + isTranslatorBudgetExceededError, + releaseTranslatedEvent, + retainTranslatedEvent, + type TranslatorBudget, +} from "../lib/translator-budget"; + +/** + * Cognition signs reasoning after text/tools. Claude Code treats a trailing empty + * thinking block as its final result, so Messages needs reasoning before content. + * Consume after raw-event preflight and drain on demand, avoiding a queue burst. + */ +export async function* orderDevinMessagesOutput( + source: AsyncIterable, + budget: TranslatorBudget, + signal: AbortSignal, + abortProducer: () => void, +): AsyncGenerator { + const held: Array = []; + let cancelled = signal.aborted; + let terminalDelivered = false; + let delivering: AdapterEvent | undefined; + const release = () => { + for (const event of held) if (event) releaseTranslatedEvent(event, budget); + held.length = 0; + if (delivering) releaseTranslatedEvent(delivering, budget); + }; + const cancel = () => { cancelled = true; release(); }; + signal.addEventListener("abort", cancel, { once: true }); + const cancelledTerminal = (event: AdapterEvent): AdapterEvent => { + if (event.type !== "done" && event.type !== "incomplete") return event; + return { type: "error", status: 499, message: "client closed request", retryable: false, + ...(event.usage ? { usage: event.usage } : {}) }; + }; + async function* drain() { + for (let index = 0; index < held.length && !cancelled; index++) { + delivering = held[index]; + held[index] = undefined; + try { + if (delivering) { + terminalDelivered = delivering.type === "done" || delivering.type === "error" || delivering.type === "incomplete"; + yield delivering; + } + } + finally { + if (delivering) releaseTranslatedEvent(delivering, budget); + delivering = undefined; + } + } + held.length = 0; + } + try { + for await (const event of source) { + const terminal = event.type === "done" || event.type === "error" || event.type === "incomplete"; + if (cancelled) { + // The adapter's cancellation terminal retains its measured usage. Client + // stream cancellation instead returns this iterator and stops consumption. + if (terminal) { yield cancelledTerminal(event); return; } + continue; + } + if (event.type === "heartbeat" || event.type === "thinking_delta" + || event.type === "thinking_signature" || event.type === "redacted_thinking" + || event.type === "reasoning_raw_delta" || event.type === "kiro_redacted_reasoning") { + yield event; + continue; + } + // Retention owns a snapshot, including nested usage, so later producer or + // consumer mutations cannot change the bytes measured by the budget. + const copy = structuredClone(event); + try { + retainTranslatedEvent(copy, budget, held.at(-1)); + held.push(copy); + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + release(); + // Stop only the producer: aborting the request signal here would let the + // hosted-search loop replace the typed overflow with a client-cancel error. + abortProducer(); + yield { type: "error", status: 413, errorType: "request_too_large", + code: "translation_buffer_limit", message: error.message }; + return; + } + if (terminal) { + yield* drain(); + if (cancelled && !terminalDelivered) yield cancelledTerminal(event); + return; + } + // Raw preflight already observed real output; feed the stream watchdog while + // the answer waits for its signature. Original heartbeats stay verbatim. + yield { type: "heartbeat" }; + } + if (!cancelled) yield* drain(); + } finally { + signal.removeEventListener("abort", cancel); + release(); + } +} diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 11cfa90d7fe..b038bffb191 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -50,6 +50,7 @@ import { undeclaredToolCallMessage } from "../responses-undeclared-tool-guard"; import { planWebSearch } from "../../web-search"; import { runTurnWebSearchInitialParsed, runTurnWebSearchLoop } from "../../web-search/run-turn-loop"; import { WEB_SEARCH_TOOL_NAME } from "../../web-search/synthetic-tool"; +import { orderDevinMessagesOutput } from "../../claude/devin-output-order"; // LOCAL PATCH (runturn-websearch): top-level fields route binding or the // adapter itself may write during a turn. Iteration-local `turnParsed` objects @@ -181,6 +182,13 @@ export async function executeResponsesRunTurn( const runTurnAbort = new AbortController(); const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal); + const devinProducers = new Set(); + const orderMessagesOutput = (source: AsyncIterable): AsyncIterable => + inboundWire === "anthropic" && transportState.runTurnAdapter.name === "devin" && !routedCompaction + ? orderDevinMessagesOutput(source, translatorBudget, runTurnAbort.signal, () => { + for (const producer of devinProducers) producer.abort(); + }) + : source; const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); @@ -229,6 +237,8 @@ export async function executeResponsesRunTurn( options.onCompactionRecoveryAdapterEvent?.(event); targetQueue.push(event); }; + let producerAbort = runTurnAbort; + let cleanupProducerAbort: (() => void) | undefined; try { if (!pacingSlotAcquired) { pacingSlot = await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); @@ -261,11 +271,16 @@ export async function executeResponsesRunTurn( turnScopedPacing: true, }, ); + if (inboundWire === "anthropic" && transportState.runTurnAdapter.name === "devin" && !routedCompaction) { + producerAbort = new AbortController(); + cleanupProducerAbort = linkAbortSignal(producerAbort, runTurnAbort.signal); + devinProducers.add(producerAbort); + } await transportState.runTurnAdapter.runTurn?.( turnParsed, { headers: requestState.selectedForwardHeaders, - abortSignal: runTurnAbort.signal, + abortSignal: producerAbort.signal, comboAttempt: options.comboAttempt === true, translatorBudget, providerFetch: runTurnProviderFetch, @@ -321,6 +336,8 @@ export async function executeResponsesRunTurn( message: err instanceof Error ? err.message : String(err), }); } finally { + devinProducers.delete(producerAbort); + cleanupProducerAbort?.(); releaseProviderRequestSlot(pacingSlot); // Cursor assigns a stable conversation id inside runTurn on the first headerless // turn; backfill so Logs can filter/total that opening request (#330 / #522). @@ -345,14 +362,14 @@ export async function executeResponsesRunTurn( }); void runTurnAttempt(iterQueue, undefined, false, iterParsed); const stream = iterQueue.stream(); - if (!runTurnFailoverArmed()) return stream; + if (!runTurnFailoverArmed()) return orderMessagesOutput(stream); // LOCAL PATCH (runturn-websearch): a post-search iteration can open on a // 429 too — the search cells already reached the client, so only this // answer call rotates. Preflight replays it on the next account with the // grown history intact; a mid-stream error still ends the turn as before. - return (async function* () { + return orderMessagesOutput((async function* () { yield* await preflightRunTurnFailover(stream, iterParsed); - })(); + })()); }; // Rebind the turn to an admitted account. The failed attempt emitted no client-visible bytes, // so replay is safe, but a Cursor conversation/checkpoint is credential-scoped: carrying its @@ -614,7 +631,7 @@ export async function executeResponsesRunTurn( onBacklogExceeded: () => runTurnAbort.abort(), }); void runTurnAttempt(retryQueue, "empty-completion"); - return retryQueue.stream(); + return orderMessagesOutput(retryQueue.stream()); }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = toolBridgeMaps; @@ -651,6 +668,7 @@ export async function executeResponsesRunTurn( retryAfter: resolveClientRetryAfter({ status: httpStatus, message: error.message }), }); }; + // Messages ingress forces internal streaming, including buffered client requests. if (parsed.stream) { try { void runTurn(); @@ -699,6 +717,8 @@ export async function executeResponsesRunTurn( } eventSource = preflight.stream; } + // Preflight sees raw output before Messages delays text for the late signature. + eventSource = orderMessagesOutput(eventSource); // LOCAL PATCH (runturn-websearch): intercept web_search calls across // iterations; terminal output keeps flowing through the same queue/bridge. if (wsPlan) { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 97d24db4803..4d433a3ab47 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -29,6 +29,29 @@ Native OpenAI pool routing also accepts [Orca-linked accounts](../codex-home.md#orca-source-owned-account-import), whose source resolution belongs to the shared account store. The import CLI adds pool rows independently of Desktop profiles. +## Devin Messages output ordering + +`src/claude/devin-output-order.ts` orders each physical Devin turn after raw-event preflight in +`src/server/responses/run-turn-execution.ts` when the original inbound wire is Anthropic +Messages. Provider names may be customized; the selected adapter determines applicability. +Text and tool events wait for that turn's terminal so Cognition's late reasoning signature +precedes them. Claude Code therefore receives a final text or tool block rather than an empty +signature-only thinking block. Reasoning and transport progress remain live; answer text and +tool dispatch incur turn-completion latency. Responses and Chat retain their original ordering, +and routed compaction is excluded. + +Retained events own deep snapshots, including nested usage, so producer/consumer mutations +cannot alter their measured payload. They share the request translator budget and drain on demand without a synchronous +burst into the adapter queue. They are released on terminal, cancellation, overflow, or adapter +EOF. Overflow emits one typed `translation_buffer_limit` error and aborts only active Devin +producers, preserving error classification through hosted search. Cancellation drops held +semantic output; this consumer preserves error terminals and maps cancelled success/incomplete +terminals to a 499 with their original usage, so partial output cannot commit completed replay +state. Hosted search retains its existing independent cancellation mapping. +Adapter error and incomplete terminals retain partial output and their original usage. +Ordering occurs before hosted-search interception, independently for each physical iteration, +so one iteration's signature cannot be attached to another iteration's answer. + ## Desktop modes: gateway and first-party `src/claude/desktop-first-party.ts` owns the Desktop mode contract. Two modes exist and are diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 991a3723faa..f6586964eac 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,7 +1,7 @@ # Providers And Adapters Anthropic account pause, model routes, and quota labels follow the -[Anthropic account-pool contract](providers/anthropic-account-pool.md). +[Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). An Anthropic 429 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling is skipped only after the matching account cooldown is present. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 72f3945eb10..314fbee80b0 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -56,6 +56,10 @@ lifecycle, cancellation races, protocol envelopes, and the real HTTP admission b ## Stream-buffer accounting +Devin's [Messages ordering buffer](../clients/claude-desktop.md#devin-messages-output-ordering) charges retained +semantic events consumed from the independently bounded adapter queue to the shared translator +budget until downstream delivery. Cancellation and overflow release held events before producer shutdown. + `src/web-search/run-turn-loop.ts` charges retained iteration events and generated replay history to the request translator budget. Each owner releases its own reservations on completion, error, cancellation or consumer closure; a buffer-limit failure terminates without another search. diff --git a/tests/claude-integration/claude-devin-output-order.test.ts b/tests/claude-integration/claude-devin-output-order.test.ts new file mode 100644 index 00000000000..982a840fe0c --- /dev/null +++ b/tests/claude-integration/claude-devin-output-order.test.ts @@ -0,0 +1,422 @@ +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { orderDevinMessagesOutput } from "../../src/claude/devin-output-order"; +import { collectAnthropicMessage, responsesSseToAnthropicSse } from "../../src/claude/outbound"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { mapOcxMessagesToDevin } from "../../src/adapters/devin"; +import { messagesToResponsesTranslation } from "../../src/protocols/codecs/messages"; +import { parseRequest } from "../../src/responses/parser"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { createAdapterEventQueue } from "../../src/adapters/run-turn-queue"; +import { runTurnWebSearchLoop } from "../../src/web-search/run-turn-loop"; +import { DEVIN_CLI_CREDENTIALS_ENV } from "../../src/oauth/devin/cli-import"; +import { saveCredential } from "../../src/oauth/store"; +import { createTempHome } from "../helpers/temp-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { installIsolatedCodexHome } from "../helpers/isolated-codex-home"; + +const signature = encodeDevinSignature("sealed.v1.synthetic-attestation", "sealed"); +const usage = { inputTokens: 12, outputTokens: 3, totalTokens: 15 }; +const terminal: AdapterEvent = { type: "done", usage }; +let upstreamEvents: AdapterEvent[] = []; +let afterFirstEvent: (() => Promise) | undefined; +let paceFragments = false; +const resolver = await import("../../src/server/adapter-resolve"); +const originalResolver = { ...resolver }; +mock.module("../../src/server/adapter-resolve", () => ({ ...originalResolver, + resolveAdapter(provider: OcxProviderConfig, cache?: "none" | "short" | "long") { + if (provider.adapter !== "devin") return originalResolver.resolveAdapter(provider, cache); + return { + name: "devin", + buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), + async *parseStream() { yield terminal; }, + async runTurn(_parsed, _incoming, emit) { + for (let index = 0; index < upstreamEvents.length; index++) { + emit(upstreamEvents[index]!); + if (index === 0) await afterFirstEvent?.(); + if (paceFragments && index % 16 === 0) await new Promise(resolve => setImmediate(resolve)); + } + }, + } satisfies ProviderAdapter; + }, +})); +const { handleClaudeMessages } = await import("../../src/server/claude-messages"); +const { handleResponses } = await import("../../src/server/responses"); +afterAll(() => { mock.module("../../src/server/adapter-resolve", () => originalResolver); }); + +let home: ReturnType; +let codexHome: ReturnType; +let releaseSpend: () => void; +let previousCliCredentialsPath: string | undefined; +beforeEach(() => { + home = createTempHome("ocx-claude-devin-output-"); + codexHome = installIsolatedCodexHome("ocx-claude-devin-codex-"); + releaseSpend = acquireOwnedSpendHome(); + previousCliCredentialsPath = process.env[DEVIN_CLI_CREDENTIALS_ENV]; + process.env[DEVIN_CLI_CREDENTIALS_ENV] = home.path("absent-devin-cli.toml"); + upstreamEvents = []; + afterFirstEvent = undefined; + paceFragments = false; +}); +afterEach(() => { + if (previousCliCredentialsPath === undefined) delete process.env[DEVIN_CLI_CREDENTIALS_ENV]; + else process.env[DEVIN_CLI_CREDENTIALS_ENV] = previousCliCredentialsPath; + releaseSpend(); codexHome.restore(); home.remove(); +}); + +async function ordered(events: AdapterEvent[]) { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const output: AdapterEvent[] = []; + for await (const event of orderDevinMessagesOutput((async function* () { yield* events; })(), + budget, abort.signal, () => abort.abort())) output.push(event); + return { output, budget }; +} + +async function message(events: AdapterEvent[]) { + const { output, budget } = await ordered(events); + const source = (async function* () { yield* output; })(); + const bridged = bridgeToResponsesSSE(source, "swe-2", undefined, undefined, undefined, undefined, 0, { translatorBudget: budget }); + return collectAnthropicMessage(responsesSseToAnthropicSse(bridged, "devin/swe-2", { + translatorBudget: budget, pingIntervalMs: 0, + }), "devin/swe-2", budget); +} + +describe("Devin Messages late signatures", () => { + test("late signature keeps the answer last, signed reasoning intact, and exact usage", async () => { + const result = await message([ + { type: "thinking_delta", thinking: "A thought" }, + { type: "text_delta", text: "O" }, { type: "text_delta", text: "K" }, + { type: "thinking_signature", signature }, terminal, + ]); + expect(result.content).toEqual([ + { type: "thinking", thinking: "A thought", signature }, { type: "text", text: "OK" }, + ]); + expect(result.stop_reason).toBe("end_turn"); + expect(result.usage).toMatchObject({ input_tokens: 12, output_tokens: 3 }); + }); + + test("signature-only turns preserve both the signature and final text", async () => { + const result = await message([ + { type: "text_delta", text: "OK" }, { type: "thinking_signature", signature }, terminal, + ]); + expect(result.content).toEqual([ + { type: "thinking", thinking: "", signature }, { type: "text", text: "OK" }, + ]); + }); + + test("a signed tool turn survives Messages inbound replay to the Devin prompt", async () => { + const result = await message([ + { type: "thinking_delta", thinking: "Read the file" }, + { type: "tool_call_start", id: "call_read", name: "Read" }, + { type: "tool_call_delta", arguments: '{"file_path":"/synthetic/file.txt"}' }, + { type: "tool_call_end" }, { type: "thinking_signature", signature }, terminal, + ]); + expect(result.stop_reason).toBe("tool_use"); + expect(result.content.at(-1)).toMatchObject({ type: "tool_use", id: "call_read", name: "Read", + input: { file_path: "/synthetic/file.txt" } }); + const translated = messagesToResponsesTranslation({ model: "devin/swe-2", max_tokens: 64, + messages: [{ role: "user", content: "Read" }, { role: "assistant", content: result.content }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_read", content: "marker" }] }], + }, undefined, createTestTranslatorBudget()); + const history = mapOcxMessagesToDevin(parseRequest(translated.body)); + expect(history.find(row => row.role === "assistant")).toMatchObject({ + thinking: "Read the file", signature: "sealed.v1.synthetic-attestation", signature_type: "sealed", + }); + expect(history.find(row => row.role === "tool")?.content).toBe("marker"); + }); + + test("independently signed reasoning blocks retain their associations", async () => { + const result = await message([ + { type: "thinking_delta", thinking: "First" }, { type: "thinking_signature", signature: "sig-first" }, + { type: "text_delta", text: "answer" }, + { type: "thinking_delta", thinking: "Second" }, { type: "thinking_signature", signature: "sig-second" }, terminal, + ]); + expect(result.content).toEqual([ + { type: "thinking", thinking: "First", signature: "sig-first" }, + { type: "thinking", thinking: "Second", signature: "sig-second" }, { type: "text", text: "answer" }, + ]); + }); + + for (const ending of [terminal, { type: "incomplete", reason: "max_tokens", usage }, + { type: "error", status: 502, message: "synthetic reset", usage }] as AdapterEvent[]) { + test(`${ending.type} preserves partial content and the original terminal`, async () => { + const { output, budget } = await ordered([{ type: "text_delta", text: "partial" }, ending]); + expect(output.filter(event => event.type !== "heartbeat")).toEqual([{ type: "text_delta", text: "partial" }, ending]); + expect(budget.snapshot().currentBytes).toBe(0); + }); + } + + test("holding output keeps progress live and cancellation preserves the adapter terminal and usage", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const queue = createAdapterEventQueue(); + const iterator = orderDevinMessagesOutput(queue.stream(), budget, abort.signal, () => abort.abort()); + queue.push({ type: "text_delta", text: "still generating" }); + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + queue.push({ type: "heartbeat", replayUnsafe: true }); + expect((await iterator.next()).value).toEqual({ type: "heartbeat", replayUnsafe: true }); + abort.abort(); + expect(budget.snapshot().currentBytes).toBe(0); + const cancelled: AdapterEvent = { type: "error", status: 499, message: "client closed request", usage }; + queue.push(cancelled); + queue.close(); + expect((await iterator.next()).value).toEqual(cancelled); + expect((await iterator.next()).done).toBe(true); + }); + + test("overflow emits one typed error and stops only the producer, preserving search error classification", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 160 }); + const requestAbort = new AbortController(); + const producerAbort = new AbortController(); + const output: AdapterEvent[] = []; + const source = (async function* () { + yield { type: "text_delta", text: "small" } as AdapterEvent; + yield { type: "text_delta", text: "x".repeat(200) } as AdapterEvent; + yield terminal; + })(); + for await (const event of orderDevinMessagesOutput(source, budget, requestAbort.signal, () => producerAbort.abort())) output.push(event); + expect(output.filter(event => event.type === "error")).toHaveLength(1); + expect(output.at(-1)).toMatchObject({ type: "error", status: 413, code: "translation_buffer_limit" }); + expect(producerAbort.signal.aborted).toBe(true); + expect(requestAbort.signal.aborted).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + }); + +}); + +const config = (): OcxConfig => ({ port: 0, defaultProvider: "cognition-custom", claudeCode: { enabled: true }, + providers: { "cognition-custom": { adapter: "devin", baseUrl: "https://synthetic.invalid", apiKey: "synthetic-key", models: ["swe-2"] } }, +}); + +test("retained terminal usage is isolated from producer and consumer mutations", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const originalUsage = { ...usage, rawUsage: { detail: { tokens: 3 } } }; + const ending: AdapterEvent = { type: "done", usage: originalUsage }; + const iterator = orderDevinMessagesOutput((async function* () { + yield { type: "text_delta", text: "OK" } as AdapterEvent; + yield ending; + })(), budget, abort.signal, () => abort.abort()); + await iterator.next(); // Progress while the answer is retained. + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: "OK" }); + const measuredBytes = budget.snapshot().currentBytes; + originalUsage.outputTokens = 999; + originalUsage.rawUsage.detail.tokens = 999; + const result = (await iterator.next()).value; + expect(result).toEqual({ type: "done", usage: { ...usage, rawUsage: { detail: { tokens: 3 } } } }); + if (!result || result.type !== "done" || !result.usage) throw new Error("missing terminal usage"); + result.usage.outputTokens = 1; + expect(originalUsage.outputTokens).toBe(999); + expect(budget.snapshot().currentBytes).toBeLessThan(measuredBytes); + expect((await iterator.next()).done).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); +}); + +test("a stalled Devin turn sends repeated Messages pings before releasing the held answer", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const queue = createAdapterEventQueue(); + const events = orderDevinMessagesOutput(queue.stream(), budget, abort.signal, () => abort.abort()); + const bridge = bridgeToResponsesSSE(events, "swe-2", undefined, undefined, undefined, undefined, 2000, { translatorBudget: budget }); + const reader = responsesSseToAnthropicSse(bridge, "devin/swe-2", { translatorBudget: budget, pingIntervalMs: 10 }).getReader(); + const chunks: string[] = []; + const decoder = new TextDecoder(); + let timer: ReturnType | undefined; + queue.push({ type: "text_delta", text: "OK" }); + try { + await Promise.race([(async () => { + // Include a periodic ping after the progress ping from held text. + while ((chunks.join("").match(/event: ping/g) ?? []).length < 3) { + const next = await reader.read(); + if (next.done) throw new Error("stream ended before the signature"); + chunks.push(decoder.decode(next.value, { stream: true })); + } + })(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("stalled Messages stream sent no pings")), 1000); })]); + expect(chunks.join("")).not.toContain("text_delta"); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + queue.push({ type: "thinking_signature", signature }); + queue.push(terminal); + queue.close(); + for (;;) { + const next = await reader.read(); + if (next.done) break; + chunks.push(decoder.decode(next.value, { stream: true })); + } + const wire = chunks.join(""); + expect(wire).toContain('"text":"OK"'); + expect(wire.indexOf("signature_delta")).toBeLessThan(wire.indexOf("text_delta")); + expect(wire).toContain("event: message_stop"); + } finally { clearTimeout(timer); queue.close(); await reader.cancel(); budget.dispose(); } +}); + +for (const stream of [true, false]) { + test(`actual Messages ingress orders a renamed Devin provider, stream=${stream}`, async () => { + upstreamEvents = [{ type: "text_delta", text: "OK" }, { type: "thinking_signature", signature }, terminal]; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cognition-custom/swe-2", max_tokens: 64, stream, + messages: [{ role: "user", content: "Reply OK" }] }), + }), config(), {}); + expect(response.status).toBe(200); + const result = stream ? await collectAnthropicMessage(response.body!, "cognition-custom/swe-2", createTestTranslatorBudget()) + : await response.json(); + expect(result.content.at(-1)).toEqual({ type: "text", text: "OK" }); + expect(result.content[0]).toEqual({ type: "thinking", thinking: "", signature }); + }); +} + +test("the Responses ingress retains incremental text before the late signature", async () => { + upstreamEvents = [{ type: "text_delta", text: "OK" }, { type: "thinking_signature", signature }, terminal]; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cognition-custom/swe-2", input: "Reply OK", stream: true }), + }), config(), {}); + const text = await response.text(); + expect(text.indexOf("response.output_text.delta")).toBeLessThan(text.indexOf("\"type\":\"reasoning\"")); +}); + + +test("a tool call with 3,000 streamed argument fragments drains without overflowing the real queue", async () => { + const argumentsText = JSON.stringify({ file_path: "/synthetic/file.txt", content: "x".repeat(3000) }); + paceFragments = true; + upstreamEvents = [{ type: "tool_call_start", id: "call_write", name: "Write" }, + ...[...argumentsText].map(fragment => ({ type: "tool_call_delta", arguments: fragment }) as AdapterEvent), + { type: "tool_call_end" }, { type: "thinking_signature", signature }, terminal]; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cognition-custom/swe-2", max_tokens: 4096, stream: false, + messages: [{ role: "user", content: "Write" }], + tools: [{ name: "Write", input_schema: { type: "object", properties: { content: { type: "string" } } } }], + }), + }), config(), {}); + const result = await response.json(); + expect(result.stop_reason).toBe("tool_use"); + expect(result.content.at(-1)).toMatchObject({ type: "tool_use", input: JSON.parse(argumentsText) }); + expect(result.usage).toMatchObject({ input_tokens: 12, output_tokens: 3 }); +}); + +test("OAuth preflight releases Messages headers at the first text, before a late signature", async () => { + await saveCredential("devin", { access: "devin-session-token$synthetic", refresh: "synthetic", + expires: Number.MAX_SAFE_INTEGER, accountId: "synthetic-account", source: "oauth", + apiBaseUrl: "https://server.codeium.com" }); + let finish!: () => void; + afterFirstEvent = () => new Promise(resolve => { finish = resolve; }); + upstreamEvents = [{ type: "text_delta", text: "OK" }, { type: "thinking_signature", signature }, terminal]; + const oauthConfig: OcxConfig = { port: 0, defaultProvider: "devin", claudeCode: { enabled: true }, + providers: { devin: { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com", models: ["swe-2"] } } }; + const responsePromise = handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "devin/swe-2", max_tokens: 64, stream: true, + messages: [{ role: "user", content: "Reply OK" }] }), + }), oauthConfig, {}); + let headerTimer: ReturnType | undefined; + try { + const response = await Promise.race([responsePromise, + new Promise((_, reject) => { headerTimer = setTimeout(() => reject(new Error("headers waited for terminal")), 1000); })]); + expect(response.status).toBe(200); + expect(finish).toBeDefined(); + finish(); + const result = await collectAnthropicMessage(response.body!, "devin/swe-2", createTestTranslatorBudget()); + expect(result.content.at(-1)).toEqual({ type: "text", text: "OK" }); + } finally { clearTimeout(headerTimer); finish?.(); } +}); + + +test("hosted search forwards the typed overflow instead of inventing a client cancellation", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 160 }); + const requestAbort = new AbortController(); + const producerAbort = new AbortController(); + const source = orderDevinMessagesOutput((async function* () { + yield { type: "text_delta", text: "x".repeat(200) } as AdapterEvent; + })(), budget, requestAbort.signal, () => producerAbort.abort()); + const loop = runTurnWebSearchLoop(source, { + parsed: { modelId: "swe-2", stream: true, options: {}, context: { messages: [], tools: [] } }, + plan: { backend: "exa", hostedTool: { type: "web_search" }, maxSearches: 1, + settings: { model: "fixture", reasoning: "low", timeoutMs: 100 }, + routedModelStallTimeoutMs: 100, stallTimeoutSec: 1, streamRoutedModelOutput: true }, + abortSignal: requestAbort.signal, translatorBudget: budget, + dispatch: async function* () { throw new Error("no search dispatch expected"); }, + }); + const output: AdapterEvent[] = []; + for await (const event of loop) output.push(event); + expect(output).toEqual([expect.objectContaining({ type: "error", status: 413, code: "translation_buffer_limit" })]); + expect(producerAbort.signal.aborted).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); +}); + +test("cancelling during the terminal drain preserves usage and clears retained semantic events", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const iterator = orderDevinMessagesOutput((async function* () { + yield { type: "text_delta", text: "partial" } as AdapterEvent; + yield terminal; + })(), budget, abort.signal, () => abort.abort()); + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: "partial" }); + abort.abort(); + expect(budget.snapshot().currentBytes).toBe(0); + expect((await iterator.next()).value).toMatchObject({ type: "error", status: 499, usage }); + expect((await iterator.next()).done).toBe(true); +}); + +test("a consumer return releases the ordering buffer without a terminal", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const iterator = orderDevinMessagesOutput((async function* () { + yield { type: "text_delta", text: "held" } as AdapterEvent; + yield terminal; + })(), budget, abort.signal, () => abort.abort()); + await iterator.next(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + await iterator.return(); + expect(budget.snapshot().currentBytes).toBe(0); +}); + + +for (const queuedTerminal of [terminal, { type: "incomplete", reason: "max_tokens", usage }] as AdapterEvent[]) { + test(`cancel before consuming a queued ${queuedTerminal.type} keeps usage but forbids a successful terminal`, async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const queue = createAdapterEventQueue(); + const iterator = orderDevinMessagesOutput(queue.stream(), budget, abort.signal, () => abort.abort()); + queue.push({ type: "text_delta", text: "held" }); + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + queue.push(queuedTerminal); + queue.close(); + abort.abort(); + expect((await iterator.next()).value).toMatchObject({ type: "error", status: 499, retryable: false, usage }); + expect((await iterator.next()).done).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); + }); +} + +test("cancelled partial drain never commits a completed response or replay state", async () => { + const budget = createTestTranslatorBudget(); + const abort = new AbortController(); + const orderedEvents = orderDevinMessagesOutput((async function* () { + yield { type: "text_delta", text: "partial" } as AdapterEvent; + yield { type: "tool_call_start", id: "call_partial", name: "Write" } as AdapterEvent; + yield { type: "tool_call_delta", arguments: '{"content":' } as AdapterEvent; + yield terminal; + })(), budget, abort.signal, () => abort.abort()); + const cancelDuringDrain = (async function* () { + for await (const event of orderedEvents) { + yield event; + if (event.type === "text_delta") abort.abort(); + } + })(); + let completions = 0; + const observedUsage: unknown[] = []; + const bridge = bridgeToResponsesSSE(cancelDuringDrain, "swe-2", undefined, undefined, undefined, undefined, 0, { + translatorBudget: budget, onCompletedResponse: () => { completions++; }, onUsage: value => observedUsage.push(value), + }); + const bytes = await new Response(bridge).text(); + expect(bytes).not.toContain("response.completed"); + expect(completions).toBe(0); + expect(observedUsage).toContainEqual(usage); + expect(bytes).toContain("client closed request"); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3bf189236ef..83170e64b6b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "claude-devin-output-order.test.ts": "claude-integration", "codex-config-preservation.test.ts": "codex-integration", "codex-credits.test.ts": "codex-integration", "codex-credits-settings.test.ts": "codex-integration", From 8ad261e00074ffada82e1f4179a18263941a0f16 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 11:23:05 +0900 Subject: [PATCH 17/21] fix(gui): bridge quota popover hover gap and link account management (#6345) Carries #6278 with maintainer review fixes: the chip description points at the text-only quota table, and the hover bridge spans both the chip and popover widths so wide chips keep the popover open. Co-authored-by: colthreepv <2657230+colthreepv@users.noreply.github.com> --- .../src/content/docs/guides/web-dashboard.md | 7 +- gui/README.md | 12 ++ gui/package.json | 3 +- .../quota-summary-bar/QuotaSummaryBar.tsx | 48 +++-- .../quota-summary-bar/quota-summary-bar.css | 22 +++ gui/tests/quota-summary-bar.test.tsx | 127 +++++++++++- gui/tests/quota-summary-hover-browser.ts | 185 ++++++++++++++++++ structure/dashboard-and-usage.md | 4 +- 8 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 gui/tests/quota-summary-hover-browser.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 3645ae7c1d3..7af6c4dc4c9 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -87,9 +87,12 @@ is visible) and never forces an upstream refresh. provider-named window or prepaid credits. - A chip turns amber at 70% used and red at 90% used. - Hover or keyboard-focus a chip to see every reported window with its reset time and the time the - reading was taken. On a touch screen, the first tap shows those details. + reading was taken. The details stay open as the pointer moves from the chip into the popover, + including across the small visual gap. On a touch screen, the first tap shows those details. - Click a chip (or tap it a second time) to open that provider's Accounts tab in Providers, where - its accounts or API keys are managed. + its accounts or API keys are managed. The popover's **Open account management** link opens the + same tab; keyboard users can Tab from the chip to this link and press Enter. Escape closes the + popover and returns focus to the chip when focus was inside the popover. - The bar always stays on one line. When the chips do not fit, scroll it sideways or use the « and » buttons at either end. - Providers that report no quota window are left out. The bar is hidden when no provider reports one. diff --git a/gui/README.md b/gui/README.md index e55b038fb64..6029356e5e5 100644 --- a/gui/README.md +++ b/gui/README.md @@ -53,6 +53,18 @@ bun run setup:hooks # pre-push runs doctor when gui/ changed Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage. +## Quota popover hover browser regression + +```bash +cd gui +bun run build +bun run test:quota-hover +``` + +This opt-in check is not part of CI. It moves a real pointer from a quota chip into its popover in an +installed Chrome/Chromium (`CHROME_BIN` when not on PATH), which happy-dom unit tests cannot hit-test. +Rebuild after changing CSS: it uses the production CSS in `dist`. + ## Sidebar version browser regression ```bash diff --git a/gui/package.json b/gui/package.json index cd9d7fcf917..d7000ade3d2 100644 --- a/gui/package.json +++ b/gui/package.json @@ -12,7 +12,8 @@ "doctor": "npx --yes react-doctor@0.9.11 --verbose --scope changed --base origin/main --no-telemetry", "doctor:full": "npx --yes react-doctor@0.9.11 --verbose --scope full --no-telemetry", "preview": "vite preview", - "test:sidebar-version": "bun tests/sidebar-version-browser.ts" + "test:sidebar-version": "bun tests/sidebar-version-browser.ts", + "test:quota-hover": "bun tests/quota-summary-hover-browser.ts" }, "dependencies": { "@tanstack/react-virtual": "^3.14.9", diff --git a/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx b/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx index 957694475f6..5be2df49c64 100644 --- a/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx +++ b/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx @@ -28,7 +28,7 @@ interface QuotaSummaryData { const POLL_MS = 60_000; /** Gap between the chip and its fixed-position popover, and the popover's viewport margin. */ -const POPOVER_GAP = 4; +const POPOVER_GAP = 7; const VIEWPORT_MARGIN = 8; /** A « / » press pages the strip by this share of its visible width. */ const PAGE_FRACTION = 0.8; @@ -83,6 +83,12 @@ function usePopoverPlacement(open: boolean, anchorRef: RefObject { - if (event.key === "Escape") close(); + if (event.key === "Escape") { + const returnFocus = popoverRef.current?.contains(document.activeElement); + close(); + if (returnFocus) { + chipRef.current?.focus(); + setSuppressed(true); + } + } }; document.addEventListener("pointerdown", onPointer); document.addEventListener("keydown", onKey); @@ -169,19 +182,23 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo className={`quota-summary-item quota-summary-item--${row.severity}`} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} + onFocus={event => { + if (!isKeyboardFocus(event.target)) return; + setSuppressed(false); + setFocused(true); + }} + onBlur={event => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); + }} > { pointerTypeRef.current = event.pointerType; }} - onFocus={event => { - if (!isKeyboardFocus(event.currentTarget)) return; - setSuppressed(false); - setFocused(true); - }} - onBlur={() => setFocused(false)} onClick={onClick} > {row.label} @@ -191,12 +208,12 @@ function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; lo {t("quotaSummary.openAccounts")} {open && ( -