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
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,9 @@
"codex-catalog-restore.test.ts": "codex-integration",
"codex-catalog-sync-hardening.test.ts": "codex-integration",
"codex-catalog-routed-removal.test.ts": "codex-integration",
"codex-catalog-intent.test.ts": "codex-integration",
"codex-home-owner.test.ts": "codex-integration",
"codex-home-owner-lifecycle.test.ts": "codex-integration",
"codex-catalog-write-serialization.test.ts": "codex-integration",
"codex-catalog-writer.test.ts": "codex-integration",
"codex-catalog.test.ts": "codex-integration",
Expand Down
7 changes: 6 additions & 1 deletion src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,8 @@ const commandRunners: Record<string, CommandRunner> = {
const cacheGateSnapshot = deps.loadConfig();
const desiredDisabled = !shouldSyncCodexOnStart(cacheGateSnapshot);
const invalidated = withCatalogWriteSerialization(owningCodexHome, permit =>
invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, { allowWhenDesiredDisabled: true }));
invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, { allowWhenDesiredDisabled: true }),
{ intent: "cache", writer: "sync-cache" });
const cacheJson = cacheArgs.includes("--json");
const jsonSafeLog = cacheJson
? { log: (...values: unknown[]) => console.error(...values), error: (...values: unknown[]) => console.error(...values) }
Expand Down Expand Up @@ -633,6 +634,10 @@ const commandRunners: Record<string, CommandRunner> = {
console.log("No Codex catalog to derive a cache from; nothing to sync.");
} else if (unchanged) {
console.log("Codex model cache is already current; nothing to sync.");
} else if (invalidated.kind === "unavailable"
&& (invalidated.reason === "foreign-owner" || invalidated.reason === "owner-unknown")) {
const { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE } = await import("../codex/catalog/routed-removal");
console.error(invalidated.reason === "foreign-owner" ? FOREIGN_CODEX_HOME_OWNER_MESSAGE : UNKNOWN_CODEX_HOME_OWNER_MESSAGE);
} else if (!ok) {
console.error(`Cache refresh did not complete (${invalidated.kind}). The Codex model cache was not rewritten.`);
}
Expand Down
39 changes: 38 additions & 1 deletion src/codex/catalog-write-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { chmodSync, lstatSync, realpathSync } from "node:fs";

import { Database } from "bun:sqlite";

import { inspectCodexHomeOwner } from "./codex-home-owner";
import {
CodexUserIdentityRefusal,
resolveCodexCatalogSerializationDatabasePath,
Expand All @@ -51,9 +52,17 @@ export interface CatalogWritePermit {

declare const catalogWritePermitBrand: unique symbol;

/** Refresh derives from config; pull uses hub authority; restore deliberately returns to native. */
export type CatalogWriteIntent = "refresh" | "cache" | "pull" | "restore";

export interface CatalogWriteOptions {
readonly intent: CatalogWriteIntent;
readonly writer: string;
}

export type CatalogSerializationOutcome<T> =
| { kind: "completed"; value: T }
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" };
| { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" | "foreign-owner" | "owner-unknown" };

export class CatalogWritePermitRefusal extends Error {
readonly code = "CODEX_CATALOG_WRITE_PERMIT_REFUSED";
Expand All @@ -69,6 +78,8 @@ interface PermitRegistration {
readonly canonicalCodexHome: string;
/** Identifies the acquisition, so a permit cannot outlive its transaction. */
readonly transactionId: string;
readonly intent: CatalogWriteIntent;
readonly writer: string;
live: boolean;
}

Expand Down Expand Up @@ -121,6 +132,18 @@ export function assertCatalogWritePermit(
}
}

/** Only a live acquisition may expose its declared intent and writer. */
export function catalogWritePermitContext(permit: CatalogWritePermit): CatalogWriteOptions {
const registration = activePermits.get(permit as unknown as object);
if (!registration?.live) throw new CatalogWritePermitRefusal("The catalog write permit is not live.");
return { intent: registration.intent, writer: registration.writer };
}

function ownerRefusal(canonicalCodexHome: string): "foreign-owner" | "owner-unknown" | null {
const owner = inspectCodexHomeOwner(canonicalCodexHome);
return owner.kind === "foreign" ? "foreign-owner" : owner.kind === "unknown" ? "owner-unknown" : null;
}

/**
* Acquire K for one canonical CODEX_HOME and run `write` while it is held.
*
Expand All @@ -135,7 +158,11 @@ export function assertCatalogWritePermit(
export function withCatalogWriteSerialization<T>(
canonicalCodexHome: string,
write: (permit: CatalogWritePermit) => T,
options: CatalogWriteOptions,
): CatalogSerializationOutcome<T> {
// Re-evaluate on every acquisition; unknown evidence cannot authorize a takeover.
const refusedOwner = ownerRefusal(canonicalCodexHome);
if (refusedOwner) return { kind: "unavailable", reason: refusedOwner };
let databasePath: string;
try {
databasePath = resolveCodexCatalogSerializationDatabasePath(
Expand Down Expand Up @@ -188,10 +215,20 @@ export function withCatalogWriteSerialization<T>(
database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
transactionOpen = true;

// Ownership may have changed while opening K. Refuse before minting a permit or invoking work.
const lockedOwnerRefusal = ownerRefusal(canonicalCodexHome);
if (lockedOwnerRefusal) {
database.exec("ROLLBACK");
transactionOpen = false;
return { kind: "unavailable", reason: lockedOwnerRefusal };
}

acquisitionCounter += 1;
registration = {
canonicalCodexHome,
transactionId: `${process.pid}:${acquisitionCounter}`,
intent: options.intent,
writer: options.writer,
live: true,
};
// A bare object: nothing about it is guessable or reconstructable, because
Expand Down
28 changes: 13 additions & 15 deletions src/codex/catalog/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { replaceActiveCodexCatalog } from "../internal/catalog-writer";
import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
import { getCodexHome } from "../paths";
import { readCodexCatalogPathForHome } from "./parsing";
import { invalidateCodexModelsCacheWithPermit } from "./sync";
import { invalidateCodexModelsCacheWithPermitOutcome } from "./sync";

const DEFAULT_TIMEOUT_MS = 15_000;
const MAX_MODELS = 2_000;
Expand All @@ -17,7 +17,7 @@ const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]);
export type RemoteCatalogFailureCode =
| "url_invalid" | "insecure_http_refused" | "credential_invalid" | "request_failed"
| "redirect_refused" | "http_error" | "body_too_large" | "body_invalid"
| "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path";
| "catalog_invalid" | "write_failed" | "lock_busy" | "lock_database" | "unsafe_path" | "foreign_owner" | "owner_unknown";

export class RemoteCatalogError extends Error {
constructor(readonly code: RemoteCatalogFailureCode, message: string, readonly status?: number) {
Expand Down Expand Up @@ -202,7 +202,10 @@ export async function fetchRemoteCatalog(

function mapSerializationFailure<T>(outcome: CatalogSerializationOutcome<T>): never {
if (outcome.kind === "completed") throw new RemoteCatalogError("write_failed", "Remote catalog installation failed");
const code = outcome.reason === "busy" ? "lock_busy" : outcome.reason === "database" ? "lock_database" : "unsafe_path";
const code = outcome.reason === "busy" ? "lock_busy"
: outcome.reason === "database" ? "lock_database"
: outcome.reason === "foreign-owner" ? "foreign_owner"
: outcome.reason === "owner-unknown" ? "owner_unknown" : "unsafe_path";
throw new RemoteCatalogError(code, `Remote catalog installation unavailable (${outcome.reason})`);
}

Expand Down Expand Up @@ -235,23 +238,18 @@ export async function pullRemoteCatalog(input: string, options: PullRemoteCatalo
const fetched = await fetchRemoteCatalog(input, options);
const codexHome = options.codexHome ?? getCodexHome();
const catalogPath = readCodexCatalogPathForHome(codexHome);
const current = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
const candidate = Buffer.from(fetched.content, "utf8");
if (current?.equals(candidate)) {
return { status: "unchanged", catalogWritten: false, cacheSynced: false, codexHome, catalogPath, modelCount: fetched.document.models.length };
}
const outcome = withCatalogWriteSerialization(codexHome, permit => {
// Re-check under K: another writer may have installed these bytes while the request was in flight.
const lockedCurrent = existsSync(catalogPath) ? readFileSync(catalogPath) : null;
if (lockedCurrent?.equals(candidate)) return { catalogWritten: false, cacheSynced: false };
replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content });
const cacheSynced = invalidateCodexModelsCacheWithPermit(permit, codexHome, { allowWhenDesiredDisabled: true });
if (!cacheSynced) {
restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent);
const catalog = replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: fetched.content });
if (catalog.kind === "refused") throw new RemoteCatalogError("write_failed", "Remote catalog publication refused");
const cache = invalidateCodexModelsCacheWithPermitOutcome(permit, codexHome, { allowWhenDesiredDisabled: true });
if (cache !== "written" && cache !== "unchanged") {
if (catalog.kind === "written") restorePreviousCatalog(permit, codexHome, catalogPath, lockedCurrent);
throw new RemoteCatalogError("write_failed", "Remote catalog cache synchronization failed");
}
return { catalogWritten: true, cacheSynced: true };
});
return { catalogWritten: catalog.kind === "written", cacheSynced: cache === "written" };
}, { intent: "pull", writer: "catalog-pull" });
if (outcome.kind !== "completed") return mapSerializationFailure(outcome);
return {
status: outcome.value.catalogWritten ? "updated" : "unchanged",
Expand Down
18 changes: 13 additions & 5 deletions src/codex/catalog/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { RETIRED_NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./m
import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
import {
withCatalogWriteSerialization,
CatalogWritePermitRefusal,
type CatalogWritePermit,
} from "../catalog-write-serialization";
import { replaceActiveCodexCatalog } from "../internal/catalog-writer";
import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE } from "./routed-removal";

function visibleAccountReplacementNatives(
models: readonly RawEntry[],
Expand Down Expand Up @@ -94,10 +96,11 @@ export function restoreCodexCatalogWithPermit(
models: [...backup.models.filter(m => typeof m.slug !== "string"
|| !RETIRED_NATIVE_OPENAI_MODELS.has(trustedAccountBoundNativeCatalogSlug(m) ?? m.slug)), ...userNativeAdditions],
};
replaceActiveCodexCatalog(permit, owningCodexHome, {
const replacement = replaceActiveCodexCatalog(permit, owningCodexHome, {
path: catalogPath,
content: `${JSON.stringify(restored, null, 2)}\n`,
});
if (replacement.kind === "refused") throw new CatalogWritePermitRefusal("Catalog restoration was refused.");
return { removed, kept: restored.models.length, path: catalogPath };
}
const before = catalog.models.length;
Expand All @@ -110,10 +113,11 @@ export function restoreCodexCatalogWithPermit(
const removed = before - native.length;
if (removed > 0) {
catalog.models = native;
replaceActiveCodexCatalog(permit, owningCodexHome, {
const replacement = replaceActiveCodexCatalog(permit, owningCodexHome, {
path: catalogPath,
content: `${JSON.stringify(catalog, null, 2)}\n`,
});
if (replacement.kind === "refused") throw new CatalogWritePermitRefusal("Catalog restoration was refused.");
}
return { removed, kept: native.length, path: catalogPath };
}
Expand All @@ -123,10 +127,14 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
const outcome = withCatalogWriteSerialization(
owningCodexHome,
permit => restoreCodexCatalogWithPermit(permit, owningCodexHome),
{ intent: "restore", writer: "catalog-restore" },
);
return outcome.kind === "completed"
? outcome.value
: { removed: 0, kept: 0, path: readCodexCatalogPath() };
if (outcome.kind === "unavailable") {
throw new CatalogWritePermitRefusal(outcome.reason === "foreign-owner"
? FOREIGN_CODEX_HOME_OWNER_MESSAGE : outcome.reason === "owner-unknown"
? UNKNOWN_CODEX_HOME_OWNER_MESSAGE : `Catalog restoration unavailable (${outcome.reason}).`);
}
return outcome.value;
}

/** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */
46 changes: 27 additions & 19 deletions src/codex/catalog/retained-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
nativeMultiAgentDefaults,
} from "./parsing";
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
import { routedRemovalBackedByConfigFile, unconfiguredRoutedRemoval } from "./routed-removal";
import { ocxRoutedNamespaceCounts, routedRemovalBackedByConfigFile, unconfiguredRoutedRemoval } from "./routed-removal";
import { ConfigMutationLockError } from "../../config/mutation-lock";
import {
accountBoundNativeOpenAiSlugsBySelector,
Expand Down Expand Up @@ -71,6 +71,7 @@ import {
replaceActiveCodexCatalog,
replaceCodexModelsCache,
type PreparedCatalogFileWrite,
type CatalogFileReplacement,
} from "../internal/catalog-writer";
import { visibleCodexAccountSelectors } from "./account-models";
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
Expand Down Expand Up @@ -110,9 +111,10 @@ interface RetainedCatalogSyncResult {
/**
* `desired_disabled`: observed under K after the provider await. `unbacked_routed_removal`: the
* catalog would lose routed namespaces config.json still enables, or config.json is missing or
* unreadable (#6529). Nothing was written in either case.
* unreadable (#6529). `foreign_owner`: this Codex home is bound to another OPENCODEX_HOME. Nothing
* was written in any of these cases.
*/
skippedReason?: "desired_disabled" | "unbacked_routed_removal";
skippedReason?: "desired_disabled" | "unbacked_routed_removal" | "foreign_owner" | "owner_unknown";
/** With `unbacked_routed_removal`: how many routed namespaces the refused write would have emptied. */
protectedRoutedNamespaces?: number;
}
Expand Down Expand Up @@ -565,6 +567,15 @@ function writeRetainedCatalogSync({
// A refresh may drop a provider's rows only when config.json on disk agrees the provider is gone.
// Without that, a config that is not the user's (missing or unreadable file read as defaults,
// another OPENCODEX_HOME) would publish a native-only catalog and exit cleanly (#6529).
const refused = (protectedRoutedNamespaces: number): RetainedCatalogSyncResult => ({
added: 0,
path: catalogPath,
catalogWritten: false,
comboOmissions,
skippedReason: "unbacked_routed_removal",
protectedRoutedNamespaces,
});
let replacement: CatalogFileReplacement | undefined;
const removal = unconfiguredRoutedRemoval(onDiskCatalog, catalog, config);
if (removal !== null) {
// Hold C (K -> C) from the config read through the replacement, so a save that enables one of
Expand All @@ -573,29 +584,23 @@ function writeRetainedCatalogSync({
try {
backed = withConfigMutationLockSync(() => {
if (!routedRemovalBackedByConfigFile(removal)) return false;
replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
return true;
replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
return replacement.kind !== "refused";
});
} catch (error) {
if (!(error instanceof ConfigMutationLockError)) throw error;
}
if (!backed) {
return {
added: 0,
path: catalogPath,
catalogWritten: false,
comboOmissions,
skippedReason: "unbacked_routed_removal",
protectedRoutedNamespaces: removal.namespaces.length,
};
return refused(removal.namespaces.length);
}
} else {
replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
if (replacement.kind === "refused") return refused(ocxRoutedNamespaceCounts(onDiskCatalog).size);
}
return {
added,
path: catalogPath,
catalogWritten: true,
catalogWritten: replacement?.kind === "written",
comboOmissions,
};
}
Expand Down Expand Up @@ -685,7 +690,7 @@ export async function syncCatalogModels(
owningCodexHome,
modelEntitlements,
});
});
}, { intent: "refresh", writer: "retained-sync" });
if (committed.kind === "completed" && committed.value !== null) {
return {
...committed.value,
Expand All @@ -698,6 +703,10 @@ export async function syncCatalogModels(
catalogWritten: false,
comboOmissions,
refreshOutcome: "refused",
...(committed.kind === "unavailable" && committed.reason === "foreign-owner"
? { skippedReason: "foreign_owner" as const } : {}),
...(committed.kind === "unavailable" && committed.reason === "owner-unknown"
? { skippedReason: "owner_unknown" as const } : {}),
};
}

Expand Down Expand Up @@ -765,9 +774,7 @@ export function invalidateCodexModelsCacheWithPermitOutcome(
// `cacheSynced` mean what its name and its consumers already assume, and what
// `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
// already assert: a write happened.
if (!preparedBytesDifferFromDisk(preparedCache)) return "unchanged";
replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
return "written";
return replaceCodexModelsCache(permit, owningCodexHome, preparedCache).kind;
} catch {
return "failed";
}
Expand All @@ -786,6 +793,7 @@ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): b
const outcome = withCatalogWriteSerialization(
owningCodexHome,
permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
{ intent: "cache", writer: "cache-invalidate" },
);
return outcome.kind === "completed" && outcome.value;
}
Loading
Loading