From 71985dc7ea5ba074ce4cedf4b38f15e8ac28373f Mon Sep 17 00:00:00 2001 From: Veritas-7 <234569343+Veritas-7@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:06:50 +0900 Subject: [PATCH 1/3] fix(providers): let field-masked writes reach canonical OpenAI past stored overlays --- src/server/auth-cors.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4bf99b4f0f..fc29ece78f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -602,6 +602,23 @@ function sameCanonicalProviderSeed(actual: Record, expected: Oc return actualKeys.every(key => JSON.stringify(actual[key]) === JSON.stringify((expected as unknown as Record)[key])); } +/** + * Operator-overlay tolerant variant of the canonical seed check: every key the registry + * seed defines must still match the submitted provider verbatim, but keys the seed never + * defines are ignored instead of failing the comparison. Field-masked writes (PATCH, + * the provider editor, reload) merge onto the persisted row, so the submitted candidate + * legitimately carries stored operator overlays like `selectedModels` or `disabled`. + * Those fields are validated by their own write boundaries and cannot widen what the + * forward proxy claims. Full-object writes (POST) keep the strict exact-key comparison + * so a forged overlay cannot ride in on a canonical transport seed. + */ +function matchesCanonicalProviderSeed(actual: Record, expected: OcxProviderConfig): boolean { + return Object.keys(expected).every( + key => Object.hasOwn(actual, key) + && JSON.stringify(actual[key]) === JSON.stringify((expected as unknown as Record)[key]), + ); +} + function positiveWindowValue(value: unknown): boolean { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; } @@ -638,7 +655,11 @@ function nativeContextOverlayError(raw: Record): string | null * string, or null when the provider may be persisted. Caller-controlled names/fields are * redacted and JSON-escaped so secrets never reach the response. */ -export function providerManagementConfigError(name: unknown, provider: unknown): string | null { +export function providerManagementConfigError( + name: unknown, + provider: unknown, + options?: { allowOperatorOverlays?: boolean }, +): string | null { if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) { return "provider must be a plain object"; } @@ -683,7 +704,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // validation and then rejected by the seed comparison, so canonical OpenAI could never // set OR clear it — the value was admitted and then refused in the same request. delete canonicalCandidate.annotateEmptyToolOutputs; - const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); + const canonical = seed && (options?.allowOperatorOverlays + ? matchesCanonicalProviderSeed(canonicalCandidate, seed) + : sameCanonicalProviderSeed(canonicalCandidate, seed)); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; } From 726d2dde53e6e0219e99ab9004118f863d12e3dc Mon Sep 17 00:00:00 2001 From: Veritas-7 <234569343+Veritas-7@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:06:52 +0900 Subject: [PATCH 2/3] fix(providers): let field-masked writes reach canonical OpenAI past stored overlays --- src/server/management/provider-routes.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6977a17783..b479ba700d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -257,7 +257,10 @@ function providerEditorCandidate( if (namespaceCollision) return { ok: false, status: 409, error: namespaceCollision, code: "provider_namespace_conflict" }; const merged = mergeProviderEditorRow(persisted.providers[name], baseline.providers[name], publicProvider); const transportCandidate = providerTransportValidationCandidate(merged as unknown as Record); - const providerError = providerManagementConfigError(name, transportCandidate) + // The editor merges onto the persisted row, so stored operator overlays (selectedModels, + // disabled, …) ride along in the candidate. They are owned by their own write boundaries; + // the seed check must only pin the canonical transport/auth keys. + const providerError = providerManagementConfigError(name, transportCandidate, { allowOperatorOverlays: true }) ?? providerEmptyToolOutputConfigError(name, transportCandidate) ?? providerServiceTierConfigError(name, transportCandidate); if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" }; @@ -829,6 +832,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise), + // Reload validates a row straight off disk, which legitimately carries stored + // operator overlays; only the canonical transport/auth keys need to match the seed. + { allowOperatorOverlays: true }, ) ?? providerEmptyToolOutputConfigError(name, provider); if (providerError) return jsonResponse({ error: "provider reload target invalid" }, 409); @@ -1271,6 +1277,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise), + // PATCH merges the mask onto the persisted row, which legitimately carries + // stored operator overlays (selectedModels, disabled, …); only the canonical + // transport/auth keys need to match the seed. + { allowOperatorOverlays: true }, ) ?? providerEmptyToolOutputConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); @@ -1314,6 +1324,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise), + { allowOperatorOverlays: true }, ) ?? providerEmptyToolOutputConfigError(name, replay.next); if (syncError) { From ed9655286cd2e4cd8655b3c0c057d9f820764c6b Mon Sep 17 00:00:00 2001 From: Veritas-7 <234569343+Veritas-7@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:06:54 +0900 Subject: [PATCH 3/3] fix(providers): let field-masked writes reach canonical OpenAI past stored overlays --- .../management-provider-validation.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 949791f2d6..1a9174ea24 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -1490,6 +1490,69 @@ describe("provider management validation", () => { } }); + // selectedModels is written by the dedicated /api/selected-models route, so a canonical + // provider that ever had a model chosen carries it on disk. The exact-key seed comparison + // counted that operator overlay as a transport divergence and rejected every later PATCH + // (context windows included) with "must equal the canonical built-in provider seed". + test("canonical OpenAI with selectedModels can still PATCH modelContextWindows", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + openaiProviderTierVersion: 2, + defaultProvider: "openai", + providers: { + openai: { ...canonicalDirect, selectedModels: ["gpt-6-astra", "gpt-5.6-luna"] }, + }, + } as OcxConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + + const server = startServer(0); + try { + const patch = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelContextWindows: { "gpt-6-astra": 872000 } }), + }); + expect(patch.status).toBe(200); + expect(loadConfig().providers.openai?.modelContextWindows).toEqual({ "gpt-6-astra": 872000 }); + expect(loadConfig().providers.openai?.selectedModels).toEqual(["gpt-6-astra", "gpt-5.6-luna"]); + } finally { + resolvedError.mockRestore(); + await server.stop(true); + } + }); + + test("canonical OpenAI with selectedModels still rejects transport tampering", async () => { + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + openaiProviderTierVersion: 2, + defaultProvider: "openai", + providers: { + openai: { ...canonicalDirect, selectedModels: ["gpt-6-astra"] }, + }, + } as OcxConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + + const server = startServer(0); + try { + const patch = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "https://attacker.example.com/v1" }), + }); + expect(patch.status).toBe(400); + expect(loadConfig().providers.openai?.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + } finally { + resolvedError.mockRestore(); + await server.stop(true); + } + }); + // #1409: the add/edit form's payload type has no member for contextWindow or test("provider POST overwrite preserves an explicit annotateEmptyToolOutputs: false", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR);