Skip to content

Commit 2491f13

Browse files
authored
Merge pull request #836 from lidge-jun/agent/fix-default-catalog-preservation-814
fix(catalog): preserve on-disk rows on default sync
2 parents 030e4d6 + b52f316 commit 2491f13

3 files changed

Lines changed: 73 additions & 4 deletions

File tree

src/codex/catalog/sync.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
543543
const catalog = loadCatalogForSync(catalogPath);
544544
if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
545545

546+
// The bundled catalog is a reliable native template on the default path, but it is not the
547+
// merge source. Preservation must inspect the file that this sync is about to overwrite;
548+
// otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
549+
const onDiskCatalog = readCatalog(catalogPath);
550+
const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? [];
551+
546552
const template = findNativeTemplate(catalog);
547553

548554
const comboOmissions: ComboCatalogOmission[] = [];
@@ -585,7 +591,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{
585591
// bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
586592
// providers are configured yet (fresh install / catalog bootstrap tests).
587593
const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai;
588-
catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
594+
catalog.models = mergeCatalogEntriesForSync(catalogModelsForMerge, goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi);
589595
clampCatalogModelsToCodexSupport(catalog.models);
590596

591597
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");

structure/03_catalog-and-subagents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
rather than assuming a single file;
2121
- invalidates `$CODEX_HOME/models_cache.json` when model visibility changes.
2222

23+
On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's
24+
bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies
25+
the rows being merged. This split is required because empty or partial provider discovery must
26+
preserve routed entries and genuine user-native rows from the file that will be overwritten; a
27+
bundled catalog never contains those rows.
28+
2329
Codex App model picker visibility comes from this shared catalog, not from patching the App.
2430

2531
Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding,

tests/codex-catalog-sync-hardening.test.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,49 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
33
import { spawnSync } from "node:child_process";
44
import { tmpdir } from "node:os";
55
import { dirname, join, resolve } from "node:path";
66
import { fileURLToPath } from "node:url";
77

88
const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
99

10-
function runScript(codexHome: string, opencodexHome: string, script: string): { stdout: string; status: number; stderr: string } {
10+
function runScript(
11+
codexHome: string,
12+
opencodexHome: string,
13+
script: string,
14+
extraEnv: Record<string, string> = {},
15+
): { stdout: string; status: number; stderr: string } {
1116
const result = spawnSync(process.execPath, ["--eval", script], {
1217
cwd: repoRoot,
13-
env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome },
18+
env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: opencodexHome, ...extraEnv },
1419
encoding: "utf8",
1520
});
1621
return { stdout: result.stdout?.trim() ?? "", stderr: result.stderr ?? "", status: result.status ?? 1 };
1722
}
1823

24+
function createCodexCatalogFixture(dir: string): string {
25+
const scriptPath = join(dir, "codex-catalog-fixture.js");
26+
const bundled = JSON.stringify({ models: [nativeEntry("gpt-5.5", 0)] });
27+
writeFileSync(scriptPath, [
28+
'if (process.argv.includes("--version")) {',
29+
' console.log("codex-cli 0.999.0");',
30+
'} else {',
31+
` process.stdout.write(${JSON.stringify(bundled)});`,
32+
'}',
33+
].join("\n"), "utf8");
34+
35+
if (process.platform === "win32") {
36+
const commandPath = join(dir, "codex-catalog-fixture.cmd");
37+
writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`, "utf8");
38+
return commandPath;
39+
}
40+
41+
const commandPath = join(dir, "codex-catalog-fixture");
42+
writeFileSync(commandPath, `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}" "$@"\n`, "utf8");
43+
chmodSync(commandPath, 0o755);
44+
return commandPath;
45+
}
46+
1947
function nativeEntry(slug: string, priority: number): Record<string, unknown> {
2048
return {
2149
slug,
@@ -118,6 +146,35 @@ describe("Codex catalog sync hardening", () => {
118146
expect(slugs).toContain("gpt-5.5");
119147
});
120148

149+
test("default catalog path merges from disk instead of replacing it with bundled rows", () => {
150+
const catalogPath = join(codexHome, "opencodex-catalog.json");
151+
writeFileSync(join(codexHome, "config.toml"), 'openai_base_url = "http://127.0.0.1:10100/v1"\n', "utf8");
152+
writeFileSync(catalogPath, JSON.stringify({
153+
models: [
154+
nativeEntry("gpt-5.5", 0),
155+
nativeEntry("user-native", 4),
156+
routedEntry("kiro/claude-opus-4.8", 5),
157+
routedEntry("opencode-go/glm-5.2", 6),
158+
],
159+
}, null, 2) + "\n");
160+
161+
// Force the default-path bundled shortcut to succeed. The fixture intentionally returns only
162+
// a native row so this test fails if sync uses the bundled catalog as its merge input.
163+
const codexCliPath = createCodexCatalogFixture(opencodexHome);
164+
const r = runScript(codexHome, opencodexHome, `
165+
const { syncCatalogModels } = require("./src/codex/catalog");
166+
syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res)));
167+
`, { CODEX_CLI_PATH: codexCliPath });
168+
expect(r.status).toBe(0);
169+
expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries");
170+
171+
const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug);
172+
expect(slugs).toContain("gpt-5.5");
173+
expect(slugs).toContain("user-native");
174+
expect(slugs).toContain("kiro/claude-opus-4.8");
175+
expect(slugs).toContain("opencode-go/glm-5.2");
176+
});
177+
121178
test("empty routed refresh drops compatibility-excluded rows while preserving other routed entries", () => {
122179
const catalogPath = join(codexHome, "catalog.json");
123180
writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8");

0 commit comments

Comments
 (0)