From 435ade0c3fe2059b5d17f982da0b0a91413cfcfb Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Wed, 5 Aug 2026 22:42:20 -0400 Subject: [PATCH] feat(codex): add account picker visibility setting --- src/codex/account-namespaces.ts | 11 ++++ src/config.ts | 39 ++++++++++- src/types.ts | 5 ++ tests/codex-account-namespaces.test.ts | 24 +++++++ tests/config.test.ts | 90 +++++++++++++++++++++++++- tests/router.test.ts | 10 +++ 6 files changed, 177 insertions(+), 2 deletions(-) diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 6c41850b54..4ebf6affa4 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -147,3 +147,14 @@ export function codexAccountNamespaceEntries( return Object.entries(config.codexAccountNamespaces ?? {}) .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); } + +/** + * Whether generated account-qualified rows are enabled for catalog discovery. + * A non-empty hand-written map predating the explicit override remains enabled. + */ +export function codexAccountPickerEnabled( + config: Pick, +): boolean { + return (config.codexAccountPickerEnabled === undefined || config.codexAccountPickerEnabled === true) + && Object.keys(config.codexAccountNamespaces ?? {}).length > 0; +} diff --git a/src/config.ts b/src/config.ts index 784c475fff..355da23d0a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1060,6 +1060,9 @@ const configSchema = z.object({ // pool accounts. Warning emitted in loadConfig. codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined), activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined), + // A malformed hand edit must degrade to false without discarding providers, accounts, + // or the exact selector map. Live writes remain strict. + codexAccountPickerEnabled: z.boolean().optional().catch(false), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -1712,6 +1715,18 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField return `${field} ignored: expected ${expected}`; } +function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null; + if (typeof raw.codexAccountPickerEnabled === "boolean") return null; + return "codexAccountPickerEnabled ignored: expected a boolean"; +} + +function warnDegradedCodexAccountPicker(rawParsed: unknown): void { + const warning = malformedCodexAccountPickerWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null { if (config.syncCodexSubagentDefaults !== true) return null; const malformed = malformedNativeSubagentFields(rawParsed); @@ -1763,6 +1778,7 @@ export function loadConfig(): OcxConfig { warnDegradedCodexAccountPriorities(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); } // Schema validation failed — merge defaults into the raw object instead of @@ -1783,6 +1799,7 @@ export function loadConfig(): OcxConfig { warnDegradedCodexAccountPriorities(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); } // Merge couldn't fix it — truly broken config @@ -1832,6 +1849,8 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`); } warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); + const pickerWarning = malformedCodexAccountPickerWarning(rawParsed); + if (pickerWarning) warnings.push(pickerWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -1935,13 +1954,31 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted"; } +function codexAccountPickerEnabledError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled"); + if (!descriptor) { + return "codexAccountPickerEnabled" in raw + ? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted" + : null; + } + if (!("value" in descriptor)) { + return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"; + } + const enabled = descriptor.value; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted"; +} + /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { const boundaryError = blankHostnameError(value) ?? claudeSubagentEffortError(value) ?? appOwnedMemoryBudgetError(value) ?? googleAntigravityStaticCatalogVersionError(value) - ?? codexAccountPrioritiesError(value); + ?? codexAccountPrioritiesError(value) + ?? codexAccountPickerEnabledError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) }; diff --git a/src/types.ts b/src/types.ts index 8d877b66b0..de6c530e46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -782,6 +782,11 @@ export interface OcxConfig { * are intentionally separate from these selectors. */ codexAccountNamespaces?: Record; + /** + * Picker visibility override for account-qualified native models. When omitted, a non-empty + * selector map remains visible for compatibility with hand-written configurations. + */ + codexAccountPickerEnabled?: boolean; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts index 5083f7c99c..b510112262 100644 --- a/tests/codex-account-namespaces.test.ts +++ b/tests/codex-account-namespaces.test.ts @@ -12,6 +12,7 @@ import { } from "../src/codex/account-namespace-match"; import { appendDefaultCodexAccountNamespace, + codexAccountPickerEnabled, codexAccountNamespaceEntries, defaultCodexAccountNamespaces, isMainCodexAccountTarget, @@ -306,6 +307,29 @@ describe("Codex account namespace foundations", () => { })).toEqual({ "main-2": "@main", p454545: "main" }); }); + test("keeps existing selector maps enabled unless the visibility override is false", () => { + expect(codexAccountPickerEnabled({ codexAccountNamespaces: { desktop: "@main" } })).toBe(true); + expect(codexAccountPickerEnabled({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + })).toBe(true); + expect(codexAccountPickerEnabled({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: false, + })).toBe(false); + expect(codexAccountPickerEnabled({ + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + })).toBe(false); + expect(codexAccountPickerEnabled({})).toBe(false); + for (const malformed of [null, "false", 0, {}, []]) { + expect(codexAccountPickerEnabled({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: malformed as never, + })).toBe(false); + } + }); + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { const inherited = Object.create({ inherited: "account-id" }) as Record; inherited.side = "side-account-id"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 0f233a52d8..ec75200ded 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1516,7 +1516,7 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); - test("persists an explicit Codex account selector map without enabling it by default", () => { + test("persists an explicit Codex account selector map without adding one to defaults", () => { const selectors = { desktop: "@main", work: "work-account", @@ -1531,6 +1531,94 @@ describe("opencodex config defaults", () => { expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); }); + test("persists the optional picker override without adding it to defaults", () => { + for (const enabled of [true, false]) { + writeAccountNamespaceConfig({ desktop: "@main" }, { codexAccountPickerEnabled: enabled }); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountPickerEnabled).toBe(enabled); + } + + expect(Object.hasOwn(getDefaultConfig(), "codexAccountPickerEnabled")).toBe(false); + }); + + test("malformed persisted picker visibility fails closed without discarding accounts or providers", () => { + writeAccountNamespaceConfig({ desktop: "@main", side: "stored-account" }, { + codexAccountPickerEnabled: "yes", + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "stored-account", email: "side@example.test", isMain: false }, + ], + }); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics).toMatchObject({ + source: "file", + error: null, + config: { + defaultProvider: "openai", + providers: { openai: { baseUrl: "https://chatgpt.com/backend-api/codex" } }, + codexAccounts: [ + { id: "main", email: "main@example.test", isMain: true }, + { id: "stored-account", email: "side@example.test", isMain: false }, + ], + codexAccountNamespaces: { desktop: "@main", side: "stored-account" }, + codexAccountPickerEnabled: false, + }, + }); + expect(diagnostics.warnings).toContain( + "codexAccountPickerEnabled ignored: expected a boolean", + ); + expect(backupNames()).toEqual([]); + + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(loadConfig()).toMatchObject({ + codexAccountPickerEnabled: false, + codexAccountNamespaces: { desktop: "@main", side: "stored-account" }, + providers: { openai: { baseUrl: "https://chatgpt.com/backend-api/codex" } }, + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("codexAccountPickerEnabled ignored")); + } finally { + warnSpy.mockRestore(); + } + + for (const invalid of [null, "false", 1]) { + expect(validateConfigCandidate({ + ...getDefaultConfig(), + codexAccountPickerEnabled: invalid, + })).toMatchObject({ + ok: false, + error: expect.stringContaining("codexAccountPickerEnabled"), + }); + } + + const inherited = Object.assign( + Object.create({ codexAccountPickerEnabled: true }) as Record, + getDefaultConfig(), + ); + expect(validateConfigCandidate(inherited)).toMatchObject({ + ok: false, + error: expect.stringContaining("own boolean data property"), + }); + + let getterCalls = 0; + const accessor = { ...getDefaultConfig() } as Record; + Object.defineProperty(accessor, "codexAccountPickerEnabled", { + enumerable: true, + get() { + getterCalls += 1; + return true; + }, + }); + expect(validateConfigCandidate(accessor)).toMatchObject({ + ok: false, + error: expect.stringContaining("own boolean data property"), + }); + expect(getterCalls).toBe(0); + }); + test("validates Claude Desktop profiles and Codex account selectors independently", () => { const desktopProfile = { version: 1, diff --git a/tests/router.test.ts b/tests/router.test.ts index c4d992516a..e8ec59c2af 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -130,6 +130,16 @@ describe("routeModel registry effort defaults", () => { }); expect(() => routeModel(config, "side/claude-opus-4-6")) .toThrow("only supports native OpenAI model ids"); + + config.codexAccountPickerEnabled = false; + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "pool", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + provider: { authMode: "forward" }, + }); }); test("requires an enabled canonical OpenAI forward provider before exact credential injection", () => {