From f67788e90cdf5aaa59fbf9057962ef9886b9f57a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 19:00:40 +0900 Subject: [PATCH 01/37] fix(responses): fail closed on replayed agent_message ciphertext (#4454) A routed Responses destination could receive a private `agent_message` item together with ChatGPT-backend ciphertext. Two checks bounded that item and neither covered the gap between them: `hasUnreadableEncryptedAgentTask` asks whether the current worker task is readable and inspects only the tail item, while `normalizeRoutedAgentMessages` asks whether every content part can be lowered onto a public message and forwards the item verbatim when one cannot. An item mixing `input_text` with `encrypted_content` answers "readable" to the first and "not lowerable" to the second, so it passed the guard and reached the provider as ciphertext plus an item type only the Codex backend declares. xAI answered `422 unknown item type "agent_message"` after the bytes were sent. `agentMessageCiphertextIndex` asks the egress question over the whole expanded input, and the request path asks it against the final route, after recovery has had its chance to replace the ciphertext with plaintext. A hit returns HTTP 400 `unforwardable_encrypted_agent_message` with the item index and nothing else from the item. The gate resolves the same wire override the adapter is built from, so it fires only for the raw Responses passthrough on a non-forward destination; translated wires, forward destinations and routes explicitly trusted with `allowEncryptedV2AgentTasks` are unchanged, as is the tail NEW_TASK envelope and its opt-in recovery. Reported by @321sssrt-bit. --- .../content/docs/reference/proxy-formats.md | 9 + src/server/responses.ts | 2 +- src/server/responses/core.ts | 52 ++++- src/server/responses/encrypted-payload.ts | 48 +++++ structure/subagents.md | 36 ++++ structure/transports/responses.md | 12 +- .../server/v2-agent-message-failfast.test.ts | 196 ++++++++++++++++++ 7 files changed, 347 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 6fd9561221..ff02f8ec7b 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -670,6 +670,7 @@ Errors use the client dialect's envelope where needed, but these status/code mea | 403 | `origin_rejected` | A Responses/OpenAI data-plane request or WebSocket upgrade came from a disallowed origin | | 503 | `combo_unavailable` | Every target in the selected combo is unavailable, in cooldown, disabled, or otherwise ineligible | | 400 | `unreadable_encrypted_agent_task` | An encrypted v2 worker task has no eligible canonical ChatGPT target or direct key-auth Responses target explicitly trusted with `allowEncryptedV2AgentTasks: true` that can consume it | +| 400 | `unforwardable_encrypted_agent_message` | A replayed `agent_message` still carries ChatGPT-backend ciphertext and the selected routed Responses destination cannot read it. `item_index` names the position in `input` | | 426 | `upgrade_required` | The Responses WebSocket transport is disabled or the upgrade failed; use HTTP | Anthropic-origin failures are rendered in Anthropic's error envelope, so the origin rejection is a @@ -688,3 +689,11 @@ that repair, it becomes a normal user message. If a current v2 task remains genu but the selected routed target cannot read native ChatGPT ciphertext, opencodex fails with `unreadable_encrypted_agent_task` instead of sending unreadable bytes to that provider. See [Sub-agent Surface](/guides/sub-agent-surface/) for the client behavior around worker tasks. + +The same rule covers history, not just the current task. A replayed `agent_message` that mixes +readable text with backend ciphertext cannot be lowered to a public message, so a routed Responses +destination would otherwise receive the ciphertext together with an item type only the ChatGPT +backend declares. opencodex fails those requests with `unforwardable_encrypted_agent_message` +before dispatch and reports only the item's position, never its contents. Native forward +destinations, explicitly trusted `allowEncryptedV2AgentTasks` routes, and translated Chat or +Anthropic wires are unaffected. diff --git a/src/server/responses.ts b/src/server/responses.ts index 2a446619a2..6aa1ae5271 100644 --- a/src/server/responses.ts +++ b/src/server/responses.ts @@ -5,7 +5,7 @@ import { requestPacingOverloadResponse } from "./responses/pacing-overload"; export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration"; export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration"; -export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload"; +export { agentMessageCiphertextIndex, hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload"; export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact"; export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers"; export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core"; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 87124ab57f..0ccae24b38 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -394,7 +394,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; -import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload"; +import { agentMessageCiphertextIndex, hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel, storedPoolReplayDispatchNotifier, type ProviderFetchOptions } from "./fetch-helpers"; import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability"; import { @@ -2087,6 +2087,9 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers { const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE = "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model."; +const UNFORWARDABLE_ENCRYPTED_AGENT_MESSAGE_MESSAGE = + "A replayed agent message still carries ChatGPT-backend ciphertext that the selected provider cannot read. opencodex will not forward the private item or its ciphertext. Continue this thread on a native ChatGPT model, or start one whose sub-agent results are delivered as plaintext."; + // Whole-body policy for non-streaming upstream JSON responses (see the application/json // branch of the passthrough return path). 32 MiB matches the continuation snapshot read // bound and is far above any legitimate non-streaming completion, including base64 image @@ -2135,6 +2138,27 @@ function unreadableEncryptedAgentTaskResponse(reason?: AgentTaskRecoveryFailureR ); } +/** + * Deliberately a separate code from `unreadable_encrypted_agent_task`. That one says the + * current worker task cannot be delivered; this one says a message already in the history + * cannot leave the process. An operator reading a log needs to tell the two apart, because + * the second survives every retry of the same thread. `item_index` is positional metadata + * only -- no ciphertext, no plaintext, nothing from the item itself. + */ +function unforwardableEncryptedAgentMessageResponse(itemIndex: number): Response { + return new Response( + JSON.stringify({ + error: { + message: UNFORWARDABLE_ENCRYPTED_AGENT_MESSAGE_MESSAGE, + type: "invalid_request_error", + code: "unforwardable_encrypted_agent_message", + item_index: itemIndex, + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); +} + /** * Keep this trust boundary deliberately narrow: only a key-auth Responses route may consume * opaque child-task ciphertext, and the model's final wire override must still be Responses. @@ -4024,6 +4048,32 @@ async function handleResponsesInner( return unreadableEncryptedAgentTaskResponse(recoveryFailureReason); } + // The guard above asks whether the CURRENT worker task is readable, and it only inspects the + // tail item. An `agent_message` that mixes readable text with backend ciphertext answers + // "readable" to that question at every position, so it passed -- and then + // `normalizeRoutedAgentMessages` refused to lower it, because lowering requires every part to + // be representable. The raw Responses passthrough serialized the private item as it stood and + // xAI answered `422 unknown item type "agent_message"`, with the ciphertext already on the + // wire (#4454). Ask the egress question here instead: after recovery has had its chance to + // turn that ciphertext into plaintext, and against the FINAL route. + if (inboundWire === "responses" && !finalRouteCanPassThroughEncryptedTask) { + const wireProvider = resolveWireProtocolOverride( + route.providerName, + route.modelId, + route.provider, + inboundWire, + ); + // Mirror the adapter's own condition exactly. Only the raw Responses passthrough puts input + // items on the wire verbatim, and only when the destination is not `forward` -- a forward + // destination is Codex-backend-shaped and owns the private item by design. Translated wires + // rebuild the body from parsed messages, where `inputContentParts` drops an encrypted part + // instead of forwarding it. + if (wireProvider.adapter === "openai-responses" && (wireProvider.authMode ?? "key") !== "forward") { + const ciphertextIndex = agentMessageCiphertextIndex((body as { input?: unknown } | undefined)?.input); + if (ciphertextIndex >= 0) return unforwardableEncryptedAgentMessageResponse(ciphertextIndex); + } + } + // The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no // safe way to recover the omitted history. Fail before auth, adapter construction, or upstream // I/O instead of stripping the id and silently forwarding a context-free delta (#702). diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 82d4b0514e..6fbaefedbb 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -320,6 +320,54 @@ export function hasEncryptedContentPart(content: unknown): boolean { +/** + * Index of the first `agent_message` still carrying ChatGPT-backend ciphertext, or -1. + * + * `hasUnreadableEncryptedAgentTask` above answers a different question: can the CURRENT + * worker task be read at all? It inspects only the tail item and reports false the moment any + * plaintext survives the envelope. `normalizeRoutedAgentMessages` asks the opposite question -- + * is EVERY part lowerable? -- and forwards the private item verbatim when one is not. A mixed + * `input_text` + `encrypted_content` item answers "readable" to the first and "not lowerable" + * to the second, so it fell between them: the guard never fired, the adapter refused to lower + * it, and the raw Responses passthrough put a private item and backend ciphertext on the wire + * (#4454). Position was never the discriminator -- a replayed child result lands mid-history + * and the tail-only scan cannot see it -- but the tail is equally exposed when it is mixed. + * + * This is the egress question, so it is deliberately not the readability question: every + * surviving `encrypted_content` part counts, and so does a Fernet run embedded in text or in + * string content, which the adapter would otherwise lower verbatim. Plaintext parked in an + * encrypted slot is already rewritten by `sanitizeEncryptedContentInPlace` before this runs, + * so a part that is still `encrypted_content` here is backend ciphertext or a protected + * multipart fragment of one. + */ +export function agentMessageCiphertextIndex(input: unknown): number { + if (!Array.isArray(input)) return -1; + for (let index = 0; index < input.length; index += 1) { + const item = input[index]; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + if ((item as { type?: unknown }).type !== "agent_message") continue; + const content = (item as { content?: unknown }).content; + if (typeof content === "string") { + if (fernetTokenRuns(content).length > 0) return index; + continue; + } + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown }; + if (record.type === "encrypted_content" && typeof record.encrypted_content === "string") return index; + if ( + (record.type === "input_text" || record.type === "text") + && typeof record.text === "string" + && fernetTokenRuns(record.text).length > 0 + ) return index; + } + } + return -1; +} + + + export function sanitizeEncryptedContentInPlace(input: unknown): number { if (!Array.isArray(input)) return 0; let rewritten = 0; diff --git a/structure/subagents.md b/structure/subagents.md index 98c827ffa7..fd6ded6159 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -148,6 +148,42 @@ unreadable split-token shapes. The sanitizer preserves just those fragment objec normalizing independent plaintext slots. Detection never authorizes reconstruction or recovery; other fragment layouts and mixed readable content retain their documented residual boundaries. +## Routed agent-message ciphertext egress + +Two questions about an `agent_message` were asked in two places, and the gap between them was +open. `hasUnreadableEncryptedAgentTask` asks whether the CURRENT worker task can be read and +inspects only the tail item; `normalizeRoutedAgentMessages` asks whether EVERY part can be lowered +onto a public message and forwards the private item verbatim when one cannot. An item mixing +`input_text` with `encrypted_content` is readable by the first measure and unlowerable by the +second, so it passed the guard, kept its private type through the raw Responses passthrough, and +left the process as backend ciphertext plus an item type only the Codex backend declares. The +destination answered `422 unknown item type "agent_message"` after the bytes were already sent. +Position was incidental: a replayed child result sits mid-history, where a tail-only scan cannot +see it, and the tail is exposed the same way once it is mixed. + +`agentMessageCiphertextIndex` in `src/server/responses/encrypted-payload.ts` asks the egress +question instead: does any `agent_message` anywhere in the expanded input still carry ciphertext, +in an `encrypted_content` part, in the text of an `input_text` part, or as string content the +adapter would lower verbatim? `src/server/responses/core.ts` asks it against the final route, +after `expandPreviousResponseInput`, after the sanitizer has rewritten plaintext parked in +encrypted slots, and after recovery has had its chance to replace ciphertext with plaintext. A hit +returns HTTP 400 `unforwardable_encrypted_agent_message` carrying the item index and nothing else +from the item. + +The gate resolves the same wire override the adapter is built from rather than restating routing +policy, and fires only for `openai-responses` with a non-`forward` auth mode. That distinction +decides the reported destination, where the provider-wide adapter is the Chat wire and a registry +model default moves the model onto Responses. Translated wires are untouched because +`inputContentParts` drops an encrypted part instead of forwarding it; forward destinations own the +private item; `canPassThroughEncryptedV2AgentTask` keeps an explicitly trusted route exempt. + +This is an egress boundary, not a widening of recovery. Nothing here decrypts, the tail NEW_TASK +envelope keeps `unreadable_encrypted_agent_task` and its opt-in recovery unchanged, and the two +codes stay separate because an operator has to tell an undeliverable task from a history that +cannot leave the process. An `agent_message` carrying unknown parts but no ciphertext still +reaches the wire unchanged and still draws the destination's own 422: a compatibility gap rather +than an egress one. Covered by `tests/server/v2-agent-message-failfast.test.ts`. + ## Subagents New non-OAuth provider registrations carry `initialModelSelection` with a unique diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c00d08f18c..2a43d02a3a 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -288,12 +288,12 @@ call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-p on the standard port. A nonblank string becomes one `input_text` part with the original text; the same author/recipient attribution is retained and the private transport item id is removed. -This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, -malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed -path. Forward destinations never enable the option. The parser and encrypted-task recovery -owners are unchanged, and no broad content-schema validation or adapter-wide string conversion -is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over -SSE and JSON while preserving actual tool-call/result pairs. +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, malformed, +ciphertext-only and mixed unknown/encrypted content stays unlowered here and fails closed at the [egress +gate](../subagents.md#routed-agent-message-ciphertext-egress). Forward destinations never enable the +option. The parser and encrypted-task recovery owners are unchanged, and no broad content-schema +validation or adapter-wide string conversion is introduced. Mocked server fixtures cover parent, child, +and parent-result continuation over SSE and JSON while preserving actual tool-call/result pairs. OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and diff --git a/tests/server/v2-agent-message-failfast.test.ts b/tests/server/v2-agent-message-failfast.test.ts index 0d2b77dcdd..f373cb1ddb 100644 --- a/tests/server/v2-agent-message-failfast.test.ts +++ b/tests/server/v2-agent-message-failfast.test.ts @@ -506,3 +506,199 @@ describe("V2 routed agent-message ciphertext guard", () => { expect(forwardedBody).toContain(FERNET_TASK); }); }); + +/** + * #4454. The guard above asks whether the CURRENT worker task is readable, and reads only the + * tail item. The adapter asks whether EVERY part can be lowered onto a public message. An item + * that mixes readable text with ciphertext answers "readable" to the first and "not lowerable" + * to the second, so it passed the guard, kept its private `agent_message` type through the raw + * Responses passthrough, and reached xAI as `422 unknown item type "agent_message"` -- with the + * ciphertext already sent. Position is incidental: a replayed child result simply tends to sit + * mid-history, where the tail-only scan could never have seen it. + */ +describe("routed Responses agent-message ciphertext egress", () => { + function routedResponsesConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "relay", + providers: { + relay: { + adapter: "openai-responses", + baseUrl: "https://relay.example/v1", + authMode: "key", + apiKey: "test-relay-key", + }, + }, + } as OcxConfig; + } + + function mixedChildResult(): Record { + return { + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [ + { type: "input_text", text: "the child finished the migration" }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ], + }; + } + + const userTurn = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + + function refuseDispatch(): () => number { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + throw new Error("provider dispatch must not happen"); + }) as typeof fetch; + return () => calls; + } + + test("blocks a mixed child result replayed behind a later user turn", async () => { + const dispatches = refuseDispatch(); + + const response = await post(routedResponsesConfig(), "relay/child-model", [mixedChildResult(), userTurn]); + const raw = await response.text(); + + expect(response.status).toBe(400); + expect(JSON.parse(raw)).toMatchObject({ + error: { + type: "invalid_request_error", + code: "unforwardable_encrypted_agent_message", + item_index: 0, + }, + }); + expect(dispatches()).toBe(0); + expect(raw).not.toContain(FERNET_TASK); + expect(raw).not.toContain("gAAAA"); + }); + + test("blocks the same shape at the tail, where the readability guard reports readable", async () => { + const input = [mixedChildResult()]; + // The gap itself: this is the guard that was supposed to be the fail-closed boundary. + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + const dispatches = refuseDispatch(); + + const response = await post(routedResponsesConfig(), "relay/child-model", input); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "unforwardable_encrypted_agent_message", item_index: 0 }, + }); + expect(dispatches()).toBe(0); + }); + + test("blocks ciphertext that arrives as text rather than in an encrypted slot", async () => { + // #3021 saw a delegated reply reach the parent as raw `gAAAA...` text. The adapter lowers a + // readable-looking item like this one onto a public message, which would put the ciphertext + // on the wire as prose. The readability guard is unchanged and still reports it readable. + const input = [{ type: "agent_message", author: "/root/child", recipient: "/root", content: FERNET_TASK }]; + expect(hasUnreadableEncryptedAgentTask(input)).toBe(false); + const dispatches = refuseDispatch(); + + const response = await post(routedResponsesConfig(), "relay/child-model", input); + const raw = await response.text(); + + expect(response.status).toBe(400); + expect(JSON.parse(raw)).toMatchObject({ error: { code: "unforwardable_encrypted_agent_message" } }); + expect(dispatches()).toBe(0); + expect(raw).not.toContain(FERNET_TASK); + }); + + test("blocks the reported xAI destination, whose Responses wire comes from a model default", async () => { + // #4454 as filed: the provider-wide adapter is the Chat wire, and the registry moves + // grok-4.6 onto the raw Responses passthrough for an OAuth caller speaking Responses. + // Reading `route.provider.adapter` would miss exactly this case, so the gate resolves the + // same wire override the adapter is built from. + const config = { + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }, + }, + } as OcxConfig; + const dispatches = refuseDispatch(); + + const response = await post(config, "xai/grok-4.6", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "unforwardable_encrypted_agent_message", item_index: 0 }, + }); + expect(dispatches()).toBe(0); + }); + + test("still lowers a fully readable child result onto a public message", async () => { + let forwardedBody = ""; + globalThis.fetch = (async (_input, init) => { + forwardedBody = typeof init?.body === "string" ? init.body : ""; + return Response.json({ + id: "resp_routed", + object: "response", + status: "completed", + model: "grok-4.6", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await post(routedResponsesConfig(), "relay/child-model", [{ + type: "agent_message", + author: "/root/child", + recipient: "/root", + content: [{ type: "input_text", text: "the child finished the migration" }], + }, userTurn]); + + expect(response.status).toBe(200); + expect(forwardedBody).toContain("the child finished the migration"); + expect(forwardedBody).not.toContain("agent_message"); + }); + + test("leaves a translated Chat destination on its existing path", async () => { + // The private item never reaches that wire: the parser rebuilds the body from messages and + // drops an encrypted part outright. Failing this request closed would break a thread that + // works today, so the gate is scoped to the raw Responses passthrough. + let forwardedBody = ""; + globalThis.fetch = (async (_input, init) => { + forwardedBody = typeof init?.body === "string" ? init.body : ""; + return Response.json({ + id: "chatcmpl_routed", + object: "chat.completion", + model: "grok-4.5", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await post(routedConfig(), "xai/grok-4.5", [mixedChildResult(), userTurn]); + + expect(response.status).toBe(200); + expect(forwardedBody).toContain("the child finished the migration"); + expect(forwardedBody).not.toContain(FERNET_TASK); + expect(forwardedBody).not.toContain("agent_message"); + }); + + test("leaves a forward destination's private-item ownership untouched", async () => { + let forwardedBody = ""; + globalThis.fetch = (async (_input, init) => { + forwardedBody = typeof init?.body === "string" ? init.body : ""; + return Response.json({ + id: "resp_native_mixed", + object: "response", + status: "completed", + model: "gpt-5.5", + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + const response = await post(nativeConfig(), "gpt-5.5", [mixedChildResult(), userTurn], { + authorization: "Bearer caller-codex-token", + }); + + expect(response.status).toBe(200); + expect(forwardedBody).toContain(FERNET_TASK); + expect(forwardedBody).toContain("agent_message"); + }); +}); From 0b89a77b5ad9403fe7f6b58ac352f86774a5520b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 19:06:33 +0900 Subject: [PATCH 02/37] fix(catalog): bound custom native-id effort lists on gateways [skip ci] Intersect explicit custom reasoning lists with pinned native metadata whenever the model id itself is capability-backed, including YYLJ/gpt-6-astra. Desktop validates the model id, so none/minimal must not remain on those catalog rows. Full native identity still requires the canonical openai Codex-forward destination. Stored configuration and request-time clamps are unchanged. Refs #3775. Original report by @leonclab. #3804 already bounded the canonical forward case; this is the remaining catalog projection. --- .../content/docs/guides/codex-app-models.md | 12 +++-- .../docs/reference/configuration/providers.md | 11 ++-- src/codex/catalog/provider-fetch.ts | 18 +++++-- src/codex/catalog/sync.ts | 15 ++++-- structure/catalog.md | 21 ++++---- .../claude-models-discovery.test.ts | 6 +-- tests/codex-integration/codex-catalog.test.ts | 51 ++++++++++++++----- 7 files changed, 91 insertions(+), 43 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 8219dd2281..209469f41d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -73,13 +73,15 @@ the resulting list; otherwise the native default is used when present, then the choice. Stored custom configuration is unchanged, and repeated syncs do not add `max` back to a narrow custom list. -This requires the exact provider, destination, and capability-backed model identity. An arbitrary -gateway such as `YYLJ/gpt-6-astra` does not inherit native capabilities from its name. Its explicit -custom ladder continues to override discovered provider metadata under the normal routed rules. +The same catalog bound applies when the custom model id has pinned native capability metadata, +including an arbitrary gateway such as `YYLJ/gpt-6-astra`. Desktop validates the model id, so +`none` and `minimal` are stripped from that catalog row. Full native identity still requires the +exact provider, destination, and capability-backed model identity; a gateway does not inherit +Responses Lite, multi-agent, or native windows from its name. Codex's native Astra `ultra` choice is retained: it is a client delegation mode converted to a supported wire effort, distinct from the [API model's effort list](https://developers.openai.com/api/docs/models/gpt-6-astra). -Catalog normalization does not rewrite existing thread settings or establish support for a -particular installed Desktop version. +Catalog normalization does not rewrite existing thread settings. Request-time native effort +clamps remain canonical-forward only. When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If `codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a80eab897f..752c13b395 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -231,11 +231,12 @@ provider request is sent. Changing away and back also ends that continuation. St use the new selection. Selection changes before the first provider send retain normal reselection. Custom-model `reasoningEfforts` normally override discovered provider metadata. The bounded -exception is an explicit Astra or Daybreak custom row on the canonical `openai` Codex-forward -destination: its advertised list is intersected with that model's pinned native capabilities. -An explicit empty list remains empty with no default; a nonempty incompatible list falls back -to the native default as a single choice. Defaults must belong to the final list. This changes -the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name. +exception is an explicit custom row whose model id has pinned native Codex capabilities, +including Astra or Daybreak on an arbitrary gateway: its advertised list is intersected with +that model's pinned native capabilities. Full native identity still requires the canonical +`openai` Codex-forward destination. An explicit empty list remains empty with no default; a +nonempty incompatible list falls back to the native default as a single choice. Defaults must +belong to the final list. This changes the catalog projection, not stored configuration. See [custom native catalog examples](/guides/codex-app-models/). ### Operator-pinned reasoning effort diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f4560a03b8..f76e0b98fb 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -2321,7 +2321,7 @@ async function gatherRoutedModelsWithAuth( return models; } -/** Bound a proven Codex-forward custom row without changing its stored configuration. */ +/** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */ function boundCustomNativeReasoning( model: CatalogModel, allowed: readonly string[], @@ -2625,8 +2625,8 @@ async function gatherRoutedModelsUncached( : {}), // Explicit custom-row ladder wins over the inherited provider row below: the merge only // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept - // instead of being replaced by that row's metadata. Only proven native aliases are - // bounded against their own capability source after the merge. + // instead of being replaced by that row's metadata. Capability-backed native model ids + // are bounded against their own pinned ladder after the merge, including gateways. ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), @@ -2679,8 +2679,16 @@ async function gatherRoutedModelsUncached( ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), } : base; - const reasoningBounded = codexForwardNativeCapabilityAlias - ? boundCustomNativeReasoning(merged, nativeReasoningEfforts(cm.modelId), nativeAliasDefaultEffort) + // Catalog-advertised efforts are bounded whenever the model id is a pinned native + // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot + // advertise none/minimal. Full native identity stays behind the alias predicate. + const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId); + const reasoningBounded = nativeEffortSource + ? boundCustomNativeReasoning( + merged, + nativeReasoningEfforts(cm.modelId), + nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId), + ) : merged; // Vision-sidecar coverage only: when the enriched provider's shared predicate matches // noVisionModels or text-without-image modelInputModalities, advertise image input so the diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index edf41e74c9..d3380020b0 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -49,7 +49,7 @@ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "../account-l import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; +import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, hasNativeOpenAiCapabilityMetadata, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, RETIRED_NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -316,6 +316,13 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick