From 80940782c55a3dbd5e99a00d17d0dd6b30b413d3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:26:35 +0900 Subject: [PATCH 1/3] fix(codex): bind catalog writes to home ownership and intent Carry ownership, intent and idempotence from #6537 with unknown-evidence refusal, lifecycle protection and accurate no-op outcomes. Co-authored-by: lcxhh521 <59329914+lcxhh521@users.noreply.github.com> --- scripts/test-layout/layout.json | 3 + src/cli/dispatch.ts | 7 +- src/codex/catalog-write-serialization.ts | 39 ++- src/codex/catalog/remote.ts | 28 +- src/codex/catalog/restore.ts | 18 +- src/codex/catalog/retained-sync.ts | 46 ++-- src/codex/catalog/routed-removal.ts | 29 +- src/codex/codex-home-owner.ts | 124 +++++++++ src/codex/convergence.ts | 39 ++- src/codex/inject.ts | 12 +- src/codex/inject/restore.ts | 42 ++- src/codex/internal/catalog-writer.ts | 52 +++- src/codex/journal.ts | 60 +++- src/codex/refresh.ts | 17 +- src/codex/sync.ts | 16 +- src/server/index.ts | 2 +- structure/codex-home.md | 10 + .../catalog-remote-pull.test.ts | 49 +++- .../codex-catalog-intent.test.ts | 170 ++++++++++++ .../codex-catalog-write-serialization.test.ts | 31 ++- .../codex-catalog-writer.test.ts | 8 +- ...odex-convergence-account-selectors.test.ts | 3 + .../codex-convergence-contract.test.ts | 11 + .../codex-home-owner-lifecycle.test.ts | 257 ++++++++++++++++++ .../codex-home-owner.test.ts | 139 ++++++++++ tests/codex-integration/codex-journal.test.ts | 15 +- .../codex-models-cache-invalidate.test.ts | 7 +- .../codex-retained-root-serialization.test.ts | 33 ++- tests/fixtures/test-layout-expected.json | 3 + 29 files changed, 1158 insertions(+), 112 deletions(-) create mode 100644 src/codex/codex-home-owner.ts create mode 100644 tests/codex-integration/codex-catalog-intent.test.ts create mode 100644 tests/codex-integration/codex-home-owner-lifecycle.test.ts create mode 100644 tests/codex-integration/codex-home-owner.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bfc55a47e5c..ce3bb8d2951 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 8d24913f23c..0ede901fcb6 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -570,7 +570,8 @@ const commandRunners: Record = { 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) } @@ -633,6 +634,10 @@ const commandRunners: Record = { 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.`); } diff --git a/src/codex/catalog-write-serialization.ts b/src/codex/catalog-write-serialization.ts index 92f8e7f9495..8b9a17b7f1f 100644 --- a/src/codex/catalog-write-serialization.ts +++ b/src/codex/catalog-write-serialization.ts @@ -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, @@ -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 = | { 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"; @@ -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; } @@ -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. * @@ -135,7 +158,11 @@ export function assertCatalogWritePermit( export function withCatalogWriteSerialization( canonicalCodexHome: string, write: (permit: CatalogWritePermit) => T, + options: CatalogWriteOptions, ): CatalogSerializationOutcome { + // 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( @@ -188,10 +215,20 @@ export function withCatalogWriteSerialization( 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 diff --git a/src/codex/catalog/remote.ts b/src/codex/catalog/remote.ts index 9a6b534cdaa..ee3347e201e 100644 --- a/src/codex/catalog/remote.ts +++ b/src/codex/catalog/remote.ts @@ -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; @@ -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) { @@ -202,7 +202,10 @@ export async function fetchRemoteCatalog( function mapSerializationFailure(outcome: CatalogSerializationOutcome): 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})`); } @@ -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", diff --git a/src/codex/catalog/restore.ts b/src/codex/catalog/restore.ts index 5393d584105..e9afa084efb 100644 --- a/src/codex/catalog/restore.ts +++ b/src/codex/catalog/restore.ts @@ -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[], @@ -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; @@ -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 }; } @@ -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. */ diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 874e738de8a..582f54cfaf1 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -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, @@ -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"; @@ -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; } @@ -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 @@ -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, }; } @@ -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, @@ -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 } : {}), }; } @@ -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"; } @@ -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; } diff --git a/src/codex/catalog/routed-removal.ts b/src/codex/catalog/routed-removal.ts index dc1b038dfa2..cdd1f4b0f2c 100644 --- a/src/codex/catalog/routed-removal.ts +++ b/src/codex/catalog/routed-removal.ts @@ -20,7 +20,7 @@ export interface UnconfiguredRoutedRemoval { type RoutedNamespaceConfig = Pick; function routedNamespace(entry: RawEntry): string | null { - if (typeof entry.slug !== "string") return null; + if (!entry || typeof entry !== "object" || typeof entry.slug !== "string") return null; // Account-bound native rows follow the account pool, not provider config. if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null; if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE; @@ -34,6 +34,24 @@ function routedNamespace(entry: RawEntry): string | null { return entry.slug.slice(0, entry.slug.indexOf("/")); } +/** OpenCodex-authored routed rows per namespace; foreign and account-bound rows never count. */ +export function ocxRoutedNamespaceCounts(catalog: RawCatalog | null): Map { + const counts = new Map(); + for (const entry of catalog?.models ?? []) { + const namespace = routedNamespace(entry); + if (namespace !== null) counts.set(namespace, (counts.get(namespace) ?? 0) + 1); + } + return counts; +} + +/** Total OpenCodex-authored routed rows, or null for a value that is not a catalog. */ +export function ocxRoutedRowCount(catalog: unknown): number | null { + if (catalog === null || typeof catalog !== "object" || !Array.isArray((catalog as RawCatalog).models)) return null; + let total = 0; + for (const count of ocxRoutedNamespaceCounts(catalog as RawCatalog).values()) total += count; + return total; +} + /** Whether a config still produces routed rows for a namespace: an enabled provider, or any combo. */ export function configEnablesRoutedNamespace(config: RoutedNamespaceConfig, namespace: string): boolean { if (namespace === COMBO_NAMESPACE && Object.keys(config.combos ?? {}).length > 0) return true; @@ -80,6 +98,15 @@ export function routedRemovalBackedByConfigFile( return removal.namespaces.every(namespace => !configEnablesRoutedNamespace(snapshot.diagnostics.config, namespace)); } +/** One line for logs when K refused a writer from another OPENCODEX_HOME; names no path. */ +export const FOREIGN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: this Codex home was set up by " + + "another OpenCodex home (OPENCODEX_HOME), recorded in its opencodex-journal.json. Run opencodex " + + "from that home, or run `ocx restore` there first."; + +/** Unavailable evidence is not proof of a foreign owner. No paths or journal content are exposed. */ +export const UNKNOWN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: ownership of this Codex home " + + "could not be checked. Restore readable ownership evidence and retry."; + /** One line for logs: counts only, never provider names, model ids or paths. */ export function unbackedRoutedRemovalMessage(count: number): string { return `Codex catalog left unchanged: this refresh would remove the routed models of ${count} ` diff --git a/src/codex/codex-home-owner.ts b/src/codex/codex-home-owner.ts new file mode 100644 index 00000000000..e7bf82fbc31 --- /dev/null +++ b/src/codex/codex-home-owner.ts @@ -0,0 +1,124 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { samePathIdentity } from "./user-identity"; + +export const CODEX_HOME_JOURNAL_FILE = "opencodex-journal.json"; +const MAX_JOURNAL_BYTES = 1024 * 1024; + +/** K imports this module; keep it independent of the config barrel and journal writer. */ +export function currentOpencodexHome(): string { + const configured = getConfigDir(); + try { return realpathSync.native(configured); } + catch { return resolve(configured); } +} + +export type CodexHomeOwnership = + | { readonly kind: "unbound" } + | { readonly kind: "owned" } + | { readonly kind: "stale"; readonly boundHome: string } + | { readonly kind: "foreign"; readonly boundHome: string } + | { readonly kind: "unknown" }; + +export type CodexHomeJournalInspection = + | { kind: "missing" } + | { kind: "read"; journal: Record } + | { kind: "unknown" }; + +function absent(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === "ENOENT"; +} + +/** ENOENT through a dangling link or an unresolvable ancestor is not proven absence. */ +function provenAbsent(path: string): boolean { + try { lstatSync(path); return false; } + catch (error) { if (!absent(error)) return false; } + const parent = dirname(path); + if (parent === path) return false; + try { return statSync(realpathSync.native(parent)).isDirectory(); } + catch (error) { return absent(error) && provenAbsent(parent); } +} + +function validJournal(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const journal = value as Record; + return journal.version === 1 && typeof journal.originalConfig === "string" + && (journal.originalProfile === null || typeof journal.originalProfile === "string") + && (journal.opencodexHome === undefined || (typeof journal.opencodexHome === "string" + && isAbsolute(journal.opencodexHome) && !journal.opencodexHome.includes("\0"))); +} + +/** Bounded, regular-file-only observation. Failure never supplies cleanup authority. */ +export function readCodexHomeJournal(journalPath: string): CodexHomeJournalInspection { + let fd: number | undefined; + try { + const before = lstatSync(journalPath); + if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_JOURNAL_BYTES) return { kind: "unknown" }; + fd = openSync(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + const opened = fstatSync(fd); + if (!opened.isFile() || opened.size > MAX_JOURNAL_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) return { kind: "unknown" }; + const bytes = Buffer.alloc(opened.size + 1); + let length = 0; + while (length < bytes.length) { + const count = readSync(fd, bytes, length, bytes.length - length, null); + if (!count) break; + length += count; + } + const after = lstatSync(journalPath); + if (length !== opened.size || !after.isFile() || after.dev !== opened.dev || after.ino !== opened.ino + || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return { kind: "unknown" }; + const journal: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); + return validJournal(journal) ? { kind: "read", journal } : { kind: "unknown" }; + } catch (error) { + return fd === undefined && absent(error) && provenAbsent(journalPath) ? { kind: "missing" } : { kind: "unknown" }; + } finally { if (fd !== undefined) closeSync(fd); } +} + +function inspectBinding(boundHome: string, current: string): CodexHomeOwnership { + let physical: string; + try { + physical = realpathSync.native(boundHome); + if (!statSync(physical).isDirectory()) return { kind: "unknown" }; + } catch { return provenAbsent(boundHome) ? { kind: "stale", boundHome } : { kind: "unknown" }; } + try { + const own = realpathSync.native(current); + if (!statSync(own).isDirectory()) return { kind: "unknown" }; + return samePathIdentity(physical, own) ? { kind: "owned" } : { kind: "foreign", boundHome }; + } catch { return { kind: "unknown" }; } +} + +export function inspectCodexHomeOwner(canonicalHome: string, current = currentOpencodexHome()): CodexHomeOwnership { + const read = readCodexHomeJournal(join(canonicalHome, CODEX_HOME_JOURNAL_FILE)); + if (read.kind === "unknown") return { kind: "unknown" }; + if (read.kind === "missing" || read.journal.opencodexHome === undefined) return { kind: "unbound" }; + return inspectBinding(read.journal.opencodexHome as string, current); +} + +/** Preserve extant and uncertain bindings; only legacy or proven stale records are adopted. */ +export function opencodexHomeForInjection(recorded: string | undefined, current = currentOpencodexHome()): string { + if (recorded === undefined) return current; + return inspectBinding(recorded, current).kind === "stale" ? current : recorded; +} + +export type CodexHomeOwnerRefusalReason = "foreign-owner" | "owner-unknown"; +export class CodexHomeOwnerRefusal extends Error { + constructor(readonly reason: CodexHomeOwnerRefusalReason) { + super(reason === "foreign-owner" + ? "Codex home is bound to another OpenCodex home; configuration and journal were preserved. Restore from the owning home first." + : "Codex home ownership could not be verified; configuration and journal were preserved."); + this.name = "CodexHomeOwnerRefusal"; + } +} +export function assertCodexHomeOwner(home: string): void { + const owner = inspectCodexHomeOwner(home); + if (owner.kind === "foreign" || owner.kind === "unknown") { + throw new CodexHomeOwnerRefusal(owner.kind === "foreign" ? "foreign-owner" : "owner-unknown"); + } +} + +/** Compensation must not replace evidence another home published during this operation. */ +export function codexHomeOwnerBlocksCompensation(home: string, error: unknown): boolean { + if (!(error instanceof CodexHomeOwnerRefusal)) return false; + const owner = inspectCodexHomeOwner(home); + return owner.kind === "foreign" || owner.kind === "unknown"; +} diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 4317d2dcdb9..2fc93d544ec 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -84,6 +84,8 @@ import { } from "./model-entitlements"; import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission"; import { + FOREIGN_CODEX_HOME_OWNER_MESSAGE, + UNKNOWN_CODEX_HOME_OWNER_MESSAGE, routedRemovalBackedByConfigFile, unbackedRoutedRemovalMessage, unconfiguredRoutedRemoval, @@ -115,14 +117,20 @@ import type { export interface CatalogWriteReceipt { readonly keyedBackup: "written" | "preserved" | "not-requested"; readonly legacyBackup: "written" | "preserved" | "not-requested"; - readonly catalog: "written" | "not-written"; - readonly cache: "written" | "not-written"; + /** `unchanged`: the bytes on disk already matched, so nothing was rewritten. */ + readonly catalog: "written" | "unchanged" | "not-written"; + readonly cache: "written" | "unchanged" | "not-written"; } export type CodexCatalogCommitResult = | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt } | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" | "account-entitlement" } - | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" | "unbacked-routed-removal" } + /** + * `unbacked-routed-removal`: the candidate drops routed namespaces config.json does not back, or + * clears every routed row while config.json is unreadable. `foreign-owner`: this Codex home is + * bound to another OPENCODEX_HOME (#6529). + */ + | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" | "unbacked-routed-removal" | "foreign-owner" | "owner-unknown" } | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt }; declare const catalogCandidateBrand: unique symbol; @@ -146,7 +154,6 @@ interface CandidateState { readonly cache: PreparedCatalogFileWrite; readonly keyedBackup?: PreparedCatalogFileWrite; readonly legacyBackup?: PreparedCatalogFileWrite; - readonly changed: boolean; readonly notices: readonly CatalogNotice[]; readonly modelEntitlements: CodexModelEntitlementSnapshot; readonly discoveryConfig?: OcxConfig; @@ -545,8 +552,6 @@ export async function gatherCodexCatalogCandidate( ...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}), ...(pristineBytes && paths.legacyBackup ? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}), - changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes - || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes, notices: Object.freeze([...notices]), modelEntitlements, ...(discoveryChanged ? { discoveryConfig } : {}), @@ -635,11 +640,14 @@ function fixedCommit(state: CandidateState, permit: Parameters= deadline) return { kind: "busy" }; await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now()))); } diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 1457b380d0d..e1936a9eb2b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -7,6 +7,7 @@ import { readConfigAdmissionSnapshot, withConfigMutationLockSync, } from "../config"; +import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner"; import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock"; import { localClientSkipMessage, @@ -134,6 +135,7 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo export interface CodexInjectResult { + ownershipRefusal?: CodexHomeOwnerRefusalReason; success: boolean; message: string; /** False when injection intentionally preserves configuration owned by another provider. */ @@ -192,8 +194,9 @@ export async function injectCodexConfig( if (siblingOfLivePort() !== null) { return { success: true, status: "skipped", skippedReason: "sibling", message: siblingSkipMessage() }; } - try { return await injectCodexConfigImpl(port, config, options); } + try { assertCodexHomeOwner(getCodexHome()); return await injectCodexConfigImpl(port, config, options); } catch (error) { + if (error instanceof CodexHomeOwnerRefusal) return { success: false, ownershipRefusal: error.reason, message: error.message }; if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` }; if (error instanceof CodexInjectRefusal) return error.result; throw error; @@ -415,6 +418,7 @@ async function injectCodexConfigImpl( * flip included — before the result is reported. */ const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => { + assertCodexHomeOwner(getCodexHome()); if (missingConfig) createEmptyCodexConfigInBoundary(); let nativeInput = rawContent; let plan = admittedPlan; @@ -453,6 +457,7 @@ async function injectCodexConfigImpl( beforeHistoryArtifactCommitForTests?.(eligibility.kind); plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan); historyArtifactStageForTests?.("after-preflight"); + assertCodexHomeOwner(getCodexHome()); writeJournal({ currentStateIsNative: journalBaselineIsNative(nativeInput), configContent: plan.baselineContent, @@ -463,6 +468,7 @@ async function injectCodexConfigImpl( if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); atomicWriteFile(CODEX_CONFIG_PATH, plan.content); historyArtifactStageForTests?.("after-config"); + assertCodexHomeOwner(getCodexHome()); atomicWriteFile(CODEX_PROFILE_PATH, plan.profileContent); markJournalInjectedState(plan.content, plan.profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is @@ -526,6 +532,7 @@ async function injectCodexConfigImpl( }; } runClientWriteGuard(options.beforeClientWrite); + assertCodexHomeOwner(getCodexHome()); /* * One preimage covers the reconcile and the artifact commit together: a * refusal after the feature transition hands back the exact bytes the @@ -537,6 +544,7 @@ async function injectCodexConfigImpl( applyNativeArtifacts(resolved.plan, resolved.nativeInput); effectivePlan = resolved.plan; } catch (error) { + if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error; const restored = restoreCodexPreImages(preImages); if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored); throw error; @@ -582,6 +590,7 @@ async function injectCodexConfigImpl( // transition or capturing preimages; rejection must not compensate over // a disconnect's restored files. runClientWriteGuard(options.beforeClientWrite); + assertCodexHomeOwner(getCodexHome()); /* * Exact pre-images, captured under the lock and used for compensation. * @@ -630,6 +639,7 @@ async function injectCodexConfigImpl( } applyNativeArtifacts(resolved.plan, resolved.nativeInput); } catch (error) { + if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error; // Compensate, then ALWAYS throw. Returning a partial result would let the // lock commit a row describing an apply that did not finish. const restored = restoreCodexPreImages(preImages); diff --git a/src/codex/inject/restore.ts b/src/codex/inject/restore.ts index 42b357f85ed..533ae8bc1e9 100644 --- a/src/codex/inject/restore.ts +++ b/src/codex/inject/restore.ts @@ -1,3 +1,4 @@ +import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner"; import { loadConfig } from "../../config"; import { shouldSyncCodexOnStart } from "../desired-state"; import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start"; @@ -24,6 +25,7 @@ import { import { journaledInjectedCatalogPath, removeJournal, + releaseJournalHomeBinding, restoreJournalState, } from "../journal"; import { @@ -128,6 +130,7 @@ export interface CodexRestoreHistoryResult { } export interface CodexNativeRestoreResult { + ownershipRefusal?: CodexHomeOwnerRefusalReason; success: boolean; message: string; externalProvider?: string; @@ -339,6 +342,9 @@ export interface RestoreConfigOptions { /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = {}): CodexRestoreConfigResult { + assertCodexHomeOwner(getCodexHome()); + beforeRestoreConfigForTests?.(kind); + assertCodexHomeOwner(getCodexHome()); const preImages = captureCodexPreImages(); const result = restoreCodexConfigInlineImpl(kind, options); if (result.state === "failed") { @@ -350,7 +356,6 @@ function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOptions): CodexRestoreConfigResult { try { - beforeRestoreConfigForTests?.(kind); const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable); if (disposition.kind === "refuse") { return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${disposition.reason}.` }; @@ -368,12 +373,20 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption // accepting that tagged conversations stop opening. const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock(); const journal = restoreJournalState(); + if (journal.ownershipRefusal) throw new CodexHomeOwnerRefusal(journal.ownershipRefusal); if (journal.unverified) { return { state: "failed", changed: false, action: "failed", message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", }; } + if (journal.profileRestoreFailed) { + return { + state: "failed", changed: false, action: "failed", + message: "Codex profile could not be restored; native files and the journal binding were preserved.", + }; + } + assertCodexHomeOwner(getCodexHome()); const restored = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal.", retainedProviderTable: undefined as string[] | undefined } : removeCodexConfig({ @@ -403,6 +416,9 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) { retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines; } + // All native work and the final migration check succeeded. A field-level + // fallback retains its snapshot, but no longer owns this Codex home. + releaseJournalHomeBinding(); } if (restored.success && retainedLines !== null) { return { @@ -430,6 +446,7 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption } : { state: "failed", changed: false, action: "failed", message: restored.message }; } catch (error) { + if (error instanceof CodexHomeOwnerRefusal) throw error; return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) }; } } @@ -451,7 +468,8 @@ function restoreCodexCatalogArtifact( const restored = withCatalogWriteSerialization(owningCodexHome, permit => revalidateDesiredState && shouldSyncCodexOnStart(loadConfig()) ? null - : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath)); + : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath), + { intent: "restore", writer: "codex-restore" }); return restored.kind === "completed" && restored.value !== null ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." } : restored.kind === "completed" @@ -487,8 +505,10 @@ export async function restoreNativeCodexAsync( const sibling = siblingRestoreSkip(); if (sibling) return sibling; try { + assertCodexHomeOwner(getCodexHome()); return await restoreNativeCodexAsyncImpl(options); } catch (error) { + if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error); if (!(error instanceof CodexRestoreRefusal)) throw error; return failedConfigRestoreEnvelope(error.config); } @@ -549,6 +569,7 @@ async function restoreNativeCodexAsyncImpl( readAdmissionUnderLock: () => witness, }, (ctx) => { + assertCodexHomeOwner(getCodexHome()); if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) { throw new CodexWriteLockSkipped("desired_enabled"); } @@ -576,6 +597,7 @@ async function restoreNativeCodexAsyncImpl( // Throw inside N so the published remove transition rolls back too. if (restored.state === "failed") throw new CodexRestoreRefusal(restored); } catch (error) { + if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error; const compensated = restoreCodexPreImages(preImages); if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored); throw error; @@ -681,11 +703,27 @@ async function restoreNativeCodexAsyncImpl( }; } +function homeOwnerRestoreRefusal(error: CodexHomeOwnerRefusal): CodexNativeRestoreResult { + return { ...skippedRestoreEnvelope(false, error.message), ownershipRefusal: error.reason }; +} + export function restoreNativeCodex( options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {}, ): CodexNativeRestoreResult { const sibling = siblingRestoreSkip(); if (sibling) return sibling; + try { + assertCodexHomeOwner(getCodexHome()); + return restoreNativeCodexImpl(options); + } catch (error) { + if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error); + throw error; + } +} + +function restoreNativeCodexImpl( + options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {}, +): CodexNativeRestoreResult { const activeProvider = currentExternalCodexModelProvider(); if (activeProvider) { removeJournal(); diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 99c269ce09c..756f12b1122 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -5,12 +5,17 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, - getConfigDir, resolveWriteTarget, type AtomicWriteIO, -} from "../../config"; +} from "../../config/atomic-write"; +import { getConfigDir } from "../../config/paths"; +import { readConfigAdmissionSnapshot } from "../../config/diagnostics"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config/mutation-lock"; +import { ocxRoutedRowCount } from "../catalog/routed-removal"; import { assertCatalogWritePermit, + catalogWritePermitContext, + CatalogWritePermitRefusal, type CatalogWritePermit, } from "../catalog-write-serialization"; import { @@ -199,16 +204,50 @@ function publishCatalogBackup( return "written"; } -/** Replace the active catalog with the caller's already-prepared bytes. */ +export type CatalogFileReplacement = + | { readonly kind: "written" } + | { readonly kind: "unchanged" } + | { readonly kind: "refused"; readonly reason: "unbacked-routed-clear" }; + +function routedRowsFromJson(content: string): number | null { + try { return ocxRoutedRowCount(JSON.parse(content)); } catch { return null; } +} + +/** One funnel for exact-byte idempotence and intent admission, under a live K permit. */ export function replaceActiveCodexCatalog( permit: CatalogWritePermit, owningCodexHome: string, prepared: PreparedCatalogFileWrite, io?: AtomicWriteIO, -): void { +): CatalogFileReplacement { assertCatalogWritePermit(permit, owningCodexHome); + const { intent } = catalogWritePermitContext(permit); + if (intent === "cache") { + throw new CatalogWritePermitRefusal("A models-cache permit cannot replace the Codex catalog."); + } + if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" }; + let routedBefore: number | null = null; + try { routedBefore = routedRowsFromJson(readFileSync(prepared.path, "utf8")); } catch { /* absent/unreadable */ } + if (intent === "refresh" && (routedBefore ?? 0) > 0 && routedRowsFromJson(prepared.content) === 0) { + // K -> C, including the replacement: a config save cannot race the authority check. + try { + return withConfigMutationLockSync(() => { + const snapshot = readConfigAdmissionSnapshot(); + if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") { + return { kind: "refused", reason: "unbacked-routed-clear" } as const; + } + atomicWriteFile(prepared.path, prepared.content, io); + resetCodexAppServerCatalogStateCache(); + return { kind: "written" } as const; + }); + } catch (error) { + if (error instanceof ConfigMutationLockError) return { kind: "refused", reason: "unbacked-routed-clear" }; + throw error; + } + } atomicWriteFile(prepared.path, prepared.content, io); resetCodexAppServerCatalogStateCache(); + return { kind: "written" }; } /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */ @@ -242,8 +281,11 @@ export function replaceCodexModelsCache( owningCodexHome: string, prepared: PreparedCatalogFileWrite, io?: AtomicWriteIO, -): void { +): Exclude { assertCatalogWritePermit(permit, owningCodexHome); + catalogWritePermitContext(permit); + if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" }; atomicWriteFile(prepared.path, prepared.content, io); resetCodexAppServerCatalogStateCache(); + return { kind: "written" }; } diff --git a/src/codex/journal.ts b/src/codex/journal.ts index 586ea049e87..f9ea7caa7e5 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile } from "../config"; +import { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner"; import { hasInjectedCodexRouting } from "./injected-marker"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; @@ -14,7 +15,7 @@ import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; * agreed with the producer, and the pair stayed green. One exported constant * removes the opportunity. */ -export const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json"); +export const JOURNAL_PATH = join(CODEX_HOME, CODEX_HOME_JOURNAL_FILE); export type JournalOwner = | { kind: "process"; pid: number } @@ -71,12 +72,17 @@ interface Journal { * file we actually touched. */ injectedCatalogPath?: string | null; + /** Injection binding survives native snapshot replacement and is released by native restore. */ + opencodexHome?: string; pid: number; owner?: JournalOwner; timestamp: string; } export interface RestoreJournalResult { + /** An unchanged generated profile could not be restored; distinct from a preserved user edit. */ + profileRestoreFailed?: true; + ownershipRefusal?: CodexHomeOwnerRefusalReason; configRestored: boolean; profileRestored: boolean; configChanged: boolean; @@ -155,7 +161,12 @@ export function writeJournal(options: WriteJournalOptions = {}): void { // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than // the check above (it may describe bytes read a moment earlier), so an // unclassified call creates a first snapshot but never overwrites one. - if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return; + const inspection = readCodexHomeJournal(JOURNAL_PATH); + if (inspection.kind === "unknown") { + throw new CodexHomeOwnerRefusal("owner-unknown"); + } + const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; + if (previous && options.currentStateIsNative !== true) return; const profile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; @@ -163,6 +174,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void { version: 1, originalConfig: Buffer.from(config).toString("base64"), originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null, + opencodexHome: opencodexHomeForInjection(previous?.opencodexHome), pid: process.pid, owner: options.owner?.kind === "client" ? { kind: "client", apiKeyId: options.owner.apiKeyId } @@ -187,7 +199,9 @@ export function markJournalInjectedState( profile: string | null, ownership: InjectedJournalOwnership, ): void { - const journal = readJournal(); + const inspection = readCodexHomeJournal(JOURNAL_PATH); + if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); + const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; if (!journal) return; // The first exact injected config is the only safe whole-snapshot restore boundary for // the first native snapshot. A later reinjection may preserve user edits made while routed; @@ -204,6 +218,7 @@ export function markJournalInjectedState( journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null; journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null; journal.injectedCatalogPath = ownership.injectedCatalogPath; + journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome); atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal)); } @@ -240,19 +255,24 @@ export function journaledInjectedCatalogPath(): string | null { } export function removeJournal(): void { + assertCodexHomeOwner(CODEX_HOME); try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ } } -function readJournal(cleanInvalid = true): Journal | null { - if (!existsSync(JOURNAL_PATH)) return null; - try { - const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal; - if (journal.version !== 1) throw new Error("unknown version"); - return journal; - } catch { - if (cleanInvalid) removeJournal(); - return null; - } +/** Successful field-level native restore releases ownership while retaining recovery evidence. */ +export function releaseJournalHomeBinding(): void { + assertCodexHomeOwner(CODEX_HOME); + const inspection = readCodexHomeJournal(JOURNAL_PATH); + if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); + if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return; + delete inspection.journal.opencodexHome; + atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal)); +} + +// Kept compatible with existing read-only callers; invalid evidence is never cleanup authority. +function readJournal(_cleanInvalid = true): Journal | null { + const inspected = readCodexHomeJournal(JOURNAL_PATH); + return inspected.kind === "read" ? inspected.journal as unknown as Journal : null; } /** @@ -274,6 +294,12 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner } export function restoreJournalState(): RestoreJournalResult { + try { assertCodexHomeOwner(CODEX_HOME); } + catch (error) { + if (!(error instanceof CodexHomeOwnerRefusal)) throw error; + return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, + complete: false, unverified: true, ownershipRefusal: error.reason }; + } const journal = readJournal(); if (!journal) { return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; @@ -295,6 +321,7 @@ export function restoreJournalState(): RestoreJournalResult { }; } + assertCodexHomeOwner(CODEX_HOME); let configRestored = comparison.configAlreadyOriginal; let profileRestored = comparison.profileAlreadyOriginal; if (configUnchanged && !configRestored) { @@ -330,6 +357,7 @@ export function restoreJournalState(): RestoreJournalResult { profileChanged: !profileUnchanged, complete, unverified: false, + ...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}), }; } @@ -342,6 +370,12 @@ export interface ReconcileJournalOptions { } export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { + try { assertCodexHomeOwner(CODEX_HOME); } + catch (error) { + if (!(error instanceof CodexHomeOwnerRefusal)) throw error; + console.error(error.message); + return false; + } const journal = readJournal(); if (!journal) return false; const owner = journalOwner(); diff --git a/src/codex/refresh.ts b/src/codex/refresh.ts index e280835c8d8..cd7eb309fb6 100644 --- a/src/codex/refresh.ts +++ b/src/codex/refresh.ts @@ -1,8 +1,10 @@ import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog"; import type { ComboCatalogOmission } from "./catalog/aggregation"; -import { CODEX_MODELS_CACHE_PATH } from "./paths"; -import { atomicWriteFile } from "../config"; +import { getCodexHome } from "./paths"; +import { withCatalogWriteSerialization, CatalogWritePermitRefusal } from "./catalog-write-serialization"; +import { replaceCodexModelsCache } from "./internal/catalog-writer"; import type { OcxConfig } from "../types"; import type { CodexCatalogSyncOptions } from "./catalog/sync"; @@ -18,7 +20,7 @@ export interface CodexCatalogRefreshResult { * Desired OFF observed under K during the catalog commit, or a write refused because it would * empty routed namespaces config.json does not back (#6529); no cache write either way. */ - skippedReason?: "desired_disabled" | "unbacked_routed_removal"; + skippedReason?: "desired_disabled" | "unbacked_routed_removal" | "foreign_owner" | "owner_unknown"; protectedRoutedNamespaces?: number; } @@ -35,8 +37,13 @@ const defaultDeps: RefreshDeps = { }; export function syncCodexModelsCacheFromCatalog(catalogPath: string): void { - const content = readFileSync(catalogPath, "utf8"); - atomicWriteFile(CODEX_MODELS_CACHE_PATH, content); + const owningCodexHome = getCodexHome(); + const outcome = withCatalogWriteSerialization(owningCodexHome, permit => + replaceCodexModelsCache(permit, owningCodexHome, { + path: join(owningCodexHome, "models_cache.json"), + content: readFileSync(catalogPath, "utf8"), + }), { intent: "cache", writer: "cache-from-catalog" }); + if (outcome.kind === "unavailable") throw new CatalogWritePermitRefusal(`Catalog cache synchronization unavailable (${outcome.reason}).`); } /** diff --git a/src/codex/sync.ts b/src/codex/sync.ts index 445bd184440..1b29b80db59 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -1,5 +1,5 @@ import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject"; -import { unbackedRoutedRemovalMessage } from "./catalog/routed-removal"; +import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE, unbackedRoutedRemovalMessage } from "./catalog/routed-removal"; import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings"; import { refreshCodexModelCatalog } from "./refresh"; import { applyProxyEnv, loadConfig } from "../config"; @@ -273,10 +273,13 @@ export async function syncModelsToCodex( catalogPathForInjection = cat.catalogExists ? cat.path : null; catalogPath = catalogPathForInjection; comboOmissions = cat.comboOmissions ?? []; - if (cat.skippedReason === "unbacked_routed_removal") { + if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") { // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety // reads exactly like a successful sync (#6529). - warning = unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1); + warning = cat.skippedReason === "foreign_owner" + ? FOREIGN_CODEX_HOME_OWNER_MESSAGE + : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE + : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1); log?.error(`[opencodex] ${warning}`); } else if (cat.added > 0) { log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`); @@ -376,10 +379,13 @@ async function refreshCatalogForSync( cacheSynced = cat.cacheSynced; catalogPath = cat.catalogExists ? cat.path : null; comboOmissions = cat.comboOmissions ?? []; - if (cat.skippedReason === "unbacked_routed_removal") { + if (cat.skippedReason === "unbacked_routed_removal" || cat.skippedReason === "foreign_owner" || cat.skippedReason === "owner_unknown") { // The catalog on disk is kept as it was; say so, or a native-only refresh refused for safety // reads exactly like a successful sync (#6529). - warning = unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1); + warning = cat.skippedReason === "foreign_owner" + ? FOREIGN_CODEX_HOME_OWNER_MESSAGE + : cat.skippedReason === "owner_unknown" ? UNKNOWN_CODEX_HOME_OWNER_MESSAGE + : unbackedRoutedRemovalMessage(cat.protectedRoutedNamespaces ?? 1); log?.error(`[opencodex] ${warning}`); } else if (cat.added > 0) { log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`); diff --git a/src/server/index.ts b/src/server/index.ts index bc827af06fe..02d8e03c196 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -281,7 +281,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe // with the later startup sync and warns ONCE about stale app-servers; warning // here instead would read a catalog mtime the sync is about to move. const outcome = withCatalogWriteSerialization(startupCodexHome, permit => - invalidateCodexModelsCacheWithPermit(permit, startupCodexHome)); + invalidateCodexModelsCacheWithPermit(permit, startupCodexHome), { intent: "cache", writer: "startup-cache" }); // A refused permit is not a write; only a completed run that returned true is. setStartupCacheInvalidationWrite(outcome.kind === "completed" && outcome.value === true); } catch { /* no readable Codex home: nothing to invalidate */ } diff --git a/structure/codex-home.md b/structure/codex-home.md index 976b20d8f48..12d44b282a9 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -394,6 +394,16 @@ to drop routed rows. Coverage: `tests/codex-integration/codex-catalog-routed-rem `tests/codex-integration/codex-catalog-sync-hardening.test.ts`, and `tests/codex-integration/codex-convergence-contract.test.ts`. +## Catalog writer ownership and intent + +`src/codex/codex-home-owner.ts` reads the injection journal's `opencodexHome` binding without modifying recovery evidence. A readable legacy journal has no binding; the next injection records its owner. Canonical aliases of the same physical OpenCodex home remain owned. A proven vanished home is stale and can be adopted, while foreign active or unknown ownership refuses writes. Every acquisition rechecks ownership under K before issuing its live permit; later calls re-evaluate the evidence. + +Injection and both native-restore paths check ownership before cleanup and at publication. Successful deliberate config/profile restoration releases ownership at the native restoration boundary. Exact restoration removes the journal; successful field-level restoration preserves its recovery data and releases only the home binding. Catalog-only restore retains the binding. A later catalog failure remains a failed artifact. This does not add a new transaction across native and catalog restoration or guarantee custom-path recovery after the original journal was released. + +Each `withCatalogWriteSerialization` call supplies `refresh`, `cache`, `pull` or `restore` intent and a writer label. The existing permit registry binds that context to one live acquisition. `src/codex/internal/catalog-writer.ts` skips identical catalog/cache bytes and preserves their mtime. A refresh cannot clear all routed rows without readable file-backed config; restore deliberately clears them, pull keeps the hub's authority, and a cache permit cannot replace the catalog. Remote pull accepts an unchanged derived cache as success. Sync and sync-cache report owner refusals without private paths. + +These checks coordinate cooperating processes running as the same OS user. They do not isolate files from an arbitrary process with that user's direct filesystem access. + ## Codex-home diagnostics Desktop executable membership uses the [discovered installation root](runtime.md#codex-desktop-process-membership), independently of the Codex state directory resolved here. diff --git a/tests/codex-integration/catalog-remote-pull.test.ts b/tests/codex-integration/catalog-remote-pull.test.ts index e92e0ccdf65..1082a4c6f46 100644 --- a/tests/codex-integration/catalog-remote-pull.test.ts +++ b/tests/codex-integration/catalog-remote-pull.test.ts @@ -15,6 +15,9 @@ import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdent import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +/** Every K acquisition states its intent (#6529); these tests exercise the lock, not the intent. */ +const TEST_CATALOG_WRITE = { intent: "refresh", writer: "test" } as const; + const homes: string[] = []; afterEach(() => { while (homes.length) removeTreeWithRetry(homes.pop()!); }); @@ -302,6 +305,32 @@ describe("remote catalog validation", () => { }); describe("remote catalog coordinated installation", () => { + for (const ownership of ["foreign", "unknown"] as const) { + test(`an identical remote pull still refuses ${ownership} ownership`, async () => { + const codexHome = home(); + const opencodexHome = home(); + const foreignHome = home(); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = opencodexHome; + try { + const first = await pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + }); + writeFileSync(join(codexHome, "opencodex-journal.json"), ownership === "unknown" + ? "invalid evidence" : JSON.stringify({ version: 1, originalConfig: "", originalProfile: null, + pid: 1, timestamp: "2026-10-04T00:00:00Z", opencodexHome: foreignHome })); + const cachePath = join(codexHome, "models_cache.json"); + const before = [readFileSync(first.catalogPath), readFileSync(cachePath)]; + await expect(pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + })).rejects.toMatchObject({ code: ownership === "foreign" ? "foreign_owner" : "owner_unknown" }); + expect([readFileSync(first.catalogPath), readFileSync(cachePath)]).toEqual(before); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; + } + }); + } + test("updates catalog and cache under the shared writer even when desired integration is disabled", async () => { const codexHome = home(); const opencodexHome = home(); @@ -335,7 +364,7 @@ describe("remote catalog coordinated installation", () => { test("lock contention is typed and preserves last-known-good files", async () => { const codexHome = home(); // Materialize K, then hold BEGIN IMMEDIATE from a separate connection while pull attempts it. - expect(withCatalogWriteSerialization(codexHome, () => null).kind).toBe("completed"); + expect(withCatalogWriteSerialization(codexHome, () => null, TEST_CATALOG_WRITE).kind).toBe("completed"); const lockPath = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); const holder = new Database(lockPath); holder.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE"); @@ -350,6 +379,24 @@ describe("remote catalog coordinated installation", () => { } }); + test("a changed catalog with unchanged derived cache is a successful pull", async () => { + const codexHome = home(); + const first = await pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(catalog), + }); + const cachePath = join(codexHome, "models_cache.json"); + const cacheBefore = readFileSync(cachePath); + const mtimeBefore = statSync(cachePath).mtimeMs; + const next = { ...catalog, source_revision: "second" }; + const second = await pullRemoteCatalog("https://hub.example/v1/catalog", { + codexHome, fetchImpl: async () => response(next), + }); + expect(second).toMatchObject({ status: "updated", catalogWritten: true, cacheSynced: false }); + expect(JSON.parse(readFileSync(first.catalogPath, "utf8"))).toEqual(next); + expect(readFileSync(cachePath)).toEqual(cacheBefore); + expect(statSync(cachePath).mtimeMs).toBe(mtimeBefore); + }); + test("fetch and validation failures preserve last-known-good files", async () => { const codexHome = home(); const catalogPath = join(codexHome, "opencodex-catalog.json"); diff --git a/tests/codex-integration/codex-catalog-intent.test.ts b/tests/codex-integration/codex-catalog-intent.test.ts new file mode 100644 index 00000000000..1c07083d2ec --- /dev/null +++ b/tests/codex-integration/codex-catalog-intent.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import * as owner from "../../src/codex/codex-home-owner"; +import * as appServer from "../../src/codex/app-server-processes"; +import { + catalogWritePermitContext, CatalogWritePermitRefusal, withCatalogWriteSerialization, + type CatalogWriteIntent, type CatalogWritePermit, +} from "../../src/codex/catalog-write-serialization"; +import { replaceActiveCodexCatalog, replaceCodexModelsCache } from "../../src/codex/internal/catalog-writer"; +import { withConfigMutationLockSync } from "../../src/config/mutation-lock"; +import { restoreCodexCatalog } from "../../src/codex/catalog/restore"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +let catalogPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; +const native = `${JSON.stringify({ models: [{ slug: "gpt-5.6-sol" }] })}\n`; +const routed = `${JSON.stringify({ models: [{ slug: "test/model", description: "Routed via opencodex → test/model (fixture)." }] })}\n`; + +function acquire(intent: CatalogWriteIntent, callback: (permit: CatalogWritePermit) => T) { + return withCatalogWriteSerialization(codexHome, callback, { intent, writer: "intent-contract" }); +} +function replace(intent: CatalogWriteIntent, content = native) { + return acquire(intent, permit => replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content })); +} +function journal(boundHome: string) { + writeFileSync(join(codexHome, owner.CODEX_HOME_JOURNAL_FILE), JSON.stringify({ + version: 1, originalConfig: "", originalProfile: null, pid: 1, + timestamp: "2026-10-04T00:00:00Z", opencodexHome: boundHome, + })); +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-intent-"))); + codexHome = join(root, "codex"); + opencodexHome = join(root, "ocx"); + mkdirSync(codexHome); mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + catalogPath = join(codexHome, "opencodex-catalog.json"); + writeFileSync(catalogPath, routed); +}); + +afterEach(() => { + const lock = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${lock}${suffix}`, { force: true }); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(root); +}); + +for (const intent of ["refresh", "cache", "pull", "restore"] as const) { + test(`${intent} context is available only for its live permit`, () => { + let leaked: CatalogWritePermit | undefined; + expect(acquire(intent, permit => { + leaked = permit; + expect(catalogWritePermitContext(permit)).toEqual({ intent, writer: "intent-contract" }); + return "live"; + })).toEqual({ kind: "completed", value: "live" }); + expect(() => catalogWritePermitContext(leaked!)).toThrow(CatalogWritePermitRefusal); + expect(() => catalogWritePermitContext({} as CatalogWritePermit)).toThrow(CatalogWritePermitRefusal); + }); +} + +test("cache intent cannot replace even identical catalog bytes", () => { + expect(() => replace("cache", routed)).toThrow(CatalogWritePermitRefusal); + expect(readFileSync(catalogPath, "utf8")).toBe(routed); +}); + +for (const configState of ["missing", "malformed", "directory"] as const) { + test(`refresh cannot clear routed rows with ${configState} config`, () => { + const configPath = join(opencodexHome, "config.json"); + if (configState === "malformed") writeFileSync(configPath, "{ invalid"); + if (configState === "directory") mkdirSync(configPath); + expect(replace("refresh")).toEqual({ kind: "completed", value: { kind: "refused", reason: "unbacked-routed-clear" } }); + expect(readFileSync(catalogPath, "utf8")).toBe(routed); + }); +} + +test("refresh clear uses saved config under an already-held C transaction", () => { + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai" })); + expect(acquire("refresh", permit => withConfigMutationLockSync(() => + replaceActiveCodexCatalog(permit, codexHome, { path: catalogPath, content: native })))) + .toEqual({ kind: "completed", value: { kind: "written" } }); + expect(readFileSync(catalogPath, "utf8")).toBe(native); +}); + +test("contended C refuses refresh clear without changing catalog bytes", () => { + writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai" })); + withConfigMutationLockSync(() => undefined); + const holder = new Database(join(opencodexHome, "config-mutation.sqlite")); + holder.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE"); + try { + expect(replace("refresh")).toEqual({ kind: "completed", value: { kind: "refused", reason: "unbacked-routed-clear" } }); + expect(readFileSync(catalogPath, "utf8")).toBe(routed); + } finally { holder.exec("ROLLBACK"); holder.close(); } +}); + +for (const intent of ["pull", "restore"] as const) { + test(`${intent} can deliberately clear routed rows without config`, () => { + expect(replace(intent)).toEqual({ kind: "completed", value: { kind: "written" } }); + expect(readFileSync(catalogPath, "utf8")).toBe(native); + }); +} + +test("identical catalog and cache bytes preserve mtimes and app-server memo", () => { + const cachePath = join(codexHome, "models_cache.json"); + writeFileSync(cachePath, native); + for (const path of [catalogPath, cachePath]) utimesSync(path, 1_000, 1_000); + const before = [statSync(catalogPath).mtimeMs, statSync(cachePath).mtimeMs]; + const reset = spyOn(appServer, "resetCodexAppServerCatalogStateCache"); + try { + expect(replace("refresh", routed)).toEqual({ kind: "completed", value: { kind: "unchanged" } }); + expect(acquire("cache", permit => replaceCodexModelsCache(permit, codexHome, { path: cachePath, content: native }))) + .toEqual({ kind: "completed", value: { kind: "unchanged" } }); + expect([statSync(catalogPath).mtimeMs, statSync(cachePath).mtimeMs]).toEqual(before); + expect(reset).not.toHaveBeenCalled(); + } finally { reset.mockRestore(); } +}); + +test("raw malformed bytes are repaired despite equal UTF-8 decoded strings", () => { + writeFileSync(catalogPath, Buffer.from([0x80])); + expect(replace("refresh", "\uFFFD")).toEqual({ kind: "completed", value: { kind: "written" } }); + expect(readFileSync(catalogPath)).toEqual(Buffer.from([0xef, 0xbf, 0xbd])); +}); + +for (const state of ["foreign", "unknown"] as const) { + test(`${state} ownership refuses every intent before callback and leaves artifacts intact`, () => { + const foreign = join(root, "foreign"); mkdirSync(foreign); + if (state === "foreign") journal(foreign); + else writeFileSync(join(codexHome, owner.CODEX_HOME_JOURNAL_FILE), "invalid journal"); + const cachePath = join(codexHome, "models_cache.json"); writeFileSync(cachePath, "cache-before"); + for (const intent of ["refresh", "cache", "pull", "restore"] as const) { + let called = false; + expect(acquire(intent, () => { called = true; })) + .toEqual({ kind: "unavailable", reason: state === "foreign" ? "foreign-owner" : "owner-unknown" }); + expect(called).toBe(false); + } + expect(readFileSync(catalogPath, "utf8")).toBe(routed); + expect(readFileSync(cachePath, "utf8")).toBe("cache-before"); + expect(() => restoreCodexCatalog()).toThrow(CatalogWritePermitRefusal); + // Later calls retry ownership inspection, allowing recovered evidence. + journal(opencodexHome); + expect(acquire("cache", () => "recovered")).toEqual({ kind: "completed", value: "recovered" }); + }); +} + +for (const state of ["foreign", "unknown"] as const) { + test(`${state} ownership observed after K acquisition refuses before callback`, () => { + const inspect = spyOn(owner, "inspectCodexHomeOwner").mockReturnValueOnce({ kind: "unbound" }) + .mockReturnValueOnce(state === "foreign" ? { kind: "foreign", boundHome: join(root, "foreign") } : { kind: "unknown" }); + try { + let called = false; + expect(acquire("refresh", () => { called = true; })) + .toEqual({ kind: "unavailable", reason: state === "foreign" ? "foreign-owner" : "owner-unknown" }); + expect(inspect).toHaveBeenCalledTimes(2); + expect(called).toBe(false); + } finally { inspect.mockRestore(); } + expect(acquire("cache", () => "released")).toEqual({ kind: "completed", value: "released" }); + }); +} diff --git a/tests/codex-integration/codex-catalog-write-serialization.test.ts b/tests/codex-integration/codex-catalog-write-serialization.test.ts index 3cd1fa0cc1a..7ba3fe3a310 100644 --- a/tests/codex-integration/codex-catalog-write-serialization.test.ts +++ b/tests/codex-integration/codex-catalog-write-serialization.test.ts @@ -16,6 +16,9 @@ import { } from "../../src/codex/user-identity"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +/** Every K acquisition states its intent (#6529); these tests exercise the lock, not the intent. */ +const TEST_CATALOG_WRITE = { intent: "refresh", writer: "test" } as const; + let codexHome = ""; let otherHome = ""; let previousCodexHome: string | undefined; @@ -67,7 +70,7 @@ test("a callback holding a live permit may write for its own home", () => { const outcome = withCatalogWriteSerialization(codexHome, (permit) => { assertCatalogWritePermit(permit, codexHome); return "published"; - }); + }, TEST_CATALOG_WRITE); expect(outcome).toEqual({ kind: "completed", value: "published" }); }); @@ -83,7 +86,7 @@ test("a permit leaked out of its callback is refused afterwards", () => { leaked = permit; expect(isCatalogWritePermitLive(permit)).toBe(true); return "done"; - }); + }, TEST_CATALOG_WRITE); expect(outcome.kind).toBe("completed"); expect(isCatalogWritePermitLive(leaked!)).toBe(false); @@ -96,7 +99,7 @@ test("a permit is revoked even when its callback throws", () => { expect(() => withCatalogWriteSerialization(codexHome, (permit) => { leaked = permit; throw new Error("callback exploded"); - })).toThrow("callback exploded"); + }, TEST_CATALOG_WRITE)).toThrow("callback exploded"); expect(isCatalogWritePermitLive(leaked!)).toBe(false); expect(() => assertCatalogWritePermit(leaked!, codexHome)).toThrow(); @@ -108,14 +111,14 @@ test("a permit is revoked even when its callback throws", () => { */ test("a permit cannot be reused by a later acquisition of the same home", () => { let first: CatalogWritePermit | undefined; - withCatalogWriteSerialization(codexHome, (permit) => { first = permit; }); + withCatalogWriteSerialization(codexHome, (permit) => { first = permit; }, TEST_CATALOG_WRITE); const outcome = withCatalogWriteSerialization(codexHome, (second) => { expect(second).not.toBe(first); expect(() => assertCatalogWritePermit(first!, codexHome)).toThrow(); assertCatalogWritePermit(second, codexHome); return "second-only"; - }); + }, TEST_CATALOG_WRITE); expect(outcome).toEqual({ kind: "completed", value: "second-only" }); }); @@ -131,7 +134,7 @@ test("a live permit for one home is refused by a writer for another home", () => expect(() => assertCatalogWritePermit(permit, otherHome)) .toThrow("authorizes a different CODEX_HOME"); return "home-bound"; - }); + }, TEST_CATALOG_WRITE); expect(outcome).toEqual({ kind: "completed", value: "home-bound" }); }); @@ -148,7 +151,7 @@ test("a forged permit shaped like the real one is refused", () => { .toThrow("was not minted by the serialization owner"); } return null; - }); + }, TEST_CATALOG_WRITE); }); /** @@ -157,20 +160,20 @@ test("a forged permit shaped like the real one is refused", () => { */ test("a second acquisition during a live callback is typed busy, not blocked", () => { const outcome = withCatalogWriteSerialization(codexHome, () => { - const nested = withCatalogWriteSerialization(codexHome, () => "should-not-run"); + const nested = withCatalogWriteSerialization(codexHome, () => "should-not-run", TEST_CATALOG_WRITE); expect(nested).toEqual({ kind: "unavailable", reason: "busy" }); return "outer-kept-k"; - }); + }, TEST_CATALOG_WRITE); expect(outcome).toEqual({ kind: "completed", value: "outer-kept-k" }); }); test("a different home is not excluded by a live acquisition", () => { const outcome = withCatalogWriteSerialization(codexHome, () => { - const other = withCatalogWriteSerialization(otherHome, () => "other-home-ran"); + const other = withCatalogWriteSerialization(otherHome, () => "other-home-ran", TEST_CATALOG_WRITE); expect(other).toEqual({ kind: "completed", value: "other-home-ran" }); return "independent"; - }); + }, TEST_CATALOG_WRITE); expect(outcome).toEqual({ kind: "completed", value: "independent" }); }); @@ -182,15 +185,15 @@ test("a different home is not excluded by a live acquisition", () => { test("a callback error propagates rather than becoming a lock outcome", () => { expect(() => withCatalogWriteSerialization(codexHome, () => { throw new TypeError("derivation failed"); - })).toThrow(TypeError); + }, TEST_CATALOG_WRITE)).toThrow(TypeError); // K must be released, so the next acquisition succeeds rather than hanging. - expect(withCatalogWriteSerialization(codexHome, () => "recovered")) + expect(withCatalogWriteSerialization(codexHome, () => "recovered", TEST_CATALOG_WRITE)) .toEqual({ kind: "completed", value: "recovered" }); }); test("K creates its database private to the effective user", () => { - withCatalogWriteSerialization(codexHome, () => null); + withCatalogWriteSerialization(codexHome, () => null, TEST_CATALOG_WRITE); const path = resolveCodexCatalogSerializationDatabasePath( resolveEffectiveUserIdentity(), codexHome, diff --git a/tests/codex-integration/codex-catalog-writer.test.ts b/tests/codex-integration/codex-catalog-writer.test.ts index 5182b672303..53c726a7f8e 100644 --- a/tests/codex-integration/codex-catalog-writer.test.ts +++ b/tests/codex-integration/codex-catalog-writer.test.ts @@ -49,6 +49,9 @@ import { } from "../../src/lib/config-ownership"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +/** Every K acquisition states its intent (#6529); these tests exercise the lock, not the intent. */ +const TEST_CATALOG_WRITE = { intent: "refresh", writer: "test" } as const; + interface MutatorCase { readonly name: string; readonly invoke: ( @@ -170,7 +173,7 @@ function expectRefusedBeforeFilesystemEffect( } function withLivePermit(callback: (permit: CatalogWritePermit) => T): T { - const outcome = withCatalogWriteSerialization(codexHome, callback); + const outcome = withCatalogWriteSerialization(codexHome, callback, TEST_CATALOG_WRITE); expect(outcome.kind).toBe("completed"); if (outcome.kind !== "completed") throw new Error(`K unavailable: ${outcome.reason}`); return outcome.value; @@ -227,7 +230,7 @@ test("every mutator refuses leaked and revoked permits before temp creation", () expect(() => withCatalogWriteSerialization(codexHome, (permit) => { revoked = permit; throw new Error("revoke this acquisition"); - })).toThrow("revoke this acquisition"); + }, TEST_CATALOG_WRITE)).toThrow("revoke this acquisition"); for (const mutator of mutators) { expectRefusedBeforeFilesystemEffect(mutator, leaked!); @@ -290,6 +293,7 @@ for (const mutator of mutators) { expect(publishIndex).toBeGreaterThanOrEqual(0); expect(hardenIndex).toBeLessThan(publishIndex); if (isBackup) expect(result).toBe("written"); + else expect(result).toEqual({ kind: "written" }); }); } diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 7e5426e15f7..5d7bd925655 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -696,6 +696,9 @@ test("retained and convergence writers resolve, clear, reject, and recover auto- for (const writer of ["retained", "convergence"] as const) { const write = async (nextConfig: OcxConfig): Promise => { + // A refresh may clear every routed row only while config.json is a readable file (#6529), + // and the provider-emptied step below does exactly that, as a real save would. + saveConfig(nextConfig); if (writer === "retained") { const result = await syncCatalogModels(nextConfig); expect(result.catalogWritten).toBe(true); diff --git a/tests/codex-integration/codex-convergence-contract.test.ts b/tests/codex-integration/codex-convergence-contract.test.ts index e552a4045f5..b7e9b69c9af 100644 --- a/tests/codex-integration/codex-convergence-contract.test.ts +++ b/tests/codex-integration/codex-convergence-contract.test.ts @@ -156,6 +156,17 @@ test("generation drift rejects before every catalog target write", async () => { expect(manifest(codexHome)).toEqual(before); }); +test("a fresh identical candidate reports unchanged receipts and preserves artifacts", async () => { + expect((await commitCodexCatalogCandidate(await candidate(), 1_000)).kind).toBe("committed"); + const gathered = await candidate(); + const before = manifest(codexHome); + expect(await commitCodexCatalogCandidate(gathered, 1_000)).toEqual({ + kind: "committed", changed: false, + writes: { keyedBackup: "preserved", legacyBackup: "preserved", catalog: "unchanged", cache: "unchanged" }, + }); + expect(manifest(codexHome)).toEqual(before); +}); + test("home-selection drift rejects before every catalog target write", async () => { const gathered = await candidate(); const other = join(root, "other-codex"); diff --git a/tests/codex-integration/codex-home-owner-lifecycle.test.ts b/tests/codex-integration/codex-home-owner-lifecycle.test.ts new file mode 100644 index 00000000000..15de69af00d --- /dev/null +++ b/tests/codex-integration/codex-home-owner-lifecycle.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CODEX_HOME_JOURNAL_FILE } from "../../src/codex/codex-home-owner"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); +let root: string, codexHome: string, ownHome: string, otherHome: string; +const original = 'model = "gpt-6.1-sol"\n'; +const injected = '# opencodex injected\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; +const profile = "# generated profile\n"; +const hash = (text: string) => createHash("sha256").update(text).digest("hex"); +function snapshot(home?: string): string { + return JSON.stringify({ version: 1, originalConfig: Buffer.from(original).toString("base64"), + originalProfile: null, injectedConfigHash: hash(injected), injectedProfileHash: hash(profile), + injectedOpenaiBaseUrl: "http://127.0.0.1:10100/v1", pid: 99999999, + timestamp: "2026-10-04T00:00:00.000Z", ...(home === undefined ? {} : { opencodexHome: home }) }); +} +function seed(bytes: string, config = injected): void { + writeFileSync(join(codexHome, "config.toml"), config); + writeFileSync(join(codexHome, "opencodex.config.toml"), profile); + writeFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), bytes); + writeFileSync(join(codexHome, "opencodex-catalog.json"), '{"models":[]}'); + writeFileSync(join(codexHome, "models_cache.json"), "cache-before"); +} +function bytes(): Record { + return Object.fromEntries(["config.toml", "opencodex.config.toml", CODEX_HOME_JOURNAL_FILE, + "opencodex-catalog.json", "models_cache.json"].map(file => [file, + existsSync(join(codexHome, file)) ? readFileSync(join(codexHome, file), "utf8") : null])); +} +function run(script: string): Record { + const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot(), + env: { ...process.env, CODEX_HOME: codexHome, CODEX_SQLITE_HOME: "", OPENCODEX_HOME: ownHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000 }); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); +} +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-owner-lifecycle-"))); + codexHome = join(root, "codex"); ownHome = join(root, "own"); otherHome = join(root, "other"); + for (const home of [codexHome, ownHome, otherHome]) mkdirSync(home); + writeFileSync(join(ownHome, "config.json"), '{"providers":{}}'); +}); +afterEach(() => removeTreeWithRetry(root)); + +for (const external of [false, true]) { + for (const owner of ["foreign", "unknown"] as const) { + test(`${owner} injection preflight and full apply preserve ${external ? "external" : "native"} configuration`, () => { + seed(owner === "foreign" ? snapshot(otherHome) : "{corrupt", external ? 'model_provider = "external"\n' : original); + const before = bytes(); + const result = run(`const {injectCodexConfig}=require("./src/codex/inject"); + const preflight=await injectCodexConfig(10100, {}, {validateOnly:true}); + const applied=await injectCodexConfig(10100, {}); + console.log(JSON.stringify({preflight,applied}));`); + const reason = owner === "foreign" ? "foreign-owner" : "owner-unknown"; + expect(result.preflight).toMatchObject({ success: false, ownershipRefusal: reason }); + expect(result.applied).toMatchObject({ success: false, ownershipRefusal: reason }); + expect(JSON.stringify(result)).not.toContain(otherHome); + expect(bytes()).toEqual(before); + }); + test(`${owner} synchronous and asynchronous restore refuse before ${external ? "external cleanup" : "native writes"}`, () => { + seed(owner === "foreign" ? snapshot(otherHome) : "{corrupt", external ? 'model_provider = "external"\n' : injected); + const before = bytes(); + const result = run(`const r=require("./src/codex/inject/restore"); + console.log(JSON.stringify({sync:r.restoreNativeCodex({skipHistory:true}),async:await r.restoreNativeCodexAsync()}));`); + for (const mode of ["sync", "async"]) { + expect(result[mode]).toMatchObject({ success: false, ownershipRefusal: owner === "foreign" ? "foreign-owner" : "owner-unknown", + artifacts: { config: { state: "skipped" }, catalog: { state: "skipped" }, history: { state: "skipped" } } }); + } + expect(JSON.stringify(result)).not.toContain(otherHome); + expect(bytes()).toEqual(before); + }); + } +} + +test("direct reconciliation, snapshot restore and cleanup preserve foreign/unknown evidence", () => { + for (const binding of [snapshot(otherHome), "{corrupt"]) { + seed(binding); const before = bytes(); + const result = run(`const j=require("./src/codex/journal"); + const reconciled=j.reconcileJournal(); const restored=j.restoreJournalState(); + let cleanup;try {j.removeJournal();} catch(e) {cleanup=e.reason;} + console.log(JSON.stringify({reconciled,restored,cleanup}));`); + expect(result.reconciled).toBe(false); + expect(result.restored).toMatchObject({ complete: false, unverified: true }); + expect(result.cleanup).toBe(binding.startsWith("{corrupt") ? "owner-unknown" : "foreign-owner"); + expect(bytes()).toEqual(before); + } +}); + +for (const mode of ["sync", "async"] as const) { + test(`${mode} restore rechecks a newly foreign binding before first artifact write`, () => { + seed(snapshot(ownHome)); + const foreign = snapshot(otherHome); + const before = bytes(); + const result = run(`const r=require("./src/codex/inject/restore");const j=require("./src/codex/journal"); + r.setBeforeRestoreConfigForTests(()=>require("node:fs").writeFileSync(j.JOURNAL_PATH,${JSON.stringify(foreign)})); + const result=${mode === "sync" ? 'r.restoreNativeCodex({skipHistory:true})' : 'await r.restoreNativeCodexAsync()'}; + console.log(JSON.stringify(result));`); + expect(result).toMatchObject({ success: false, ownershipRefusal: "foreign-owner" }); + expect(bytes()).toEqual({ ...before, [CODEX_HOME_JOURNAL_FILE]: foreign }); + }); +} + +test("injection final guard preserves newly foreign evidence rather than compensating it away", () => { + writeFileSync(join(codexHome, "config.toml"), original); + const foreign = snapshot(otherHome); + const result = run(`const i=require("./src/codex/inject");const j=require("./src/codex/journal"); + i.setBeforeHistoryArtifactCommitForTests(()=>require("node:fs").writeFileSync(j.JOURNAL_PATH,${JSON.stringify(foreign)})); + console.log(JSON.stringify(await i.injectCodexConfig(10100,{})));`); + expect(result).toMatchObject({ success: false, ownershipRefusal: "foreign-owner" }); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), "utf8")).toBe(foreign); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); +}); + +test("owned injection failures retain the existing compensation", () => { + writeFileSync(join(codexHome, "config.toml"), original); + const result = run(`const i=require("./src/codex/inject"); + i.setHistoryArtifactStageForTests(stage=>{if(stage==="after-config")throw new Error("fixture write failure");}); + let failed=false;try {await i.injectCodexConfig(10100,{});} catch(e) {failed=e.message==="fixture write failure";} + console.log(JSON.stringify({failed}));`); + expect(result.failed).toBe(true); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(codexHome, CODEX_HOME_JOURNAL_FILE))).toBe(false); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); +}); + +test("owner and legacy journal restores release binding after native config/profile restoration", () => { + for (const binding of [ownHome, undefined]) { + seed(snapshot(binding)); + const result = run(`const j=require("./src/codex/journal");console.log(JSON.stringify(j.restoreJournalState()));`); + expect(result).toMatchObject({ complete: true, configRestored: true, profileRestored: true }); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(codexHome, CODEX_HOME_JOURNAL_FILE))).toBe(false); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + } +}); + +test("hashless legacy restore remains unverified and preserves its snapshot", () => { + const legacy = JSON.parse(snapshot()); delete legacy.injectedConfigHash; delete legacy.injectedProfileHash; + seed(JSON.stringify(legacy)); const before = bytes(); + const result = run(`console.log(JSON.stringify(require("./src/codex/journal").restoreJournalState()));`); + expect(result).toMatchObject({ complete: false, unverified: true }); + expect(bytes()).toEqual(before); +}); + +test("successful native restore retains its release point even when catalog K is busy", () => { + seed(snapshot(ownHome)); + const result = run(`const {withCatalogWriteSerialization}=require("./src/codex/catalog-write-serialization"); + const {restoreNativeCodex}=require("./src/codex/inject/restore"); + const outer=withCatalogWriteSerialization(process.env.CODEX_HOME,()=>restoreNativeCodex({skipHistory:true}), + {intent:"restore",writer:"test-owner-release"}); + if(outer.kind!=="completed") throw new Error(JSON.stringify(outer)); + console.log(JSON.stringify(outer.value));`); + expect(result).toMatchObject({ success: false, artifacts: { config: { state: "ok" }, catalog: { state: "failed" } } }); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(codexHome, CODEX_HOME_JOURNAL_FILE))).toBe(false); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); +}); + +test("catalog-only restoration retains the owner binding", () => { + seed(snapshot(ownHome)); const before = readFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), "utf8"); + run(`require("./src/codex/catalog/restore").restoreCodexCatalog();console.log("{}");`); + expect(readFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), "utf8")).toBe(before); +}); + +test("proven stale binding reconciliation restores and releases the native snapshot", () => { + seed(snapshot(otherHome)); rmSync(otherHome, { recursive: true }); + const result = run(`console.log(JSON.stringify({restored:require("./src/codex/journal").reconcileJournal()}));`); + expect(result.restored).toBe(true); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(existsSync(join(codexHome, CODEX_HOME_JOURNAL_FILE))).toBe(false); +}); + +for (const mode of ["sync", "async"] as const) { + test(`${mode} owner restore after config edits releases binding for second-home admission`, () => { + const journalBytes = snapshot(ownHome); + const edited = injected + '# user edit after injection\nmodel = "user-edited-model"\n'; + seed(journalBytes, edited); + const result = run(`const r=require("./src/codex/inject/restore"); + const fs=require("node:fs");const j=require("./src/codex/journal"); + const restored=${mode === "sync" ? 'r.restoreNativeCodex({skipHistory:true})' : 'await r.restoreNativeCodexAsync()'}; + const retained=JSON.parse(fs.readFileSync(j.JOURNAL_PATH,"utf8")); + process.env.OPENCODEX_HOME=${JSON.stringify(otherHome)}; + const {withCatalogWriteSerialization}=require("./src/codex/catalog-write-serialization"); + const admission=withCatalogWriteSerialization(process.env.CODEX_HOME,()=>"second-home-admitted", + {intent:"refresh",writer:"test-second-home"}); + console.log(JSON.stringify({restored,retained,admission}));`); + expect(result.restored).toMatchObject({ success: true, artifacts: { config: { state: "ok", action: "owned-fields-stripped" } } }); + const recovery = JSON.parse(journalBytes); delete recovery.opencodexHome; + expect(result.retained).toEqual(recovery); + expect(result.admission).toEqual({ kind: "completed", value: "second-home-admitted" }); + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('model = "user-edited-model"'); + expect(config).not.toContain("openai_base_url"); + expect(existsSync(join(codexHome, "opencodex.config.toml"))).toBe(false); + }); +} + +test("failed field-level binding release compensates the old binding and native files", () => { + seed(snapshot(ownHome), injected + '# preserve user edit\n'); + const before = bytes(); + const result = run(`const {spyOn}=require("bun:test");const config=require("./src/config"); + const r=require("./src/codex/inject/restore");const j=require("./src/codex/journal"); + const originalWrite=config.atomicWriteFile; + const spy=spyOn(config,"atomicWriteFile").mockImplementation((path,content,...rest)=>{ + const result=originalWrite(path,content,...rest); + if(path===j.JOURNAL_PATH && JSON.parse(content).opencodexHome===undefined) + throw new Error("fixture binding release failure"); + return result; + }); + try {console.log(JSON.stringify(r.restoreNativeCodex({skipHistory:true})));} finally {spy.mockRestore();}`); + expect(result).toMatchObject({ success: false, artifacts: { config: { state: "failed" } } }); + expect(bytes()).toEqual(before); +}); + +for (const mode of ["sync", "async"] as const) { + for (const edited of [false, true]) { + test(`${mode} generated-profile unlink EPERM retains binding and compensates ${edited ? "edited" : "unchanged"} config`, () => { + seed(snapshot(ownHome), injected + (edited ? '# user edit after injection\n' : "")); + const before = bytes(); + const result = run(`const {spyOn}=require("bun:test");const fs=require("node:fs"); + const CODEX_PROFILE_PATH=require("node:path").join(process.env.CODEX_HOME,"opencodex.config.toml"); + const originalUnlink=fs.unlinkSync;let denied=0; + const spy=spyOn(fs,"unlinkSync").mockImplementation((path)=>{ + if(path===CODEX_PROFILE_PATH) {denied++;throw Object.assign(new Error("fixture profile unlink denied"),{code:"EPERM"});} + return originalUnlink(path); + }); + require("node:module").syncBuiltinESMExports(); + const r=require("./src/codex/inject/restore"); + try { + const restored=${mode === "sync" ? 'r.restoreNativeCodex({skipHistory:true})' : 'await r.restoreNativeCodexAsync()'}; + console.log(JSON.stringify({restored,denied})); + } finally {spy.mockRestore();}`); + expect(result).toMatchObject({ denied: 1, restored: { success: false, + artifacts: { config: { state: "failed", changed: false }, catalog: { state: "skipped" }, history: { state: "skipped" } } } }); + expect(bytes()).toEqual(before); + }); + } +} + +test("deliberately preserved user-edited profile still permits successful native restore and release", () => { + seed(snapshot(ownHome), injected + '# user config edit\n'); + const editedProfile = profile + '# user profile edit\n'; + writeFileSync(join(codexHome, "opencodex.config.toml"), editedProfile); + const result = run(`const r=require("./src/codex/inject/restore"); + console.log(JSON.stringify(r.restoreNativeCodex({skipHistory:true})));`); + expect(result).toMatchObject({ success: true, artifacts: { config: { state: "ok" } } }); + expect(readFileSync(join(codexHome, "opencodex.config.toml"), "utf8")).toBe(editedProfile); + const retained = JSON.parse(readFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), "utf8")); + expect(retained.opencodexHome).toBeUndefined(); +}); diff --git a/tests/codex-integration/codex-home-owner.test.ts b/tests/codex-integration/codex-home-owner.test.ts new file mode 100644 index 00000000000..d61fdfe267e --- /dev/null +++ b/tests/codex-integration/codex-home-owner.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CODEX_HOME_JOURNAL_FILE, currentOpencodexHome, inspectCodexHomeOwner, opencodexHomeForInjection } from "../../src/codex/codex-home-owner"; +import { repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +setDefaultTimeout(SPAWN_BUDGET_MS); +let root: string, codexHome: string, ownHome: string, otherHome: string; +let previous: string | undefined; +const journal = (home?: string) => ({ version: 1, originalConfig: "", originalProfile: null, + pid: 1, timestamp: "2026-10-04T00:00:00.000Z", ...(home === undefined ? {} : { opencodexHome: home }) }); +const bind = (home?: string) => writeFileSync(join(codexHome, CODEX_HOME_JOURNAL_FILE), JSON.stringify(journal(home))); +function run(script: string): Record { + const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot(), + env: { ...process.env, CODEX_HOME: codexHome, CODEX_SQLITE_HOME: "", OPENCODEX_HOME: ownHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000 }); + expect(child.status, child.stderr).toBe(0); + return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); +} +beforeEach(() => { + previous = process.env.OPENCODEX_HOME; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-owner-"))); + codexHome = join(root, "codex"); ownHome = join(root, "own"); otherHome = join(root, "other"); + for (const home of [codexHome, ownHome, otherHome]) mkdirSync(home); + process.env.OPENCODEX_HOME = ownHome; +}); +afterEach(() => { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(root); +}); + +test("missing and valid hashless legacy journals are unbound", () => { + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unbound" }); + bind(); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unbound" }); +}); +test("canonical paths and aliases on both sides identify the physical owner", () => { + bind(ownHome); + expect(currentOpencodexHome()).toBe(ownHome); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "owned" }); + const alias = join(root, "alias"); symlinkSync(ownHome, alias, "dir"); + bind(alias); + expect(inspectCodexHomeOwner(codexHome, ownHome)).toEqual({ kind: "owned" }); + bind(ownHome); + expect(inspectCodexHomeOwner(codexHome, alias)).toEqual({ kind: "owned" }); +}); +test("extant foreign directories are refused and proven absent bindings are stale", () => { + bind(otherHome); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "foreign", boundHome: otherHome }); + rmSync(otherHome, { recursive: true }); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "stale", boundHome: otherHome }); +}); +test("uncertain current resolution and dangling owner links fail closed", () => { + bind(ownHome); + expect(inspectCodexHomeOwner(codexHome, join(root, "missing"))).toEqual({ kind: "unknown" }); + const alias = join(root, "dangling"); symlinkSync(join(root, "absent"), alias, "dir"); + bind(alias); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); + expect(opencodexHomeForInjection(alias)).toBe(alias); +}); +test("malformed, invalid binding and oversized journals remain unknown and unchanged", () => { + const path = join(codexHome, CODEX_HOME_JOURNAL_FILE); + for (const bytes of ["{broken", "null", "{}", JSON.stringify(journal("relative")), + JSON.stringify({ ...journal(), opencodexHome: null }), " ".repeat(1024 * 1024 + 1)]) { + writeFileSync(path, bytes); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); + expect(readFileSync(path, "utf8")).toBe(bytes); + } +}); +test("symlinked journal, directory journal and owner file are not authority", () => { + const path = join(codexHome, CODEX_HOME_JOURNAL_FILE); + const target = join(root, "target"); writeFileSync(target, JSON.stringify(journal(ownHome))); + symlinkSync(target, path, "file"); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); + rmSync(path); mkdirSync(path); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); + rmSync(path, { recursive: true }); bind(target); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); +}); +test("unresolvable owner paths and journals fail closed without a blocking read", () => { + const loop = join(root, "loop"); symlinkSync(loop, loop, "dir"); + bind(loop); + expect(inspectCodexHomeOwner(codexHome)).toEqual({ kind: "unknown" }); + expect(inspectCodexHomeOwner(join(loop, "codex"))).toEqual({ kind: "unknown" }); + expect(opencodexHomeForInjection(loop)).toBe(loop); +}); +test("injection binding selection preserves foreign evidence and adopts only legacy/stale", () => { + expect(opencodexHomeForInjection(undefined)).toBe(ownHome); + expect(opencodexHomeForInjection(ownHome)).toBe(ownHome); + expect(opencodexHomeForInjection(otherHome)).toBe(otherHome); + rmSync(otherHome, { recursive: true }); + expect(opencodexHomeForInjection(otherHome)).toBe(ownHome); +}); +test("snapshot and injected-state writers fill legacy and preserve foreign native snapshots", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-6.1-sol"\n'); + const script = `const j = require("./src/codex/journal"); + j.writeJournal({currentStateIsNative:true}); + j.markJournalInjectedState("routed", null, {injectedOpenaiBaseUrl:null,injectedRealtimeWsBaseUrl:null,injectedCatalogPath:null}); + console.log(require("node:fs").readFileSync(j.JOURNAL_PATH,"utf8"));`; + expect(run(script).opencodexHome).toBe(ownHome); + bind(); expect(run(script).opencodexHome).toBe(ownHome); + bind(otherHome); + const replaced = run(script); + expect(replaced.opencodexHome).toBe(otherHome); + expect(replaced.originalConfig).toBe(Buffer.from('model = "gpt-6.1-sol"\n').toString("base64")); + rmSync(otherHome, { recursive: true }); + expect(run(script).opencodexHome).toBe(ownHome); +}); +test("corrupt journals refuse direct snapshot and mark without overwriting evidence", () => { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-6.1-sol"\n'); + const path = join(codexHome, CODEX_HOME_JOURNAL_FILE); writeFileSync(path, "{broken"); + const result = run(`const j = require("./src/codex/journal"); let reasons=[]; + for (const fn of [()=>j.writeJournal({currentStateIsNative:true}), + ()=>j.markJournalInjectedState("routed",null,{injectedOpenaiBaseUrl:null,injectedRealtimeWsBaseUrl:null,injectedCatalogPath:null})]) { + try {fn();} catch(e) {reasons.push(e.reason);} + } console.log(JSON.stringify({reasons}));`); + expect(result.reasons).toEqual(["owner-unknown", "owner-unknown"]); + expect(readFileSync(path, "utf8")).toBe("{broken"); +}); +test("foreign valid providerless sync preserves catalog and cache", () => { + const catalog = JSON.stringify({ models: [ + { slug: "gpt-6.1-sol", display_name: "native", description: "native", priority: 1, visibility: "list" }, + { slug: "ark/glm-5.3", display_name: "routed", description: "Routed via opencodex → ark/glm-5.3 (ark).", priority: 5, visibility: "list" }, + ] }); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n'); + writeFileSync(join(codexHome, "catalog.json"), catalog); + writeFileSync(join(codexHome, "models_cache.json"), "cache-before"); + writeFileSync(join(ownHome, "config.json"), JSON.stringify({ providers: {} })); bind(otherHome); + const result = run(`const {syncCatalogModels}=require("./src/codex/catalog"); + console.log(JSON.stringify(await syncCatalogModels({providers:{}})));`); + expect(result).toMatchObject({ catalogWritten: false, refreshOutcome: "refused", skippedReason: "foreign_owner" }); + expect(readFileSync(join(codexHome, "catalog.json"), "utf8")).toBe(catalog); + expect(readFileSync(join(codexHome, "models_cache.json"), "utf8")).toBe("cache-before"); +}); diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 8d08d2adcf8..c19e999e976 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -304,7 +304,7 @@ describe("codex-journal", () => { expect(existsSync(journalPath)).toBe(false); }); - test("reconcileJournal handles corrupt JSON gracefully", () => { + test("reconcileJournal preserves corrupt JSON without authorizing recovery", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "NOT VALID JSON{{{", "utf8"); @@ -315,7 +315,8 @@ describe("codex-journal", () => { `); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).restored).toBe(false); - expect(existsSync(journalPath)).toBe(false); + expect(readFileSync(journalPath, "utf8")).toBe("NOT VALID JSON{{{"); + expect(r.stderr).toContain("ownership could not be verified"); }); test("reconcileJournal no-ops when no journal exists", () => { @@ -386,19 +387,21 @@ describe("codex-journal", () => { expect(existsSync(journalPath)).toBe(false); }); - test("removeJournal cleans up", () => { + test("removeJournal refuses unknown journal evidence", () => { const journalPath = join(testDir, "opencodex-journal.json"); writeFileSync(journalPath, "{}", "utf8"); const r = runScript(testDir, ` const { removeJournal } = require("./src/codex/journal"); - removeJournal(); + let refusal; + try { removeJournal(); } catch (error) { refusal = error.reason; } const fs = require("fs"); const path = require("path"); - console.log(JSON.stringify({ exists: fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json")) })); + console.log(JSON.stringify({ refusal, exists: fs.existsSync(path.join(process.env.CODEX_HOME, "opencodex-journal.json")) })); `); expect(r.status).toBe(0); - expect(JSON.parse(r.stdout).exists).toBe(false); + expect(JSON.parse(r.stdout)).toEqual({ refusal: "owner-unknown", exists: true }); + expect(readFileSync(journalPath, "utf8")).toBe("{}"); }); test("removeCodexConfig is a successful no-op when Codex is not installed", () => { diff --git a/tests/codex-integration/codex-models-cache-invalidate.test.ts b/tests/codex-integration/codex-models-cache-invalidate.test.ts index c8264d50d0c..d39315d4ebe 100644 --- a/tests/codex-integration/codex-models-cache-invalidate.test.ts +++ b/tests/codex-integration/codex-models-cache-invalidate.test.ts @@ -16,6 +16,9 @@ import { flushConfigDirHardening } from "../../src/config/paths"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +/** Every K acquisition states its intent (#6529); these tests exercise the lock, not the intent. */ +const TEST_CATALOG_WRITE = { intent: "cache", writer: "test" } as const; + const emptyConfig = { port: 10100, defaultProvider: "openai", @@ -69,7 +72,7 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { models: [{ slug: "gpt-5.5" }], }, null, 2) + "\n"); const invalidate = () => withCatalogWriteSerialization(codexHome, permit => - invalidateCodexModelsCacheWithPermitOutcome(permit, codexHome)); + invalidateCodexModelsCacheWithPermitOutcome(permit, codexHome), TEST_CATALOG_WRITE); expect(invalidate()).toMatchObject({ kind: "completed", value: "written" }); const cachePath = join(codexHome, "models_cache.json"); @@ -94,7 +97,7 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { process.env.CODEX_HOME = ambientCodexHome; const outcome = withCatalogWriteSerialization(codexHome, permit => - invalidateCodexModelsCacheWithPermit(permit, codexHome)); + invalidateCodexModelsCacheWithPermit(permit, codexHome), TEST_CATALOG_WRITE); expect(outcome).toMatchObject({ kind: "completed", value: true }); expect(existsSync(join(ambientCodexHome, "models_cache.json"))).toBe(false); diff --git a/tests/codex-integration/codex-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts index c8f6a5bd2c2..22f5d11ed98 100644 --- a/tests/codex-integration/codex-retained-root-serialization.test.ts +++ b/tests/codex-integration/codex-retained-root-serialization.test.ts @@ -227,7 +227,7 @@ async function holdCatalogLock(sandbox: Sandbox): Promise<{ writeFileSync(${JSON.stringify(ready)}, "ready"); const waiter = new Int32Array(new SharedArrayBuffer(4)); while (!existsSync(${JSON.stringify(release)})) Atomics.wait(waiter, 0, 0, 10); - }); + }, { intent: "refresh", writer: "test" }); if (outcome.kind !== "completed") throw new Error(JSON.stringify(outcome)); `; const child = Bun.spawn([process.execPath, "--eval", script], { @@ -714,3 +714,34 @@ test("two processes at the post-approval management seam serialize instead of in const fromB = slugs.some(s => s.includes("seam-model-b")); expect(fromA && fromB).toBe(false); }, SPAWN_BUDGET_MS); + +for (const ownership of ["foreign", "unknown"] as const) { + test(`sync-cache reports ${ownership} owner refusal in human and JSON output`, () => { + const sandbox = makeSandbox("ocx-cache-owner-refusal-"); + const catalogPath = seedCatalog(sandbox, catalogBytes("list", true)); + const cachePath = join(sandbox.codexHome, "models_cache.json"); + writeFileSync(cachePath, "cache-before"); + const foreignHome = join(sandbox.root, "other-opencodex"); mkdirSync(foreignHome); + writeFileSync(join(sandbox.codexHome, "opencodex-journal.json"), ownership === "unknown" + ? "unreadable journal evidence" + : JSON.stringify({ version: 1, originalConfig: "", originalProfile: null, pid: 1, + timestamp: "2026-10-04T00:00:00Z", opencodexHome: foreignHome })); + const before = readFileSync(catalogPath); + for (const json of [false, true]) { + const result = Bun.spawnSync([process.execPath, + ...withOwnedServiceHomePreload(["run", "src/cli/index.ts", "sync-cache", ...(json ? ["--json"] : [])], sandbox.preloadPath), + ], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); + expect(result.exitCode).toBe(1); + if (json) { + expect(JSON.parse(result.stdout.toString())).toMatchObject({ ok: false, wrote: false, + outcome: "unavailable", reason: ownership === "foreign" ? "foreign-owner" : "owner-unknown" }); + } else { + const warning = result.stderr.toString(); + expect(warning).toContain(ownership === "foreign" ? "another OpenCodex home" : "could not be checked"); + expect(warning).not.toContain(foreignHome); + } + expect(readFileSync(catalogPath)).toEqual(before); + expect(readFileSync(cachePath, "utf8")).toBe("cache-before"); + } + }, SPAWN_BUDGET_MS); +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 99087e9ff7e..d232080a124 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -523,6 +523,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", From 3d8bc3957af227706060e5326a9426c4dcbf4f65 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:36:20 +0900 Subject: [PATCH 2/3] test(codex): keep client guard scenarios reachable after owner admission --- .../client-injection-guard.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/codex-integration/client-injection-guard.test.ts b/tests/codex-integration/client-injection-guard.test.ts index 262ede77b0e..18422c885b2 100644 --- a/tests/codex-integration/client-injection-guard.test.ts +++ b/tests/codex-integration/client-injection-guard.test.ts @@ -154,7 +154,10 @@ async function runScenario(mode: string, childDeadlineMs = 30_000): Promise { const result = await runScenario(mode); expect(result.result.success).toBe(false); - expect(result.result.message).toContain(mode.startsWith("async") ? "must be synchronous" : "client_guard_refused"); - expect(result.guardCalls).toBe(1); + if (mode === "malformed") { + // Unknown home ownership refuses before a client callback can authorize cleanup. + expect(result.result.message).toContain("ownership could not be verified"); + expect(result.guardCalls).toBe(0); + } else { + expect(result.result.message).toContain(mode.startsWith("async") ? "must be synchronous" : "client_guard_refused"); + expect(result.guardCalls).toBe(1); + } expect(result.after).toEqual(result.before); if (mode === "queued-native") expect(result.observations).toContain("prepared"); if (mode === "legacy") expect(result.contention.success).toBe(false); From 37d0a229806fbad6b24f2568c016c75bb135a641 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 16:03:13 +0900 Subject: [PATCH 3/3] fix(codex): enforce ownership at journal writer entrypoints --- src/codex/internal/catalog-writer.ts | 23 +++++++-------- src/codex/journal.ts | 2 ++ structure/codex-home.md | 2 +- .../codex-catalog-intent.test.ts | 23 +++++++++++++++ .../codex-home-owner.test.ts | 28 +++++++++++++++++-- 5 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 756f12b1122..49c0e26113c 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -66,14 +66,15 @@ let backupTempSequence = 0; * An unreadable or absent file reports "differs", so the caller performs the real * write; that also converges a file that does not exist yet. */ -export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean { - let onDisk: Buffer; - try { - onDisk = readFileSync(prepared.path); - } catch { - return true; - } - return !onDisk.equals(Buffer.from(prepared.content, "utf8")); +function readExistingCatalogBytes(path: string): Buffer | null { + try { return readFileSync(path); } catch { return null; } +} + +export function preparedBytesDifferFromDisk( + prepared: PreparedCatalogFileWrite, + onDisk: Buffer | null = readExistingCatalogBytes(prepared.path), +): boolean { + return onDisk === null || !onDisk.equals(Buffer.from(prepared.content, "utf8")); } function isMissingPathError(error: unknown): boolean { @@ -225,9 +226,9 @@ export function replaceActiveCodexCatalog( if (intent === "cache") { throw new CatalogWritePermitRefusal("A models-cache permit cannot replace the Codex catalog."); } - if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" }; - let routedBefore: number | null = null; - try { routedBefore = routedRowsFromJson(readFileSync(prepared.path, "utf8")); } catch { /* absent/unreadable */ } + const onDisk = readExistingCatalogBytes(prepared.path); + if (!preparedBytesDifferFromDisk(prepared, onDisk)) return { kind: "unchanged" }; + const routedBefore = onDisk === null ? null : routedRowsFromJson(onDisk.toString("utf8")); if (intent === "refresh" && (routedBefore ?? 0) > 0 && routedRowsFromJson(prepared.content) === 0) { // K -> C, including the replacement: a config save cannot race the authority check. try { diff --git a/src/codex/journal.ts b/src/codex/journal.ts index f9ea7caa7e5..b05f10476e9 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -152,6 +152,7 @@ export interface WriteJournalOptions { * plugins, model choice, and trusted projects. */ export function writeJournal(options: WriteJournalOptions = {}): void { + assertCodexHomeOwner(CODEX_HOME); if (!existsSync(CODEX_CONFIG_PATH)) return; const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8"); // Ownership is decided HERE, from the bytes about to be journaled — never taken @@ -199,6 +200,7 @@ export function markJournalInjectedState( profile: string | null, ownership: InjectedJournalOwnership, ): void { + assertCodexHomeOwner(CODEX_HOME); const inspection = readCodexHomeJournal(JOURNAL_PATH); if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; diff --git a/structure/codex-home.md b/structure/codex-home.md index 12d44b282a9..ea34660665e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -398,7 +398,7 @@ to drop routed rows. Coverage: `tests/codex-integration/codex-catalog-routed-rem `src/codex/codex-home-owner.ts` reads the injection journal's `opencodexHome` binding without modifying recovery evidence. A readable legacy journal has no binding; the next injection records its owner. Canonical aliases of the same physical OpenCodex home remain owned. A proven vanished home is stale and can be adopted, while foreign active or unknown ownership refuses writes. Every acquisition rechecks ownership under K before issuing its live permit; later calls re-evaluate the evidence. -Injection and both native-restore paths check ownership before cleanup and at publication. Successful deliberate config/profile restoration releases ownership at the native restoration boundary. Exact restoration removes the journal; successful field-level restoration preserves its recovery data and releases only the home binding. Catalog-only restore retains the binding. A later catalog failure remains a failed artifact. This does not add a new transaction across native and catalog restoration or guarantee custom-path recovery after the original journal was released. +Injection and both native-restore paths check ownership before cleanup and at publication. The exported journal snapshot and injected-state writers also check ownership themselves before mutation, including direct calls; a foreign binding protects both its identity and recovery metadata. Successful deliberate config/profile restoration releases ownership at the native restoration boundary. Exact restoration removes the journal; successful field-level restoration preserves its recovery data and releases only the home binding. Catalog-only restore retains the binding. A later catalog failure remains a failed artifact. This does not add a new transaction across native and catalog restoration or guarantee custom-path recovery after the original journal was released. Each `withCatalogWriteSerialization` call supplies `refresh`, `cache`, `pull` or `restore` intent and a writer label. The existing permit registry binds that context to one live acquisition. `src/codex/internal/catalog-writer.ts` skips identical catalog/cache bytes and preserves their mtime. A refresh cannot clear all routed rows without readable file-backed config; restore deliberately clears them, pull keeps the hub's authority, and a cache permit cannot replace the catalog. Remote pull accepts an unchanged derived cache as success. Sync and sync-cache report owner refusals without private paths. diff --git a/tests/codex-integration/codex-catalog-intent.test.ts b/tests/codex-integration/codex-catalog-intent.test.ts index 1c07083d2ec..0a941413bfb 100644 --- a/tests/codex-integration/codex-catalog-intent.test.ts +++ b/tests/codex-integration/codex-catalog-intent.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import * as fs from "node:fs"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import * as owner from "../../src/codex/codex-home-owner"; @@ -168,3 +169,25 @@ for (const state of ["foreign", "unknown"] as const) { expect(acquire("cache", () => "released")).toEqual({ kind: "completed", value: "released" }); }); } + +for (const mode of ["unchanged", "clearing", "missing", "read-error"] as const) { + test(`catalog ${mode} decision reads its on-disk bytes once`, () => { + if (mode === "missing") rmSync(catalogPath); + if (mode === "clearing") writeFileSync(join(opencodexHome, "config.json"), '{"providers":{}}'); + const read = fs.readFileSync; + let reads = 0; + const spy = spyOn(fs, "readFileSync").mockImplementation((...args: Parameters) => { + if (args[0] === catalogPath) { + reads += 1; + if (mode === "read-error") throw Object.assign(new Error("fixture unreadable"), { code: "EACCES" }); + } + return read(...args); + }); + try { + const result = replace(mode === "unchanged" || mode === "clearing" ? "refresh" : "restore", mode === "unchanged" ? routed : native); + expect(result).toEqual({ kind: "completed", value: { kind: mode === "unchanged" ? "unchanged" : "written" } }); + expect(reads).toBe(1); + } finally { spy.mockRestore(); } + expect(readFileSync(catalogPath, "utf8")).toBe(mode === "unchanged" ? routed : native); + }); +} diff --git a/tests/codex-integration/codex-home-owner.test.ts b/tests/codex-integration/codex-home-owner.test.ts index d61fdfe267e..85762070111 100644 --- a/tests/codex-integration/codex-home-owner.test.ts +++ b/tests/codex-integration/codex-home-owner.test.ts @@ -96,7 +96,7 @@ test("injection binding selection preserves foreign evidence and adopts only leg rmSync(otherHome, { recursive: true }); expect(opencodexHomeForInjection(otherHome)).toBe(ownHome); }); -test("snapshot and injected-state writers fill legacy and preserve foreign native snapshots", () => { +test("snapshot and injected-state writers fill legacy, refresh owned native snapshots, and adopt stale bindings", () => { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-6.1-sol"\n'); const script = `const j = require("./src/codex/journal"); j.writeJournal({currentStateIsNative:true}); @@ -104,13 +104,35 @@ test("snapshot and injected-state writers fill legacy and preserve foreign nativ console.log(require("node:fs").readFileSync(j.JOURNAL_PATH,"utf8"));`; expect(run(script).opencodexHome).toBe(ownHome); bind(); expect(run(script).opencodexHome).toBe(ownHome); - bind(otherHome); + bind(ownHome); const replaced = run(script); - expect(replaced.opencodexHome).toBe(otherHome); + expect(replaced.opencodexHome).toBe(ownHome); expect(replaced.originalConfig).toBe(Buffer.from('model = "gpt-6.1-sol"\n').toString("base64")); + bind(otherHome); rmSync(otherHome, { recursive: true }); expect(run(script).opencodexHome).toBe(ownHome); }); +test("direct snapshot and mark refuse foreign ownership preserving all recovery bytes", () => { + const configPath = join(codexHome, "config.toml"); + const profilePath = join(codexHome, "opencodex.config.toml"); + const journalPath = join(codexHome, CODEX_HOME_JOURNAL_FILE); + writeFileSync(configPath, 'model = "gpt-6.1-sol"\n'); + writeFileSync(profilePath, "profile-before\n"); + const bytes = JSON.stringify({ ...journal(otherHome), originalConfig: Buffer.from("foreign original").toString("base64"), + injectedConfigHash: "foreign-hash", injectedCatalogPath: "foreign-catalog.json" }, null, 2) + "\n"; + writeFileSync(journalPath, bytes); + const result = run(`const j=require("./src/codex/journal");const fs=require("node:fs");let reasons=[],snapshots=[]; + for(const fn of [()=>j.writeJournal({currentStateIsNative:true}), + ()=>j.markJournalInjectedState("routed", "new profile", {injectedOpenaiBaseUrl:"http://127.0.0.1:19999",injectedRealtimeWsBaseUrl:null,injectedCatalogPath:"new-catalog.json"})]) { + try {fn();} catch(e) {reasons.push(e.reason);} + snapshots.push(fs.readFileSync(j.JOURNAL_PATH,"utf8")); + }console.log(JSON.stringify({reasons,snapshots}));`); + expect(result.reasons).toEqual(["foreign-owner", "foreign-owner"]); + expect(result.snapshots).toEqual([bytes, bytes]); + expect(readFileSync(journalPath, "utf8")).toBe(bytes); + expect(readFileSync(configPath, "utf8")).toBe('model = "gpt-6.1-sol"\n'); + expect(readFileSync(profilePath, "utf8")).toBe("profile-before\n"); +}); test("corrupt journals refuse direct snapshot and mark without overwriting evidence", () => { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-6.1-sol"\n'); const path = join(codexHome, CODEX_HOME_JOURNAL_FILE); writeFileSync(path, "{broken");