From 91979cf1472ba33f4386d924ac09e2e1b81812b5 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 17 Aug 2026 22:22:37 +0800 Subject: [PATCH 1/2] fix(sync): refresh catalog for side profiles when Codex injection is OFF --- src/cli/dispatch.ts | 21 ++-- src/codex/catalog/sync.ts | 34 ++++-- src/codex/refresh.ts | 6 +- src/codex/sync.ts | 109 +++++++++++++++++++- tests/codex-composed-acceptance.test.ts | 25 ++++- tests/codex-models-cache-invalidate.test.ts | 18 ++++ tests/codex-sync-api.test.ts | 79 ++++++++++++++ 7 files changed, 271 insertions(+), 21 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f40f60c1e7..55fd3dd1da 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -204,10 +204,20 @@ const commandRunners: Record = { }, sync: async deps => { const restartCodex = deps.args.slice(1).includes("--restart-codex"); - const synced = await syncModelsToCodex((await deps.findLiveProxy())?.port); + const synced = await syncModelsToCodex( + (await deps.findLiveProxy())?.port, + undefined, + undefined, + undefined, + { catalogEvenWhenNotInjected: true }, + ); let code = 0; if (synced.status === "skipped") { console.log("Codex integration is OFF; sync skipped and no Codex files changed."); + } else if (synced.status === "catalog-only") { + // Explicit sync with the integration OFF still refreshes the catalog/cache + // for side profiles that consume the proxy without injection. + console.log(synced.message ?? "Codex integration is OFF; catalog refreshed, Codex config untouched."); } else if (!synced.ok) { code = 1; console.error("Codex sync did not complete. Fix the reported Codex config issue and retry."); @@ -227,19 +237,18 @@ const commandRunners: Record = { }, "sync-cache": async deps => { const restartCodex = deps.args.slice(1).includes("--restart-codex"); - if (!shouldSyncCodexOnStart(deps.loadConfig())) { - console.log("Codex integration is OFF; cache sync skipped and no Codex files changed."); - return 0; - } const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); const { getCodexHome } = await import("../codex/paths"); const owningCodexHome = getCodexHome(); + const desiredDisabled = !shouldSyncCodexOnStart(deps.loadConfig()); const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => - invalidateCodexModelsCacheWithPermit(permit, owningCodexHome)); + invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true })); // Only warn/restart when models_cache was actually rewritten from a readable catalog. if (invalidated.kind === "completed" && invalidated.value) { afterCatalogWriteHandleAppServers({ restart: restartCodex, log: console }); + } else if (desiredDisabled) { + console.log("Codex integration is OFF; cache sync skipped (no catalog or cache write)."); } return 0; }, diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 9ee2886028..81dc868adb 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1214,6 +1214,20 @@ interface RetainedCatalogSyncResult { skippedReason?: "desired_disabled"; } +/** + * Catalog/cache commit overrides. + * + * An explicit `ocx sync` is also the refresh path for side profiles that consume + * the OpenCodex catalog without injection (for example a custom `model_provider` + * that routes to the proxy). In that mode the Codex integration toggle only + * governs config/history injection; the catalog and models cache may still be + * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF + * gate that otherwise protects a fully native home. + */ +export interface CodexCatalogSyncOptions { + allowWhenDesiredDisabled?: boolean; +} + interface RetainedCatalogSyncWrite { readonly config: OcxConfig; readonly goModels: CatalogModel[]; @@ -1618,7 +1632,10 @@ function currentDisabledModelsForRestore(): Set | null { } } -export async function syncCatalogModels(config: OcxConfig): Promise { +export async function syncCatalogModels( + config: OcxConfig, + options?: CodexCatalogSyncOptions, +): Promise { const owningCodexHome = getCodexHome(); const preflightRead = readRetainedCatalogSync(config); if (preflightRead === null) { @@ -1654,8 +1671,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise invalidateCodexModelsCacheWithPermit(permit, owningCodexHome), + permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options), ); return outcome.kind === "completed" && outcome.value; } diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index 4b5ae47321..05d4eeaa2b 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -4,6 +4,7 @@ import type { ComboCatalogOmission } from "./catalog/aggregation"; import { CODEX_MODELS_CACHE_PATH } from "./paths"; import { atomicWriteFile } from "../config"; import type { OcxConfig } from "../types"; +import type { CodexCatalogSyncOptions } from "./catalog/sync"; export interface CodexCatalogRefreshResult { added: number; @@ -42,8 +43,9 @@ export function syncCodexModelsCacheFromCatalog(catalogPath: string): void { export async function refreshCodexModelCatalog( config: OcxConfig, deps: RefreshDeps = defaultDeps, + options?: CodexCatalogSyncOptions, ): Promise { - const result = await deps.syncCatalogModels(config); + const result = await deps.syncCatalogModels(config, options); const catalogExists = deps.existsSync(result.path); const catalogWritten = result.catalogWritten === true; const comboOmissions = result.comboOmissions ?? []; @@ -55,6 +57,6 @@ export async function refreshCodexModelCatalog( if (!catalogExists) { return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions }; } - const cacheSynced = deps.invalidateCodexModelsCache(); + const cacheSynced = deps.invalidateCodexModelsCache(options); return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions }; } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 43b1ce8986..2ecabbd94c 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -7,10 +7,15 @@ import { collectOrcaCodexHomeDiagnostic } from "./home"; import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation"; import { shouldSyncCodexOnStart } from "./desired-state"; import { admitCodexWrite, type CodexAdmission } from "./admission"; +import type { CodexCatalogSyncOptions } from "./catalog/sync"; export interface CodexSyncResult { - /** `skipped` is policy truth, never evidence that Codex was written. */ - status: "applied" | "skipped" | "refused"; + /** + * `skipped` is policy truth, never evidence that Codex was written. + * `catalog-only` means an explicit sync refreshed the catalog/cache while + * Codex injection stayed OFF; config and history were not touched. + */ + status: "applied" | "skipped" | "catalog-only" | "refused"; ok: boolean; skippedReason?: "desired_disabled"; /** Present when unattended convergence refused another service's native home. */ @@ -28,6 +33,17 @@ export interface CodexSyncResult { projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[]; } +export interface CodexSyncOptions { + /** + * Explicit `ocx sync` is also the refresh path for side profiles that consume + * the OpenCodex catalog without injection. When set, the sync still refreshes + * the catalog and models cache even if the Codex integration toggle is OFF or + * an external `model_provider` owns config.toml. Config/history injection is + * skipped in those cases, so the behavior is harmless to a native home. + */ + catalogEvenWhenNotInjected?: boolean; +} + type CodexSyncAdmission = Extract | { readonly kind: "admitted" }; interface CodexSyncDeps { @@ -62,12 +78,15 @@ export async function syncModelsToCodex( config: OcxConfig = loadConfig(), log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, + options: CodexSyncOptions = {}, ): Promise { // `config` can be the server's startup object. The decision, however, is a // durable user switch and must be read again at this production boundary: a // PUT OFF while provider discovery is in flight cannot be allowed to commit // through an older captured object. - if (!shouldSyncCodexOnStart(loadConfig())) { + const desiredDisabled = !shouldSyncCodexOnStart(loadConfig()); + const catalogEvenWhenNotInjected = options.catalogEvenWhenNotInjected === true; + if (desiredDisabled && !catalogEvenWhenNotInjected) { return { status: "skipped", skippedReason: "desired_disabled", @@ -99,7 +118,44 @@ export async function syncModelsToCodex( } const p = port ?? config.port ?? 10100; const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); + + if (desiredDisabled && catalogEvenWhenNotInjected) { + // Explicit `ocx sync` with the integration OFF: refresh the catalog/cache so + // side profiles that route to the proxy keep their model list current, but + // never touch config, journal, or history. + applyProxyEnv(config); + const refreshed = await refreshCatalogForSync(config, deps, { allowWhenDesiredDisabled: true }, log); + const message = refreshed.catalogWritten || refreshed.cacheSynced + ? "Codex integration is OFF; catalog and models cache refreshed, Codex config untouched." + : "Codex integration is OFF; catalog refresh skipped, Codex config untouched."; + return { + status: "catalog-only", + ok: true, + ...refreshed, + message, + ...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}), + }; + } + if (externalProvider) { + if (catalogEvenWhenNotInjected) { + // External providers own config.toml, so the injection below is only a + // courtesy. The catalog is still refreshed: the side profile consumes it. + applyProxyEnv(config); + const refreshed = await refreshCatalogForSync(config, deps, undefined, log); + const result = await deps.injectCodexConfig(p, config, {}); + if (result.success) log?.log(result.message); + else log?.error(result.message); + reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); + return { + status: "applied", + ok: result.success, + ...refreshed, + message: result.message, + ...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}), + ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), + }; + } const result = await deps.injectCodexConfig(p, config, {}); if (result.success) log?.log(result.message); else log?.error(result.message); @@ -214,3 +270,50 @@ export async function syncModelsToCodex( } : {}), }; } + +async function refreshCatalogForSync( + config: OcxConfig, + deps: CodexSyncDeps, + catalogOptions: CodexCatalogSyncOptions | undefined, + log: Pick | null, +): Promise<{ + added: number; + catalogPath: string | null; + catalogExists: boolean; + catalogWritten: boolean; + cacheSynced: boolean; + comboOmissions: ComboCatalogOmission[]; + warning?: string; +}> { + let added = 0; + let catalogPath: string | null = null; + let catalogExists = false; + let catalogWritten = false; + let cacheSynced = false; + let warning: string | undefined; + let comboOmissions: ComboCatalogOmission[] = []; + try { + const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions); + added = cat.added; + catalogExists = cat.catalogExists; + catalogWritten = cat.catalogWritten; + cacheSynced = cat.cacheSynced; + catalogPath = cat.catalogExists ? cat.path : null; + comboOmissions = cat.comboOmissions ?? []; + if (cat.added > 0) { + log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`); + } else if (!cat.catalogExists) { + warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog."; + log?.error(warning); + } + if (comboOmissions.length > 0) { + const summary = summarizeComboCatalogOmissions(comboOmissions); + log?.error(summary); + warning = warning ? `${warning} ${summary}` : summary; + } + } catch (e) { + warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`; + log?.error(warning); + } + return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions, ...(warning ? { warning } : {}) }; +} diff --git a/tests/codex-composed-acceptance.test.ts b/tests/codex-composed-acceptance.test.ts index 0468eaadbb..857c27b1a8 100644 --- a/tests/codex-composed-acceptance.test.ts +++ b/tests/codex-composed-acceptance.test.ts @@ -60,6 +60,13 @@ function manifest(root: string): Record { return entries; } +/** The catalog/cache artifacts an explicit side-profile sync may legitimately write while OFF. */ +function manifestWithoutCatalogArtifacts(entries: Record): Record { + return Object.fromEntries( + Object.entries(entries).filter(([key]) => !key.includes("opencodex-catalog") && key !== "models_cache.json"), + ); +} + async function waitFor(read: () => T | null | Promise, label: string, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -289,8 +296,13 @@ describe("WP13 composed toggle acceptance", () => { } }, 45_000); - /** RED: remove `shouldSyncCodexOnStart` or the under-lock desired-state read; an OFF row writes native bytes. */ - test("A-reduced: real CLI and HTTP entry points preserve an OFF Codex home", async () => { + /** + * RED: remove shouldSyncCodexOnStart or the under-lock desired-state read; an + * OFF row writes native config bytes. Explicit CLI sync/sync-cache may still + * refresh the catalog/cache for side profiles (catalog-only), so those two + * commands are compared without catalog artifacts; config/history must not move. + */ + test("A-reduced: real CLI and HTTP entry points preserve an OFF Codex config/home", async () => { const fx = fixture(); fx.writeConfig({ clientIntegrations: { codex: false, grok: false, "claude-desktop": false } }); mkdirSync(join(fx.homeA, ".grok")); @@ -299,11 +311,16 @@ describe("WP13 composed toggle acceptance", () => { const server = await fx.start(); try { expect(manifest(fx.codex)).toEqual(before); - for (const argv of [["ensure"], ["sync"], ["restore"], ["sync-cache"]]) { + for (const argv of [["ensure"], ["restore"]]) { const result = await fx.runCli(argv); expect(result.exitCode).toBe(0); expect(manifest(fx.codex)).toEqual(before); } + for (const argv of [["sync"], ["sync-cache"]]) { + const result = await fx.runCli(argv); + expect(result.exitCode).toBe(0); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); + } const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(200); expect(sync.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); @@ -314,7 +331,7 @@ describe("WP13 composed toggle acceptance", () => { expect([200, 404]).toContain(toggle.status); expect(toggle.body).toHaveProperty("desiredEnabled", false); } - expect(manifest(fx.codex)).toEqual(before); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); // P08 is intentionally the ON control: it must reach the same running // server through the real CLI without passing a port flag. const enabled = await fx.request(server.runtime, "/api/native-integrations/codex", { diff --git a/tests/codex-models-cache-invalidate.test.ts b/tests/codex-models-cache-invalidate.test.ts index 8800f0001a..2c7d3896ec 100644 --- a/tests/codex-models-cache-invalidate.test.ts +++ b/tests/codex-models-cache-invalidate.test.ts @@ -104,6 +104,24 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); + test("catalog-only override writes models_cache when desired state is OFF", () => { + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + mkdirSync(join(opencodexHome, ".opencodex"), { recursive: true }); + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ + port: 10100, + defaultProvider: "openai", + providers: {}, + clientIntegrations: { codex: false }, + }, null, 2) + "\n"); + + // Explicit sync/sync-cache refresh the cache for side profiles even when the + // Codex integration toggle is OFF; only config/history stay native. + expect(invalidateCodexModelsCache({ allowWhenDesiredDisabled: true })).toBe(true); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(true); + }); + test("returns false for a missing catalog and does not warn/restart app-servers", () => { const errors: string[] = []; const logs: string[] = []; diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 88332ffe8b..0a579727fa 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -217,6 +217,85 @@ describe("GUI/CLI Codex sync backend", () => { expect(injected).toBe(false); }); + test("explicit sync refreshes the catalog when Codex integration is OFF without injecting", async () => { + let refreshed = 0; + let injected = false; + let refreshOptions: unknown; + writeFileSync(join(TEST_OCX_HOME, "config.json"), JSON.stringify({ + ...config, + clientIntegrations: { codex: false }, + })); + const result = await syncModelsToCodex(12345, config, null, { + admitCodexWrite: admittedSync, + refreshCodexModelCatalog: async (_config: unknown, _deps: unknown, options: unknown) => { + refreshed++; + refreshOptions = options; + return { + added: 3, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + }; + }, + injectCodexConfig: async () => { + injected = true; + throw new Error("must not inject"); + }, + currentExternalCodexModelProvider: () => null, + }, { catalogEvenWhenNotInjected: true }); + + expect(refreshed).toBe(1); + expect(refreshOptions).toEqual({ allowWhenDesiredDisabled: true }); + expect(injected).toBe(false); + expect(result).toMatchObject({ + status: "catalog-only", + ok: true, + added: 3, + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + catalogPath: "/tmp/opencodex-catalog.json", + }); + expect(result.message).toContain("Codex config untouched"); + }); + + test("explicit sync refreshes the catalog before preserving an external provider", async () => { + let refreshed = 0; + let injectCalls = 0; + const result = await syncModelsToCodex(10100, config, null, { + admitCodexWrite: admittedSync, + refreshCodexModelCatalog: async () => { + refreshed++; + return { + added: 2, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + }; + }, + injectCodexConfig: async () => { + injectCalls++; + return { success: true, message: "external provider preserved" }; + }, + currentExternalCodexModelProvider: () => "custom", + }, { catalogEvenWhenNotInjected: true }); + + expect(refreshed).toBe(1); + expect(injectCalls).toBe(1); + expect(result).toMatchObject({ + status: "applied", + ok: true, + added: 2, + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + }); + }); + /** * The lost-transition race, with a REAL second process. The caller's config * snapshot says ON; while provider discovery is awaited, another process From 086a950798f44163d87813345508b43632194780 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Tue, 18 Aug 2026 13:59:33 +0800 Subject: [PATCH 2/2] fix(sync): never inject or touch the journal in external-provider catalog-only mode --- src/codex/sync.ts | 20 ++++++++++---------- tests/codex-sync-api.test.ts | 11 ++++++++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 2ecabbd94c..4c10068b74 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -139,21 +139,21 @@ export async function syncModelsToCodex( if (externalProvider) { if (catalogEvenWhenNotInjected) { - // External providers own config.toml, so the injection below is only a - // courtesy. The catalog is still refreshed: the side profile consumes it. + // External providers own config.toml, and the injector removes the OpenCodex + // journal for external providers (inject.ts). This explicit catalog-only sync + // must not touch config, journal, or history, so refresh the catalog/cache and + // return without injection. applyProxyEnv(config); const refreshed = await refreshCatalogForSync(config, deps, undefined, log); - const result = await deps.injectCodexConfig(p, config, {}); - if (result.success) log?.log(result.message); - else log?.error(result.message); - reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic); + const message = refreshed.catalogWritten || refreshed.cacheSynced + ? "External provider owns config.toml; catalog and models cache refreshed, Codex config/journal untouched." + : "External provider owns config.toml; catalog refresh skipped, Codex config/journal untouched."; return { - status: "applied", - ok: result.success, + status: "catalog-only", + ok: true, ...refreshed, - message: result.message, + message, ...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}), - ...(result.nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning: result.nativeSubagentDefaultsWarning } : {}), }; } const result = await deps.injectCodexConfig(p, config, {}); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 0a579727fa..8810633d0d 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -261,9 +261,12 @@ describe("GUI/CLI Codex sync backend", () => { expect(result.message).toContain("Codex config untouched"); }); - test("explicit sync refreshes the catalog before preserving an external provider", async () => { + test("explicit sync refreshes the catalog without injecting or touching the journal for an external provider", async () => { let refreshed = 0; let injectCalls = 0; + const journalPath = join(TEST_CODEX_HOME, "opencodex-journal.json"); + const journalBytes = Buffer.from(JSON.stringify({ injectedOpenaiBaseUrl: "http://127.0.0.1:1/v1" })); + writeFileSync(journalPath, journalBytes); const result = await syncModelsToCodex(10100, config, null, { admitCodexWrite: admittedSync, refreshCodexModelCatalog: async () => { @@ -285,15 +288,17 @@ describe("GUI/CLI Codex sync backend", () => { }, { catalogEvenWhenNotInjected: true }); expect(refreshed).toBe(1); - expect(injectCalls).toBe(1); + expect(injectCalls).toBe(0); + expect(readFileSync(journalPath)).toEqual(journalBytes); expect(result).toMatchObject({ - status: "applied", + status: "catalog-only", ok: true, added: 2, catalogExists: true, catalogWritten: true, cacheSynced: true, }); + expect(String(result.message)).toContain("journal untouched"); }); /**