diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 624950dd5e..fc8a92ccd4 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -133,6 +133,21 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +## Codex catalog and root `config.toml` settings + +These settings belong in the root of `$CODEX_HOME/config.toml`, alongside +`approvals_reviewer`; they are not provider fields. + +| Field | Type | Meaning | +| --- | --- | --- | +| `auto_review_model` | `string` | Public catalog selector in `provider/model` form, for example `opencode-go/deepseek-v4-flash`. After each catalog merge, OpenCodex resolves it against the final catalog and stamps the trimmed value as `auto_review_model_override` on catalog entries. Boundary whitespace is removed; the selector's slash-delimited components are otherwise unchanged. If the value is absent or blank, existing routed overrides are cleared and normal upstream auto-review selection is preserved. If it is syntactically invalid or absent from the final catalog (including after provider/model removal), OpenCodex fails closed for the override only: it clears the dead override, preserves normal upstream behavior, and emits a diagnostic. Re-adding the provider/model on a later sync allows the configured selector to be stamped again. | + +The setting is evaluated after provider discovery, model filtering, native/account-row +projection, and merge precedence, so only a selector present in the catalog produced by +that sync can become an override. Native upstream values are preserved when the setting is +cleared or unresolved. The persisted catalog field is read by Codex for the current turn's +model, which is why a valid configured selector is copied to each applicable entry. + ### FastWire B1 capability migration Fast capability and arbitrary Chat caller-tier forwarding are independent after FastWire B1. The diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 07f9599f50..ebb77d3359 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -34,6 +34,8 @@ export type ClaudeEnvDeps = { authDetect?: Omit, "env" | "ownTokens">; /** Test seam; production uses the authenticated Node-launcher context. */ preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null; + /** Explicit unsafe opt-in from a root `--dangerously-skip-permissions` launch. */ + allowRootSkipPermissions?: boolean; }; function isClaudeLoopbackHostname(hostname: string): boolean { @@ -115,6 +117,9 @@ export function buildClaudeEnv( if (env[name] !== undefined && env[name] !== "") return; // user wins env[name] = value; }; + if (deps.allowRootSkipPermissions === true) { + setDefault("IS_SANDBOX", "1"); + } setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`); const existingBaseUrl = env.ANTHROPIC_BASE_URL; if (existingBaseUrl) { @@ -301,6 +306,22 @@ export function claudeNotFoundHint( return platform === "win32" && code === 9009 && !signal ? CLAUDE_INSTALL_HINT : null; } +export function shouldAllowRootSkipPermissions( + args: readonly string[], + getuid: (() => number) | null | undefined = process.getuid, +): boolean { + return args.includes("--dangerously-skip-permissions") + && typeof getuid === "function" + && getuid() === 0; +} + +export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string { + if (env.IS_SANDBOX === "1") { + return "⚠ Root --dangerously-skip-permissions requested: OpenCodex set IS_SANDBOX=1 to bypass Claude Code's root guard. OpenCodex did not create an OS sandbox; prefer running as a non-root user."; + } + return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`; +} + export async function cmdClaude(args: string[]): Promise { const config = loadConfig(); if (config.claudeCode?.enabled === false) { @@ -313,7 +334,11 @@ export async function cmdClaude(args: string[]): Promise { return 1; } const contextWindows = await fetchClaudeContextWindows(config, port); - const env = buildClaudeEnv(config, port, process.env, contextWindows); + const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); + const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions }); + if (allowRootSkipPermissions) { + console.error(rootSkipPermissionsNotice(env)); + } // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI // never refreshes it, so the picker would keep showing yesterday's aliases. try { diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 8a0acb06fd..ce7eaf1615 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync"; export type { ObservedCatalogMergeInput } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 7d150567d9..0293b4bd04 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -223,6 +223,22 @@ export function readCodexCatalogPathForHome(codexHome: string): string { return join(codexHome, "opencodex-catalog.json"); } +/** + * Read the configured auto-review model from the root of Codex's config.toml (issue #1225). + * Stamped onto catalog entries as `auto_review_model_override` during sync so the auto-review + * subagent uses the operator's chosen model across catalog regenerations. + */ +export function readConfiguredAutoReviewModel(): string | null { + try { + const configPath = activeCodexConfigPath(); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + return readRootTomlString(toml, "auto_review_model"); + } + } catch { /* ignore */ } + return null; +} + export function parseCatalogJson(raw: string): RawCatalog | null { try { const cat = JSON.parse(raw); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d5a894cd0e..fda9724849 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, 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, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1403,6 +1403,130 @@ function catalogModelsForMergeWithNativeRecovery( ]); } +const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/; + +export function isValidAutoReviewModel(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + return Boolean(trimmed) + && trimmed.length <= 1024 + && !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed); +} + +export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved"; + +function isRoutedCatalogEntry(entry: RawEntry): boolean { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return slug.includes("/") + || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); +} + +function clearAutoReviewModelOverride( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[] = [], +): void { + const observedModels = [...models, ...sourceModels]; + const configuredValues = new Set(observedModels.flatMap(entry => { + const value = entry?.auto_review_model_override; + return typeof value === "string" && value.trim() ? [value] : []; + })); + const globalStamp = configuredValues.size === 1 + && observedModels.some(entry => { + const value = entry.auto_review_model_override; + return isRoutedCatalogEntry(entry) + && typeof value === "string" + && value.trim().length > 0 + && configuredValues.has(value); + }) + && observedModels.every(entry => { + const value = entry?.auto_review_model_override; + return value === null + || value === undefined + || (typeof value === "string" && configuredValues.has(value)); + }); + for (const entry of models) { + if (!entry || typeof entry !== "object") continue; + const current = entry.auto_review_model_override; + if (isRoutedCatalogEntry(entry) + || (globalStamp && typeof current === "string" && configuredValues.has(current))) { + entry.auto_review_model_override = null; + } + } +} + +function warnAutoReviewModelDiagnostic( + reason: "invalid" | "unresolved", + configured: string, +): void { + const safeConfigured = JSON.stringify(redactSecretString(configured)); + const detail = reason === "unresolved" + ? "the selector was not found in the final catalog" + : "the selector format is invalid"; + console.warn( + `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`, + ); +} + +function preserveNativeAutoReviewModelOverrides( + models: readonly RawEntry[], + sourceModels: readonly RawEntry[], +): void { + const existing = new Map(); + for (const entry of sourceModels) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + const value = entry.auto_review_model_override; + if (!slug || isRoutedCatalogEntry(entry)) continue; + if (typeof value === "string" || value === null) existing.set(slug, value); + } + for (const entry of models) { + const slug = typeof entry.slug === "string" ? entry.slug : undefined; + if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue; + entry.auto_review_model_override = existing.get(slug) ?? null; + } +} + +export function applyAutoReviewModelOverride( + models: RawEntry[] | undefined, + autoReviewModel: string | null | undefined, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (!models || !Array.isArray(models)) return "absent"; + if (autoReviewModel === null || autoReviewModel === undefined) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + const trimmed = autoReviewModel.trim(); + if (!trimmed) { + clearAutoReviewModelOverride(models, sourceModels); + return "absent"; + } + if (!isValidAutoReviewModel(trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("invalid", trimmed); + return "invalid"; + } + if (!configuredCatalogEntry(models, trimmed)) { + clearAutoReviewModelOverride(models, sourceModels); + warnAutoReviewModelDiagnostic("unresolved", trimmed); + return "unresolved"; + } + for (const entry of models) { + if (entry && typeof entry === "object") { + entry.auto_review_model_override = trimmed; + } + } + return "applied"; +} + +/** Apply the root Codex auto-review selector after the final catalog merge. */ +export function finalizeAutoReviewModelOverride( + models: RawEntry[] | undefined, + sourceModels: readonly RawEntry[] = [], +): AutoReviewModelOverrideResult { + if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels); + return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); +} + function writeRetainedCatalogSync({ config, goModels, @@ -1596,6 +1720,7 @@ function writeRetainedCatalogSync({ }, }); clampCatalogModelsToCodexSupport(catalog.models); + finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge); const added = goEntries.length + accountBoundEntries.length; const content = `${JSON.stringify(catalog, null, 2)}\n`; @@ -1833,10 +1958,10 @@ export function invalidateCodexModelsCacheWithPermit( // keeps the cache consistent with the catalog it just wrote. if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; const catalogPath = readCodexCatalogPathForHome(owningCodexHome); - const cachePath = join(owningCodexHome, "models_cache.json"); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; + const cachePath = join(owningCodexHome, "models_cache.json"); const currentCache = readCatalog(cachePath); const existingSlugs = new Set(models.flatMap((entry: RawEntry) => typeof entry.slug === "string" ? [entry.slug] : [])); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 67d09ec873..b338aa9d3b 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -42,6 +42,7 @@ import { import { buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + finalizeAutoReviewModelOverride, mergeCatalogEntriesFromObservedState, mergeCatalogModelsWithNativeRecovery, orderForSubagents, @@ -371,6 +372,7 @@ function prepareCatalog( ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog) : null, ); + finalizeAutoReviewModelOverride(mergedModels, catalogModels); catalog.models = mergedModels; return catalog; } diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index e782252bf4..aa0950a9b8 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { claudeNotFoundHint } from "../src/cli/claude"; +import { buildClaudeEnv, claudeNotFoundHint, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; import { commandInvocation } from "../src/lib/win-exec"; -import { buildClaudeEnv } from "../src/cli/claude"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -27,6 +26,40 @@ const AUTH_PRESENT = { }; describe("ocx claude env assembly", () => { + test("root skip-permissions bypass requires both the explicit flag and uid 0", () => { + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 0)).toBe(true); + expect(shouldAllowRootSkipPermissions([], () => 0)).toBe(false); + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 1000)).toBe(false); + expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], null)).toBe(false); + }); + + test("root skip-permissions opt-in marks only that launch as sandboxed", () => { + const bypass = buildClaudeEnv(cfg(), 10100, {}, {}, { + ...AUTH_PRESENT, + allowRootSkipPermissions: true, + }); + expect(bypass.IS_SANDBOX).toBe("1"); + + const ordinary = buildClaudeEnv(cfg(), 10100, {}, {}, AUTH_PRESENT); + expect(ordinary.IS_SANDBOX).toBeUndefined(); + }); + + test("an explicit user sandbox value wins over the root skip-permissions opt-in", () => { + const env = buildClaudeEnv(cfg(), 10100, { IS_SANDBOX: "0" }, {}, { + ...AUTH_PRESENT, + allowRootSkipPermissions: true, + }); + expect(env.IS_SANDBOX).toBe("0"); + expect(rootSkipPermissionsNotice(env)).toContain("preserving user IS_SANDBOX=0"); + expect(rootSkipPermissionsNotice(env)).toContain("root guard remains in control"); + }); + + test("the unsafe root bypass notice discloses that no OS sandbox was created", () => { + const notice = rootSkipPermissionsNotice({ IS_SANDBOX: "1" }); + expect(notice).toContain("set IS_SANDBOX=1"); + expect(notice).toContain("did not create an OS sandbox"); + }); + test("injects base URL, discovery flag and model slots — NO auth token by default (subscription mode)", () => { const env = buildClaudeEnv(cfg({ claudeCode: { model: "claude-ocx-gemini--gemini-3-pro", smallFastModel: "gemini/gemini-3-flash" }, diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 99a10ff559..e507e8778f 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -5684,6 +5684,79 @@ describe("Codex reasoning-effort capability clamp", () => { expect(models).toEqual(before); }); }); + +describe("auto_review_model configuration (#1225)", () => { + test("applyAutoReviewModelOverride sets auto_review_model_override across all entries", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: null }, + ]; + + applyAutoReviewModelOverride(entries, " opencode-go/deepseek-v4-flash "); + expect(entries[0].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + expect(entries[1].auto_review_model_override).toBe("opencode-go/deepseek-v4-flash"); + }); + + test("applyAutoReviewModelOverride clears routed state when autoReviewModel is null or empty", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "existing-model" }, + { slug: "opencode-go/glm-5.2", auto_review_model_override: "old-model" }, + ]; + + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + expect(entries[1].auto_review_model_override).toBeNull(); + applyAutoReviewModelOverride(entries, " "); + expect(entries[0].auto_review_model_override).toBe("existing-model"); + expect(entries[1].auto_review_model_override).toBeNull(); + }); + + test("applyAutoReviewModelOverride rejects invalid format with control chars or inner spaces", () => { + const { applyAutoReviewModelOverride, isValidAutoReviewModel } = require("../src/codex/catalog/sync"); + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: "native-preserved" }, + ]; + + expect(isValidAutoReviewModel("valid/model-slug_1")).toBe(true); + expect(isValidAutoReviewModel("invalid slug with spaces")).toBe(false); + expect(isValidAutoReviewModel("invalid\x00slug")).toBe(false); + applyAutoReviewModelOverride(entries, "invalid slug with spaces"); + expect(entries[0].auto_review_model_override).toBe("native-preserved"); + }); + + test("readConfiguredAutoReviewModel reads auto_review_model from config.toml", () => { + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + }); + + test("writeRetainedCatalogSync stamps auto_review_model_override into persisted catalog", () => { + const { applyAutoReviewModelOverride } = require("../src/codex/catalog/sync"); + const { readConfiguredAutoReviewModel } = require("../src/codex/catalog/parsing"); + + // Simulate a config-driven write path: entries are regenerated from a template, + // then the override is stamped before serialization. + const entries = [ + { slug: "gpt-5.5", auto_review_model_override: null }, + { slug: "opencode-go/deepseek-v4-flash", auto_review_model_override: "old-model" }, + ]; + const configuredValue = " opencode-go/deepseek-v4-flash "; + const trimmedValue = configuredValue.trim(); + + expect(typeof readConfiguredAutoReviewModel).toBe("function"); + + // Absent value: no override is written. + applyAutoReviewModelOverride(entries, null); + expect(entries[0].auto_review_model_override).toBeNull(); + expect(entries[1].auto_review_model_override).toBeNull(); + + // Present value: trimmed override replaces every entry (including native rows). + applyAutoReviewModelOverride(entries, configuredValue); + expect(entries[0].auto_review_model_override).toBe(trimmedValue); + expect(entries[1].auto_review_model_override).toBe(trimmedValue); + }); +}); import { ManagementRequest as Request } from "./helpers/management-auth"; describe("#2465 model preset management routes", () => { diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 9206aaa9e4..9f4be0f031 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -179,6 +179,34 @@ function config(pickerEnabled: boolean, disabledModels: string[] = []): OcxConfi }; } +function autoReviewConfig(models: string[]): OcxConfig { + const nextConfig = config(false); + nextConfig.providers.static = { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models, + }; + return nextConfig; +} + +function writeAutoReviewModel(value?: string): void { + writeFileSync( + join(codexHome, "config.toml"), + value === undefined ? "" : `auto_review_model = ${JSON.stringify(value)}\n`, + ); +} + +function autoReviewSeed(routeOverride: string | null = "stale-override"): RawEntry[] { + return [ + { ...nativeEntry(), slug: "gpt-5.4", auto_review_model_override: "native-upstream" }, + { + ...generatedRoutedEntry("static/deepseek-v4-flash"), + auto_review_model_override: routeOverride, + }, + ]; +} + function writeCatalog(models: RawEntry[]): void { writeFileSync(catalogPath, `${JSON.stringify({ models }, null, 2)}\n`); } @@ -608,6 +636,75 @@ test("retained sync removes a deleted pre-marker custom row while discovery is d expect(models.some(entry => entry.slug === "offline/discovered-sibling")).toBe(true); }); +test("retained and convergence writers resolve, clear, reject, and recover auto-review selectors", async () => { + primeCodexRuntimeFixture(); + + for (const writer of ["retained", "convergence"] as const) { + const write = async (nextConfig: OcxConfig): Promise => { + if (writer === "retained") { + const result = await syncCatalogModels(nextConfig); + expect(result.catalogWritten).toBe(true); + } else { + const disposition = await convergeCatalogDisposition(nextConfig); + expect(disposition).toMatchObject({ status: "committed" }); + } + return JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + }; + + // A configured selector is resolved against the final catalog and trimmed before stamping. + writeAutoReviewModel(" static/deepseek-v4-flash "); + writeCatalog(autoReviewSeed()); + let catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); + + // Clearing the root key removes stale routed state while preserving an upstream native value. + writeAutoReviewModel(); + writeCatalog(autoReviewSeed()); + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "gpt-5.4")) + .toHaveProperty("auto_review_model_override", "native-upstream"); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", null); + + // A syntactically valid but missing selector is diagnosed and cannot persist a dead override. + writeAutoReviewModel("static/missing-model"); + writeCatalog(autoReviewSeed()); + const unresolvedWarning = spyOn(console, "warn").mockImplementation(() => {}); + let unresolvedWarningCalls: unknown[][] = []; + try { + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + unresolvedWarningCalls = unresolvedWarning.mock.calls; + } finally { + unresolvedWarning.mockRestore(); + } + expect(unresolvedWarningCalls.some(call => String(call[0]).includes("not found in the final catalog"))).toBe(true); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", null); + + // Removing the configured model from the provider makes the target unresolved; recovery + // must stamp it again once the model is advertised by the next catalog. + writeAutoReviewModel("static/deepseek-v4-flash"); + writeCatalog(autoReviewSeed()); + const removedWarning = spyOn(console, "warn").mockImplementation(() => {}); + let removedWarningCalls: unknown[][] = []; + try { + catalog = await write(autoReviewConfig([])); + removedWarningCalls = removedWarning.mock.calls; + } finally { + removedWarning.mockRestore(); + } + expect(removedWarningCalls.some(call => String(call[0]).includes("not found in the final catalog"))).toBe(true); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")).toBeUndefined(); + + writeAutoReviewModel("static/deepseek-v4-flash"); + writeCatalog(autoReviewSeed(null)); + catalog = await write(autoReviewConfig(["deepseek-v4-flash"])); + expect(catalog.models?.find(entry => entry.slug === "static/deepseek-v4-flash")) + .toHaveProperty("auto_review_model_override", "static/deepseek-v4-flash"); + } +}); + test("degraded preservation still honors explicit routed visibility policy", async () => { writeCatalog([ nativeEntry(),