diff --git a/src/config.ts b/src/config.ts index 4b83c82954..3935e3dc51 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2172,6 +2172,26 @@ export function loadConfig(): OcxConfig { warnDegradedAgentTaskRecovery(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } + // Still failing, but if every complaint is about one or more named entries + // in an independent section, drop exactly those and keep the rest. Falling + // back to defaults here would silently retire the operator's providers, + // keys and prices over a mistake in one routing profile. + const salvaged = salvageConfigCandidate(merged, retryResult.error); + if (salvaged) { + { + warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues); + const config = normalizeApiKeyIds(salvaged.parsed); + warnDegradedHostname(parsed, config); + warnDegradedApiKeys(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); + warnDegradedClaudeSubagentEffort(parsed); + warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); + warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedAgentTaskRecovery(parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); + } + } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); return getDefaultConfig(); @@ -2450,11 +2470,31 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed); } - const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed)); + const merged = mergeConfigDefaults(parsed); + const retryResult = configSchema.safeParse(merged); if (retryResult.success) { return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed); } + // #1785: one invalid routing profile must not make diagnostics report the built-in + // defaults AS the config, because a later config write persists those defaults over the + // operator's providers, keys and prices. + // + // The failure is still reported. `source` stays "fallback" and `error` keeps the real + // schema message -- diagnostics is the surface that tells callers the file is invalid, + // and every consumer that must refuse an invalid config (provider reload, catalog sync, + // cost reconcile, codex admission) gates on exactly those two fields. Only `config` + // changes: it carries the salvaged document instead of factory defaults, so a caller + // that ignores the error and writes it back preserves what the operator configured. + const salvaged = salvageConfigCandidate(merged, retryResult.error); + if (salvaged) { + return { + config: normalizeApiKeyIds(salvaged.parsed), + source: "fallback", + error: schemaDiagnosticsError(result.error), + }; + } + return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) }; } catch { return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }; @@ -3428,6 +3468,194 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void { console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`); } +/** + * Sections whose entries are independent of one another, so one bad entry is + * safe to drop without changing what the rest mean. + * + * Both are validated entry-by-entry in the `superRefine` above, which raises + * every finding as a *document*-level issue. That is what made a single routing + * candidate naming a disabled provider discard the operator's whole config — + * all eleven providers, every API key, and the entire `modelCosts` table — + * while the proxy carried on serving from built-in defaults and reporting + * healthy. + */ +const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const; + +/** + * Drop just the named entries a parse failure blamed, so the rest of the + * document survives. + * + * Returns `null` when the failure was not confined to those sections — the + * caller then keeps its existing behaviour rather than guessing. + * + * The whole entry goes, not the individual offending candidate. A routing + * profile that quietly loses one candidate still routes, just not where the + * operator said it should, and a policy that silently changed shape is a worse + * outcome than one that is plainly absent. Absent is also the loud option: a + * dry-run against it answers `unknown_profile`, which — paired with the warning + * this emits — points at the real mistake. + */ +function dropInvalidConfigSections( + parsed: unknown, + error: z.ZodError, +): { candidate: Record; dropped: string[] } | null { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + + const doomed = new Map>(); + for (const issue of error.issues) { + if (isUnsalvageableIssue(issue)) return null; + const [section, id] = issue.path; + if (typeof section !== "string" || typeof id !== "string") return null; + if (!(SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return null; + // A complaint about the container itself ("combos must be an object") is + // not about one entry, so there is nothing selective to drop. + if (issue.path.length < 2) return null; + let ids = doomed.get(section); + if (!ids) doomed.set(section, ids = new Set()); + ids.add(id); + } + if (doomed.size === 0) return null; + + const candidate: Record = { ...(parsed as Record) }; + const dropped: string[] = []; + for (const [section, ids] of doomed) { + const current = candidate[section]; + if (!current || typeof current !== "object" || Array.isArray(current)) return null; + const kept: Record = {}; + for (const [key, value] of Object.entries(current as Record)) { + if (ids.has(key)) dropped.push(`${section}.${key}`); + else kept[key] = value; + } + candidate[section] = kept; + } + return dropped.length > 0 ? { candidate, dropped } : null; +} + +/** + * Salvage until the document parses, not just once. + * + * One pass is not enough because the sections depend on each other: routing + * profiles are validated against the combo map, so dropping an invalid combo can + * expose a profile that referenced it. A single-pass salvage sees that second + * failure and gives up, discarding the whole config -- the exact outcome this + * code exists to prevent. + * + * `rawDocument` is the operator's document before defaults were merged in. When + * supplied, the same entries are deleted from it too, so a diagnostics caller can + * still tell an absent optional setting from one we injected. + */ + +/** + * Findings that must never be salvaged away. + * + * Salvage removes the entry a finding blamed, which is right for an ordinary + * validation mistake and wrong for a namespace collision: the collision is a + * *relationship* between a combo/profile and a Codex account selector, and it is + * reported on the combo. Dropping that combo makes the document parse and quietly + * admits the account selector the schema just refused, turning a hard admission + * boundary into a config that loads. Refuse the whole document instead. + */ +const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [ + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, +]; + +function isUnsalvageableIssue(issue: z.ZodIssue): boolean { + return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message)); +} +function salvageConfigCandidate( + merged: unknown, + initialError: z.ZodError, + rawDocument?: unknown, +): { + candidate: Record; + rawCandidate: unknown; + parsed: OcxConfig; + dropped: string[]; + issues: z.ZodIssue[]; +} | null { + let candidate: unknown = merged; + let rawCandidate: unknown = rawDocument; + let error = initialError; + const dropped: string[] = []; + const issues: z.ZodIssue[] = []; + // Bounded by construction: every pass must remove at least one entry, and there + // are only so many entries to remove. + const budget = countSalvageableEntries(merged) + 1; + for (let pass = 0; pass < budget; pass++) { + const step = dropInvalidConfigSections(candidate, error); + if (!step || step.dropped.length === 0) return null; + dropped.push(...step.dropped); + issues.push(...error.issues); + candidate = step.candidate; + rawCandidate = deleteEntryPaths(rawCandidate, step.dropped); + const result = configSchema.safeParse(candidate); + if (result.success) { + return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues }; + } + error = result.error; + } + return null; +} + +function countSalvageableEntries(document: unknown): number { + if (!document || typeof document !== "object" || Array.isArray(document)) return 0; + let total = 0; + for (const section of SALVAGEABLE_CONFIG_SECTIONS) { + const value = (document as Record)[section]; + if (value && typeof value === "object" && !Array.isArray(value)) { + total += Object.keys(value as Record).length; + } + } + return total; +} + +/** Delete `section.id` entries from a copy of the raw document. */ +function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown { + if (!document || typeof document !== "object" || Array.isArray(document)) return document; + const next: Record = { ...(document as Record) }; + for (const entryPath of entryPaths) { + const separator = entryPath.indexOf("."); + if (separator <= 0) continue; + const section = entryPath.slice(0, separator); + const id = entryPath.slice(separator + 1); + const container = next[section]; + if (!container || typeof container !== "object" || Array.isArray(container)) continue; + const kept: Record = { ...(container as Record) }; + delete kept[id]; + next[section] = kept; + } + return next; +} + +/** + * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches + * the log unredacted. Static section names stay readable -- they are the part that + * tells the operator where to look. + */ +function redactEntryPath(entryPath: string): string { + const separator = entryPath.indexOf("."); + if (separator <= 0) return redactSecretString(entryPath); + return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1)); +} + +function redactIssuePath(path: readonly PropertyKey[]): string { + return path + .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment)))) + .join("."); +} + +function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void { + if (warnedConfigFallbacks.has(configPath)) return; + warnedConfigFallbacks.add(configPath); + const reasons = issues + .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`) + .join("; "); + console.error( + `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. ` + + "Everything else in your config, including providers and modelCosts, is preserved.", + ); +} + export function readPidFileValue(): number | null { try { return parsePidFile(readFileSync(getPidPath(), "utf-8")); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5ec0d918a7..1cd7d810cc 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1580,6 +1580,201 @@ describe("opencodex config defaults", () => { } }); + describe("one bad entry in an independent section does not discard the config (#1785)", () => { + /** Two usable providers, a disabled one, prices, and a profile worth keeping. */ + function configWith(extra: Record): void { + writeConfig({ + port: 10100, + providers: { + TR: { adapter: "openai-chat", baseUrl: "https://tr.example/v1", disabled: true }, + keep1: { adapter: "openai-chat", baseUrl: "https://keep1.example/v1" }, + keep2: { adapter: "openai-chat", baseUrl: "https://keep2.example/v1" }, + }, + modelCosts: { "keep1/m": { input: 1, output: 2 } }, + ...extra, + }); + } + + test("a candidate naming a disabled provider drops only its profile", () => { + configWith({ + routingProfiles: { + good: { candidates: [{ provider: "keep1", model: "m" }] }, + bad: { candidates: [{ provider: "TR", model: "moonshotai/kimi-k3" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const loaded = loadConfig() as Record; + + // The reported symptom was 11 providers on disk and 1 served. + expect(Object.keys(loaded.providers)).toEqual(expect.arrayContaining(["TR", "keep1", "keep2"])); + expect(loaded.modelCosts).toHaveProperty("keep1/m"); + expect(Object.keys(loaded.routingProfiles)).toEqual(["good"]); + + // Naming both the profile and why, so the operator can act on it. + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("routingProfiles.bad")); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("is disabled")); + } finally { + errorSpy.mockRestore(); + } + }); + + test("salvaging is not a fallback, so no invalid-* backup piles up", () => { + // The reporter accumulated 10 of these before noticing anything was wrong; + // a backup on every load is the signal that the config was discarded. + configWith({ routingProfiles: { bad: { candidates: [{ provider: "TR", model: "m" }] } } }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + loadConfig(); + expect(backupNames()).toEqual([]); + } finally { + errorSpy.mockRestore(); + } + }); + + + test("diagnostics keep the operator's config instead of reporting defaults", () => { + // The salvage in loadConfig was not enough on its own. readConfigDiagnostics returned + // getDefaultConfig(), and a config command writing that result back would have persisted + // built-in defaults over the operator's providers, keys and prices. + // + // The failure is still REPORTED -- source stays "fallback" and error keeps the schema + // message, which is what provider reload, catalog sync, cost reconcile and codex admission + // gate on. Only the config payload changes. + configWith({ + routingProfiles: { + good: { candidates: [{ provider: "keep1", model: "m" }] }, + bad: { candidates: [{ provider: "TR", model: "moonshotai/kimi-k3" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const diagnostics = readConfigDiagnostics(); + + // Still invalid, and still says why. + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("is disabled"); + + // But the payload is the operator's config, not the factory defaults. + const config = diagnostics.config as Record; + expect(Object.keys(config.providers)).toEqual(expect.arrayContaining(["TR", "keep1", "keep2"])); + expect(config.modelCosts).toHaveProperty("keep1/m"); + expect(Object.keys(config.routingProfiles ?? {})).toEqual(["good"]); + } finally { + errorSpy.mockRestore(); + } + }); + + test("salvage repeats when dropping one entry exposes a new failure", () => { + // Sections are not independent of each other: a profile alias is validated against the + // combo map, so removing an invalid combo can surface a NEW failure in a profile that + // was fine while that combo existed. A single-pass salvage saw that second failure and + // discarded the whole config -- the outcome this exists to stop. + configWith({ + combos: { + badCombo: { members: [{ provider: "nope-not-configured", model: "m" }] }, + }, + routingProfiles: { + alsoBad: { candidates: [{ provider: "TR", model: "m" }] }, + good: { candidates: [{ provider: "keep2", model: "m" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const loaded = loadConfig() as Record; + + // Everything unrelated survives, and both bad entries are gone. + expect(Object.keys(loaded.providers)).toEqual(expect.arrayContaining(["TR", "keep1", "keep2"])); + expect(loaded.modelCosts).toHaveProperty("keep1/m"); + expect(Object.keys(loaded.combos ?? {})).not.toContain("badCombo"); + expect(Object.keys(loaded.routingProfiles ?? {})).toEqual(["good"]); + } finally { + errorSpy.mockRestore(); + } + }); + + test("a token-shaped entry id is not echoed into the warning", () => { + // Entry ids are operator-chosen and can be pasted secrets. The warning names the + // section so the operator knows where to look, but nothing dynamic goes out raw. + const tokenId = "sk-ant-api03-" + "A".repeat(40); + configWith({ + routingProfiles: { + [tokenId]: { candidates: [{ provider: "TR", model: "m" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + loadConfig(); + const logged = errorSpy.mock.calls.map(call => String(call[0])).join("\n"); + expect(logged).toContain("routingProfiles."); + expect(logged).not.toContain(tokenId); + } finally { + errorSpy.mockRestore(); + } + }); + test("combos are salvaged the same way", () => { + configWith({ + combos: { + good: { members: [{ provider: "keep1", model: "m" }] }, + bad: { members: [{ provider: "nope-not-configured", model: "m" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + const loaded = loadConfig() as Record; + expect(Object.keys(loaded.providers)).toEqual(expect.arrayContaining(["keep1", "keep2"])); + expect(Object.keys(loaded.combos ?? {})).not.toContain("bad"); + } finally { + errorSpy.mockRestore(); + } + }); + + test("every bad profile is dropped, not just the first", () => { + configWith({ + routingProfiles: { + bad1: { candidates: [{ provider: "TR", model: "m" }] }, + good: { candidates: [{ provider: "keep1", model: "m" }] }, + bad2: { candidates: [{ provider: "also-not-configured", model: "m" }] }, + }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(Object.keys((loadConfig() as Record).routingProfiles)).toEqual(["good"]); + } finally { + errorSpy.mockRestore(); + } + }); + + test("a failure outside those sections still falls back, unchanged", () => { + // The salvage path must not become a way to load configs that are broken + // somewhere it cannot reason about. + writeConfig({ + port: 10100, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", headers: { Authorization: "Bearer secret" } }, + }, + routingProfiles: { bad: { candidates: [{ provider: "nope", model: "m" }] } }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(backupNames()).toHaveLength(1); + } finally { + errorSpy.mockRestore(); + } + }); + + test("a malformed container is not an entry, so it is not salvaged", () => { + configWith({ routingProfiles: [] }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + } finally { + errorSpy.mockRestore(); + } + }); + }); + test("provider names reject namespace-breaking and reserved object keys", () => { expect(isValidProviderName("openrouter")).toBe(true); expect(isValidProviderName("ollama-cloud")).toBe(true); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 0c04384619..335392ea8d 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../src/config"; +import { getConfigPath, saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../src/server/gui-static"; @@ -586,6 +586,50 @@ describe("management and data-plane credential separation", () => { } }); + + test("config salvage does not weaken the management auth boundary (#1785)", async () => { + // Salvage keeps a config loading after dropping an invalid entry. That must not turn into + // a way to reach the management plane: the credential separation is enforced before route + // dispatch, and a partially-salvaged config has to behave exactly like a clean one. + const salvageable = remoteConfig() as Record; + salvageable.routingProfiles = { + good: { candidates: [{ provider: "test", model: "gpt-test" }] }, + bad: { candidates: [{ provider: "not-configured", model: "gpt-test" }] }, + }; + writeFileSync(getConfigPath(), JSON.stringify(salvageable, null, 2), { mode: 0o600 }); + + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const server = startServer(0); + try { + const anonymous = await fetch(new URL("/api/config", server.url)); + expect(anonymous.status).toBe(401); + + const withDataToken = await fetch(new URL("/api/config", server.url), { + headers: { "x-opencodex-api-key": "data-secret" }, + }); + expect(withDataToken.status).toBe(401); + + const withWrongAdminToken = await fetch(new URL("/api/config", server.url), { + headers: { "x-opencodex-api-key": "not-the-admin-secret" }, + }); + expect(withWrongAdminToken.status).toBe(401); + + const withAdminToken = await fetch(new URL("/api/config", server.url), { + headers: { "x-opencodex-api-key": "admin-secret" }, + }); + expect(withAdminToken.status).toBe(200); + + // The salvaged config is what the authorized caller sees: the provider survives and only + // the invalid profile is gone. A fallback here would hand back built-in defaults, which a + // later write would persist over the operator's providers. + const body = await withAdminToken.json() as Record; + expect(Object.keys(body.providers ?? {})).toContain("test"); + expect(Object.keys(body.routingProfiles ?? {})).not.toContain("bad"); + } finally { + await server.stop(true); + errorSpy.mockRestore(); + } + }); test("a management token that matches the data environment token closes only the management plane", async () => { process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "data-secret"; saveConfig(remoteConfig());