Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/codex/account-namespaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OcxConfig, "codexAccountNamespaces" | "codexAccountPickerEnabled">,
): boolean {
return (config.codexAccountPickerEnabled === undefined || config.codexAccountPickerEnabled === true)
&& Object.keys(config.codexAccountNamespaces ?? {}).length > 0;
Comment thread
chrisae9 marked this conversation as resolved.
}
39 changes: 38 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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) };
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,11 @@ export interface OcxConfig {
* are intentionally separate from these selectors.
*/
codexAccountNamespaces?: Record<string, string>;
/**
* 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;
Comment thread
chrisae9 marked this conversation as resolved.
/** Active pool account id for next session. undefined = main (passthrough as-is). */
activeCodexAccountId?: string;
/** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
Expand Down
24 changes: 24 additions & 0 deletions tests/codex-account-namespaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "../src/codex/account-namespace-match";
import {
appendDefaultCodexAccountNamespace,
codexAccountPickerEnabled,
codexAccountNamespaceEntries,
defaultCodexAccountNamespaces,
isMainCodexAccountTarget,
Expand Down Expand Up @@ -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<string, string>;
inherited.side = "side-account-id";
Expand Down
90 changes: 89 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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<string, unknown>,
getDefaultConfig(),
);
expect(validateConfigCandidate(inherited)).toMatchObject({
ok: false,
error: expect.stringContaining("own boolean data property"),
});

let getterCalls = 0;
const accessor = { ...getDefaultConfig() } as Record<string, unknown>;
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,
Expand Down
10 changes: 10 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test("requires an enabled canonical OpenAI forward provider before exact credential injection", () => {
Expand Down
Loading