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
21 changes: 15 additions & 6 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,20 @@ const commandRunners: Record<string, CommandRunner> = {
},
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.");
Expand All @@ -227,19 +237,18 @@ const commandRunners: Record<string, CommandRunner> = {
},
"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;
},
Expand Down
34 changes: 28 additions & 6 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -1618,7 +1632,10 @@ function currentDisabledModelsForRestore(): Set<string> | null {
}
}

export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCatalogSyncResult> {
export async function syncCatalogModels(
config: OcxConfig,
options?: CodexCatalogSyncOptions,
): Promise<RetainedCatalogSyncResult> {
const owningCodexHome = getCodexHome();
const preflightRead = readRetainedCatalogSync(config);
if (preflightRead === null) {
Expand Down Expand Up @@ -1654,8 +1671,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
// evidence revalidation below cannot see that — intent lives in our config,
// not in the catalog files — so the policy is re-read here, under K, right
// before the only write. A lost race becomes the discriminated skip instead
// of a routed catalog/cache surviving a completed disable.
if (!shouldSyncCodexOnStart(loadConfig())) {
// of a routed catalog/cache surviving a completed disable. An explicit
// catalog-only sync opts out of that gate: the user asked for a refresh even
// when injection is OFF, and the toggle only protects config/history writes.
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) {
return {
added: 0,
path: prepared.catalogPath,
Expand Down Expand Up @@ -1755,13 +1774,16 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
export function invalidateCodexModelsCacheWithPermit(
permit: CatalogWritePermit,
owningCodexHome: string,
options?: CodexCatalogSyncOptions,
): boolean {
try {
// This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
// K before this rewrite runs, so the commit-path desired-state check cannot
// cover it. A disable landing in that gap must not be overwritten by a
// routed cache write — re-read intent under this permit, same as the commit.
if (!shouldSyncCodexOnStart(loadConfig())) return false;
// The catalog-only sync override applies here too so an explicit refresh
// keeps the cache consistent with the catalog it just wrote.
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
const catalogPath = readCodexCatalogPath();
if (!existsSync(catalogPath)) return false;
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
Expand Down Expand Up @@ -1803,11 +1825,11 @@ export function invalidateCodexModelsCacheWithPermit(
}
}

export function invalidateCodexModelsCache(): boolean {
export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
const owningCodexHome = getCodexHome();
const outcome = withCatalogWriteSerialization(
owningCodexHome,
permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome),
permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
);
return outcome.kind === "completed" && outcome.value;
}
6 changes: 4 additions & 2 deletions src/codex/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,8 +43,9 @@ export function syncCodexModelsCacheFromCatalog(catalogPath: string): void {
export async function refreshCodexModelCatalog(
config: OcxConfig,
deps: RefreshDeps = defaultDeps,
options?: CodexCatalogSyncOptions,
): Promise<CodexCatalogRefreshResult> {
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 ?? [];
Expand All @@ -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 };
}
109 changes: 106 additions & 3 deletions src/codex/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<CodexAdmission, { kind: "refused" }> | { readonly kind: "admitted" };

interface CodexSyncDeps {
Expand Down Expand Up @@ -62,12 +78,15 @@ export async function syncModelsToCodex(
config: OcxConfig = loadConfig(),
log: Pick<Console, "log" | "error"> | null = console,
deps: CodexSyncDeps = defaultDeps,
options: CodexSyncOptions = {},
): Promise<CodexSyncResult> {
// `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",
Expand Down Expand Up @@ -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, 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 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: "catalog-only",
ok: true,
...refreshed,
message,
...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}),
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const result = await deps.injectCodexConfig(p, config, {});
if (result.success) log?.log(result.message);
else log?.error(result.message);
Expand Down Expand Up @@ -214,3 +270,50 @@ export async function syncModelsToCodex(
} : {}),
};
}

async function refreshCatalogForSync(
config: OcxConfig,
deps: CodexSyncDeps,
catalogOptions: CodexCatalogSyncOptions | undefined,
log: Pick<Console, "log" | "error"> | 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 } : {}) };
}
25 changes: 21 additions & 4 deletions tests/codex-composed-acceptance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ function manifest(root: string): Record<string, string> {
return entries;
}

/** The catalog/cache artifacts an explicit side-profile sync may legitimately write while OFF. */
function manifestWithoutCatalogArtifacts(entries: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(entries).filter(([key]) => !key.includes("opencodex-catalog") && key !== "models_cache.json"),
);
}

async function waitFor<T>(read: () => T | null | Promise<T | null>, label: string, timeoutMs = 10_000): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
Expand Down Expand Up @@ -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"));
Expand All @@ -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 });
Expand All @@ -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", {
Expand Down
18 changes: 18 additions & 0 deletions tests/codex-models-cache-invalidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading
Loading