From 2d12373069505317d21873b0771362861a8319a3 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Fri, 28 Aug 2026 10:59:28 +0900 Subject: [PATCH 1/6] fix(uninstall): remove only recorded catalog backups --- src/codex/catalog/parsing.ts | 3 ++ src/codex/internal/catalog-writer.ts | 6 +++- src/lib/config-ownership.ts | 19 ------------ .../config/config-ownership-uninstall.test.ts | 31 ++++++------------- 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 3975047468b..f467359a39d 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -36,6 +36,7 @@ import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget" import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; import { NATIVE_GPT6_ASTRA_MODEL } from "./native-models"; +import { recordOwnedConfigPath } from "../../lib/config-ownership"; export function legacyCatalogBackupPath(): string { return join(getConfigDir(), "catalog-backup.json"); @@ -973,10 +974,12 @@ export function writePristineCatalogBackup(backupPath: string, catalogPath: stri const onDisk = readCatalog(catalogPath); if (onDisk && !catalogHasRoutedEntries(onDisk)) { copyFileSync(catalogPath, backupPath); + recordOwnedConfigPath(getConfigDir(), backupPath); return; } if (!catalogHasRoutedEntries(catalog)) { atomicWriteFile(backupPath, JSON.stringify(catalog, null, 2) + "\n"); + recordOwnedConfigPath(getConfigDir(), backupPath); } } diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 08a89b55da4..9dcb9b3e2db 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -5,6 +5,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWriteFile, + getConfigDir, resolveWriteTarget, type AtomicWriteIO, } from "../../config"; @@ -17,6 +18,7 @@ import { hardenSecretPath, } from "../../lib/windows-secret-acl"; import { resetCodexAppServerCatalogStateCache } from "../app-server-processes"; +import { recordOwnedConfigPath } from "../../lib/config-ownership"; export interface PreparedCatalogFileWrite { readonly path: string; @@ -211,7 +213,9 @@ export function publishHashedCodexCatalogBackup( io?: CatalogBackupWriteIO, ): CatalogBackupPublication { assertCatalogWritePermit(permit, owningCodexHome); - return publishCatalogBackup(prepared, io); + const publication = publishCatalogBackup(prepared, io); + if (publication === "written" && !io) recordOwnedConfigPath(getConfigDir(), prepared.path); + return publication; } /** Atomically publish the legacy immutable backup without clobbering. */ diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index 069dd128de6..fe159010186 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -337,25 +337,6 @@ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult { } } - // Per-catalog backups are named `catalog-backup-<16 hex>.json` (catalogBackupPathFor), one per - // CODEX_HOME, so they cannot be enumerated as literal manifest entries the way every other - // owned file can. Without this, `ocx uninstall` always reported "unowned files remain" and - // refused to remove a home OpenCodex created itself — the file is unambiguously ours, produced - // by our own writer, and the strict hex shape keeps the match from widening. - for (const name of readdirSync(configDir)) { - if (!/^catalog-backup-[0-9a-f]{16}\.json$/.test(name)) continue; - const path = join(configDir, name); - try { - removeOwnedEntry(rootPath, path); - } catch (error) { - return { - status: "partial", - reason: `could not remove owned path ${name}: ${error instanceof Error ? error.message : String(error)}`, - residualPaths: [path], - }; - } - } - try { unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST)); unlinkSync(join(configDir, CONFIG_OWNER_FILE)); diff --git a/tests/config/config-ownership-uninstall.test.ts b/tests/config/config-ownership-uninstall.test.ts index 79c8888f90b..02011b939dc 100644 --- a/tests/config/config-ownership-uninstall.test.ts +++ b/tests/config/config-ownership-uninstall.test.ts @@ -207,21 +207,7 @@ describe("owned config uninstall", () => { } }); - /** - * The uninstall path could not remove a home OpenCodex created itself (#1048). - * - * Found by running the disposable-host service acceptance for real: `ocx uninstall` reported - * "partial uninstall: unowned files remain" and left the whole config directory behind. Two of - * our OWN writers produce files the manifest never claimed — - * `admin-api-token` (lib/admin-secrets.ts) and the per-CODEX_HOME - * `catalog-backup-<16 hex>.json` (catalog/parsing.ts `catalogBackupPathFor`). - * - * The hashed backup is the interesting one: its name depends on which Codex home it mirrors, - * so it cannot be a literal manifest entry the way every other owned file is. It is matched by - * its exact shape instead — narrow enough that a user's own file cannot collide, which is what - * keeps the "never delete what we do not own" guarantee intact. - */ - test("uninstall removes the admin token and per-home catalog backups it wrote itself", () => { + test("uninstall removes recorded admin tokens and per-home catalog backups", () => { const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-self-written-")); const dir = join(parent, "config"); @@ -230,7 +216,9 @@ describe("owned config uninstall", () => { expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); writeFileSync(join(dir, "config.json"), "{}\n"); writeFileSync(join(dir, "admin-api-token"), "token\n"); - writeFileSync(join(dir, "catalog-backup-0123456789abcdef.json"), "{}\n"); + const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); + expect(recordOwnedConfigPath(dir, backupPath)).toBe(true); + writeFileSync(backupPath, "{}\n"); const result = removeOwnedConfigState(dir); expect(result).toMatchObject({ status: "removed" }); @@ -240,19 +228,20 @@ describe("owned config uninstall", () => { } }); - test("a lookalike that is not our generated backup name is still never removed", () => { + test("an unrecorded matching catalog backup name is never removed", () => { const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-lookalike-")); const dir = join(parent, "config"); try { expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); - // Not our shape: the digest segment is the wrong length, so this is a user file. - const foreign = join(dir, "catalog-backup-notahash.json"); - writeFileSync(foreign, "mine\n"); + const foreign = join(dir, "catalog-backup-0123456789abcdef.json"); + mkdirSync(foreign); + const nested = join(foreign, "mine.txt"); + writeFileSync(nested, "mine\n"); const result = removeOwnedConfigState(dir); expect(result.status).toBe("partial"); - expect(readFileSync(foreign, "utf8")).toBe("mine\n"); + expect(readFileSync(nested, "utf8")).toBe("mine\n"); } finally { removeTreeWithRetry(parent); } From 3afce371ebd06634b910782bf903f9876b310c4a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:04:31 +0900 Subject: [PATCH 2/6] fix(uninstall): record pre-existing hashed catalog backups in the ledger writePristineCatalogBackup and publishHashedCodexCatalogBackup returned early when the backup file already existed, so a backup written by a run that predates the ownership ledger was never recorded and uninstall could not remove it. Adopt the existing file into the ledger on the early-return/preserved path too. --- src/codex/catalog/parsing.ts | 9 ++- src/codex/internal/catalog-writer.ts | 4 +- .../codex-catalog-writer.test.ts | 42 +++++++++++++- .../config/config-ownership-uninstall.test.ts | 55 +++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f467359a39d..542c76f37b1 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -970,7 +970,14 @@ export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean { } export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void { - if (existsSync(backupPath)) return; + if (existsSync(backupPath)) { + // A backup written by an earlier run predates the ownership ledger: adopt it so + // uninstall can still remove the file we produced. Both call sites pass our own + // deterministic names — the hashed backup, or the legacy name already claimed by + // INITIAL_OWNED_PATHS. + recordOwnedConfigPath(getConfigDir(), backupPath); + return; + } const onDisk = readCatalog(catalogPath); if (onDisk && !catalogHasRoutedEntries(onDisk)) { copyFileSync(catalogPath, backupPath); diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 9dcb9b3e2db..dbb0af51df9 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -214,7 +214,9 @@ export function publishHashedCodexCatalogBackup( ): CatalogBackupPublication { assertCatalogWritePermit(permit, owningCodexHome); const publication = publishCatalogBackup(prepared, io); - if (publication === "written" && !io) recordOwnedConfigPath(getConfigDir(), prepared.path); + // Record both outcomes: "written" is ours by construction, and "preserved" means the + // hashed backup already on disk came from an earlier run that predates the ledger. + if (!io) recordOwnedConfigPath(getConfigDir(), prepared.path); return publication; } diff --git a/tests/codex-integration/codex-catalog-writer.test.ts b/tests/codex-integration/codex-catalog-writer.test.ts index b73789ef200..de116e62e52 100644 --- a/tests/codex-integration/codex-catalog-writer.test.ts +++ b/tests/codex-integration/codex-catalog-writer.test.ts @@ -36,6 +36,11 @@ import { replaceActiveCodexCatalog, replaceCodexModelsCache, } from "../../src/codex/internal/catalog-writer"; +import { + CONFIG_UNINSTALL_MANIFEST, + recordOwnedConfigPath, + removeOwnedConfigState, +} from "../../src/lib/config-ownership"; import { removeTreeWithRetry } from "../helpers/remove-tree"; interface MutatorCase { @@ -51,10 +56,15 @@ interface MutatorCase { let testRoot = ""; let codexHome = ""; let otherCodexHome = ""; +let openCodexHome = ""; let targetDir = ""; let previousCodexHome: string | undefined; let previousOpenCodexHome: string | undefined; +function manifestPaths(dir: string): string[] { + return (JSON.parse(readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8")) as { paths: string[] }).paths; +} + function atomicIo(effects: string[]): AtomicWriteIO { return { write(path, content) { @@ -166,12 +176,13 @@ beforeEach(() => { testRoot = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-catalog-writer-"))); codexHome = join(testRoot, "codex-home"); otherCodexHome = join(testRoot, "other-codex-home"); + openCodexHome = join(testRoot, "opencodex-home"); targetDir = join(testRoot, "external-catalog-targets"); - for (const path of [codexHome, otherCodexHome, targetDir, join(testRoot, "opencodex-home")]) { + for (const path of [codexHome, otherCodexHome, targetDir, openCodexHome]) { mkdirSync(path, { recursive: true }); } process.env.CODEX_HOME = codexHome; - process.env.OPENCODEX_HOME = join(testRoot, "opencodex-home"); + process.env.OPENCODEX_HOME = openCodexHome; }); afterEach(() => { @@ -293,3 +304,30 @@ for (const [name, publish] of [ expect(readdirSync(targetDir).filter(entry => entry.endsWith(".tmp"))).toEqual([]); }); } + +test("hashed backup publication records a backup it writes itself", () => { + const path = join(openCodexHome, "catalog-backup-0123456789abcdef.json"); + expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); + + const result = withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }) + ); + + expect(result).toBe("written"); + expect(manifestPaths(openCodexHome)).toContain("catalog-backup-0123456789abcdef.json"); +}); + +test("hashed backup publication records a backup that already exists", () => { + const path = join(openCodexHome, "catalog-backup-0123456789abcdef.json"); + expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); + writeFileSync(path, "earlier run\n", { mode: 0o600 }); + + const result = withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "late contender\n" }) + ); + + expect(result).toBe("preserved"); + expect(readFileSync(path, "utf8")).toBe("earlier run\n"); + expect(manifestPaths(openCodexHome)).toContain("catalog-backup-0123456789abcdef.json"); + expect(removeOwnedConfigState(openCodexHome).status).toBe("removed"); +}); diff --git a/tests/config/config-ownership-uninstall.test.ts b/tests/config/config-ownership-uninstall.test.ts index 02011b939dc..7cfe70661fb 100644 --- a/tests/config/config-ownership-uninstall.test.ts +++ b/tests/config/config-ownership-uninstall.test.ts @@ -8,9 +8,14 @@ import { recordOwnedConfigPath, removeOwnedConfigState, } from "../../src/lib/config-ownership"; +import { writePristineCatalogBackup } from "../../src/codex/catalog/parsing"; import { getDefaultConfig, saveConfig } from "../../src/config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +function manifestPaths(dir: string): string[] { + return (JSON.parse(readFileSync(join(dir, CONFIG_UNINSTALL_MANIFEST), "utf8")) as { paths: string[] }).paths; +} + describe("owned config uninstall", () => { test("first owned write creates a missing config root and its metadata", () => { const parent = mkdtempSync(join(tmpdir(), "ocx-config-first-owned-path-")); @@ -246,4 +251,54 @@ describe("owned config uninstall", () => { removeTreeWithRetry(parent); } }); + + test("writePristineCatalogBackup records a backup it writes itself", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-write-")); + const dir = join(parent, "config"); + const catalogPath = join(parent, "models.json"); + const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + + try { + mkdirSync(dir, { recursive: true }); + // Ownership is claimed by the first owned write into an empty dir, as in production. + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(catalogPath, '{"models":[]}\n'); + + writePristineCatalogBackup(backupPath, catalogPath, { models: [] }); + + expect(readFileSync(backupPath, "utf8")).toBe('{"models":[]}\n'); + expect(manifestPaths(dir)).toContain("catalog-backup-0123456789abcdef.json"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); + + test("writePristineCatalogBackup records a hashed backup that already exists", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-existing-")); + const dir = join(parent, "config"); + const catalogPath = join(parent, "models.json"); + const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + + try { + mkdirSync(dir, { recursive: true }); + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(catalogPath, '{"models":[]}\n'); + writeFileSync(backupPath, '{"models":[{"slug":"earlier-run"}]}\n'); + + writePristineCatalogBackup(backupPath, catalogPath, { models: [] }); + + expect(readFileSync(backupPath, "utf8")).toBe('{"models":[{"slug":"earlier-run"}]}\n'); + expect(manifestPaths(dir)).toContain("catalog-backup-0123456789abcdef.json"); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); }); From 0b309a39ae884aab0b3d538a3733090c9fe327e8 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 25 Sep 2026 04:10:17 +0000 Subject: [PATCH 3/6] fix(uninstall): never adopt preserved catalog backups Do not record an existing pristine backup or a preserved no-replace publication as newly owned. Keep previously recorded ownership intact, and preserve unrecorded matching regular files and directories through uninstall. Document conservative handling of pre-ledger hashed backups without migrating existing fixed-name manifest entries. Replace the unsafe adoption expectations and add successful-creation, identical-content preservation, failed-write and repeated-owned-write regressions in the two existing test files. Validation: native Bun 1.4.0 ran 34 tests with no failures on Linux, macOS and Windows. Restoring the two old writers made all four targeted regular-file preservation regressions fail as expected. Typecheck and privacy scan passed. The only subsequent edits were to two structure documents; structure and privacy checks passed on this exact final tree. Code validation: luvs01/opencodex/actions/runs/36093023282 Final documentation validation: luvs01/opencodex/actions/runs/36093222830 Full repository and required PR CI remain separate. Isolated validation workflow files and commits are not included in this commit's ancestry. --- src/codex/catalog/parsing.ts | 10 +- src/codex/internal/catalog-writer.ts | 6 +- structure/catalog.md | 9 ++ structure/config.md | 3 + .../codex-catalog-writer.test.ts | 75 ++++++++--- .../config/config-ownership-uninstall.test.ts | 127 +++++++++++++++++- 6 files changed, 196 insertions(+), 34 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 542c76f37b1..9a55680038c 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -970,14 +970,8 @@ export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean { } export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void { - if (existsSync(backupPath)) { - // A backup written by an earlier run predates the ownership ledger: adopt it so - // uninstall can still remove the file we produced. Both call sites pass our own - // deterministic names — the hashed backup, or the legacy name already claimed by - // INITIAL_OWNED_PATHS. - recordOwnedConfigPath(getConfigDir(), backupPath); - return; - } + // An existing name is not evidence of ownership; keep pre-ledger/user backups unclaimed. + if (existsSync(backupPath)) return; const onDisk = readCatalog(catalogPath); if (onDisk && !catalogHasRoutedEntries(onDisk)) { copyFileSync(catalogPath, backupPath); diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index dbb0af51df9..028b7e4584c 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -214,9 +214,9 @@ export function publishHashedCodexCatalogBackup( ): CatalogBackupPublication { assertCatalogWritePermit(permit, owningCodexHome); const publication = publishCatalogBackup(prepared, io); - // Record both outcomes: "written" is ours by construction, and "preserved" means the - // hashed backup already on disk came from an earlier run that predates the ledger. - if (!io) recordOwnedConfigPath(getConfigDir(), prepared.path); + // Only a new publication proves creation; preserving an existing name proves no provenance. + // Existing ownership entries remain valid without re-adopting a preserved file. + if (!io && publication === "written") recordOwnedConfigPath(getConfigDir(), prepared.path); return publication; } diff --git a/structure/catalog.md b/structure/catalog.md index df1aa8747fb..b01568731c7 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -75,6 +75,15 @@ provider-wide fallback. Exact model output limits precede the provider default o native rows from the output without rewriting the pristine backup or unrelated snapshots; - invalidates `$CODEX_HOME/models_cache.json` when model visibility changes. +Per-catalog hashed backups are recorded only after a new backup is successfully written by +`src/codex/catalog/parsing.ts` or published by `src/codex/internal/catalog-writer.ts`. +Preserving an existing file does not register it, even if its deterministic name or bytes match. +Previously recorded paths keep their ownership; unrecorded pre-ledger backups remain residuals. +After stopping OpenCodex and completing any needed restore, review and archive those exact residual +paths before manually removing only confirmed obsolete backups. Never infer ownership from a glob. +The legacy fixed-name entries already present in ownership manifests are not migrated by this rule. +Uninstall retains the [manifest validation and residual reporting contract](config.md#restore). + Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors. `src/codex/catalog/model-visibility.ts` also excludes models owned by disabled providers, including custom rows. `src/codex/catalog/routed-gather.ts` does not inherit provider configuration into custom rows while that provider is disabled. diff --git a/structure/config.md b/structure/config.md index d05cd9b6e68..e60923d4b9f 100644 --- a/structure/config.md +++ b/structure/config.md @@ -468,6 +468,9 @@ and removes only normalized manifest entries. Manifest-owned directory links are traversing their targets. Unknown files remain in place and make the command report a partial uninstall with their exact paths. +Per-catalog hashed backups follow the [catalog ownership rules](catalog.md#shared-catalog): +only new writes are registered; existing unrecorded files remain for manual review. + The newly created OAuth downgrade copy is registered after copying, so owned uninstall includes it. Destructive OAuth mutations rewrite that copy without the removed provider through the no-follow writer variant that leaves the owner manifest untouched, so a copy an earlier install diff --git a/tests/codex-integration/codex-catalog-writer.test.ts b/tests/codex-integration/codex-catalog-writer.test.ts index de116e62e52..627c7ca97af 100644 --- a/tests/codex-integration/codex-catalog-writer.test.ts +++ b/tests/codex-integration/codex-catalog-writer.test.ts @@ -305,29 +305,72 @@ for (const [name, publish] of [ }); } -test("hashed backup publication records a backup it writes itself", () => { - const path = join(openCodexHome, "catalog-backup-0123456789abcdef.json"); +test("new hashed publication is recorded and remains owned when preserved later", () => { + const name = "catalog-backup-0123456789abcdef.json"; + const path = join(openCodexHome, name); expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); - const result = withLivePermit((permit) => publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }) ); - expect(result).toBe("written"); - expect(manifestPaths(openCodexHome)).toContain("catalog-backup-0123456789abcdef.json"); + const before = manifestPaths(openCodexHome); + expect(before).toContain(name); + expect(withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "later\n" }) + )).toBe("preserved"); + expect(manifestPaths(openCodexHome)).toEqual(before); + expect(readFileSync(path, "utf8")).toBe("pristine\n"); + expect(removeOwnedConfigState(openCodexHome).status).toBe("removed"); + expect(existsSync(path)).toBe(false); }); -test("hashed backup publication records a backup that already exists", () => { - const path = join(openCodexHome, "catalog-backup-0123456789abcdef.json"); - expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); - writeFileSync(path, "earlier run\n", { mode: 0o600 }); +for (const existing of ["user-owned\n", "pristine\n"]) { + test(`hashed publication does not adopt an existing regular backup: ${existing.trim()}`, () => { + const name = "catalog-backup-0123456789abcdef.json"; + const path = join(openCodexHome, name); + expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); + writeFileSync(path, existing, { mode: 0o600 }); + const before = manifestPaths(openCodexHome); + const result = withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }) + ); + expect(result).toBe("preserved"); + expect(manifestPaths(openCodexHome)).toEqual(before); + expect(manifestPaths(openCodexHome)).not.toContain(name); + const removal = removeOwnedConfigState(openCodexHome); + expect(removal.status).toBe("partial"); + expect(removal.residualPaths).toEqual([path]); + expect(readFileSync(path, "utf8")).toBe(existing); + }); +} - const result = withLivePermit((permit) => - publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "late contender\n" }) - ); +test("hashed publication does not adopt an existing backup directory", () => { + const name = "catalog-backup-0123456789abcdef.json"; + const path = join(openCodexHome, name); + expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); + mkdirSync(path); + const nested = join(path, "mine.txt"); + writeFileSync(nested, "keep me\n"); + const before = manifestPaths(openCodexHome); + expect(withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }) + )).toBe("preserved"); + expect(manifestPaths(openCodexHome)).toEqual(before); + const removal = removeOwnedConfigState(openCodexHome); + expect(removal.status).toBe("partial"); + expect(removal.residualPaths).toEqual([path]); + expect(readFileSync(nested, "utf8")).toBe("keep me\n"); +}); - expect(result).toBe("preserved"); - expect(readFileSync(path, "utf8")).toBe("earlier run\n"); - expect(manifestPaths(openCodexHome)).toContain("catalog-backup-0123456789abcdef.json"); - expect(removeOwnedConfigState(openCodexHome).status).toBe("removed"); +test("failed hashed publication does not record an unwritten backup", () => { + expect(recordOwnedConfigPath(openCodexHome, join(openCodexHome, "config.json"))).toBe(true); + const blocked = join(openCodexHome, "blocked-parent"); + writeFileSync(blocked, "not a directory\n"); + const path = join(blocked, "catalog-backup-0123456789abcdef.json"); + const before = manifestPaths(openCodexHome); + expect(() => withLivePermit((permit) => + publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }) + )).toThrow(); + expect(existsSync(path)).toBe(false); + expect(manifestPaths(openCodexHome)).toEqual(before); }); diff --git a/tests/config/config-ownership-uninstall.test.ts b/tests/config/config-ownership-uninstall.test.ts index 7cfe70661fb..81447901df8 100644 --- a/tests/config/config-ownership-uninstall.test.ts +++ b/tests/config/config-ownership-uninstall.test.ts @@ -277,24 +277,137 @@ describe("owned config uninstall", () => { } }); - test("writePristineCatalogBackup records a hashed backup that already exists", () => { - const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-existing-")); + for (const existing of ['{"models":[{"slug":"user-owned"}]}\n', '{"models":[]}\n']) { + test(`writePristineCatalogBackup does not adopt an existing regular backup: ${existing.trim()}`, () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-existing-")); + const dir = join(parent, "config"); + const catalogPath = join(parent, "models.json"); + const name = "catalog-backup-0123456789abcdef.json"; + const backupPath = join(dir, name); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(catalogPath, '{"models":[]}\n'); + writeFileSync(backupPath, existing); + const before = manifestPaths(dir); + writePristineCatalogBackup(backupPath, catalogPath, { models: [] }); + expect(manifestPaths(dir)).toEqual(before); + expect(manifestPaths(dir)).not.toContain(name); + const result = removeOwnedConfigState(dir); + expect(result.status).toBe("partial"); + expect(result.residualPaths).toEqual([backupPath]); + expect(readFileSync(backupPath, "utf8")).toBe(existing); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); + } + + test("serialized pristine fallback records only the newly written backup", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-fallback-")); const dir = join(parent, "config"); - const catalogPath = join(parent, "models.json"); const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); const previous = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = dir; + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writePristineCatalogBackup(backupPath, join(parent, "missing.json"), { models: [] }); + expect(JSON.parse(readFileSync(backupPath, "utf8"))).toEqual({ models: [] }); + expect(manifestPaths(dir)).toContain("catalog-backup-0123456789abcdef.json"); + expect(removeOwnedConfigState(dir).status).toBe("removed"); + expect(existsSync(backupPath)).toBe(false); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); + test("a routed-only catalog creates neither a backup nor an ownership entry", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-routed-")); + const dir = join(parent, "config"); + const name = "catalog-backup-0123456789abcdef.json"; + const backupPath = join(dir, name); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + const before = manifestPaths(dir); + writePristineCatalogBackup(backupPath, join(parent, "missing.json"), { models: [{ slug: "provider/model" }] }); + expect(existsSync(backupPath)).toBe(false); + expect(manifestPaths(dir)).toEqual(before); + expect(manifestPaths(dir)).not.toContain(name); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); + + test("failed pristine copying does not register an unwritten backup", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-failure-")); + const dir = join(parent, "config"); + const catalogPath = join(parent, "models.json"); + const backupPath = join(dir, "missing", "catalog-backup-0123456789abcdef.json"); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; try { - mkdirSync(dir, { recursive: true }); expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); writeFileSync(catalogPath, '{"models":[]}\n'); - writeFileSync(backupPath, '{"models":[{"slug":"earlier-run"}]}\n'); + const before = manifestPaths(dir); + expect(() => writePristineCatalogBackup(backupPath, catalogPath, { models: [] })).toThrow(); + expect(existsSync(backupPath)).toBe(false); + expect(manifestPaths(dir)).toEqual(before); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); + test("preserving an already recorded pristine backup keeps its original ownership", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-owned-")); + const dir = join(parent, "config"); + const catalogPath = join(parent, "models.json"); + const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + writeFileSync(catalogPath, '{"models":[]}\n'); writePristineCatalogBackup(backupPath, catalogPath, { models: [] }); + const before = manifestPaths(dir); + writePristineCatalogBackup(backupPath, catalogPath, { models: [{ slug: "other" }] }); + expect(manifestPaths(dir)).toEqual(before); + expect(readFileSync(backupPath, "utf8")).toBe('{"models":[]}\n'); + expect(removeOwnedConfigState(dir).status).toBe("removed"); + expect(existsSync(backupPath)).toBe(false); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + removeTreeWithRetry(parent); + } + }); - expect(readFileSync(backupPath, "utf8")).toBe('{"models":[{"slug":"earlier-run"}]}\n'); - expect(manifestPaths(dir)).toContain("catalog-backup-0123456789abcdef.json"); + test("an existing matching backup directory stays unclaimed after the writer visits it", () => { + const parent = mkdtempSync(join(tmpdir(), "ocx-uninstall-pristine-directory-")); + const dir = join(parent, "config"); + const backupPath = join(dir, "catalog-backup-0123456789abcdef.json"); + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { + expect(recordOwnedConfigPath(dir, join(dir, "config.json"))).toBe(true); + mkdirSync(backupPath); + const nested = join(backupPath, "mine.txt"); + writeFileSync(nested, "keep me\n"); + const before = manifestPaths(dir); + writePristineCatalogBackup(backupPath, join(parent, "missing.json"), { models: [] }); + expect(manifestPaths(dir)).toEqual(before); + expect(removeOwnedConfigState(dir).residualPaths).toEqual([backupPath]); + expect(readFileSync(nested, "utf8")).toBe("keep me\n"); } finally { if (previous === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previous; From 10696b8a25f86ed0739268773da1f94585f9718b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:52:25 +0000 Subject: [PATCH 4/6] fix(uninstall): record created backups before temporary cleanup --- src/codex/catalog/retained-sync.ts | 5 +- src/codex/internal/catalog-writer.ts | 17 +++-- src/lib/config-ownership.ts | 14 +++- structure/catalog.md | 2 + .../codex-catalog-writer.test.ts | 67 ++++++++++++++++++- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index aab9ebc205d..03beecfedbb 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { loadConfig, websocketsEnabled } from "../../config"; +import { initializeConfigOwnership } from "../../lib/config-ownership"; +import { getConfigDir, loadConfig, websocketsEnabled } from "../../config"; import { shouldSyncCodexOnStart } from "../desired-state"; import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration"; import { getCodexHome } from "../paths"; @@ -289,6 +290,8 @@ function writeRetainedCatalogSync({ // (later syncs would otherwise overwrite it with featured-modified priorities). const pristine = pristineCatalogBytes(read); if (pristine !== null) { + // Initialize metadata while the root is empty; never pre-claim the hashed path. + initializeConfigOwnership(getConfigDir()); publishHashedCodexCatalogBackup(permit, owningCodexHome, { path: catalogBackupPathFor(catalogPath), content: pristine, diff --git a/src/codex/internal/catalog-writer.ts b/src/codex/internal/catalog-writer.ts index 028b7e4584c..99c269ce09c 100644 --- a/src/codex/internal/catalog-writer.ts +++ b/src/codex/internal/catalog-writer.ts @@ -171,6 +171,7 @@ function scrubAndRemoveUnpublishedTemp( function publishCatalogBackup( prepared: PreparedCatalogFileWrite, suppliedIo?: CatalogBackupWriteIO, + onPublished?: () => void, ): CatalogBackupPublication { const io = suppliedIo ?? defaultBackupWriteIO(prepared.path); const target = io.resolveTarget(prepared.path); @@ -189,7 +190,12 @@ function publishCatalogBackup( throw error; } - removePublishedTemp(tempPath, io); + try { + // Publication already succeeded; cleanup failure must not erase that ownership. + onPublished?.(); + } finally { + removePublishedTemp(tempPath, io); + } return "written"; } @@ -213,11 +219,10 @@ export function publishHashedCodexCatalogBackup( io?: CatalogBackupWriteIO, ): CatalogBackupPublication { assertCatalogWritePermit(permit, owningCodexHome); - const publication = publishCatalogBackup(prepared, io); - // Only a new publication proves creation; preserving an existing name proves no provenance. - // Existing ownership entries remain valid without re-adopting a preserved file. - if (!io && publication === "written") recordOwnedConfigPath(getConfigDir(), prepared.path); - return publication; + return publishCatalogBackup(prepared, io, io ? undefined : () => { + // Called only after a new no-replace publication, never for preserved winners. + recordOwnedConfigPath(getConfigDir(), prepared.path); + }); } /** Atomically publish the legacy immutable backup without clobbering. */ diff --git a/src/lib/config-ownership.ts b/src/lib/config-ownership.ts index fe159010186..0cd34c4a948 100644 --- a/src/lib/config-ownership.ts +++ b/src/lib/config-ownership.ts @@ -267,9 +267,8 @@ function removeOwnedEntry(root: string, path: string): void { rmdirSync(path); } -export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean { - const rel = manifestRelativePath(configDir, candidatePath); - if (!rel) return false; +/** Initialize only an empty or already-owned root; do not claim a candidate path. */ +export function initializeConfigOwnership(configDir: string): boolean { const cacheKey = ownershipCacheKey(configDir); if (!existsSync(configDir)) { ownershipCache.delete(cacheKey); @@ -280,6 +279,15 @@ export function recordOwnedConfigPath(configDir: string, candidatePath: string): ownership = loadOwnership(configDir) ?? createOwnership(configDir); ownershipCache.set(cacheKey, ownership); } + return ownership !== null; +} + +export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean { + const rel = manifestRelativePath(configDir, candidatePath); + if (!rel) return false; + if (!initializeConfigOwnership(configDir)) return false; + const cacheKey = ownershipCacheKey(configDir); + const ownership = ownershipCache.get(cacheKey); if (!ownership) return false; if (ownership.manifest.paths.includes(rel)) return true; const manifest = { diff --git a/structure/catalog.md b/structure/catalog.md index b01568731c7..6172a8be171 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -78,6 +78,8 @@ provider-wide fallback. Exact model output limits precede the provider default o Per-catalog hashed backups are recorded only after a new backup is successfully written by `src/codex/catalog/parsing.ts` or published by `src/codex/internal/catalog-writer.ts`. Preserving an existing file does not register it, even if its deterministic name or bytes match. +Retained sync initializes metadata in an empty root before publication, without claiming the hashed path. +Successful publication records ownership before temporary-file cleanup; cleanup errors remain visible. Previously recorded paths keep their ownership; unrecorded pre-ledger backups remain residuals. After stopping OpenCodex and completing any needed restore, review and archive those exact residual paths before manually removing only confirmed obsolete backups. Never infer ownership from a glob. diff --git a/tests/codex-integration/codex-catalog-writer.test.ts b/tests/codex-integration/codex-catalog-writer.test.ts index 627c7ca97af..5182b672303 100644 --- a/tests/codex-integration/codex-catalog-writer.test.ts +++ b/tests/codex-integration/codex-catalog-writer.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, @@ -15,9 +15,13 @@ import { unlinkSync, writeFileSync, } from "node:fs"; +import * as filesystem from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { AtomicWriteResidualTempError } from "../../src/config"; +import { syncCatalogModels } from "../../src/codex/catalog/retained-sync"; +import { catalogBackupPathFor } from "../../src/codex/catalog/parsing"; import type { AtomicWriteIO } from "../../src/config"; import { type CatalogWritePermit, @@ -38,6 +42,8 @@ import { } from "../../src/codex/internal/catalog-writer"; import { CONFIG_UNINSTALL_MANIFEST, + CONFIG_OWNER_FILE, + initializeConfigOwnership, recordOwnedConfigPath, removeOwnedConfigState, } from "../../src/lib/config-ownership"; @@ -374,3 +380,62 @@ test("failed hashed publication does not record an unwritten backup", () => { expect(existsSync(path)).toBe(false); expect(manifestPaths(openCodexHome)).toEqual(before); }); + +test("metadata-only initialization never claims a hashed backup candidate", () => { + const name = "catalog-backup-0123456789abcdef.json"; + expect(initializeConfigOwnership(openCodexHome)).toBe(true); + expect(existsSync(join(openCodexHome, CONFIG_OWNER_FILE))).toBe(true); + expect(manifestPaths(openCodexHome)).not.toContain(name); + expect(initializeConfigOwnership(openCodexHome)).toBe(true); + expect(manifestPaths(openCodexHome)).not.toContain(name); +}); +test("metadata initialization refuses a pre-existing unowned backup", () => { + const path = join(openCodexHome, "catalog-backup-0123456789abcdef.json"); + writeFileSync(path, "user-owned\n"); + expect(initializeConfigOwnership(openCodexHome)).toBe(false); + expect(existsSync(join(openCodexHome, CONFIG_OWNER_FILE))).toBe(false); + expect(removeOwnedConfigState(openCodexHome).status).toBe("refused"); + expect(readFileSync(path, "utf8")).toBe("user-owned\n"); +}); +test("retained catalog sync initializes an empty home before publishing its backup", async () => { + const path = join(codexHome, "custom-catalog.json"); + const pristine = JSON.stringify({ models: [{ slug: "user-native", display_name: "User model" }] }) + "\n"; + writeFileSync(path, pristine); + writeFileSync(join(codexHome, "config.toml"), `model_catalog_json = ${JSON.stringify(path)}\n`); + expect(readdirSync(openCodexHome)).toEqual([]); + const result = await syncCatalogModels({ port: 10100, defaultProvider: "openai", providers: {}, subagentModels: [] }, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + const backup = catalogBackupPathFor(path); + expect(readFileSync(backup, "utf8")).toBe(pristine); + expect(manifestPaths(openCodexHome)).toContain(backup.split(/[\\/]/).pop()!); + expect(removeOwnedConfigState(openCodexHome).status).toBe("removed"); + expect(existsSync(backup)).toBe(false); +}, 15000); +test("a published backup is recorded even when both temporary unlink attempts fail", () => { + expect(initializeConfigOwnership(openCodexHome)).toBe(true); + const name = "catalog-backup-0123456789abcdef.json"; + const path = join(openCodexHome, name); + const realUnlink = filesystem.unlinkSync; + let attempts = 0; + let residual = ""; + const mock = spyOn(filesystem, "unlinkSync").mockImplementation(candidate => { + if (String(candidate).startsWith(path + ".ocx.") && String(candidate).endsWith(".tmp")) { + attempts += 1; + residual = String(candidate); + throw Object.assign(new Error("injected sharing violation"), { code: "EACCES" }); + } + return realUnlink(candidate); + }); + try { + expect(() => withLivePermit(permit => publishHashedCodexCatalogBackup(permit, codexHome, { path, content: "pristine\n" }))) + .toThrow(AtomicWriteResidualTempError); + expect(attempts).toBe(2); + expect(readFileSync(path, "utf8")).toBe("pristine\n"); + expect(manifestPaths(openCodexHome)).toContain(name); + expect(existsSync(residual)).toBe(true); + } finally { mock.mockRestore(); } + // The failed cleanup is reported, not silently treated as a clean publication. + unlinkSync(residual); + expect(removeOwnedConfigState(openCodexHome).status).toBe("removed"); + expect(existsSync(path)).toBe(false); +}); From 94e921c6a2571678544dbbf0a1057afe69da1ac5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 21:59:14 +0900 Subject: [PATCH 5/6] docs(structure): keep config.md and catalog.md within their line budgets With current dev merged in, structure/config.md reached 603 and structure/catalog.md 603 lines against the 600-line budget. Fold the config.md pointer into the existing residual sentence and tighten the catalog.md paragraph; every stated rule is kept. --- structure/catalog.md | 16 ++++++---------- structure/config.md | 7 ++----- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/structure/catalog.md b/structure/catalog.md index 3ec0a8b118a..dac835a5eca 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -79,16 +79,12 @@ provider-wide fallback. Exact model output limits precede the provider default o native rows from the output without rewriting the pristine backup or unrelated snapshots; - invalidates `$CODEX_HOME/models_cache.json` when model visibility changes. -Per-catalog hashed backups are recorded only after a new backup is successfully written by -`src/codex/catalog/parsing.ts` or published by `src/codex/internal/catalog-writer.ts`. -Preserving an existing file does not register it, even if its deterministic name or bytes match. -Retained sync initializes metadata in an empty root before publication, without claiming the hashed path. -Successful publication records ownership before temporary-file cleanup; cleanup errors remain visible. -Previously recorded paths keep their ownership; unrecorded pre-ledger backups remain residuals. -After stopping OpenCodex and completing any needed restore, review and archive those exact residual -paths before manually removing only confirmed obsolete backups. Never infer ownership from a glob. -The legacy fixed-name entries already present in ownership manifests are not migrated by this rule. -Uninstall retains the [manifest validation and residual reporting contract](config.md#restore). +Per-catalog hashed backups are recorded only after `src/codex/catalog/parsing.ts` writes or `src/codex/internal/catalog-writer.ts` publishes a new one; +preserving an existing file never registers it, even when its deterministic name or bytes match. Retained sync initializes metadata in an empty +root before publication without claiming the hashed path, and publication records ownership before temporary-file cleanup, whose errors stay visible. +Recorded paths keep their ownership; unrecorded pre-ledger backups remain residuals (after stopping OpenCodex and any needed restore, review and +archive those exact paths, then remove only confirmed obsolete backups, never by glob). Legacy fixed-name manifest entries are not migrated by +this rule, and uninstall keeps the [manifest validation and residual reporting contract](config.md#restore). Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors. diff --git a/structure/config.md b/structure/config.md index b75569601a4..5866cef58e2 100644 --- a/structure/config.md +++ b/structure/config.md @@ -466,11 +466,8 @@ Full `ocx uninstall` config cleanup is ownership-manifest based. A fresh config root-bound owner marker and an uninstall manifest before its first atomic config write. Uninstall validates both bounded metadata files, rejects path traversal and a symlink/junction config root, and removes only normalized manifest entries. Manifest-owned directory links are unlinked without -traversing their targets. Unknown files remain in place and make the command report a partial -uninstall with their exact paths. - -Per-catalog hashed backups follow the [catalog ownership rules](catalog.md#shared-catalog): -only new writes are registered; existing unrecorded files remain for manual review. +traversing their targets. Unknown files, including unrecorded per-catalog hashed backups ([catalog ownership rules](catalog.md#shared-catalog)), +remain in place and make the command report a partial uninstall with their exact paths. The newly created OAuth downgrade copy is registered after copying, so owned uninstall includes it. Destructive OAuth mutations rewrite that copy without the removed provider through the From f5113d83d67a479f12a77bc31d907dcc7e52b699 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 25 Sep 2026 22:19:38 +0900 Subject: [PATCH 6/6] docs(structure): qualify catalog backup ownership recording recordOwnedConfigPath returns false for a non-empty root with missing or invalid ownership metadata, and the writers ignore that result, so publication attempts registration rather than guaranteeing it. State the residual outcome. --- structure/catalog.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/structure/catalog.md b/structure/catalog.md index dac835a5eca..c3a95e1546d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -81,7 +81,8 @@ provider-wide fallback. Exact model output limits precede the provider default o Per-catalog hashed backups are recorded only after `src/codex/catalog/parsing.ts` writes or `src/codex/internal/catalog-writer.ts` publishes a new one; preserving an existing file never registers it, even when its deterministic name or bytes match. Retained sync initializes metadata in an empty -root before publication without claiming the hashed path, and publication records ownership before temporary-file cleanup, whose errors stay visible. +root before publication without claiming the hashed path, and publication attempts to record ownership before temporary-file cleanup, whose errors stay visible; +a root with missing or invalid ownership metadata leaves the new backup unregistered, so uninstall reports it as a residual. Recorded paths keep their ownership; unrecorded pre-ledger backups remain residuals (after stopping OpenCodex and any needed restore, review and archive those exact paths, then remove only confirmed obsolete backups, never by glob). Legacy fixed-name manifest entries are not migrated by this rule, and uninstall keeps the [manifest validation and residual reporting contract](config.md#restore).