diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index c53a17a4960..f27110ab904 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -3,7 +3,33 @@ import { useCallback, useEffect, useRef, useState } from "react"; export interface Config { port: number; defaultProvider: string; - providers: Record; + providers: Record & { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; xaiResponsesOptInState?: boolean | "mixed" }>; +} + +const PROVIDER_EDITOR_DERIVED_FIELDS = [ + "hasApiKey", + "hasHeaders", + "xaiResponsesOptInState", +] as const; + +type ProviderEditorConfig = { + defaultProvider: string; + providers: Record>; +}; + +const PROVIDER_EDITOR_DERIVED_FIELD_SET = new Set(PROVIDER_EDITOR_DERIVED_FIELDS); + +function projectProviderEditorConfig(config: Config): ProviderEditorConfig { + return { + defaultProvider: config.defaultProvider, + providers: Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => { + const projected: Record = {}; + for (const [field, value] of Object.entries(provider)) { + if (!PROVIDER_EDITOR_DERIVED_FIELD_SET.has(field)) projected[field] = structuredClone(value); + } + return [name, projected]; + })), + }; } export function useJsonConfigEditor(deps: { @@ -25,17 +51,25 @@ export function useJsonConfigEditor(deps: { const jsonEditorOpenRef = useRef(false); useEffect(() => { - if (config && !jsonEditorOpenRef.current) setDraft(JSON.stringify(config, null, 2)); + if (config && !jsonEditorOpenRef.current) setDraft(JSON.stringify(projectProviderEditorConfig(config), null, 2)); }, [config]); const saveConfig = useCallback(async (): Promise => { setJsonSaving(true); + let parsed: unknown; + try { + parsed = JSON.parse(draft); + } catch { + notify(t("prov.invalidJson"), false); + setJsonSaving(false); + return false; + } try { - const parsed = JSON.parse(draft); - const res = await fetch(`${apiBase}/api/config`, { + const baseline = JSON.parse(jsonBaseline) as unknown; + const res = await fetch(`${apiBase}/api/providers`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(parsed), + body: JSON.stringify({ baseline, next: parsed }), }); if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; @@ -53,15 +87,15 @@ export function useJsonConfigEditor(deps: { onSaved(); return true; } catch { - notify(t("prov.invalidJson"), false); + notify(t("prov.saveFailed"), false); return false; } finally { setJsonSaving(false); } - }, [apiBase, draft, fetchConfig, fetchProviderQuotas, notify, onSaved, t]); + }, [apiBase, draft, fetchConfig, fetchProviderQuotas, jsonBaseline, notify, onSaved, t]); const openJsonEditor = useCallback(() => { - const baseline = config ? JSON.stringify(config, null, 2) : draft; + const baseline = config ? JSON.stringify(projectProviderEditorConfig(config), null, 2) : draft; setJsonBaseline(baseline); setDraft(baseline); setJsonLeaveOpen(false); @@ -73,7 +107,7 @@ export function useJsonConfigEditor(deps: { setJsonLeaveOpen(false); setJsonEditorOpen(false); jsonEditorOpenRef.current = false; - const baseline = config ? JSON.stringify(config, null, 2) : jsonBaseline; + const baseline = config ? JSON.stringify(projectProviderEditorConfig(config), null, 2) : jsonBaseline; setJsonBaseline(baseline); setDraft(baseline); }, [config, jsonBaseline]); diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx new file mode 100644 index 00000000000..787db615e10 --- /dev/null +++ b/gui/tests/use-json-config-editor.test.tsx @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { useJsonConfigEditor, type Config } from "../src/hooks/useJsonConfigEditor"; + +const originalFetch = globalThis.fetch; +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; +const originalNavigator = globalThis.navigator; + +const config: Config = { + port: 10100, + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + modelContextWindows: { "alpha-old": 131_072 }, + modelReasoningEfforts: { "alpha-old": ["low", "high"] }, + noVisionModels: ["alpha-old"], + allowPrivateNetwork: true, + hasApiKey: true, + hasHeaders: true, + note: "derived registry note", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + hasApiKey: false, + }, + }, +} as Config; + +type Editor = ReturnType; +type RequestRecord = { url: string; method: string; body: unknown }; + +let testWindow: Window; +let host: HTMLElement; +let root: Root | null; +let editor: Editor | null; +let requests: RequestRecord[]; +let responseFactory: () => Promise; +let configRefreshes: number; +let quotaRefreshes: number; +let savedCallbacks: number; +let notifications: Array<{ message: string; ok?: boolean }>; + +function Harness() { + editor = useJsonConfigEditor({ + apiBase: "/editor", + config, + notify: (message, ok) => { notifications.push({ message, ok }); }, + fetchConfig: async () => { configRefreshes += 1; }, + fetchProviderQuotas: async () => { quotaRefreshes += 1; }, + onSaved: () => { savedCallbacks += 1; }, + t: key => key, + }); + return null; +} + +async function mountHook(): Promise { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); +} + +beforeEach(() => { + testWindow = new Window({ url: "http://localhost" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); + root = null; + editor = null; + requests = []; + configRefreshes = 0; + quotaRefreshes = 0; + savedCallbacks = 0; + notifications = []; + responseFactory = async () => Response.json({ success: true }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body, + }); + return responseFactory(); + }) as typeof fetch; +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + await testWindow.happyDOM?.close?.(); + Object.defineProperties(globalThis, { + document: { configurable: true, value: originalDocument }, + window: { configurable: true, value: originalWindow }, + navigator: { configurable: true, value: originalNavigator }, + }); + globalThis.fetch = originalFetch; +}); + +test("Save sends one atomic provider PUT with baseline and next, then refreshes", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + + const baseline = { + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + modelContextWindows: { "alpha-old": 131_072 }, + modelReasoningEfforts: { "alpha-old": ["low", "high"] }, + noVisionModels: ["alpha-old"], + allowPrivateNetwork: true, + note: "derived registry note", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + }, + }, + }; + expect(JSON.parse(editor!.draft)).toEqual(baseline); + + const next = structuredClone(baseline); + next.defaultProvider = "beta"; + next.providers.alpha.defaultModel = "alpha-new"; + await act(async () => { editor!.setDraft(JSON.stringify(next, null, 2)); }); + + let saved = false; + await act(async () => { saved = await editor!.saveConfig(); }); + + expect(saved).toBe(true); + expect(requests).toEqual([{ + url: "/editor/api/providers", + method: "PUT", + body: { baseline, next }, + }]); + expect(requests.some(request => request.url.endsWith("/api/config") && request.method === "PUT")).toBe(false); + expect(requests.some(request => ["POST", "PATCH", "DELETE"].includes(request.method))).toBe(false); + expect(configRefreshes).toBe(1); + expect(quotaRefreshes).toBe(1); + expect(savedCallbacks).toBe(1); +}); + +test("parse failures stay distinct from server failures and failed saves do not refresh", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + await act(async () => { editor!.setDraft("{bad json"); }); + + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + expect(requests).toHaveLength(0); + expect(notifications.at(-1)).toEqual({ message: "prov.invalidJson", ok: false }); + + await act(async () => { editor!.restoreJsonEditor(); }); + responseFactory = async () => Response.json({ error: "stale baseline" }, { status: 409 }); + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + + expect(notifications.at(-1)).toEqual({ message: "stale baseline", ok: false }); + responseFactory = async () => { throw new Error("network down"); }; + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + expect(notifications.at(-1)).toEqual({ message: "prov.saveFailed", ok: false }); + expect(configRefreshes).toBe(0); + expect(quotaRefreshes).toBe(0); + expect(savedCallbacks).toBe(0); +}); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 10516ca31c7..8da10d5f44f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -749,73 +749,258 @@ export function copyIfDefined( if (value !== undefined) out[key as string] = value as unknown; } +/** + * Exhaustive provider-field policy shared by dashboard redaction and editor + * admission. `satisfies Record` makes a newly added + * provider field fail typecheck until it is deliberately classified. + * + * `editor` fields are user-authored, `redacted` fields may contain credentials, + * and `runtime` fields are observations/limits that must never become editor write + * authority. MCP and desktop executor blocks are redacted as a whole because both + * contain arbitrary environment variables and/or headers. + */ +type ProviderConfigFieldPolicy = "editor" | "redacted" | "runtime"; + +const PROVIDER_CONFIG_FIELD_POLICY = { + alias: "editor", + modelAliases: "editor", + modelDisplayNames: "editor", + defaultAliases: "editor", + adapter: "editor", + codexToolMode: "editor", + requestPacing: "editor", + mcpMaxTools: "editor", + mcpMaxSchemaBytes: "editor", + mcpMaxResultBytes: "editor", + modelAdapters: "editor", + fastWire: "editor", + baseUrl: "editor", + responsesPath: "editor", + commandCodeVersion: "editor", + statelessResponses: "editor", + requiresAdjacentResponsesToolResults: "editor", + annotateEmptyToolOutputs: "editor", + supportsServiceTier: "editor", + modelSupportsServiceTier: "editor", + preserveResponsesReasoningContent: "editor", + decodesNativeCompactionBlobs: "editor", + allowPrivateNetwork: "editor", + upstreamHttpVersion: "editor", + upstreamWebsocket: "editor", + directGeminiWireRenames: "editor", + disabled: "editor", + codexAccountMode: "editor", + apiKey: "redacted", + apiKeyTransport: "editor", + apiKeyPool: "redacted", + defaultModel: "editor", + models: "editor", + liveModels: "editor", + selectedModels: "editor", + retainModels: "editor", + newModelPolicy: "editor", + modelPreset: "editor", + contextWindow: "editor", + modelContextWindows: "editor", + modelInputModalities: "editor", + modelMaxInputTokens: "runtime", + modelAutoCompactTokenLimits: "editor", + defaultMaxOutputTokens: "editor", + modelMaxOutputTokens: "editor", + modelCosts: "editor", + headers: "redacted", + openRouterRouting: "editor", + modelOpenRouterRouting: "editor", + vercelGatewayRouting: "editor", + modelVercelGatewayRouting: "editor", + authMode: "editor", + oauthAccountFailover: "editor", + keyOptional: "editor", + freeTier: "editor", + note: "editor", + modelSuffixBracketStrip: "editor", + refreshPolicy: "editor", + reasoningEfforts: "editor", + modelReasoningEfforts: "editor", + modelDefaultReasoningEfforts: "editor", + modelSupportsReasoningSummaries: "editor", + modelSupportsVerbosity: "editor", + supportsVerbosity: "editor", + modelReasoningSummaryDelivery: "editor", + modelPreferHostedTools: "editor", + supportsOpenAiWebSearchToolFields: "editor", + xaiResponsesXSearch: "editor", + supportsResponsesCustomTools: "editor", + responsesSnapshotRepair: "editor", + reasoningEffortMap: "editor", + modelReasoningEffortMap: "editor", + reasoningWireFormat: "editor", + noReasoningModels: "editor", + noTemperatureModels: "editor", + noTopPModels: "editor", + noPenaltyModels: "editor", + noStructuredOutputModels: "editor", + omitReasoningEffortWithToolsModels: "editor", + parallelToolCalls: "editor", + pinParallelToolCallsFalse: "editor", + terminalContinuationGuard: "editor", + openaiChatEofTolerance: "editor", + promptCacheKey: "editor", + chatServiceTier: "editor", + responsesItemIdRepair: "editor", + autoToolChoiceOnlyModels: "editor", + preserveReasoningContentModels: "editor", + requiresReasoningPlaceholderModels: "editor", + retryOn429: "editor", + transientRetryOn5xx: "editor", + reasoningSplitModels: "editor", + reasoningDetailsModels: "editor", + thinkingToggleModels: "editor", + thinkingBudgetModels: "editor", + escapeBuiltinToolNames: "editor", + anthropicEofTolerance: "editor", + noVisionModels: "editor", + googleMode: "editor", + project: "editor", + location: "editor", + mcpServers: "redacted", + desktopExecutor: "redacted", + unsafeAllowNativeLocalExec: "editor", + nativeLocalExec: "editor", +} as const satisfies Record; + +type ProviderFieldWithPolicy = { + [Field in keyof typeof PROVIDER_CONFIG_FIELD_POLICY]: + typeof PROVIDER_CONFIG_FIELD_POLICY[Field] extends Policy ? Field : never; +}[keyof typeof PROVIDER_CONFIG_FIELD_POLICY]; + +type RedactedProviderField = ProviderFieldWithPolicy<"redacted">; +type RuntimeProviderField = ProviderFieldWithPolicy<"runtime">; +export const REDACTED_PROVIDER_FIELDS = Object.freeze(Object.entries(PROVIDER_CONFIG_FIELD_POLICY) + .filter(([, policy]) => policy === "redacted") + .map(([field]) => field as RedactedProviderField)); +const RUNTIME_PROVIDER_FIELDS = Object.freeze(Object.entries(PROVIDER_CONFIG_FIELD_POLICY) + .filter(([, policy]) => policy === "runtime") + .map(([field]) => field as RuntimeProviderField)); + +const PROVIDER_EDITOR_DERIVED_FIELDS = [ + ...RUNTIME_PROVIDER_FIELDS, + ...FORBIDDEN_PROVIDER_RUNTIME_FIELDS, + "fetch", + "hasApiKey", + "hasHeaders", + "xaiResponsesOptInState", +] as const; + +export const PROVIDER_EDITOR_DENIED_FIELDS = [ + ...REDACTED_PROVIDER_FIELDS, + ...PROVIDER_EDITOR_DERIVED_FIELDS, +] as const; + +export type ProviderEditorProviderDTO = Omit + & Record; + +export interface ProviderEditorConfigDTO { + defaultProvider: string; + providers: Record; +} + +export type ProviderEditorConfigParseResult = + | { ok: true; value: ProviderEditorConfigDTO } + | { ok: false; error: string; code: "invalid_provider_editor_body" | "invalid_provider_editor_field" }; + +const PROVIDER_EDITOR_DENIED_FIELD_SET = new Set(PROVIDER_EDITOR_DENIED_FIELDS); +const PROVIDER_CONFIG_FIELD_SET = new Set(Object.keys(PROVIDER_CONFIG_FIELD_POLICY)); + +function isPlainDataRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Project one provider through the same redaction path used by both the public + * config DTO and the raw editor. Persisted unknown fields remain on disk but are + * not exposed until OcxProviderConfig classifies them as editor-safe. + */ +function providerEditorProviderDTO(name: string, provider: OcxProviderConfig): ProviderEditorProviderDTO { + const dto = Object.fromEntries(Object.entries(provider) + .filter(([field]) => PROVIDER_CONFIG_FIELD_SET.has(field) && !PROVIDER_EDITOR_DENIED_FIELD_SET.has(field)) + .map(([field, value]) => [field, structuredClone(value)])) as Record; + dto.baseUrl = publicProviderBaseUrl(provider.baseUrl); + const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); + if (modelCosts) dto.modelCosts = modelCosts; + else delete dto.modelCosts; + + const registryNote = (providerMatchesRegistryTransport(name, provider) + ? getProviderRegistryEntry(name) + : registryEntryForProviderDestination(provider))?.note; + if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; + const codexAccountMode = providerCodexAccountMode(name, provider); + if (codexAccountMode) dto.codexAccountMode = codexAccountMode; + return dto as ProviderEditorProviderDTO; +} + +/** The complete non-secret provider shape the raw GUI editor may round-trip. */ +export function providerEditorConfigDTO(config: OcxConfig): ProviderEditorConfigDTO { + const providers: Record = Object.create(null); + for (const [name, provider] of Object.entries(config.providers)) { + providers[name] = providerEditorProviderDTO(name, provider); + } + return { defaultProvider: config.defaultProvider, providers }; +} + +/** Parse an editor snapshot; unknown, redacted, and derived fields fail closed. */ +export function parseProviderEditorConfigDTO(value: unknown): ProviderEditorConfigParseResult { + if (!isPlainDataRecord(value)) { + return { ok: false, error: "provider editor config must be a plain object", code: "invalid_provider_editor_body" }; + } + const rootKeys = Object.keys(value); + if (rootKeys.length !== 2 || !Object.hasOwn(value, "defaultProvider") || !Object.hasOwn(value, "providers")) { + return { ok: false, error: "provider editor config must contain only defaultProvider and providers", code: "invalid_provider_editor_body" }; + } + if (typeof value.defaultProvider !== "string" || value.defaultProvider.trim() === "") { + return { ok: false, error: "defaultProvider must be a non-empty string", code: "invalid_provider_editor_body" }; + } + if (!isPlainDataRecord(value.providers)) { + return { ok: false, error: "providers must be a plain object", code: "invalid_provider_editor_body" }; + } + + const providers: Record = Object.create(null); + for (const [name, provider] of Object.entries(value.providers)) { + if (!isPlainDataRecord(provider)) { + return { ok: false, error: `provider ${JSON.stringify(redactSecretString(name))} must be a plain object`, code: "invalid_provider_editor_body" }; + } + const deniedField = Object.keys(provider).find(field => + !PROVIDER_CONFIG_FIELD_SET.has(field) || PROVIDER_EDITOR_DENIED_FIELD_SET.has(field)); + if (deniedField) { + return { + ok: false, + error: `provider ${JSON.stringify(redactSecretString(name))} contains non-editable field ${JSON.stringify(redactSecretString(deniedField))}`, + code: "invalid_provider_editor_field", + }; + } + providers[name] = structuredClone(provider) as ProviderEditorProviderDTO; + } + return { + ok: true, + value: { defaultProvider: value.defaultProvider, providers }, + }; +} + /** Public dashboard DTO for config.json: provider entries with secrets stripped and documented fields exposed (including `modelCosts`). */ export function safeConfigDTO(config: OcxConfig): unknown { + const editor = providerEditorConfigDTO(config); const providers: Record> = {}; for (const [name, provider] of Object.entries(config.providers)) { const dto: Record = { - adapter: provider.adapter, - baseUrl: publicProviderBaseUrl(provider.baseUrl), + ...editor.providers[name], hasApiKey: !!provider.apiKey, hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0, }; if (name === "xai") { dto.xaiResponsesOptInState = xaiResponsesOptInState(provider); } - for (const key of [ - "defaultModel", - "alias", - "modelAliases", - "defaultAliases", - "disabled", - "allowPrivateNetwork", - "authMode", - "apiKeyTransport", - "keyOptional", - "freeTier", - "liveModels", - "requestPacing", - "models", - "contextWindow", - "modelContextWindows", - "modelAutoCompactTokenLimits", - "defaultMaxOutputTokens", - "modelMaxOutputTokens", - "openRouterRouting", - "modelOpenRouterRouting", - "vercelGatewayRouting", - "modelVercelGatewayRouting", - "reasoningEfforts", - "modelReasoningEfforts", - "reasoningWireFormat", - "noVisionModels", - "noReasoningModels", - "noTemperatureModels", - "noTopPModels", - "noPenaltyModels", - "noStructuredOutputModels", - "retainModels", - "omitReasoningEffortWithToolsModels", - "upstreamHttpVersion", - "autoToolChoiceOnlyModels", - "preserveReasoningContentModels", - "requiresReasoningPlaceholderModels", - "escapeBuiltinToolNames", - ] as const) { - copyIfDefined(dto, provider, key); - } - const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); - if (modelCosts) dto.modelCosts = modelCosts; - // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is - // still pointed at the same vendor route, and a usage restriction the user needs to see - // must not disappear because the row was renamed. Prefer the same-name entry so an - // unrenamed provider keeps its exact registry note. - const registryNote = (providerMatchesRegistryTransport(name, provider) - ? getProviderRegistryEntry(name) - : registryEntryForProviderDestination(provider))?.note; - if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; - const codexAccountMode = providerCodexAccountMode(name, provider); - if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; } return { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 2eedb1d5e64..8b9f8d0dd45 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch"; @@ -11,6 +12,7 @@ import { isValidProviderName, modelDisplayNamesConfigError, multiAgentGuidanceEnabled, + mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, providerBaseUrlConfigError, @@ -19,6 +21,7 @@ import { readConfigAdmissionSnapshot, saveConfigPreservingClaudeCode, upstreamHttpVersionConfigError, + validateConfigCandidate, withConfigMutationLockSync, } from "../../config"; import { @@ -77,7 +80,17 @@ import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { + isAllowedRequestOrigin, + jsonResponse, + parseProviderEditorConfigDTO, + providerEditorConfigDTO, + providerManagementConfigError, + publicProviderBaseUrl, + safeConfigDTO, + type ProviderEditorConfigDTO, + type ProviderEditorProviderDTO, +} from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; @@ -86,10 +99,12 @@ import { LOCAL_PROVIDER_RELOAD_PATH, } from "../../lib/local-provider-reload-contract"; import { refreshUserCostOverlays } from "../../usage/user-cost-overlays"; +import { redactSecretString } from "../../lib/redact"; import { XAI_RESPONSES_OPT_IN_MODELS, xaiResponsesOptInState, } from "../../providers/xai-responses-opt-in"; +import { dropProviderCustomModels } from "../../providers/provider-id-rewrite"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -163,6 +178,116 @@ function restorePersistedAliasOverlays(target: OcxProviderConfig, existing: OcxP } } +type ProviderEditorCandidateResult = + | { ok: true; config: OcxConfig; removedProviders: string[] } + | { ok: false; status: 400 | 409; error: string; code: string }; + +type ProviderEditorMutationValue = ProviderEditorCandidateResult; + +function mergeProviderEditorRow( + persisted: OcxProviderConfig | undefined, + baseline: ProviderEditorProviderDTO | undefined, + next: ProviderEditorProviderDTO, +): OcxProviderConfig { + const merged = structuredClone(persisted ?? {}) as Record; + const fields = new Set([...Object.keys(baseline ?? {}), ...Object.keys(next)]); + for (const field of fields) { + const baselineHasField = baseline !== undefined && Object.hasOwn(baseline, field); + const nextHasField = Object.hasOwn(next, field); + if ( + baselineHasField === nextHasField + && (!baselineHasField || isDeepStrictEqual(baseline[field], next[field])) + ) { + continue; + } + if (nextHasField) merged[field] = structuredClone(next[field]); + else delete merged[field]; + } + return merged as unknown as OcxProviderConfig; +} + +/** Build and validate a complete candidate without mutating the caller's snapshot. */ +function providerEditorCandidate( + persisted: OcxConfig, + baseline: ProviderEditorConfigDTO, + next: ProviderEditorConfigDTO, +): ProviderEditorCandidateResult { + const candidate = structuredClone(persisted); + const removedProviders = Object.keys(persisted.providers) + .filter(name => !Object.hasOwn(next.providers, name)); + + for (const name of removedProviders) { + const dependentCombos = Object.entries(persisted.combos ?? {}) + .filter(([, combo]) => combo.targets.some(target => target.provider === name)) + .map(([id]) => id) + .sort((a, b) => a.localeCompare(b)); + if (dependentCombos.length > 0) { + return { + ok: false, + status: 409, + error: `cannot delete provider ${JSON.stringify(redactSecretString(name))} while combos depend on it`, + code: "provider_has_dependent_combos", + }; + } + } + + const providers: Record = Object.create(null); + for (const [name, publicProvider] of Object.entries(next.providers)) { + if (!isValidProviderName(name)) { + return { + ok: false, + status: 400, + error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key", + code: "invalid_provider_name", + }; + } + const namespaceCollision = codexAccountNamespaceProviderCollisionError(candidate.codexAccountNamespaces, name); + 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) + ?? providerEmptyToolOutputConfigError(name, transportCandidate) + ?? providerServiceTierConfigError(name, transportCandidate); + if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" }; + providers[name] = merged; + } + + const defaultProvider = next.defaultProvider.trim(); + const selectedDefault = providers[defaultProvider]; + if (!selectedDefault) { + return { ok: false, status: 400, error: "defaultProvider must name a configured provider", code: "invalid_default_provider" }; + } + if (selectedDefault.disabled === true) { + return { ok: false, status: 400, error: "defaultProvider cannot be disabled", code: "default_provider_disabled" }; + } + + candidate.defaultProvider = defaultProvider; + candidate.providers = providers; + for (const name of removedProviders) { + dropProviderCustomModels(candidate, name); + setProviderContextCap(candidate, name, false); + } + const validated = validateConfigCandidate(candidate); + if (!validated.ok) { + return { ok: false, status: 400, error: validated.error, code: "invalid_provider_editor_config" }; + } + return { ok: true, config: candidate, removedProviders }; +} + +function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): void { + live.defaultProvider = persisted.defaultProvider; + for (const name of Object.keys(live.providers)) { + if (!Object.hasOwn(persisted.providers, name)) delete live.providers[name]; + } + for (const [name, provider] of Object.entries(persisted.providers)) { + live.providers[name] = structuredClone(provider); + } + if (persisted.customModels === undefined) delete live.customModels; + else live.customModels = structuredClone(persisted.customModels); + if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; + else live.providerContextCaps = structuredClone(persisted.providerContextCaps); +} + /** * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once * for validation and again inside the config mutation lock against the newest provider, @@ -649,6 +774,98 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(persisted => { + if (!isDeepStrictEqual(providerEditorConfigDTO(persisted), baselineResult.value)) { + return { + changed: false, + value: { + ok: false, + status: 409, + error: "provider editor baseline is stale", + code: "stale_provider_editor_baseline", + }, + }; + } + const candidate = providerEditorCandidate(persisted, baselineResult.value, nextResult.value); + if (!candidate.ok) return { changed: false, value: candidate }; + const changed = !isDeepStrictEqual(providerEditorConfigDTO(persisted), nextResult.value); + if (!changed) return { changed: false, value: candidate }; + + persisted.defaultProvider = candidate.config.defaultProvider; + persisted.providers = structuredClone(candidate.config.providers); + for (const name of candidate.removedProviders) { + dropProviderCustomModels(persisted, name); + setProviderContextCap(persisted, name, false); + } + return { + changed: true, + value: { + ok: true, + config: structuredClone(persisted), + removedProviders: candidate.removedProviders, + }, + }; + }); + if (outcome.status === "unavailable") { + const code = outcome.reason === "conflict" ? "provider_config_conflict" : "provider_config_unavailable"; + return jsonResponse({ error: "provider config changed before it could be saved", code }, 409); + } + if (!outcome.value.ok) { + return jsonResponse({ error: outcome.value.error, code: outcome.value.code }, outcome.value.status); + } + + adoptProviderEditorCandidate(config, outcome.value.config); + reconcileLiveStateStores(); + refreshUserCostOverlays(outcome.value.config); + clearGatherRoutedModelsInflight(); + (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)(); + clearAccountQuotaCache(); + clearKeyCooldowns(); + clearModelCache(); + (deps.clearThreadAccountMap ?? clearThreadAccountMap)(); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ success: true, catalogRefresh }); + } + // Add (or overwrite) a single provider. Merges into the live in-memory config and // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config, // which would re-save the masked keys from GET). Live routing picks it up immediately. diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 95c72f50213..9a52cc9d796 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -273,6 +273,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true }, + { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } }, { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true }, // server/management/request-history-routes { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false }, diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 2006778c2d4..98e9d97b958 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -373,9 +373,9 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 14 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ - ["provider-routes.ts", 7], + ["provider-routes.ts", 8], ["model-routes.ts", 14], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], @@ -387,7 +387,7 @@ test("the route inventory contains exactly the specified 7 + 14 + 2 + 2 converge return [file, count]; })); expect(counts).toEqual({ - "provider-routes.ts": 7, + "provider-routes.ts": 8, "model-routes.ts": 14, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, @@ -456,3 +456,23 @@ test("the attested reload route converges the Codex catalog like the other write const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); expect(handlerBody).toContain("await convergeCodexCatalog()"); }); + +/** + * The eighth provider-route call belongs to the atomic provider batch PUT: one commit can + * add, edit, or remove several routed rows, so the post-commit live state must converge once. + * Keep this route-specific assertion beside the total so the inventory cannot be satisfied + * by an unrelated extra call while the batch route loses its own convergence. + */ +test("the atomic provider batch route converges the Codex catalog once", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src", "server", "management", "provider-routes.ts"), + "utf8", + ); + const handlerStart = source.indexOf('url.pathname === "/api/providers" && req.method === "PUT"'); + expect(handlerStart).toBeGreaterThan(-1); + const handlerBody = source.slice(handlerStart, source.indexOf( + 'url.pathname === "/api/providers" && req.method === "POST"', + handlerStart + 1, + )); + expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); +}); diff --git a/tests/provider-config-batch-management.test.ts b/tests/provider-config-batch-management.test.ts new file mode 100644 index 00000000000..250c1e5afdd --- /dev/null +++ b/tests/provider-config-batch-management.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as configModule from "../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../src/config"; +import * as destinationPolicy from "../src/lib/destination-policy"; +import { safeConfigDTO } from "../src/server/auth-cors"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; + +type EditorConfig = { + defaultProvider: string; + providers: Record>; +}; + +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let testDir: string; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function seededConfig(): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + apiKey: "sk-alpha-secret", + apiKeyPool: [{ id: "alpha-main", key: "sk-alpha-secret", label: "primary" }], + headers: { "x-private": "private-value", "x-private-two": "private-value-two" }, + project: "private-alpha-project", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + defaultModel: "beta-old", + apiKey: "sk-beta-secret", + headers: { "x-beta-private": "keep-me" }, + project: "private-beta-project", + }, + }, + }; +} + +function editorBaseline(config: OcxConfig): EditorConfig { + return { + defaultProvider: config.defaultProvider, + providers: Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [name, { + adapter: provider.adapter, + baseUrl: provider.baseUrl, + ...(provider.defaultModel === undefined ? {} : { defaultModel: provider.defaultModel }), + ...(provider.project === undefined ? {} : { project: provider.project }), + }])), + }; +} + +async function putBatch(liveConfig: OcxConfig, body: unknown, onCatalog = () => {}): Promise { + const request = new Request("http://127.0.0.1/api/providers", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(request, new URL(request.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(onCatalog), + }); +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-provider-batch-")); + mkdirSync(testDir, { recursive: true }); + process.env.OPENCODEX_HOME = testDir; + isolatedCodexHome = installIsolatedCodexHome("ocx-provider-batch-codex-"); +}); + +afterEach(() => { + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(testDir); +}); + +describe("atomic provider editor batch", () => { + test("round-trips an unchanged realistic provider config without rewriting values or secrets", async () => { + const liveConfig: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "woong", + providers: { + woong: { + adapter: "openai-chat", + baseUrl: "https://woong.example.test/v1", + defaultModel: "woong-reasoner", + note: "private deployment", + modelContextWindows: { "woong-reasoner": 131_072 }, + modelMaxInputTokens: { "woong-reasoner": 120_000 }, + modelReasoningEfforts: { "woong-reasoner": ["low", "medium", "high"] }, + noVisionModels: ["woong-reasoner"], + allowPrivateNetwork: true, + apiKey: "sk-woong-secret", + apiKeyPool: [{ id: "woong-main", key: "sk-woong-secret", label: "primary" }], + headers: { "x-tenant-token": "tenant-secret" }, + mcpServers: { + private: { + url: "https://mcp.example.test", + headers: { authorization: "Bearer mcp-secret" }, + }, + }, + desktopExecutor: { + computerUseCommand: "private-runner", + env: { ACCESS_TOKEN: "desktop-secret" }, + }, + }, + }, + }; + const publicRow = (safeConfigDTO(liveConfig) as { + providers: Record>; + }).providers.woong!; + for (const field of [ + "apiKey", + "apiKeyPool", + "headers", + "mcpServers", + "desktopExecutor", + "modelMaxInputTokens", + ]) { + expect(publicRow).not.toHaveProperty(field); + } + expect(JSON.stringify(publicRow)).not.toContain("secret"); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline: EditorConfig = { + defaultProvider: "woong", + providers: { + woong: { + adapter: "openai-chat", + baseUrl: "https://woong.example.test/v1", + defaultModel: "woong-reasoner", + note: "private deployment", + modelContextWindows: { "woong-reasoner": 131_072 }, + modelReasoningEfforts: { "woong-reasoner": ["low", "medium", "high"] }, + noVisionModels: ["woong-reasoner"], + allowPrivateNetwork: true, + }, + }, + }; + + const destinationSpy = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + let response: Response | null; + try { + response = await putBatch(liveConfig, { baseline, next: structuredClone(baseline) }); + } finally { + destinationSpy.mockRestore(); + } + + expect(response?.status).toBe(200); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.woong).toEqual(liveConfig.providers.woong); + expect(loadConfig().providers.woong).toMatchObject({ + apiKey: "sk-woong-secret", + apiKeyPool: [{ id: "woong-main", key: "sk-woong-secret", label: "primary" }], + headers: { "x-tenant-token": "tenant-secret" }, + }); + }); + + test("updates several providers in one commit and preserves credentials and private fields", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const baseline = editorBaseline(liveConfig); + const next: EditorConfig = structuredClone(baseline); + next.defaultProvider = "beta"; + next.providers.alpha!.defaultModel = "alpha-new"; + next.providers.beta!.baseUrl = "https://beta-new.example.test/v1"; + next.providers.beta!.defaultModel = "beta-new"; + next.providers.gamma = { + adapter: "openai-chat", + baseUrl: "https://gamma.example.test/v1", + defaultModel: "gamma-1", + }; + + let catalogRefreshes = 0; + const destinationSpy = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + const mutationSpy = spyOn(configModule, "mutatePersistedConfig"); + try { + const response = await putBatch(liveConfig, { baseline, next }, () => { catalogRefreshes += 1; }); + expect(response?.status).toBe(200); + expect(mutationSpy).toHaveBeenCalledTimes(1); + } finally { + mutationSpy.mockRestore(); + destinationSpy.mockRestore(); + } + + const persisted = loadConfig(); + expect(persisted.defaultProvider).toBe("beta"); + expect(persisted.providers.alpha).toMatchObject({ + defaultModel: "alpha-new", + apiKey: "sk-alpha-secret", + apiKeyPool: [{ id: "alpha-main", key: "sk-alpha-secret", label: "primary" }], + headers: { "x-private": "private-value", "x-private-two": "private-value-two" }, + project: "private-alpha-project", + }); + expect(persisted.providers.beta).toMatchObject({ + baseUrl: "https://beta-new.example.test/v1", + defaultModel: "beta-new", + apiKey: "sk-beta-secret", + headers: { "x-beta-private": "keep-me" }, + project: "private-beta-project", + }); + expect(persisted.providers.gamma).toEqual(next.providers.gamma); + expect(liveConfig.defaultProvider).toBe("beta"); + expect(liveConfig.providers).toEqual(persisted.providers); + expect(catalogRefreshes).toBe(1); + }); + + test("rejects one invalid row with zero persisted or live change", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const beforeLive = structuredClone(liveConfig); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "must-not-land"; + next.providers.beta!.baseUrl = "not a URL"; + let catalogRefreshes = 0; + + const response = await putBatch(liveConfig, { baseline, next }, () => { catalogRefreshes += 1; }); + + expect(response?.status).toBe(400); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(liveConfig).toEqual(beforeLive); + expect(catalogRefreshes).toBe(0); + }); + + test("rejects derived public markers instead of persisting them", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + + for (const [field, value] of [ + ["hasApiKey", true], + ["hasHeaders", true], + ["xaiResponsesOptInState", true], + ["virtualModels", { "alpha-pro": { wireModelId: "alpha", reasoningMode: "pro" } }], + ] as const) { + const next = structuredClone(baseline); + next.providers.alpha![field] = structuredClone(value); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.alpha).not.toHaveProperty(field); + } + }); + + test("rejects runtime-derived provider metadata as editor write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.modelMaxInputTokens = { "alpha-old": 128_000 }; + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.alpha).not.toHaveProperty("modelMaxInputTokens"); + }); + + test("rejects credential-bearing provider fields as editor write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + + for (const [field, value] of [ + ["apiKey", "sk-attacker-write"], + ["apiKeyPool", [{ id: "attacker", key: "sk-attacker-write" }]], + ["headers", { authorization: "Bearer attacker-write" }], + ["mcpServers", { attacker: { url: "https://mcp.example.test", headers: { authorization: "Bearer attacker-write" } } }], + ["desktopExecutor", { computerUseCommand: "runner", env: { ACCESS_TOKEN: "attacker-write" } }], + ] as const) { + const next = structuredClone(baseline); + next.providers.alpha![field] = structuredClone(value); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + } + }); + + test("rejects unknown provider fields instead of creating hidden write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.runtimeExtension = { token: "attacker-write" }; + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + }); + + test("returns 409 and preserves a concurrent edit when baseline is stale", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "stale-write"; + + const concurrent = loadConfig(); + concurrent.providers.alpha!.defaultModel = "concurrent-write"; + saveConfig(concurrent); + const concurrentBytes = readFileSync(getConfigPath(), "utf8"); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ code: "stale_provider_editor_baseline" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(concurrentBytes); + expect(loadConfig().providers.alpha?.defaultModel).toBe("concurrent-write"); + }); + + test("keeps the full-config PUT disabled", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const request = new Request("http://127.0.0.1/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(liveConfig), + }); + + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + + expect(response?.status).toBe(405); + expect(await response?.json()).toEqual({ + error: "Full config PUT is disabled. Use /api/providers POST for provider changes.", + }); + }); +});