From b577c5d3d29d6b17b6487da5ae8ab48cc3221fbf Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 14:46:46 +0900 Subject: [PATCH 01/18] fix(codex): preserve hardlink names in catalog heal paths --- src/codex/catalog/heal-observation.ts | 19 ++- structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 116 +++++++++++++++++- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 4ec119fd8ca..6579e87cf7b 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,4 +1,5 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, type Stats } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -9,9 +10,21 @@ export interface CatalogObservation { readonly catalog: RawCatalog | null; } -/** Canonical path equality when available; unavailable paths retain lexical equality. */ +function canonicalCatalogHealPath(path: string): string { + for (let links = 0; links < 40; links += 1) { + const entry = lstatSync(path); + if (!entry.isFile() && !entry.isSymbolicLink()) return realpathSync.native(path); + const parent = realpathSync.native(dirname(path)); + // macOS Bun's file realpath can name another hardlink; preserve this entry. + if (entry.isFile()) return join(parent, basename(path)); + path = resolve(parent, readlinkSync(path)); + } + throw new Error("Catalog path symlink limit reached"); +} + +/** Canonical entry equality when available; unavailable paths retain lexical equality. */ export function sameCatalogHealPath(left: string, right: string): boolean { - try { return samePath(realpathSync.native(left), realpathSync.native(right)); } + try { return samePath(canonicalCatalogHealPath(left), canonicalCatalogHealPath(right)); } catch { return samePath(left, right); } } diff --git a/structure/codex-home.md b/structure/codex-home.md index d541ba24ab5..098667dd432 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,7 +516,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission compare native realpaths with `samePath`; if either realpath is unavailable they use lexical `samePath`. Null selections remain unavailable. Directory symlink aliases and Windows casing refer to the same target; distinct paths and hardlinks do not. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` to compare canonical entries with `samePath`. A regular file retains its leaf name under its parent's native realpath, so distinct hardlink entries remain distinct even when the runtime's file realpath reports a sibling name. Final symlinks follow their target relative to the canonical parent, bounded to 40 links; unavailable or unresolved paths retain lexical `samePath`. Null selections remain unavailable. Directory symlink aliases, leaf symlinks to the same entry and Windows casing refer to the same target. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write. Publications during this healer's own convergence remain fenced until its terminal result. Deliberate restore/native release fences pending work and clears retry state; successful owner empty publication is accepted. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 62100f28e21..40c9abda867 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -1,13 +1,13 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { linkSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { CATALOG_HEAL_MAX_ATTEMPTS, CATALOG_HEAL_MAX_HEALS, CATALOG_HEAL_RECHECK_MS, CATALOG_HEAL_TICK_MS, CATALOG_HEAL_WINDOW_MS, startCodexCatalogSelfHeal, type CatalogSelfHealDeps, type CatalogSelfHealGates, type CatalogSelfHealHandle, } from "../../src/codex/catalog-self-heal"; -import { CATALOG_HEAL_MAX_BYTES, observeCatalogHealFile, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_BYTES, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { RawCatalog } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; @@ -511,6 +511,87 @@ function aliasPaths(kind: "lexical" | "physical" | "case") { return { path, alias: kind === "case" ? path.toUpperCase() : `${home}/./catalog.json` }; } +describe("catalog path comparison", () => { + test("hardlink entries remain distinct when native realpath reports their sibling", () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + expect(lstatSync(sibling).ino).toBe(lstatSync(path).ino); + const native = realpathSync.native; + // macOS Bun can return the other hardlink's name for either opened file. + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path || input === sibling ? path : native(input)); + try { + expect(sameCatalogHealPath(path, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, path)).toBe(false); + expect(sameCatalogHealPath(path, path)).toBe(true); + expect(sameCatalogHealPath(sibling, sibling)).toBe(true); + } finally { resolvePath.mockRestore(); } + }); + + test("self comparison survives alternating native hardlink names", () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + const native = realpathSync.native; + let calls = 0; + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path ? (++calls % 2 ? path : sibling) : native(input)); + try { expect(sameCatalogHealPath(path, path)).toBe(true); } + finally { resolvePath.mockRestore(); } + }); + + test("lexical and directory aliases are symmetric without merging distinct files", () => { + const { path, alias } = aliasPaths("physical"); + const distinct = join(temporaryRoot(), "catalog.json"); + writeFileSync(distinct, JSON.stringify(full)); + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, alias)).toBe(true); + expect(sameCatalogHealPath(`${dirname(path)}/./catalog.json`, alias)).toBe(true); + expect(sameCatalogHealPath(alias, distinct)).toBe(false); + expect(sameCatalogHealPath(distinct, alias)).toBe(false); + }); + + test("relative leaf symlinks use their canonical parent and preserve hardlink names", () => { + const root = temporaryRoot(), home = join(root, "home"), inner = join(home, "inner"); + mkdirSync(inner, { recursive: true }); + const path = join(home, "catalog.json"), sibling = join(root, "catalog.json"); + writeFileSync(path, JSON.stringify(full)); + linkSync(path, sibling); + const parentAlias = join(root, "alias"); + symlinkSync(inner, parentAlias, process.platform === "win32" ? "junction" : "dir"); + const leaf = join(parentAlias, "link.json"); + symlinkSync("../catalog.json", join(inner, "link.json"), "file"); + const chain = join(inner, "chain.json"); + symlinkSync("link.json", chain, "file"); + const absolute = join(root, "absolute.json"); + symlinkSync(path, absolute, "file"); + expect(statSync(leaf).ino).toBe(lstatSync(path).ino); + expect(sameCatalogHealPath(leaf, path)).toBe(true); + expect(sameCatalogHealPath(path, leaf)).toBe(true); + expect(sameCatalogHealPath(chain, path)).toBe(true); + expect(sameCatalogHealPath(path, chain)).toBe(true); + expect(sameCatalogHealPath(absolute, path)).toBe(true); + expect(sameCatalogHealPath(path, absolute)).toBe(true); + expect(sameCatalogHealPath(leaf, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, leaf)).toBe(false); + }); + + test("unavailable paths retain lexical equality, including dangling and cyclic links", () => { + const root = temporaryRoot(), missing = join(root, "missing.json"); + const dangling = join(root, "dangling.json"), cycle = join(root, "cycle.json"); + symlinkSync("missing.json", dangling, "file"); + symlinkSync("cycle.json", cycle, "file"); + for (const path of [missing, dangling, cycle]) { + expect(sameCatalogHealPath(path, `${root}/./${path.slice(root.length + 1)}`)).toBe(true); + expect(sameCatalogHealPath(path, path)).toBe(true); + } + expect(sameCatalogHealPath(dangling, missing)).toBe(false); + expect(sameCatalogHealPath(missing, dangling)).toBe(false); + }); +}); + for (const kind of ["lexical", "physical", ...(process.platform === "win32" ? ["case" as const] : [])] as const) { for (const release of ["restore", "native-release"] as const) { test(`${kind} alias ${release} during suspended heal fences publication`, async () => { @@ -603,6 +684,33 @@ test.each(["distinct", "hardlink"] as const)("%s restore target remains unrelate expect(committed).toBe(true); }); +test("sibling hardlink release cannot fence a heal when native realpath collapses entries", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + const native = realpathSync.native; + const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => + input === path || input === sibling ? path : native(input)); + try { + h.publish({ kind: "native-released", path: sibling }); + finish.resolve(); await tick; + expect(committed).toBe(true); + } finally { + finish.resolve(); await tick; + resolvePath.mockRestore(); + } +}); + for (const stage of ["Claude", "Desktop"] as const) { test(`startup loss during deferred ${stage} reconciliation is observed after a successful no-op sync`, async () => { const gate = createReadinessGate(); From b8f5df644e4bd76fac45f26a04dece3532b3a8c1 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 15:11:47 +0900 Subject: [PATCH 02/18] fix(codex): retain native catalog leaf aliases where safe --- src/codex/catalog/heal-observation.ts | 3 + structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 108 ++++++++++++++++-- 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 6579e87cf7b..ee812370ad8 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -14,8 +14,11 @@ function canonicalCatalogHealPath(path: string): string { for (let links = 0; links < 40; links += 1) { const entry = lstatSync(path); if (!entry.isFile() && !entry.isSymbolicLink()) return realpathSync.native(path); + // Preserve native leaf aliases (Windows short names, case-insensitive volumes). + if (entry.isFile() && (process.platform !== "darwin" || entry.nlink <= 1)) return realpathSync.native(path); const parent = realpathSync.native(dirname(path)); // macOS Bun's file realpath can name another hardlink; preserve this entry. + // Case/normalization aliases of a multiply linked macOS leaf remain distinct here. if (entry.isFile()) return join(parent, basename(path)); path = resolve(parent, readlinkSync(path)); } diff --git a/structure/codex-home.md b/structure/codex-home.md index 098667dd432..a28b2a77b21 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,7 +516,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` to compare canonical entries with `samePath`. A regular file retains its leaf name under its parent's native realpath, so distinct hardlink entries remain distinct even when the runtime's file realpath reports a sibling name. Final symlinks follow their target relative to the canonical parent, bounded to 40 links; unavailable or unresolved paths retain lexical `samePath`. Null selections remain unavailable. Directory symlink aliases, leaf symlinks to the same entry and Windows casing refer to the same target. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` to compare canonical entries with `samePath`. Regular files retain native realpath normalization except on macOS when they have multiple hardlinks. Those entries retain their leaf name under their parent's native realpath, so a runtime-reported sibling name does not merge distinct entries; case/normalization aliases of a multiply linked macOS leaf are not coalesced by this comparison. Final symlinks follow their target relative to the canonical parent, bounded to 40 links; unavailable or unresolved paths retain lexical `samePath`. Null selections remain unavailable. Directory aliases remain equivalent, and native leaf aliases are retained on Windows and for singly linked macOS files. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write. Publications during this healer's own convergence remain fenced until its terminal result. Deliberate restore/native release fences pending work and clears retry state; successful owner empty publication is accepted. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 40c9abda867..a10dd327a53 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { linkSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -511,8 +512,52 @@ function aliasPaths(kind: "lexical" | "physical" | "case") { return { path, alias: kind === "case" ? path.toUpperCase() : `${home}/./catalog.json` }; } +async function withCatalogPlatform(platform: "win32" | "darwin", run: () => void | Promise) { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + try { await run(); } + finally { Object.defineProperty(process, "platform", platformDescriptor); } +} + +async function withNativeFileAlias(path: string, alias: string, platform: "win32" | "darwin", run: () => void | Promise) { + const native = realpathSync.native, stat = fs.lstatSync; + // Model an OS filename alias, not a second directory entry. Linux need not expose it. + const aliasStat = spyOn(fs, "lstatSync").mockImplementation((input, options) => + input === alias ? stat(path, options) : stat(input, options)); + const aliasPath = spyOn(realpathSync, "native").mockImplementation(input => + native(input === alias ? path : input)); + try { await withCatalogPlatform(platform, run); } + finally { + aliasPath.mockRestore(); aliasStat.mockRestore(); + } +} + describe("catalog path comparison", () => { - test("hardlink entries remain distinct when native realpath reports their sibling", () => { + test.each(["win32", "darwin"] as const)("%s single-link native filename aliases remain equivalent", async platform => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), platform === "win32" ? "CATALO~9.JSO" : "CATALOG.JSON"); + expect(lstatSync(path).nlink).toBe(1); + await withNativeFileAlias(path, alias, platform, () => { + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, alias)).toBe(true); + }); + }); + + test("Windows native filename aliases remain equivalent for multiply linked files", async () => { + const { path } = aliasPaths("lexical"); + const sibling = join(temporaryRoot(), "catalog.json"), alias = join(dirname(path), "CATALO~9.JSO"); + linkSync(path, sibling); + expect(lstatSync(path).nlink).toBe(2); + await withNativeFileAlias(path, alias, "win32", () => { + expect(sameCatalogHealPath(path, alias)).toBe(true); + expect(sameCatalogHealPath(alias, path)).toBe(true); + expect(sameCatalogHealPath(alias, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, alias)).toBe(false); + }); + }); + + test("hardlink entries remain distinct when native realpath reports their sibling", async () => { const { path } = aliasPaths("lexical"); const sibling = join(temporaryRoot(), "catalog.json"); linkSync(path, sibling); @@ -522,14 +567,16 @@ describe("catalog path comparison", () => { const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => input === path || input === sibling ? path : native(input)); try { - expect(sameCatalogHealPath(path, sibling)).toBe(false); - expect(sameCatalogHealPath(sibling, path)).toBe(false); - expect(sameCatalogHealPath(path, path)).toBe(true); - expect(sameCatalogHealPath(sibling, sibling)).toBe(true); + await withCatalogPlatform("darwin", () => { + expect(sameCatalogHealPath(path, sibling)).toBe(false); + expect(sameCatalogHealPath(sibling, path)).toBe(false); + expect(sameCatalogHealPath(path, path)).toBe(true); + expect(sameCatalogHealPath(sibling, sibling)).toBe(true); + }); } finally { resolvePath.mockRestore(); } }); - test("self comparison survives alternating native hardlink names", () => { + test("self comparison survives alternating native hardlink names", async () => { const { path } = aliasPaths("lexical"); const sibling = join(temporaryRoot(), "catalog.json"); linkSync(path, sibling); @@ -537,7 +584,7 @@ describe("catalog path comparison", () => { let calls = 0; const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => input === path ? (++calls % 2 ? path : sibling) : native(input)); - try { expect(sameCatalogHealPath(path, path)).toBe(true); } + try { await withCatalogPlatform("darwin", () => { expect(sameCatalogHealPath(path, path)).toBe(true); }); } finally { resolvePath.mockRestore(); } }); @@ -592,6 +639,45 @@ describe("catalog path comparison", () => { }); }); +for (const release of ["restore", "native-release"] as const) { + test(`native filename alias ${release} fences the known target while selection is unavailable`, async () => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), "CATALO~9.JSO"); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + await withNativeFileAlias(path, alias, "win32", async () => { + try { + h.setPath(null); // Restore can remove the journal before its notification. + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(path); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } + }); + }); +} + +test("native filename alias owner publication accepts the equivalent target", async () => { + const { path } = aliasPaths("lexical"); + const alias = join(dirname(path), "CATALO~9.JSO"); + const h = harness({ path }); + h.rewrite(empty); + await withNativeFileAlias(path, alias, "win32", async () => { + h.publish({ kind: "published", path: alias, intent: "refresh" }); + await h.handle.tickForTests(); + expect(h.converges).toHaveLength(0); + }); +}); + for (const kind of ["lexical", "physical", ...(process.platform === "win32" ? ["case" as const] : [])] as const) { for (const release of ["restore", "native-release"] as const) { test(`${kind} alias ${release} during suspended heal fences publication`, async () => { @@ -702,9 +788,11 @@ test("sibling hardlink release cannot fence a heal when native realpath collapse const resolvePath = spyOn(realpathSync, "native").mockImplementation(input => input === path || input === sibling ? path : native(input)); try { - h.publish({ kind: "native-released", path: sibling }); - finish.resolve(); await tick; - expect(committed).toBe(true); + await withCatalogPlatform("darwin", async () => { + h.publish({ kind: "native-released", path: sibling }); + finish.resolve(); await tick; + expect(committed).toBe(true); + }); } finally { finish.resolve(); await tick; resolvePath.mockRestore(); From 77e37fe32936c7fdf369904f6a96269060b6a13d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 15:51:23 +0900 Subject: [PATCH 03/18] fix(codex): fence uncertain catalog entry identity --- src/codex/catalog-self-heal.ts | 39 +-- src/codex/catalog/heal-observation.ts | 100 ++++++-- src/codex/convergence.ts | 4 +- structure/codex-home.md | 4 +- .../codex-catalog-heal-integration.test.ts | 19 ++ .../codex-catalog-self-heal.test.ts | 233 +++++++++++++++++- 6 files changed, 361 insertions(+), 38 deletions(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 9fda96edd6b..d073550dfd7 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -3,7 +3,7 @@ import { readRuntimePort } from "../config/process-state"; import { readClientConnectionState } from "../client/state"; import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle"; import type { OcxConfig } from "../types"; -import { observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; +import { compareCatalogHealPaths, observeCatalogHealFile, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; import { subscribeCatalogPublication } from "./catalog/publication-observer"; import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal"; import { inspectCodexHomeOwner } from "./codex-home-owner"; @@ -138,7 +138,11 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal const read = (path: string): NamespaceObservation | null => { const quick = observe(path, false); if (quick === null) return null; - if (observed !== null && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed; + if (observed !== null) { + const relation = compareCatalogHealPaths(observed.path, path); + if (relation === "unknown") return null; + if (relation === "same" && observed.signature === quick.signature) return observed; + } const seen = observe(path, true); if (seen === null) return null; observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null }; @@ -154,18 +158,23 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal pendingRetry = null; recheckAt = 0; }; - const updateTarget = (path: string | null): void => { + const updateTarget = (path: string | null): boolean => { // Unavailable selection is not evidence of a different target or lost authority. - if (path === null) return; - if (target !== null && sameCatalogHealPath(target, path)) return; + if (path === null) return false; + if (target !== null) { + const relation = compareCatalogHealPaths(target, path); + if (relation === "unknown") return false; + if (relation === "same") return true; + } clear(); target = path; + return true; }; const beforeCommit = (path: string, entryGeneration: number): boolean => { try { const selected = catalogPath(); return !stopped && !released && generation === entryGeneration - && selected !== null && sameCatalogHealPath(selected, path) && evaluateCatalogSelfHealGates(gates).open; + && selected !== null && compareCatalogHealPaths(selected, path) === "same" && evaluateCatalogSelfHealGates(gates).open; } catch { return false; } }; const capRetryAt = (now: number): number => { @@ -182,8 +191,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal running = true; try { const path = catalogPath(); - updateTarget(path); - if (path === null) return; + if (!updateTarget(path) || path === null) return; // Pending cache/funnel failure is independent of catalog stat or apparent repaired rows. if (pendingRetry) { const config = gates.loadConfig(); @@ -226,7 +234,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal })).committed; } catch { /* Failed attempts retain pending retry and consume the attempt budget. */ } const selected = catalogPath(); - if (stopped || released || generation !== entryGeneration || selected === null || !sameCatalogHealPath(selected, path)) return; + if (stopped || released || generation !== entryGeneration || selected === null || compareCatalogHealPaths(selected, path) !== "same") return; last = { at: new Date().toISOString(), lostNamespaces: lostEnabled.length, committed }; if (committed) { heals.push(clock()); @@ -244,30 +252,31 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal // Native restoration can remove the journal before notification. Fence the known // target (and target-less releases) without resolving possibly unavailable evidence. const release = event.kind === "native-released" || event.intent === "restore"; - if (release && (event.path === null || (target !== null && sameCatalogHealPath(event.path, target)))) { + const known = event.path !== null && target !== null ? compareCatalogHealPaths(event.path, target) : "different"; + if (release && (event.path === null || known !== "different")) { clear(); released = true; return; } const path = catalogPath(); - if (event.path !== null && (target === null || !sameCatalogHealPath(event.path, target)) - && (path === null || !sameCatalogHealPath(event.path, path))) return; + const selected = event.path !== null && path !== null ? compareCatalogHealPaths(event.path, path) : "different"; + if (event.path !== null && known === "different" && selected === "different") return; if (release) { clear(); released = true; // Only a later accepted owner publication can re-arm this lifecycle. return; } + if (known === "unknown" || selected === "unknown") { clear(); return; } if (running) return; // Catalog-success/cache-failure must not accept a baseline. // A committed owner publication supplies a read-only baseline even while heal writes are gated. - if (event.path === null || path === null || !sameCatalogHealPath(event.path, path)) return; - updateTarget(path); + if (event.path === null || path === null || selected !== "same" || !updateTarget(path)) return; released = false; pendingRetry = null; recheckAt = 0; accept(event.path); }); // Capture immediately after startup sync, before a foreign write can become the first tick. - try { updateTarget(catalogPath()); if (target !== null) accept(target); } + try { const path = catalogPath(); if (updateTarget(path) && target !== null) accept(target); } catch { /* Unavailable observation can be retried by the scheduled tick. */ } const schedule = (): void => { if (stopped) return; diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index ee812370ad8..74d2f3683f9 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats, type Stats } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -10,25 +10,93 @@ export interface CatalogObservation { readonly catalog: RawCatalog | null; } -function canonicalCatalogHealPath(path: string): string { - for (let links = 0; links < 40; links += 1) { - const entry = lstatSync(path); - if (!entry.isFile() && !entry.isSymbolicLink()) return realpathSync.native(path); - // Preserve native leaf aliases (Windows short names, case-insensitive volumes). - if (entry.isFile() && (process.platform !== "darwin" || entry.nlink <= 1)) return realpathSync.native(path); - const parent = realpathSync.native(dirname(path)); - // macOS Bun's file realpath can name another hardlink; preserve this entry. - // Case/normalization aliases of a multiply linked macOS leaf remain distinct here. - if (entry.isFile()) return join(parent, basename(path)); - path = resolve(parent, readlinkSync(path)); +export type CatalogHealPathComparison = "same" | "different" | "unknown"; +export const CATALOG_HEAL_MAX_LINKS = 40; +// Small catalog-path comparisons need only a handful of reads; unusual paths defer. +export const CATALOG_HEAL_MAX_PATH_READS = 64; +type PathRead = (read: () => T) => T; +type LinkObservation = { path: string; stat: BigIntStats; value: string; parent: string; parentStat: BigIntStats }; +type PathObservation = { + kind: "native" | "hardlink"; path: string; stat: BigIntStats; links: LinkObservation[]; + canonical: string; parent?: string; parentStat?: BigIntStats; +} | { kind: "unavailable"; hardlink: boolean }; + +function unchanged(before: BigIntStats, after: BigIntStats): boolean { + return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode + && before.nlink === after.nlink && before.size === after.size + && before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; +} + +function inspectHealPath(path: string, read: PathRead): PathObservation { + let hardlink = false; + const links: LinkObservation[] = []; + try { + for (let count = 0; count < CATALOG_HEAL_MAX_LINKS; count += 1) { + const stat = read(() => lstatSync(path, { bigint: true })); + if (stat.isSymbolicLink()) { + const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); + links.push({ path, stat, value, parent, parentStat }); + path = resolve(read(() => realpathSync.native(parent)), value); + continue; + } + // Keep native leaf aliases outside the macOS multi-link FD-name bug. + hardlink = process.platform === "darwin" && stat.isFile() && stat.nlink > 1n; + if (!hardlink) return { kind: "native", path, stat, links, canonical: read(() => realpathSync.native(path)) }; + const parent = read(() => realpathSync.native(dirname(path))), parentStat = read(() => statSync(parent, { bigint: true })); + if (!parentStat.isDirectory() || !unchanged(parentStat, read(() => statSync(dirname(path), { bigint: true })))) throw new Error("Changed parent"); + return { kind: "hardlink", path, stat, links, parent, parentStat, canonical: join(parent, basename(path)) }; + } + } catch { /* Failed identity observations cannot establish a hardlink entry. */ } + return { kind: "unavailable", hardlink }; +} + +function revalidateHealPath(seen: Exclude, read: PathRead): boolean { + if (!unchanged(seen.stat, read(() => lstatSync(seen.path, { bigint: true })))) return false; + for (const link of seen.links) { + if (!unchanged(link.stat, read(() => lstatSync(link.path, { bigint: true }))) || read(() => readlinkSync(link.path)) !== link.value + || !unchanged(link.parentStat, read(() => statSync(link.parent, { bigint: true })))) return false; } - throw new Error("Catalog path symlink limit reached"); + return seen.kind !== "hardlink" || (unchanged(seen.parentStat!, read(() => statSync(seen.parent!, { bigint: true }))) + && unchanged(seen.parentStat!, read(() => statSync(dirname(seen.path), { bigint: true })))); +} + +/** Entry uncertainty is distinct from a proven different path, especially for releases. */ +export function compareCatalogHealPaths(left: string, right: string): CatalogHealPathComparison { + let reads = 0, exhausted = false; + const read: PathRead = fn => { + if (reads >= CATALOG_HEAL_MAX_PATH_READS) { exhausted = true; throw new Error("Catalog path read limit reached"); } + reads += 1; + return fn(); + }; + const a = inspectHealPath(left, read), b = inspectHealPath(right, read); + if (exhausted) return "unknown"; + const needsEntry = a.kind === "hardlink" || b.kind === "hardlink" + || (a.kind === "unavailable" && a.hardlink) || (b.kind === "unavailable" && b.hardlink); + if (!needsEntry) { + if (a.kind === "unavailable" || b.kind === "unavailable") return samePath(left, right) ? "same" : "different"; + const result = samePath(a.canonical, b.canonical) ? "same" : "different"; + // A new hardlink during native resolution must not evade the macOS branch. + try { + return process.platform !== "darwin" || (revalidateHealPath(a, read) && revalidateHealPath(b, read)) ? result : "unknown"; + } catch { return "unknown"; } + } + if (a.kind === "unavailable" || b.kind === "unavailable") return "unknown"; + try { + let result: CatalogHealPathComparison = "unknown"; + if (a.stat.dev !== b.stat.dev || a.stat.ino !== b.stat.ino) result = "different"; + else if (a.kind === "hardlink" && b.kind === "hardlink") { + if (a.parentStat!.dev !== b.parentStat!.dev || a.parentStat!.ino !== b.parentStat!.ino) result = "different"; + else if (basename(a.path) === basename(b.path)) result = "same"; + // Bun 1.4.3 opendir/Glob eagerly enumerate the whole directory. Without a + // bounded entry-name primitive, aliases and sibling hardlinks stay unknown. + } + return revalidateHealPath(a, read) && revalidateHealPath(b, read) ? result : "unknown"; + } catch { return "unknown"; } } -/** Canonical entry equality when available; unavailable paths retain lexical equality. */ +/** Admission-style convenience; release consumers must retain the unknown result. */ export function sameCatalogHealPath(left: string, right: string): boolean { - try { return samePath(canonicalCatalogHealPath(left), canonicalCatalogHealPath(right)); } - catch { return samePath(left, right); } + return compareCatalogHealPaths(left, right) === "same"; } /** Resolve authority without reading catalog bytes or cleaning journal evidence. */ diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index fc4de4a6b20..a773a37eabe 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,7 +1,7 @@ import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families"; import { configuredUltraFastSlugs } from "./catalog/configured-tiers"; import { join } from "node:path"; -import { sameCatalogHealPath } from "./catalog/heal-observation"; +import { compareCatalogHealPaths } from "./catalog/heal-observation"; import { getConfigDir, saveConfigPreservingClaudeCode, ultraFastTierEnabled, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; @@ -674,7 +674,7 @@ export async function commitCodexCatalogCandidate( const guarded = withExpectedConfigGenerationSync(state.generation, () => { const invalid = revalidateCandidate(state); if (invalid) return invalid; - if (lifecycle.expectedCatalogPath !== undefined && !sameCatalogHealPath(state.catalog.path, lifecycle.expectedCatalogPath)) { + if (lifecycle.expectedCatalogPath !== undefined && compareCatalogHealPaths(state.catalog.path, lifecycle.expectedCatalogPath) !== "same") { return { kind: "stale", reason: "target-identity" } as const; } if (lifecycle.beforeCommit && lifecycle.beforeCommit() !== true) return { kind: "stale", reason: "process-local" } as const; diff --git a/structure/codex-home.md b/structure/codex-home.md index a28b2a77b21..a7124a55eda 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,9 +516,9 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` to compare canonical entries with `samePath`. Regular files retain native realpath normalization except on macOS when they have multiple hardlinks. Those entries retain their leaf name under their parent's native realpath, so a runtime-reported sibling name does not merge distinct entries; case/normalization aliases of a multiply linked macOS leaf are not coalesced by this comparison. Final symlinks follow their target relative to the canonical parent, bounded to 40 links; unavailable or unresolved paths retain lexical `samePath`. Null selections remain unavailable. Directory aliases remain equivalent, and native leaf aliases are retained on Windows and for singly linked macOS files. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write. Publications during this healer's own convergence remain fenced until its terminal result. Deliberate restore/native release fences pending work and clears retry state; successful owner empty publication is accepted. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-integration.test.ts b/tests/codex-integration/codex-catalog-heal-integration.test.ts index 634bf70bacf..3cdc00c538c 100644 --- a/tests/codex-integration/codex-catalog-heal-integration.test.ts +++ b/tests/codex-integration/codex-catalog-heal-integration.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { armDetachedConfigBaseline, loadConfig, saveConfig } from "../../src/config"; import { captureCatalogAdmissionSnapshot, createCatalogConvergeRequest } from "../../src/codex/catalog-admission"; +import { compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; import { setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; import { catalogBackupPathFor, legacyCatalogBackupPath } from "../../src/codex/catalog/parsing"; @@ -159,6 +160,24 @@ test.each([undefined, Promise.resolve(true)])("an untyped guard returning %p can expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); +test("unknown expected-target identity prevents every catalog and cache write", async () => { + let expectedCatalogPath = catalogPath; + for (let i = 0; i < 20; i += 1) { + const next = join(codexHome, `expected-${i}.json`); + symlinkSync(expectedCatalogPath, next, "file"); expectedCatalogPath = next; + } + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(config())); + expect(gathered.kind).toBe("candidate"); + if (gathered.kind !== "candidate") throw new Error("Expected catalog candidate."); + expect(compareCatalogHealPaths(catalogPath, expectedCatalogPath)).toBe("unknown"); + expect(await commitCodexCatalogCandidate(gathered.candidate, 1_000, { expectedCatalogPath, beforeCommit: () => true })) + .toEqual({ kind: "stale", reason: "target-identity" }); + expect(readFileSync(catalogPath, "utf8")).toBe(nativeBytes); + expect(events).toEqual([]); + expect(existsSync(catalogBackupPathFor(catalogPath))).toBe(false); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + test("a journal target differing from the prepared config target prevents catalog and cache publication", async () => { const expectedCatalogPath = join(codexHome, "journal-catalog.json"); const cachePath = join(codexHome, "models_cache.json"); diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index a10dd327a53..41994eb6706 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -8,7 +8,7 @@ import { CATALOG_HEAL_TICK_MS, CATALOG_HEAL_WINDOW_MS, startCodexCatalogSelfHeal, type CatalogSelfHealDeps, type CatalogSelfHealGates, type CatalogSelfHealHandle, } from "../../src/codex/catalog-self-heal"; -import { CATALOG_HEAL_MAX_BYTES, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_BYTES, CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { RawCatalog } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; @@ -533,6 +533,90 @@ async function withNativeFileAlias(path: string, alias: string, platform: "win32 } describe("catalog path comparison", () => { + test.each(["CAFÉ-CATALOG.JSON", "cafe\u0301-catalog.json"])("Mac hardlink alias %s stays unknown without entry-name evidence", async leaf => { + const root = temporaryRoot(), path = join(root, "café-catalog.json"), alias = join(root, leaf); + writeFileSync(path, JSON.stringify(full)); + const sibling = join(temporaryRoot(), "catalog.json"); + linkSync(path, sibling); + await withNativeFileAlias(path, alias, "darwin", () => { + expect(compareCatalogHealPaths(path, alias)).toBe("unknown"); + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(compareCatalogHealPaths(path, path)).toBe("same"); + expect(compareCatalogHealPaths(path, sibling)).toBe("different"); + }); + }); + + test("Mac same-parent hardlink names are not guessed distinct or enumerated eagerly", async () => { + const { path } = aliasPaths("lexical"), sibling = join(dirname(path), "sibling.json"); + linkSync(path, sibling); + const readdir = spyOn(fs, "readdirSync").mockImplementation(() => { throw new Error("Unbounded listing"); }); + const opendir = spyOn(fs, "opendirSync").mockImplementation(() => { throw new Error("Eager Bun opendir"); }); + try { + await withCatalogPlatform("darwin", () => { + expect(compareCatalogHealPaths(path, sibling)).toBe("unknown"); + expect(compareCatalogHealPaths(sibling, path)).toBe("unknown"); + expect(readdir).not.toHaveBeenCalled(); + expect(opendir).not.toHaveBeenCalled(); + }); + } finally { readdir.mockRestore(); opendir.mockRestore(); } + }); + + test.each(["input", "parent"] as const)("Mac %s identity drift prevents a same result", async kind => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const lstat = fs.lstatSync, stat = fs.statSync; + let reads = 0; + const changed = (value: ReturnType) => Object.assign(Object.create(Object.getPrototypeOf(value)), value, + { ctimeNs: typeof value.ctimeNs === "bigint" ? value.ctimeNs + 1n : value.ctimeNs + 1 }); + const input = spyOn(fs, "lstatSync").mockImplementation((value, options) => { + const result = lstat(value, options); + return kind === "input" && value === path && ++reads >= 3 ? changed(result) : result; + }); + const parent = spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return kind === "parent" && ++reads >= 5 ? changed(result) : result; + }); + try { await withCatalogPlatform("darwin", () => { expect(compareCatalogHealPaths(path, path)).toBe("unknown"); }); } + finally { input.mockRestore(); parent.mockRestore(); } + }); + + test("a new hardlink during native resolution cannot evade Mac identity checks", async () => { + const { path } = aliasPaths("lexical"), sibling = join(temporaryRoot(), "catalog.json"); + const native = realpathSync.native; + let calls = 0; + const nativePath = spyOn(realpathSync, "native").mockImplementation(input => { + const result = native(input); + if (input === path && ++calls === 2) linkSync(path, sibling); + return result; + }); + try { await withCatalogPlatform("darwin", () => { expect(compareCatalogHealPaths(path, path)).toBe("unknown"); }); } + finally { nativePath.mockRestore(); } + }); + + test("path lookup stops at its I/O budget and reports unknown", async () => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + let alias = path; + for (let i = 0; i < 20; i += 1) { + const next = join(dirname(path), `link-${i}.json`); + symlinkSync(alias, next, "file"); alias = next; + } + const lstat = fs.lstatSync, stat = fs.statSync, readlink = fs.readlinkSync, native = realpathSync.native; + let reads = 0; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((...args) => { reads++; return lstat(...args); }), + spyOn(fs, "statSync").mockImplementation((...args) => { reads++; return stat(...args); }), + spyOn(fs, "readlinkSync").mockImplementation((...args) => { reads++; return readlink(...args); }), + spyOn(realpathSync, "native").mockImplementation((...args) => { reads++; return native(...args); }), + ]; + try { + await withCatalogPlatform("darwin", () => { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(reads).toBeLessThanOrEqual(CATALOG_HEAL_MAX_PATH_READS); + }); + } finally { for (const spy of spies) spy.mockRestore(); } + }); + test.each(["win32", "darwin"] as const)("%s single-link native filename aliases remain equivalent", async platform => { const { path } = aliasPaths("lexical"); const alias = join(dirname(path), platform === "win32" ? "CATALO~9.JSO" : "CATALOG.JSON"); @@ -625,17 +709,160 @@ describe("catalog path comparison", () => { expect(sameCatalogHealPath(sibling, leaf)).toBe(false); }); - test("unavailable paths retain lexical equality, including dangling and cyclic links", () => { + test("unavailable paths retain lexical equality while cyclic lookup exhaustion stays unknown", () => { const root = temporaryRoot(), missing = join(root, "missing.json"); const dangling = join(root, "dangling.json"), cycle = join(root, "cycle.json"); symlinkSync("missing.json", dangling, "file"); symlinkSync("cycle.json", cycle, "file"); - for (const path of [missing, dangling, cycle]) { + for (const path of [missing, dangling]) { expect(sameCatalogHealPath(path, `${root}/./${path.slice(root.length + 1)}`)).toBe(true); expect(sameCatalogHealPath(path, path)).toBe(true); } expect(sameCatalogHealPath(dangling, missing)).toBe(false); expect(sameCatalogHealPath(missing, dangling)).toBe(false); + expect(compareCatalogHealPaths(cycle, cycle)).toBe("unknown"); + expect(sameCatalogHealPath(cycle, cycle)).toBe(false); + }); +}); + +test("unknown Mac target identity defers observation reuse and healing", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + h.rewrite(empty); + await withNativeFileAlias(path, alias, "darwin", async () => { + h.setPath(alias); + await h.handle.tickForTests(); + expect(h.reads()).toBe(1); + expect(h.converges).toHaveLength(0); + }); +}); + +test("unknown Mac precommit identity refuses a suspended heal", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + h.setPath(alias); finish.resolve(); await tick; + expect(committed).toBe(false); + expect(h.handle.lastHeal()).toBeNull(); + } finally { finish.resolve(); await tick; } + }); +}); + +test("a precommit parent race refuses a suspended heal", async () => { + const { path } = aliasPaths("lexical"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + const stat = fs.statSync; + let reads = 0; + const parent = spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return ++reads >= 5 ? Object.assign(Object.create(Object.getPrototypeOf(result)), result, { ctimeNs: result.ctimeNs + 1n }) : result; + }); + try { + await withCatalogPlatform("darwin", async () => { + finish.resolve(); await tick; + expect(committed).toBe(false); + }); + } finally { finish.resolve(); await tick; parent.mockRestore(); } +}); + +test("a release whose lookup exceeds the I/O budget fences a suspended heal", async () => { + const { path } = aliasPaths("lexical"); + let alias = path; + for (let i = 0; i < 20; i += 1) { + const next = join(dirname(path), `release-${i}.json`); + symlinkSync(alias, next, "file"); alias = next; + } + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + try { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + h.publish({ kind: "native-released", path: alias }); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } +}); + +for (const release of ["restore", "native-release"] as const) { + test(`unknown Mac ${release} fences; unknown owner publication cannot rearm`, async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + h.setPath(null); + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(path); finish.resolve(); await tick; + expect(committed).toBe(false); + const reads = h.reads(); + h.publish({ kind: "published", path: alias, intent: "refresh" }); + expect(h.reads()).toBe(reads); + h.advance(CATALOG_HEAL_WINDOW_MS); await h.handle.tickForTests(); + expect(h.converges).toHaveLength(1); + h.rewrite(full); h.publish({ kind: "published", path, intent: "refresh" }); + h.rewrite(empty); await h.handle.tickForTests(); + expect(h.converges).toHaveLength(2); + expect(committed).toBe(true); + } finally { finish.resolve(); await tick; } + }); + }); +} + +test("unknown Mac owner publication invalidates pending work without accepting a baseline", async () => { + const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); + linkSync(path, join(temporaryRoot(), "catalog.json")); + const h = harness({ path }); + const entered = deferred(), finish = deferred(); + let committed = true; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); await entered.promise; + await withNativeFileAlias(path, alias, "darwin", async () => { + try { + const reads = h.reads(); + h.publish({ kind: "published", path: alias, intent: "refresh" }); + expect(h.reads()).toBe(reads); + finish.resolve(); await tick; + expect(committed).toBe(false); + } finally { finish.resolve(); await tick; } }); }); From d7d880447d51fb95f74d38a91d9600d23506d108 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:10:24 +0900 Subject: [PATCH 04/18] fix(codex): retain uncertainty after catalog hop exhaustion --- src/codex/catalog/heal-observation.ts | 5 ++++- .../codex-catalog-self-heal.test.ts | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 74d2f3683f9..06d13f33848 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -27,7 +27,8 @@ function unchanged(before: BigIntStats, after: BigIntStats): boolean { && before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; } -function inspectHealPath(path: string, read: PathRead): PathObservation { +/** @internal Path observation seam; production callers supply the fixed read budget below. */ +export function inspectHealPath(path: string, read: PathRead): PathObservation { let hardlink = false; const links: LinkObservation[] = []; try { @@ -46,6 +47,8 @@ function inspectHealPath(path: string, read: PathRead): PathObservation { if (!parentStat.isDirectory() || !unchanged(parentStat, read(() => statSync(dirname(path), { bigint: true })))) throw new Error("Changed parent"); return { kind: "hardlink", path, stat, links, parent, parentStat, canonical: join(parent, basename(path)) }; } + // Hop exhaustion requires identity evidence; it must never fall back to lexical comparison. + return { kind: "unavailable", hardlink: true }; } catch { /* Failed identity observations cannot establish a hardlink entry. */ } return { kind: "unavailable", hardlink }; } diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 41994eb6706..519cf2a89f8 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -8,7 +8,7 @@ import { CATALOG_HEAL_TICK_MS, CATALOG_HEAL_WINDOW_MS, startCodexCatalogSelfHeal, type CatalogSelfHealDeps, type CatalogSelfHealGates, type CatalogSelfHealHandle, } from "../../src/codex/catalog-self-heal"; -import { CATALOG_HEAL_MAX_BYTES, CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_BYTES, CATALOG_HEAL_MAX_LINKS, CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, inspectHealPath, observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath } from "../../src/codex/catalog/heal-observation"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { RawCatalog } from "../../src/codex/catalog/parsing"; import type { OcxConfig } from "../../src/types"; @@ -709,6 +709,19 @@ describe("catalog path comparison", () => { expect(sameCatalogHealPath(sibling, leaf)).toBe(false); }); + test("40-hop exhaustion stays uncertain independently of the comparator read budget", () => { + const root = temporaryRoot(), cycle = join(root, "cycle.json"); + symlinkSync("cycle.json", cycle, "file"); + let reads = 0; + const observed = inspectHealPath(cycle, read => { reads += 1; return read(); }); + expect(CATALOG_HEAL_MAX_LINKS).toBe(40); + expect(CATALOG_HEAL_MAX_PATH_READS).toBe(64); + expect(reads).toBe(CATALOG_HEAL_MAX_LINKS * 4); + expect(reads).toBeGreaterThan(CATALOG_HEAL_MAX_PATH_READS); + // This marker forces compareCatalogHealPaths' unknown branch, never lexical fallback. + expect(observed).toEqual({ kind: "unavailable", hardlink: true }); + }); + test("unavailable paths retain lexical equality while cyclic lookup exhaustion stays unknown", () => { const root = temporaryRoot(), missing = join(root, "missing.json"); const dangling = join(root, "dangling.json"), cycle = join(root, "cycle.json"); From 03cabdffb8cc92a08bd3e18002d0ac871f6ee4ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 19:57:49 +0900 Subject: [PATCH 05/18] fix(catalog): retain uncertainty after symlink lookup failures --- src/codex/catalog/heal-observation.ts | 6 +- structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 68 +++++++++++++++++-- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 06d13f33848..c3310636a1a 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -30,11 +30,13 @@ function unchanged(before: BigIntStats, after: BigIntStats): boolean { /** @internal Path observation seam; production callers supply the fixed read budget below. */ export function inspectHealPath(path: string, read: PathRead): PathObservation { let hardlink = false; + let observedSymlink = false; const links: LinkObservation[] = []; try { for (let count = 0; count < CATALOG_HEAL_MAX_LINKS; count += 1) { const stat = read(() => lstatSync(path, { bigint: true })); if (stat.isSymbolicLink()) { + observedSymlink = true; const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); links.push({ path, stat, value, parent, parentStat }); path = resolve(read(() => realpathSync.native(parent)), value); @@ -49,8 +51,8 @@ export function inspectHealPath(path: string, read: PathRead): PathObservation { } // Hop exhaustion requires identity evidence; it must never fall back to lexical comparison. return { kind: "unavailable", hardlink: true }; - } catch { /* Failed identity observations cannot establish a hardlink entry. */ } - return { kind: "unavailable", hardlink }; + } catch { /* An observed link cannot fall back to lexical identity after a failed lookup. */ } + return { kind: "unavailable", hardlink: hardlink || observedSymlink }; } function revalidateHealPath(seen: Exclude, read: PathRead): boolean { diff --git a/structure/codex-home.md b/structure/codex-home.md index a7124a55eda..9a1da563fc8 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -518,7 +518,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once a leaf symlink is observed, any subsequent failed path lookup remains unknown, including a dangling target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 519cf2a89f8..a07f93d1cd0 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -722,15 +722,37 @@ describe("catalog path comparison", () => { expect(observed).toEqual({ kind: "unavailable", hardlink: true }); }); - test("unavailable paths retain lexical equality while cyclic lookup exhaustion stays unknown", () => { + test.each([ + [1, "initial leaf lstat", false], + [2, "symlink parent stat", true], + [3, "symlink readlink", true], + [4, "symlink parent realpath", true], + [5, "symlink target lstat", true], + [6, "symlink target realpath", true], + ] as const)("path failure at %s (%s) preserves observed-symlink uncertainty", (failAt, _stage, uncertain) => { + const root = temporaryRoot(), target = join(root, "target.json"), alias = join(root, "alias.json"); + writeFileSync(target, JSON.stringify(full)); + symlinkSync(target, alias, "file"); + let reads = 0; + const observed = inspectHealPath(alias, read => { + if (++reads === failAt) throw new Error("Fixture filesystem observation failed"); + return read(); + }); + expect(reads).toBe(failAt); + expect(observed).toEqual({ kind: "unavailable", hardlink: uncertain }); + }); + + test("initial unavailable leaves retain lexical equality while observed dangling links stay unknown", () => { const root = temporaryRoot(), missing = join(root, "missing.json"); const dangling = join(root, "dangling.json"), cycle = join(root, "cycle.json"); symlinkSync("missing.json", dangling, "file"); symlinkSync("cycle.json", cycle, "file"); - for (const path of [missing, dangling]) { - expect(sameCatalogHealPath(path, `${root}/./${path.slice(root.length + 1)}`)).toBe(true); - expect(sameCatalogHealPath(path, path)).toBe(true); - } + expect(compareCatalogHealPaths(missing, `${root}/./missing.json`)).toBe("same"); + expect(compareCatalogHealPaths(missing, join(root, "other-missing.json"))).toBe("different"); + expect(inspectHealPath(missing, read => read())).toEqual({ kind: "unavailable", hardlink: false }); + expect(compareCatalogHealPaths(dangling, dangling)).toBe("unknown"); + expect(compareCatalogHealPaths(dangling, missing)).toBe("unknown"); + expect(compareCatalogHealPaths(missing, dangling)).toBe("unknown"); expect(sameCatalogHealPath(dangling, missing)).toBe(false); expect(sameCatalogHealPath(missing, dangling)).toBe(false); expect(compareCatalogHealPaths(cycle, cycle)).toBe("unknown"); @@ -738,6 +760,42 @@ describe("catalog path comparison", () => { }); }); +for (const release of ["restore", "native-release"] as const) { + test(`observed-symlink lookup failure ${release} fences the known target while selection is unavailable`, async () => { + const root = temporaryRoot(), target = join(root, "target.json"), alias = join(root, "alias.json"); + writeFileSync(target, JSON.stringify(full)); + linkSync(target, join(root, "sibling.json")); + symlinkSync(target, alias, "file"); + const known = join(root, "missing-known.json"), h = harness({ path: known }); + const entered = deferred(), finish = deferred(); + let committed = false; + h.setHeal(async (_config, lifecycle) => { + entered.resolve(); await finish.promise; + committed = lifecycle.beforeCommit(); return { committed }; + }); + h.rewrite(empty); + const tick = h.handle.tickForTests(); + await entered.promise; + const originalReadlink = fs.readlinkSync; + const readlink = spyOn(fs, "readlinkSync").mockImplementation((path, options) => { + if (path === alias) throw new Error("Fixture readlink observation failed"); + return originalReadlink(path, options); + }); + try { + await withCatalogPlatform("darwin", async () => { + h.setPath(null); + const comparison = compareCatalogHealPaths(alias, known); + h.publish(release === "restore" ? { kind: "published", path: alias, intent: "restore" } + : { kind: "native-released", path: alias }); + h.setPath(known); + finish.resolve(); await tick; + expect(committed).toBe(false); + expect(comparison).toBe("unknown"); + }); + } finally { finish.resolve(); await tick; readlink.mockRestore(); } + }); +} + test("unknown Mac target identity defers observation reuse and healing", async () => { const { path } = aliasPaths("lexical"), alias = join(dirname(path), "CATALOG.JSON"); linkSync(path, join(temporaryRoot(), "catalog.json")); From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 06/18] fix(test): promote Bun 1.4.4 with independent runner role verification --- .github/actions/setup-project-bun/action.yml | 4 +-- .github/workflows/ci.yml | 10 +++--- CONTRIBUTING.md | 6 ++-- docs-site/src/content/docs/contributing.md | 6 ++-- package.json | 2 +- structure/runtime.md | 4 +-- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 ++-- tests/test-layout-tooling.test.ts | 34 ++++++++++++++++---- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc9..cf28f6f9d43 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -8,8 +8,8 @@ description: >- # (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to # 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after # finalizers ran inside an earlier call and spin past its own timeout (#4956). -# Rejoin only after the upstream runner crash is fixed and a green lane=all -# dispatch proves the unified version; then delete testRunnerBun and this role input. +# Both roles now default to the package's 1.4.4 security transition, retaining role validation. +# Promotion requires a green full cross-platform lane=all run on the exact candidate head. inputs: role: description: Bun role to install (runtime or test-runner). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..7bcd0eaa3f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -578,12 +578,10 @@ jobs: printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY" printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" - # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs - # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). - # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch - # proves the unified version, then delete testRunnerBun and the action's role input. + # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json. + # Keep role validation after the historical isolate preload and spawnSync failures + # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on + # the exact candidate head; focused local results do not replace that proof. # The suite, split by file across four Linux runners. # # `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f580..bbcd248f8f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..d0021b7f12a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. -`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4. +`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. +The roles remain independently checked even when their versions match. Promotion requires +a successful full cross-platform `lane=all` run on the candidate head. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, available machine resources, or concurrent worktrees, you must at least run focused regression tests diff --git a/package.json b/package.json index 89176da2851..a270427faf2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "trustedDependencies": [ "bun" ], - "testRunnerBun": "1.4.3", + "testRunnerBun": "1.4.4", "scripts": { "dev": "bun run src/cli/index.ts start", "dev:proxy": "bun run src/cli/index.ts start", diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..763f3e90c3d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ The minute sweep checks persisted activation deadlines locally; only missing dea ## Bun runtime and test runner -`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. +`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs. The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0. #6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin. Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin. -`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split. +`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof. ## Resolved static model policy diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa90..0729ab6945d 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => { }); }); -// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956). +// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4. // Check the active setup at each test command, so a later role switch cannot evade the guard. const runsBunTests = (step: Step): boolean => /\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..23ed7b7461e 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -94,10 +94,10 @@ describe("install scripts", () => { // and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4 // batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled). // tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it. - // Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch - // proves the unified version; then delete testRunnerBun and the setup action's role. + // The security transition now defaults both roles to 1.4.4. Keep their independent + // validation; promotion still requires a green full cross-platform lane=all candidate run. expect(pkg.dependencies?.bun).toBe("1.4.4"); - expect(pkg.testRunnerBun).toBe("1.4.3"); + expect(pkg.testRunnerBun).toBe("1.4.4"); expect(pkg.dependencies?.zod).toBe("4.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe0..cb4a7482819 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup(); From 1deb67d274c8246df9ad539bff93f958782032cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:38:09 +0900 Subject: [PATCH 07/18] docs: align Docker Bun comments with unified role defaults --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf1..54e0153e0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,12 @@ # Keep the runtime aligned with package.json and pin the multi-platform image index. # Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS -# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3 -# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync -# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the -# upstream crash fix passes a unified-version lane=all dispatch, then delete -# package.json's testRunnerBun pin and setup-project-bun's role input. +# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner +# defaults are both 1.4.4; keep package.json's testRunnerBun pin and +# setup-project-bun's role input independently validated. Promotion requires +# an exact-head cross-platform lane=all dispatch, including multi-file --isolate: +# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0 +# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks. ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d FROM ${BUN_IMAGE} AS manifest From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 08/18] test: use a draining TLS origin for early upload replies --- .../claude-picker-upload.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..95c6351f04d 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ import { Agent, createServer, request } from "node:https"; import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2"; import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http"; import type { Duplex } from "node:stream"; +import { createServer as createTlsServer } from "node:tls"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; -async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void, +async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin", options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + // A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP + // server also tears down unfinished requests; overriding destroySoon alone does not prevent + // that reset racing ahead of the reply on Windows with a buffered upload. + const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => { + socket.on("error", () => socket.destroy()); + let headers = ""; + let replied = false; + socket.on("data", chunk => { + if (replied) return; + headers += chunk.toString("latin1"); + const end = headers.indexOf("\r\n\r\n"); + if (end < 0) return; + replied = true; + const early = headers.startsWith("POST /early "); + const body = early ? "early" : "ordinary"; + const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2)); + const requestBody = headers.slice(end + 4); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + if (early) earlyReplies.push(observation); + socket.once("finish", () => { observation.flushed = true; }); + socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`); + }); + }) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler); const sockets = new Set(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } }); clients.push(req); return req; }; - return { logs, h2, h1, relay, + return { logs, h2, h1, relay, earlyReplies, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,16 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture((req, res) => { - // Answer like a real origin that replies early: keep reading the body and close with a FIN. - // Bun's own server would close this unfinished request with destroySoon(), a reset that - // Windows delivers before the reply bytes, so the relay could never read what it should relay. - req.on("error", () => res.destroy()); req.resume(); - const socket = req.socket; - socket.destroySoon = () => { socket.end(); return socket; }; - if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } - else res.end("ordinary"); - }); + const f = await fixture("early-fin"); try { const session = f.h2(); const upload = protocol === "h2" @@ -373,6 +389,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); + expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From a9c3d127d3728f50533df32504b787823b5220ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:09:36 +0900 Subject: [PATCH 09/18] test: confine draining origin to buffered HTTP/1 upload --- .../claude-integration/claude-picker-upload.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 95c6351f04d..d0bb998b873 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,7 +378,16 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const) bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => { - const f = await fixture("early-fin"); + const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => { + // Answer like a real origin that replies early: keep reading the body and close with a FIN. + // Bun's own server would close this unfinished request with destroySoon(), a reset that + // Windows delivers before the reply bytes, so the relay could never read what it should relay. + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } + else res.end("ordinary"); + }); try { const session = f.h2(); const upload = protocol === "h2" @@ -389,7 +398,7 @@ for (const protocol of ["h1", "h2"] as const) { upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x"); expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); - expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" }); } finally { await wait(f.close()); } From 42a2439cc7fee874febdbc74ebde6fd200c8361a Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:29:49 +0900 Subject: [PATCH 10/18] fix(codex): resolve catalog link target parents with filesystem semantics A relative symlink target containing .. was normalized lexically, so a symlinked intermediate directory made the comparator follow the wrong parent. Walk each intermediate component within the existing read budget: resolve symlinked or missing directories with realpath, treat non-directories and failures as unknown, and keep the leaf for the next lstat hop. --- src/codex/catalog/heal-observation.ts | 23 +++++++++++++-- structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 28 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index c3310636a1a..1fab488c3af 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,5 +1,5 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats, type Stats } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, parse } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -27,6 +27,25 @@ function unchanged(before: BigIntStats, after: BigIntStats): boolean { && before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; } +/** A ".." climbs the real parent, so intermediate components need filesystem resolution. */ +function resolveLinkTarget(base: string, value: string, read: PathRead): string { + let current = base; + if (isAbsolute(value)) current = parse(value).root; + const segments = (isAbsolute(value) ? value.slice(parse(value).root.length) : value).split(/[\\/]+/); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (segment === "" || segment === ".") continue; + if (segment === "..") { current = dirname(current); continue; } + const next = join(current, segment); + if (index === segments.length - 1) return next; + const stat = read(() => lstatSync(next, { bigint: true })); + if (stat.isSymbolicLink()) current = read(() => realpathSync.native(next)); + else if (stat.isDirectory()) current = next; + else throw new Error("Link target crosses a non-directory"); + } + return current; +} + /** @internal Path observation seam; production callers supply the fixed read budget below. */ export function inspectHealPath(path: string, read: PathRead): PathObservation { let hardlink = false; @@ -39,7 +58,7 @@ export function inspectHealPath(path: string, read: PathRead): PathObservation { observedSymlink = true; const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); links.push({ path, stat, value, parent, parentStat }); - path = resolve(read(() => realpathSync.native(parent)), value); + path = resolveLinkTarget(read(() => realpathSync.native(parent)), value, read); continue; } // Keep native leaf aliases outside the macOS multi-link FD-name bug. diff --git a/structure/codex-home.md b/structure/codex-home.md index 9a1da563fc8..5289f5693de 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,7 +516,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. Intermediate directory components of a link target resolve with filesystem semantics inside the same read budget, so a `..` segment climbs the real parent rather than the lexical one, and a missing or non-directory crossing is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. Once a leaf symlink is observed, any subsequent failed path lookup remains unknown, including a dangling target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index a07f93d1cd0..38cf462fe20 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -709,6 +709,34 @@ describe("catalog path comparison", () => { expect(sameCatalogHealPath(sibling, leaf)).toBe(false); }); + test("a link target crossing a symlinked directory climbs its real parent", () => { + const root = temporaryRoot(), a = join(root, "a"), realHome = join(root, "b", "subdir"); + mkdirSync(realHome, { recursive: true }); + mkdirSync(a, { recursive: true }); + const real = join(root, "b", "catalog.json"), decoy = join(a, "catalog.json"); + writeFileSync(real, JSON.stringify(full)); + writeFileSync(decoy, JSON.stringify(full)); + symlinkSync(realHome, join(a, "bridge"), process.platform === "win32" ? "junction" : "dir"); + const link = join(a, "link.json"); + symlinkSync("bridge/../catalog.json", link, "file"); + expect(compareCatalogHealPaths(link, real)).toBe("same"); + expect(compareCatalogHealPaths(real, link)).toBe("same"); + expect(compareCatalogHealPaths(link, decoy)).toBe("different"); + expect(compareCatalogHealPaths(decoy, link)).toBe("different"); + }); + + test("a link target crossing a missing directory stays unknown", () => { + const root = temporaryRoot(), a = join(root, "a"); + mkdirSync(a, { recursive: true }); + const present = join(a, "catalog.json"); + writeFileSync(present, JSON.stringify(full)); + const link = join(a, "link.json"); + symlinkSync("missing/../catalog.json", link, "file"); + expect(compareCatalogHealPaths(link, present)).toBe("unknown"); + expect(compareCatalogHealPaths(present, link)).toBe("unknown"); + expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + }); + test("40-hop exhaustion stays uncertain independently of the comparator read budget", () => { const root = temporaryRoot(), cycle = join(root, "cycle.json"); symlinkSync("cycle.json", cycle, "file"); From e37982a8294c2ba587115821f02ea6c261f13829 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:36:30 +0900 Subject: [PATCH 11/18] fix(codex): match platform .. semantics in catalog link targets Measured OS behavior splits by platform: Windows normalizes .. lexically across junctions/reparse points and even past missing components, while POSIX resolves each directory and refuses a missing crossing. Keep lexical resolve on Windows; on POSIX walk each intermediate component inside the read budget, record intermediate symlinks in the revalidation snapshot, and treat missing or non-directory crossings as unknown. Regressions assert the independent readFileSync/realpathSync result before the comparator verdict, and the POSIX-only literal-backslash case is an explicit platform skip. --- src/codex/catalog/heal-observation.ts | 18 ++++-- structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 63 +++++++++++++++---- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 1fab488c3af..a2711310b75 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,5 +1,5 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats, type Stats } from "node:fs"; -import { basename, dirname, isAbsolute, join, parse } from "node:path"; +import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -27,11 +27,12 @@ function unchanged(before: BigIntStats, after: BigIntStats): boolean { && before.mtimeNs === after.mtimeNs && before.ctimeNs === after.ctimeNs; } -/** A ".." climbs the real parent, so intermediate components need filesystem resolution. */ -function resolveLinkTarget(base: string, value: string, read: PathRead): string { +/** Windows normalizes ".." lexically across reparse points; POSIX resolves each component. */ +function resolveLinkTarget(base: string, value: string, read: PathRead, links: LinkObservation[]): string { + if (process.platform === "win32") return resolve(base, value); let current = base; if (isAbsolute(value)) current = parse(value).root; - const segments = (isAbsolute(value) ? value.slice(parse(value).root.length) : value).split(/[\\/]+/); + const segments = (isAbsolute(value) ? value.slice(parse(value).root.length) : value).split("/"); for (let index = 0; index < segments.length; index += 1) { const segment = segments[index]; if (segment === "" || segment === ".") continue; @@ -39,7 +40,12 @@ function resolveLinkTarget(base: string, value: string, read: PathRead): string const next = join(current, segment); if (index === segments.length - 1) return next; const stat = read(() => lstatSync(next, { bigint: true })); - if (stat.isSymbolicLink()) current = read(() => realpathSync.native(next)); + if (stat.isSymbolicLink()) { + // Keep the intermediate link inside the same revalidation snapshot as leaf hops. + const parent = current, parentStat = read(() => statSync(parent, { bigint: true })), target = read(() => readlinkSync(next)); + links.push({ path: next, stat, value: target, parent, parentStat }); + current = read(() => realpathSync.native(next)); + } else if (stat.isDirectory()) current = next; else throw new Error("Link target crosses a non-directory"); } @@ -58,7 +64,7 @@ export function inspectHealPath(path: string, read: PathRead): PathObservation { observedSymlink = true; const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); links.push({ path, stat, value, parent, parentStat }); - path = resolveLinkTarget(read(() => realpathSync.native(parent)), value, read); + path = resolveLinkTarget(read(() => realpathSync.native(parent)), value, read, links); continue; } // Keep native leaf aliases outside the macOS multi-link FD-name bug. diff --git a/structure/codex-home.md b/structure/codex-home.md index 5289f5693de..3e2844aa024 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,7 +516,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. Intermediate directory components of a link target resolve with filesystem semantics inside the same read budget, so a `..` segment climbs the real parent rather than the lexical one, and a missing or non-directory crossing is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot, and a missing or non-directory crossing is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. Once a leaf symlink is observed, any subsequent failed path lookup remains unknown, including a dangling target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 38cf462fe20..3e9aa72d79c 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; -import { linkSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; +import { linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { @@ -709,32 +709,69 @@ describe("catalog path comparison", () => { expect(sameCatalogHealPath(sibling, leaf)).toBe(false); }); - test("a link target crossing a symlinked directory climbs its real parent", () => { + test("a link target crossing a symlinked directory follows the platform's .. semantics", () => { const root = temporaryRoot(), a = join(root, "a"), realHome = join(root, "b", "subdir"); mkdirSync(realHome, { recursive: true }); mkdirSync(a, { recursive: true }); const real = join(root, "b", "catalog.json"), decoy = join(a, "catalog.json"); - writeFileSync(real, JSON.stringify(full)); - writeFileSync(decoy, JSON.stringify(full)); + writeFileSync(real, "MARKER-B-REAL"); + writeFileSync(decoy, "MARKER-A-DECOY"); symlinkSync(realHome, join(a, "bridge"), process.platform === "win32" ? "junction" : "dir"); const link = join(a, "link.json"); symlinkSync("bridge/../catalog.json", link, "file"); - expect(compareCatalogHealPaths(link, real)).toBe("same"); - expect(compareCatalogHealPaths(real, link)).toBe("same"); - expect(compareCatalogHealPaths(link, decoy)).toBe("different"); - expect(compareCatalogHealPaths(decoy, link)).toBe("different"); + // Independent OS oracle: Windows normalizes ".." lexically across a reparse + // point (opens decoy); POSIX climbs the link's real parent (opens real). + const actual = process.platform === "win32" ? decoy : real; + const other = process.platform === "win32" ? real : decoy; + expect(readFileSync(link, "utf8")).toBe(readFileSync(actual, "utf8")); + expect(realpathSync.native(link)).toBe(realpathSync.native(actual)); + expect(compareCatalogHealPaths(link, actual)).toBe("same"); + expect(compareCatalogHealPaths(actual, link)).toBe("same"); + expect(compareCatalogHealPaths(link, other)).toBe("different"); + expect(compareCatalogHealPaths(other, link)).toBe("different"); }); - test("a link target crossing a missing directory stays unknown", () => { + test("a link target crossing a missing directory follows the platform's crossing rules", () => { const root = temporaryRoot(), a = join(root, "a"); mkdirSync(a, { recursive: true }); const present = join(a, "catalog.json"); - writeFileSync(present, JSON.stringify(full)); + writeFileSync(present, "MARKER-A-PRESENT"); const link = join(a, "link.json"); symlinkSync("missing/../catalog.json", link, "file"); - expect(compareCatalogHealPaths(link, present)).toBe("unknown"); - expect(compareCatalogHealPaths(present, link)).toBe("unknown"); - expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + if (process.platform === "win32") { + // The lexical ".." applies before the missing component is ever opened. + expect(readFileSync(link, "utf8")).toBe(readFileSync(present, "utf8")); + expect(realpathSync.native(link)).toBe(realpathSync.native(present)); + expect(compareCatalogHealPaths(link, present)).toBe("same"); + expect(compareCatalogHealPaths(present, link)).toBe("same"); + } else { + // POSIX refuses to cross a missing intermediate directory. + expect(() => readFileSync(link)).toThrow(); + expect(() => realpathSync.native(link)).toThrow(); + expect(compareCatalogHealPaths(link, present)).toBe("unknown"); + expect(compareCatalogHealPaths(present, link)).toBe("unknown"); + expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + } + }); + + test.skipIf(process.platform === "win32")("a POSIX link target keeps literal backslashes inside names", () => { + // Windows has no such filenames; only POSIX must not split on "\\". + const root = temporaryRoot(), a = join(root, "a"); + const backslashDir = join(a, "we\\ird"); + mkdirSync(backslashDir, { recursive: true }); + const real = join(backslashDir, "catalog.json"), decoy = join(a, "catalog.json"); + writeFileSync(real, "MARKER-BACKSLASH"); + writeFileSync(decoy, "MARKER-A-DECOY"); + const link = join(a, "link.json"), dotdot = join(a, "dot.json"); + symlinkSync("we\\ird/catalog.json", link, "file"); + symlinkSync("we\\ird/../catalog.json", dotdot, "file"); + expect(realpathSync.native(link)).toBe(realpathSync.native(real)); + expect(readFileSync(link, "utf8")).toBe(readFileSync(real, "utf8")); + expect(compareCatalogHealPaths(link, real)).toBe("same"); + expect(compareCatalogHealPaths(link, decoy)).toBe("different"); + expect(realpathSync.native(dotdot)).toBe(realpathSync.native(decoy)); + expect(compareCatalogHealPaths(dotdot, decoy)).toBe("same"); + expect(compareCatalogHealPaths(dotdot, real)).toBe("different"); }); test("40-hop exhaustion stays uncertain independently of the comparator read budget", () => { From 0b443cc6f7e1b1f07ba364b8e7af7bc9a7869815 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 24 Oct 2026 12:39:34 +0900 Subject: [PATCH 12/18] fix(codex): refuse non-directory crossings in POSIX link targets A symlinked intermediate that resolves to a regular file must fail the traversal with ENOTDIR on POSIX; dirname() after realpath would have accepted the wrong catalog. Stat the resolved intermediate inside the read budget, require a directory, and record the resolved identity in the link snapshot so revalidation also catches a target swap. --- src/codex/catalog/heal-observation.ts | 13 ++++++++++--- structure/codex-home.md | 2 +- .../codex-catalog-self-heal.test.ts | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index a2711310b75..6583f446e2e 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -15,7 +15,7 @@ export const CATALOG_HEAL_MAX_LINKS = 40; // Small catalog-path comparisons need only a handful of reads; unusual paths defer. export const CATALOG_HEAL_MAX_PATH_READS = 64; type PathRead = (read: () => T) => T; -type LinkObservation = { path: string; stat: BigIntStats; value: string; parent: string; parentStat: BigIntStats }; +type LinkObservation = { path: string; stat: BigIntStats; value: string; parent: string; parentStat: BigIntStats; resolved?: string; resolvedStat?: BigIntStats }; type PathObservation = { kind: "native" | "hardlink"; path: string; stat: BigIntStats; links: LinkObservation[]; canonical: string; parent?: string; parentStat?: BigIntStats; @@ -43,8 +43,12 @@ function resolveLinkTarget(base: string, value: string, read: PathRead, links: L if (stat.isSymbolicLink()) { // Keep the intermediate link inside the same revalidation snapshot as leaf hops. const parent = current, parentStat = read(() => statSync(parent, { bigint: true })), target = read(() => readlinkSync(next)); - links.push({ path: next, stat, value: target, parent, parentStat }); - current = read(() => realpathSync.native(next)); + const resolvedStat = read(() => statSync(next, { bigint: true })); + // POSIX refuses ".." across a non-directory; statSync follows the link. + if (!resolvedStat.isDirectory()) throw new Error("Link target crosses a non-directory"); + const resolved = read(() => realpathSync.native(next)); + links.push({ path: next, stat, value: target, parent, parentStat, resolved, resolvedStat }); + current = resolved; } else if (stat.isDirectory()) current = next; else throw new Error("Link target crosses a non-directory"); @@ -85,6 +89,9 @@ function revalidateHealPath(seen: Exclude lstatSync(link.path, { bigint: true }))) || read(() => readlinkSync(link.path)) !== link.value || !unchanged(link.parentStat, read(() => statSync(link.parent, { bigint: true })))) return false; + const resolved = link.resolved, resolvedStat = link.resolvedStat; + if (resolved !== undefined && resolvedStat !== undefined + && !unchanged(resolvedStat, read(() => statSync(resolved, { bigint: true })))) return false; } return seen.kind !== "hardlink" || (unchanged(seen.parentStat!, read(() => statSync(seen.parent!, { bigint: true }))) && unchanged(seen.parentStat!, read(() => statSync(dirname(seen.path), { bigint: true })))); diff --git a/structure/codex-home.md b/structure/codex-home.md index 3e2844aa024..02a9cacfea5 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,7 +516,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot, and a missing or non-directory crossing is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. Once a leaf symlink is observed, any subsequent failed path lookup remains unknown, including a dangling target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 3e9aa72d79c..b9fc0d45e81 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -774,6 +774,23 @@ describe("catalog path comparison", () => { expect(compareCatalogHealPaths(dotdot, real)).toBe("different"); }); + test.skipIf(process.platform === "win32")("a POSIX link target crossing a file-pointing link refuses the traversal", () => { + // "bridge" resolves to a regular file: POSIX refuses "bridge/.." with ENOTDIR. + const root = temporaryRoot(), a = join(root, "a"); + mkdirSync(a, { recursive: true }); + const present = join(a, "catalog.json"), fileTarget = join(root, "file-target.json"); + writeFileSync(present, "MARKER-A-PRESENT"); + writeFileSync(fileTarget, "MARKER-FILE"); + symlinkSync(fileTarget, join(a, "bridge"), "file"); + const link = join(a, "link.json"); + symlinkSync("bridge/../catalog.json", link, "file"); + expect(() => readFileSync(link)).toThrow(); + expect(() => realpathSync.native(link)).toThrow(); + expect(compareCatalogHealPaths(link, present)).toBe("unknown"); + expect(compareCatalogHealPaths(present, link)).toBe("unknown"); + expect(inspectHealPath(link, read => read())).toEqual({ kind: "unavailable", hardlink: true }); + }); + test("40-hop exhaustion stays uncertain independently of the comparator read budget", () => { const root = temporaryRoot(), cycle = join(root, "cycle.json"); symlinkSync("cycle.json", cycle, "file"); From ed4bd973021c12ce708d0226003b447977e1ca38 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:38:50 +0900 Subject: [PATCH 13/18] test(codex): reproduce catalog observation and reanchor regressions --- .../test-layout/layout-codex-collision.json | 1 + .../codex-catalog-heal-reanchor.test.ts | 80 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 82 insertions(+) create mode 100644 tests/codex-integration/codex-catalog-heal-reanchor.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index b35d340f1f7..be04705f588 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { + "codex-catalog-heal-reanchor.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", "codex-stored-main-duplicate.test.ts": "codex-integration", diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts new file mode 100644 index 00000000000..d4e9dfa13b6 --- /dev/null +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; +import { startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; +import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; +import type { OcxConfig } from "../../src/types"; + +const roots: string[] = []; +const fixture = () => { + const root = fs.mkdtempSync(join(tmpdir(), "ocx-heal-reanchor-")); + roots.push(root); + return root; +}; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +test("a leaf symlink retargeted before native resolution stays unknown on every platform", () => { + const root = fixture(), a = join(root, "a.json"), b = join(root, "b.json"), alias = join(root, "alias.json"); + fs.writeFileSync(a, "A"); + fs.writeFileSync(b, "B"); + fs.symlinkSync(a, alias, "file"); + const native = fs.realpathSync.native; + let swapped = false; + const spy = spyOn(fs.realpathSync, "native").mockImplementation((path, options) => { + if (!swapped) { + swapped = true; + fs.unlinkSync(alias); + fs.symlinkSync(b, alias, "file"); + } + return native(path, options); + }); + let relation; + try { relation = compareCatalogHealPaths(alias, a); } + finally { spy.mockRestore(); } + expect(swapped).toBe(true); + expect(native(alias)).toBe(native(b)); + expect(fs.readFileSync(alias, "utf8")).toBe("B"); + expect(relation).toBe("unknown"); +}); + +test("a proven selected publication reanchors when the obsolete target is a dangling symlink", async () => { + const root = fixture(), oldPath = join(root, "old.json"), newPath = join(root, "new.json"); + const full = { models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }; + fs.writeFileSync(oldPath, JSON.stringify(full)); + fs.writeFileSync(newPath, JSON.stringify(full)); + let selected = oldPath; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.expectedCatalogPath).toBe(newPath); + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + fs.renameSync(oldPath, join(root, "saved-old.json")); + fs.symlinkSync(join(root, "missing.json"), oldPath, "file"); + selected = newPath; + expect(compareCatalogHealPaths(oldPath, newPath)).toBe("unknown"); + expect(compareCatalogHealPaths(newPath, newPath)).toBe("same"); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + for (let i = 0; i < 3; i++) await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c7858ea3657..2fa1b8307dd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", From 44e12760c326a10e56740d2b3f9ecc0ac6ee2856 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:52:00 +0900 Subject: [PATCH 14/18] fix(codex): revalidate catalog evidence and reanchor selected publications --- src/codex/catalog-self-heal.ts | 23 +++-- src/codex/catalog/heal-observation.ts | 32 ++++--- structure/codex-home.md | 4 +- .../codex-catalog-heal-reanchor.test.ts | 93 ++++++++++++++++++- 4 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index d073550dfd7..5cef5f32291 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -135,10 +135,10 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal const attempts: number[] = []; const heals: number[] = []; - const read = (path: string): NamespaceObservation | null => { + const read = (path: string, fresh = false): NamespaceObservation | null => { const quick = observe(path, false); if (quick === null) return null; - if (observed !== null) { + if (!fresh && observed !== null) { const relation = compareCatalogHealPaths(observed.path, path); if (relation === "unknown") return null; if (relation === "same" && observed.signature === quick.signature) return observed; @@ -266,14 +266,21 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal released = true; // Only a later accepted owner publication can re-arm this lifecycle. return; } - if (known === "unknown" || selected === "unknown") { clear(); return; } - if (running) return; // Catalog-success/cache-failure must not accept a baseline. + if (selected === "unknown" || (known === "unknown" && selected !== "same")) { clear(); return; } + if (running) { + if (known === "unknown") clear(); + return; // Catalog-success/cache-failure must not accept a baseline. + } // A committed owner publication supplies a read-only baseline even while heal writes are gated. - if (event.path === null || path === null || selected !== "same" || !updateTarget(path)) return; + if (event.path === null || path === null || selected !== "same") return; + // Read independently of obsolete evidence, but retire it only after current selection proof. + const seen = read(path, true); + const current = catalogPath(); + if (seen?.namespaces == null || current === null || compareCatalogHealPaths(current, path) !== "same") { clear(); return; } + clear(); + target = path; + baseline = observed = seen; released = false; - pendingRetry = null; - recheckAt = 0; - accept(event.path); }); // Capture immediately after startup sync, before a foreign write can become the first tick. try { const path = catalogPath(); if (updateTarget(path) && target !== null) accept(target); } diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 6583f446e2e..077fedf6119 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -18,7 +18,7 @@ type PathRead = (read: () => T) => T; type LinkObservation = { path: string; stat: BigIntStats; value: string; parent: string; parentStat: BigIntStats; resolved?: string; resolvedStat?: BigIntStats }; type PathObservation = { kind: "native" | "hardlink"; path: string; stat: BigIntStats; links: LinkObservation[]; - canonical: string; parent?: string; parentStat?: BigIntStats; + canonical: string; parent: string; parentStat: BigIntStats; } | { kind: "unavailable"; hardlink: boolean }; function unchanged(before: BigIntStats, after: BigIntStats): boolean { @@ -59,13 +59,13 @@ function resolveLinkTarget(base: string, value: string, read: PathRead, links: L /** @internal Path observation seam; production callers supply the fixed read budget below. */ export function inspectHealPath(path: string, read: PathRead): PathObservation { let hardlink = false; - let observedSymlink = false; + let observedEntry = false; const links: LinkObservation[] = []; try { for (let count = 0; count < CATALOG_HEAL_MAX_LINKS; count += 1) { const stat = read(() => lstatSync(path, { bigint: true })); + observedEntry = true; if (stat.isSymbolicLink()) { - observedSymlink = true; const parent = dirname(path), parentStat = read(() => statSync(parent, { bigint: true })), value = read(() => readlinkSync(path)); links.push({ path, stat, value, parent, parentStat }); path = resolveLinkTarget(read(() => realpathSync.native(parent)), value, read, links); @@ -73,15 +73,15 @@ export function inspectHealPath(path: string, read: PathRead): PathObservation { } // Keep native leaf aliases outside the macOS multi-link FD-name bug. hardlink = process.platform === "darwin" && stat.isFile() && stat.nlink > 1n; - if (!hardlink) return { kind: "native", path, stat, links, canonical: read(() => realpathSync.native(path)) }; const parent = read(() => realpathSync.native(dirname(path))), parentStat = read(() => statSync(parent, { bigint: true })); if (!parentStat.isDirectory() || !unchanged(parentStat, read(() => statSync(dirname(path), { bigint: true })))) throw new Error("Changed parent"); - return { kind: "hardlink", path, stat, links, parent, parentStat, canonical: join(parent, basename(path)) }; + return { kind: hardlink ? "hardlink" : "native", path, stat, links, parent, parentStat, + canonical: hardlink ? join(parent, basename(path)) : read(() => realpathSync.native(path)) }; } // Hop exhaustion requires identity evidence; it must never fall back to lexical comparison. return { kind: "unavailable", hardlink: true }; - } catch { /* An observed link cannot fall back to lexical identity after a failed lookup. */ } - return { kind: "unavailable", hardlink: hardlink || observedSymlink }; + } catch { /* Observed entry evidence cannot fall back to lexical identity after a failed lookup. */ } + return { kind: "unavailable", hardlink: hardlink || observedEntry }; } function revalidateHealPath(seen: Exclude, read: PathRead): boolean { @@ -93,8 +93,8 @@ function revalidateHealPath(seen: Exclude statSync(resolved, { bigint: true })))) return false; } - return seen.kind !== "hardlink" || (unchanged(seen.parentStat!, read(() => statSync(seen.parent!, { bigint: true }))) - && unchanged(seen.parentStat!, read(() => statSync(dirname(seen.path), { bigint: true })))); + return unchanged(seen.parentStat, read(() => statSync(seen.parent, { bigint: true }))) + && unchanged(seen.parentStat, read(() => statSync(dirname(seen.path), { bigint: true }))); } /** Entry uncertainty is distinct from a proven different path, especially for releases. */ @@ -110,11 +110,17 @@ export function compareCatalogHealPaths(left: string, right: string): CatalogHea const needsEntry = a.kind === "hardlink" || b.kind === "hardlink" || (a.kind === "unavailable" && a.hardlink) || (b.kind === "unavailable" && b.hardlink); if (!needsEntry) { - if (a.kind === "unavailable" || b.kind === "unavailable") return samePath(left, right) ? "same" : "different"; + if (a.kind === "unavailable" || b.kind === "unavailable") { + try { + if ((a.kind !== "unavailable" && !revalidateHealPath(a, read)) + || (b.kind !== "unavailable" && !revalidateHealPath(b, read))) return "unknown"; + return samePath(left, right) ? "same" : "different"; + } catch { return "unknown"; } + } const result = samePath(a.canonical, b.canonical) ? "same" : "different"; - // A new hardlink during native resolution must not evade the macOS branch. + // Native normalization must not hide changed entries, links or parents on any platform. try { - return process.platform !== "darwin" || (revalidateHealPath(a, read) && revalidateHealPath(b, read)) ? result : "unknown"; + return revalidateHealPath(a, read) && revalidateHealPath(b, read) ? result : "unknown"; } catch { return "unknown"; } } if (a.kind === "unavailable" || b.kind === "unavailable") return "unknown"; @@ -122,7 +128,7 @@ export function compareCatalogHealPaths(left: string, right: string): CatalogHea let result: CatalogHealPathComparison = "unknown"; if (a.stat.dev !== b.stat.dev || a.stat.ino !== b.stat.ino) result = "different"; else if (a.kind === "hardlink" && b.kind === "hardlink") { - if (a.parentStat!.dev !== b.parentStat!.dev || a.parentStat!.ino !== b.parentStat!.ino) result = "different"; + if (a.parentStat.dev !== b.parentStat.dev || a.parentStat.ino !== b.parentStat.ino) result = "different"; else if (basename(a.path) === basename(b.path)) result = "same"; // Bun 1.4.3 opendir/Glob eagerly enumerate the whole directory. Without a // bounded entry-name primitive, aliases and sibling hardlinks stay unknown. diff --git a/structure/codex-home.md b/structure/codex-home.md index 0ce20b3ecc1..95afe1920f6 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -516,9 +516,9 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New `src/codex/catalog-self-heal.ts` is preloaded without timers before the owner's startup sync. `src/cli/claude-agent-startup-sync.ts` forwards the sync readiness verdict and starts observation synchronously on successful settlement, before deferred Claude roster or Desktop registry awaits. Successful no-op and OFF syncs establish observation; OFF remains gated until enabled, and failed sync never adopts a baseline. Observation stops before teardown. Its unreferenced 30-second timer observes the selected catalog's file identity and parses bounded regular-file content only after a change. Importing admission or catalog writers starts no timer. -A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. +A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Every platform revalidates observed links, resolved entries and parent evidence inside the shared read budget; drift or exhaustion returns unknown. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -Once a leaf symlink is observed, any subsequent failed path lookup remains unknown, including a dangling target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication proven to match the current selection and a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Failed observation or changed selection preserves uncertainty and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts index d4e9dfa13b6..455179e863c 100644 --- a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; import { startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { OcxConfig } from "../../src/types"; @@ -41,7 +41,7 @@ test("a leaf symlink retargeted before native resolution stays unknown on every expect(relation).toBe("unknown"); }); -test("a proven selected publication reanchors when the obsolete target is a dangling symlink", async () => { +test.each([false, true])("a proven selected publication reanchors when the obsolete target is dangling (released=%s)", async released => { const root = fixture(), oldPath = join(root, "old.json"), newPath = join(root, "new.json"); const full = { models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }; fs.writeFileSync(oldPath, JSON.stringify(full)); @@ -67,6 +67,7 @@ test("a proven selected publication reanchors when the obsolete target is a dang log: { warn() {} }, } }); try { + if (released) listener({ kind: "native-released", path: oldPath }); fs.renameSync(oldPath, join(root, "saved-old.json")); fs.symlinkSync(join(root, "missing.json"), oldPath, "file"); selected = newPath; @@ -78,3 +79,91 @@ test("a proven selected publication reanchors when the obsolete target is a dang expect(converges).toBe(1); } finally { handle.stop(); } }); + +test.each(["entry", "parent"] as const)("native %s evidence is revalidated on every platform", kind => { + const root = fixture(), path = join(root, "catalog.json"); + fs.writeFileSync(path, "catalog"); + const lstat = fs.lstatSync, stat = fs.statSync; + let entries = 0, parents = 0, changed = false; + const drift = (value: T): T => { + changed = true; + return Object.assign(Object.create(Object.getPrototypeOf(value)), value, { ctimeNs: value.ctimeNs + 1n }); + }; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((value, options) => { + const result = lstat(value, options); + return kind === "entry" && ++entries >= 3 ? drift(result) : result; + }), + spyOn(fs, "statSync").mockImplementation((value, options) => { + const result = stat(value, options); + return kind === "parent" && ++parents >= 5 ? drift(result) : result; + }), + ]; + try { + expect(compareCatalogHealPaths(path, path)).toBe("unknown"); + expect(changed).toBe(true); + } finally { for (const spy of spies) spy.mockRestore(); } +}); + +test("native link observations and revalidation share the 64-call budget", () => { + const root = fixture(), path = join(root, "catalog.json"); + fs.writeFileSync(path, "catalog"); + let alias = path; + for (let i = 0; i < 9; i++) { + const next = join(root, `link-${i}.json`); + fs.symlinkSync(alias, next, "file"); + alias = next; + } + const lstat = fs.lstatSync, stat = fs.statSync, readlink = fs.readlinkSync, native = fs.realpathSync.native; + let reads = 0; + const spies = [ + spyOn(fs, "lstatSync").mockImplementation((...args) => { reads++; return lstat(...args); }), + spyOn(fs, "statSync").mockImplementation((...args) => { reads++; return stat(...args); }), + spyOn(fs, "readlinkSync").mockImplementation((...args) => { reads++; return readlink(...args); }), + spyOn(fs.realpathSync, "native").mockImplementation((...args) => { reads++; return native(...args); }), + ]; + try { + expect(compareCatalogHealPaths(alias, path)).toBe("unknown"); + expect(reads).toBe(CATALOG_HEAL_MAX_PATH_READS); + } finally { for (const spy of spies) spy.mockRestore(); } +}); + +test.each([false, true])("an unreadable selected publication preserves uncertainty and release fences (released=%s)", async released => { + const root = fixture(), oldPath = join(root, "old.json"), newPath = join(root, "new.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(oldPath, full); + fs.writeFileSync(newPath, "invalid catalog"); + let selected = oldPath; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: oldPath }); + fs.renameSync(oldPath, join(root, "saved-old.json")); + fs.symlinkSync(join(root, "missing.json"), oldPath, "file"); + selected = newPath; + expect(compareCatalogHealPaths(newPath, newPath)).toBe("same"); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, full); + await handle.tickForTests(); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(0); + fs.writeFileSync(newPath, full); + listener({ kind: "published", path: newPath, intent: "refresh" }); + fs.writeFileSync(newPath, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); From d951d71bd8829394771dff397dc1fef578d7c9b0 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:03:29 +0900 Subject: [PATCH 15/18] fix(codex): retain recovery baseline when publication proof fails --- src/codex/catalog-self-heal.ts | 2 +- structure/codex-home.md | 2 +- .../codex-catalog-heal-reanchor.test.ts | 31 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 5cef5f32291..67fc0a4573f 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -276,7 +276,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal // Read independently of obsolete evidence, but retire it only after current selection proof. const seen = read(path, true); const current = catalogPath(); - if (seen?.namespaces == null || current === null || compareCatalogHealPaths(current, path) !== "same") { clear(); return; } + if (seen?.namespaces == null || current === null || compareCatalogHealPaths(current, path) !== "same") return; clear(); target = path; baseline = observed = seen; diff --git a/structure/codex-home.md b/structure/codex-home.md index 95afe1920f6..748f13cf47a 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -518,7 +518,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Every platform revalidates observed links, resolved entries and parent evidence inside the shared read budget; drift or exhaustion returns unknown. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication proven to match the current selection and a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Failed observation or changed selection preserves uncertainty and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication proven to match the current selection and a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Failed observation or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts index 455179e863c..d158bac6ea2 100644 --- a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths } from "../../src/codex/catalog/heal-observation"; +import { CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, observeCatalogHealFile } from "../../src/codex/catalog/heal-observation"; import { startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { OcxConfig } from "../../src/types"; @@ -167,3 +167,32 @@ test.each([false, true])("an unreadable selected publication preserves uncertain expect(converges).toBe(1); } finally { handle.stop(); } }); + +test.each(["corrupt", "unreadable"] as const)("a %s same-target publication preserves the previous loss baseline", async failure => { + const path = join(fixture(), "catalog.json"); + fs.writeFileSync(path, JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] })); + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let unreadable = false, converges = 0; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => path, + observe: (selected, content) => unreadable ? null : observeCatalogHealFile(selected, content), + subscribe: fn => { listener = fn; return () => {}; }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (failure === "corrupt") fs.writeFileSync(path, "invalid catalog"); + else unreadable = true; + listener({ kind: "published", path, intent: "refresh" }); + unreadable = false; + fs.writeFileSync(path, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); From 7651c8d30372bdb2d781024caf232a1d119494f0 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:05:21 +0900 Subject: [PATCH 16/18] fix(codex): bind heal baselines to published entry evidence --- src/codex/catalog-self-heal.ts | 17 +- src/codex/catalog/heal-observation.ts | 35 +++- structure/codex-home.md | 2 +- .../codex-catalog-heal-reanchor.test.ts | 174 +++++++++++++++++- .../codex-catalog-self-heal.test.ts | 1 + 5 files changed, 210 insertions(+), 19 deletions(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 67fc0a4573f..e47b19aeb92 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -3,7 +3,7 @@ import { readRuntimePort } from "../config/process-state"; import { readClientConnectionState } from "../client/state"; import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle"; import type { OcxConfig } from "../types"; -import { compareCatalogHealPaths, observeCatalogHealFile, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; +import { compareCatalogHealPaths, observeCatalogHealFile, retainCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation"; import { subscribeCatalogPublication } from "./catalog/publication-observer"; import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal"; import { inspectCodexHomeOwner } from "./codex-home-owner"; @@ -51,6 +51,7 @@ export interface CatalogSelfHealDeps { now?: () => number; catalogPath?: () => string | null; observe?: (path: string, readContent: boolean) => CatalogObservation | null; + retainPath?: typeof retainCatalogHealPath; converge?: (config: OcxConfig, lifecycle: CatalogHealLifecycle) => Promise; subscribe?: typeof subscribeCatalogPublication; gates?: Partial; @@ -145,8 +146,9 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal } const seen = observe(path, true); if (seen === null) return null; - observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null }; - return observed; + const result = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null }; + if (!fresh) observed = result; + return result; }; const accept = (path: string): void => { const seen = read(path); @@ -266,17 +268,18 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal released = true; // Only a later accepted owner publication can re-arm this lifecycle. return; } - if (selected === "unknown" || (known === "unknown" && selected !== "same")) { clear(); return; } if (running) { - if (known === "unknown") clear(); + if (known === "unknown" || selected === "unknown") clear(); return; // Catalog-success/cache-failure must not accept a baseline. } // A committed owner publication supplies a read-only baseline even while heal writes are gated. if (event.path === null || path === null || selected !== "same") return; // Read independently of obsolete evidence, but retire it only after current selection proof. - const seen = read(path, true); + const stillPublished = (deps.retainPath ?? retainCatalogHealPath)(event.path); + const seen = read(event.path, true); const current = catalogPath(); - if (seen?.namespaces == null || current === null || compareCatalogHealPaths(current, path) !== "same") return; + if (seen?.namespaces == null || current === null + || compareCatalogHealPaths(event.path, current) !== "same" || !stillPublished(seen.signature)) return; clear(); target = path; baseline = observed = seen; diff --git a/src/codex/catalog/heal-observation.ts b/src/codex/catalog/heal-observation.ts index 077fedf6119..746baf9647b 100644 --- a/src/codex/catalog/heal-observation.ts +++ b/src/codex/catalog/heal-observation.ts @@ -1,4 +1,4 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats, type Stats } from "node:fs"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readlinkSync, readSync, realpathSync, statSync, type BigIntStats } from "node:fs"; import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; import { readCodexHomeJournal } from "../codex-home-owner"; import { samePath, type RawCatalog } from "./parsing"; @@ -97,6 +97,21 @@ function revalidateHealPath(seen: Exclude statSync(dirname(seen.path), { bigint: true }))); } +/** Retain publication entry/link/parent evidence across its catalog read, within one budget. */ +export function retainCatalogHealPath(path: string): (signature: string) => boolean { + let reads = 0; + const read: PathRead = fn => { + if (reads++ >= CATALOG_HEAL_MAX_PATH_READS) throw new Error("Catalog path read limit reached"); + return fn(); + }; + const seen = inspectHealPath(path, read); + return observedSignature => { + try { + return seen.kind !== "unavailable" && signature(seen.stat) === observedSignature && revalidateHealPath(seen, read); + } catch { return false; } + }; +} + /** Entry uncertainty is distinct from a proven different path, especially for releases. */ export function compareCatalogHealPaths(left: string, right: string): CatalogHealPathComparison { let reads = 0, exhausted = false; @@ -159,29 +174,29 @@ export function selectCatalogHealPath( catch { return null; } } -function signature(stat: Stats): string { - return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; +function signature(stat: BigIntStats): string { + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; } /** Bounded regular descriptor reads; never open a FIFO/device or follow a replaced symlink. */ export function observeCatalogHealFile(path: string, readContent: boolean): CatalogObservation | null { let fd: number | undefined; try { - const before = lstatSync(path); - if (!before.isFile() || before.size > CATALOG_HEAL_MAX_BYTES) return null; + const before = lstatSync(path, { bigint: true }); + if (!before.isFile() || before.size > BigInt(CATALOG_HEAL_MAX_BYTES)) return null; if (!readContent) return { signature: signature(before), catalog: null }; fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); - const opened = fstatSync(fd); - if (!opened.isFile() || opened.size > CATALOG_HEAL_MAX_BYTES || signature(before) !== signature(opened)) return null; - const bytes = Buffer.alloc(opened.size + 1); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > BigInt(CATALOG_HEAL_MAX_BYTES) || signature(before) !== signature(opened)) return null; + const bytes = Buffer.alloc(Number(opened.size) + 1); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (count === 0) break; length += count; } - if (length !== opened.size || signature(fstatSync(fd)) !== signature(opened) - || signature(lstatSync(path)) !== signature(opened)) return null; + if (length !== Number(opened.size) || signature(fstatSync(fd, { bigint: true })) !== signature(opened) + || signature(lstatSync(path, { bigint: true })) !== signature(opened)) return null; let catalog: RawCatalog | null = null; try { const value: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8")); diff --git a/structure/codex-home.md b/structure/codex-home.md index 748f13cf47a..7b583a11d39 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -518,7 +518,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Every platform revalidates observed links, resolved entries and parent evidence inside the shared read budget; drift or exhaustion returns unknown. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication proven to match the current selection and a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Failed observation or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication reads the published entry itself, retaining its entry, link and parent evidence across the read within one bounded snapshot. The returned observation must match the retained full-width device, inode, size and nanosecond timestamp signature; unavailable retained identity refuses acceptance. After the read, the published entry must still match the current selection and its retained evidence must revalidate before a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Fresh publication observations remain local until acceptance; failed observation, identity drift, unknown identity or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication during active convergence invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; outside convergence it preserves the prior state; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts index d158bac6ea2..5af5a9b54d3 100644 --- a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CATALOG_HEAL_MAX_PATH_READS, compareCatalogHealPaths, observeCatalogHealFile } from "../../src/codex/catalog/heal-observation"; -import { startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; +import { CATALOG_HEAL_RECHECK_MS, startCodexCatalogSelfHeal } from "../../src/codex/catalog-self-heal"; import type { CatalogPublicationEvent } from "../../src/codex/catalog/publication-observer"; import type { OcxConfig } from "../../src/types"; @@ -196,3 +196,175 @@ test.each(["corrupt", "unreadable"] as const)("a %s same-target publication pres expect(converges).toBe(1); } finally { handle.stop(); } }); + +for (const publishedAlias of [false, true]) { + test.each([false, true])(`a publication cannot adopt a retargeted junction (publishedAlias=${publishedAlias}, released=%s)`, async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), redirected = join(b, "catalog.json"), selectedAlias = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(redirected, catalog(["ark", "tx"])); + let selected = original, armed = false, swapped = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (armed && !swapped) { + swapped = true; + fs.renameSync(alias, join(root, "saved-alias")); + fs.symlinkSync(b, alias, "junction"); + } + return observeCatalogHealFile(path, content); + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + selected = selectedAlias; + const published = publishedAlias ? selectedAlias : original; + expect(compareCatalogHealPaths(published, selected)).toBe("same"); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(swapped).toBe(true); + expect(fs.realpathSync.native(selected)).toBe(fs.realpathSync.native(redirected)); + // Rejection retains the old baseline/release fence; a valid publication still re-arms. + if (!released) { + selected = original; + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(1); + } + selected = selectedAlias; + fs.writeFileSync(redirected, catalog(["ark"])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + selected = original; + if (released) { + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(0); + } + fs.writeFileSync(original, catalog(["ark"])); + listener({ kind: "published", path: original, intent: "refresh" }); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 1 : 2); + } finally { handle.stop(); } + }); +} + +test.each([false, true])("a published junction cannot supply foreign bytes during an A-B-A retarget (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), published = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(join(b, "catalog.json"), catalog(["ark", "tx"])); + let armed = false, reads = 0, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => published, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (!armed) return observeCatalogHealFile(path, content); + if (reads++ === 0) { + fs.renameSync(alias, join(root, "saved-a")); + fs.symlinkSync(b, alias, "junction"); + } + const result = observeCatalogHealFile(path, content); + if (content) { + fs.renameSync(alias, join(root, "saved-b")); + fs.renameSync(join(root, "saved-a"), alias); + armed = false; + } + return result; + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(reads).toBe(2); + expect(compareCatalogHealPaths(original, published)).toBe("same"); + await handle.tickForTests(); + expect(converges).toBe(0); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + } finally { handle.stop(); } +}); + +test("publication entry drift preserves a pending retry and its five-minute backoff", async () => { + const root = fixture(), path = join(root, "catalog.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(path, full); + let now = 0, armed = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + now: () => now, scheduleFn: () => ({ cancel() {} }), catalogPath: () => path, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (selected, content) => { + const result = observeCatalogHealFile(selected, content); + if (armed && content) { + armed = false; + fs.renameSync(path, join(root, "saved.json")); + fs.writeFileSync(path, full); + } + return result; + }, + converge: async () => { converges++; return { committed: false }; }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + fs.writeFileSync(path, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + fs.writeFileSync(path, full); + armed = true; + listener({ kind: "published", path, intent: "refresh" }); + expect(armed).toBe(false); + now = CATALOG_HEAL_RECHECK_MS - 1; + await handle.tickForTests(); + expect(converges).toBe(1); + now++; + await handle.tickForTests(); + expect(converges).toBe(2); + } finally { handle.stop(); } +}); diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index b9fc0d45e81..9b518d22db4 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -55,6 +55,7 @@ function harness(options: { path?: string; config?: Partial; gates?: scheduleFn: (fn, ms) => { timer = fn; delays.push(ms); return { cancel: () => { cancelled += 1; timer = null; } }; }, now: () => clock, catalogPath: () => path, + retainPath: () => () => true, // This harness supplies synthetic observations, including virtual paths. observe: (_path, content) => { if (content) reads += 1; return { signature: `v${version}`, catalog: content ? structuredClone(catalog) : null }; From fdee96986d3eef5d406275fe3e680f26b710c7e5 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:09:20 +0900 Subject: [PATCH 17/18] fix(codex): keep the validated heal publication selection --- src/codex/catalog-self-heal.ts | 2 +- .../codex-catalog-heal-reanchor.test.ts | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index e47b19aeb92..7a5bdbd331c 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -281,7 +281,7 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal if (seen?.namespaces == null || current === null || compareCatalogHealPaths(event.path, current) !== "same" || !stillPublished(seen.signature)) return; clear(); - target = path; + target = current; baseline = observed = seen; released = false; }); diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts index 5af5a9b54d3..be405b3e2de 100644 --- a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -368,3 +368,50 @@ test("publication entry drift preserves a pending retry and its five-minute back expect(converges).toBe(2); } finally { handle.stop(); } }); + +test.each([false, true])("an accepted publication retains the validated post-read selection (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const published = join(a, "catalog.json"), initialSelection = join(alias, "catalog.json"); + const full = JSON.stringify({ models: [{ slug: "ark/a", description: "Routed via opencodex → ark/a (owner)." }] }); + fs.writeFileSync(published, full); + fs.writeFileSync(join(b, "catalog.json"), full); + let selected = initialSelection, armed = false, swapped = false, converges = 0; + let listener: (event: CatalogPublicationEvent) => void = () => {}; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => selected, + subscribe: fn => { listener = fn; return () => {}; }, + observe: (path, content) => { + if (armed && !swapped) { + swapped = true; + fs.renameSync(alias, join(root, "saved-alias")); + fs.symlinkSync(b, alias, "junction"); + selected = published; + } + return observeCatalogHealFile(path, content); + }, + converge: async (_config, lifecycle) => { + converges++; + expect(lifecycle.expectedCatalogPath).toBe(published); + expect(lifecycle.beforeCommit()).toBe(true); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: published }); + armed = true; + listener({ kind: "published", path: published, intent: "refresh" }); + expect(swapped).toBe(true); + expect(compareCatalogHealPaths(initialSelection, selected)).toBe("different"); + fs.writeFileSync(published, JSON.stringify({ models: [] })); + await handle.tickForTests(); + expect(converges).toBe(1); + } finally { handle.stop(); } +}); From 574f9294d85dca2a678944234ee2a18a61fcb3d7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:51:29 +0900 Subject: [PATCH 18/18] fix(codex): capture publication identity before comparisons --- src/codex/catalog-self-heal.ts | 7 +- structure/codex-home.md | 2 +- .../codex-catalog-heal-reanchor.test.ts | 68 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 7a5bdbd331c..6ee264f10f9 100644 --- a/src/codex/catalog-self-heal.ts +++ b/src/codex/catalog-self-heal.ts @@ -251,9 +251,11 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal const unsubscribe = (deps.subscribe ?? subscribeCatalogPublication)(event => { if (stopped) return; + const release = event.kind === "native-released" || event.intent === "restore"; + // Bind publication evidence before comparisons can observe a retargeted alias. + const stillPublished = !release && event.path !== null ? (deps.retainPath ?? retainCatalogHealPath)(event.path) : null; // Native restoration can remove the journal before notification. Fence the known // target (and target-less releases) without resolving possibly unavailable evidence. - const release = event.kind === "native-released" || event.intent === "restore"; const known = event.path !== null && target !== null ? compareCatalogHealPaths(event.path, target) : "different"; if (release && (event.path === null || known !== "different")) { clear(); @@ -273,9 +275,8 @@ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: Catal return; // Catalog-success/cache-failure must not accept a baseline. } // A committed owner publication supplies a read-only baseline even while heal writes are gated. - if (event.path === null || path === null || selected !== "same") return; + if (event.path === null || path === null || selected !== "same" || stillPublished === null) return; // Read independently of obsolete evidence, but retire it only after current selection proof. - const stillPublished = (deps.retainPath ?? retainCatalogHealPath)(event.path); const seen = read(event.path, true); const current = catalogPath(); if (seen?.namespaces == null || current === null diff --git a/structure/codex-home.md b/structure/codex-home.md index 7b583a11d39..af775f8652f 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -518,7 +518,7 @@ The audit artifact is `opencodex-catalog-audit.jsonl` under the Codex home. New A lost routed namespace is repaired through ordinary management convergence only while its provider remains enabled and the owner is idle: no sibling, shutdown/recycle, connected client or client-owned journal; integration remains enabled, runtime ownership is current, and the Codex-home binding permits this writer. A synchronous precommit guard rechecks lifecycle, path and ownership inside K→C after gathering. Observation reuse, target changes, publication/release, precommit/postcommit and expected-target admission use `src/codex/catalog/heal-observation.ts` with `same`, `different` and `unknown` results. Windows and singly linked macOS files retain native leaf normalization. Every platform revalidates observed links, resolved entries and parent evidence inside the shared read budget; drift or exhaustion returns unknown. Multiply linked macOS files use bigint file/parent identity and before/after metadata checks, including resolved leaf symlinks. Stable identical leaf names under one parent identify the same entry; different file identities or different parent identities identify different entries. Different leaf spellings under one parent and inode remain unknown: Bun 1.4.3's opendir and Glob both enumerate eagerly, so this comparator performs no directory listing and does not pretend to have bounded entry-name evidence or implement Unicode folding. On Windows, link-target `..` segments keep the OS's lexical normalization across reparse points; on POSIX, intermediate directory components resolve with filesystem semantics inside the same read budget, a `..` segment climbs the real parent rather than the lexical one, intermediate symlinks join the revalidation snapshot with their resolved identity, and a missing or non-directory crossing — including a link that resolves to a regular file — is unknown. Comparisons stop after 64 filesystem API reads or 40 leaf-symlink hops; exhaustion, identity races and unavailable multi-link evidence are unknown. Outside that uncertain branch, ordinary unavailable paths retain lexical `samePath`. Null selections remain unavailable. Missing/corrupt selected custom catalogs remain unavailable; healing does not select another target implicitly. -Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication reads the published entry itself, retaining its entry, link and parent evidence across the read within one bounded snapshot. The returned observation must match the retained full-width device, inode, size and nanosecond timestamp signature; unavailable retained identity refuses acceptance. After the read, the published entry must still match the current selection and its retained evidence must revalidate before a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Fresh publication observations remain local until acceptance; failed observation, identity drift, unknown identity or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication during active convergence invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; outside convergence it preserves the prior state; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. +Once an entry is observed, any subsequent failed path lookup remains unknown, including a dangling link target; lexical fallback applies only before that evidence exists. Admission and precommit accept only proven same paths; unknown target changes and observation reuse defer healing. A committed owner publication captures the published entry's identity before any known-target or selected-target comparison, retaining that same entry, link and parent evidence through the comparisons, content read and acceptance within one bounded snapshot. The returned observation must match the retained full-width device, inode, size and nanosecond timestamp signature; unavailable retained identity refuses acceptance. After the read, the published entry must still match the current selection and its retained evidence must revalidate before a stable catalog observation retires the old generation and establishes a fresh target/baseline even when the obsolete target is unknown. Fresh publication observations remain local until acceptance; failed observation, identity drift, unknown identity or changed selection preserves the previous baseline, target, retry state and any release fence. A matching committed owner publication establishes the baseline even while lifecycle gates prevent healing; baseline acceptance performs no write, and successful owner empty publication is accepted. Unknown owner publication during active convergence invalidates pending generation/baseline state without adopting a baseline or clearing a release fence; outside convergence it preserves the prior state; only a proven matching owner publication can re-arm it. Publications during this healer's own convergence remain fenced until its terminal result. Release/native restore fences a known or selected target on same or unknown comparison and clears retry state; a proven different target is ignored. The boolean comparison helper is admission-style and is not used by release consumers. A postcommit unknown result accepts no success baseline or successful-heal credit and cannot undo a write already reported by the convergence writer. Failed or refused convergence retains an explicit pending retry, including catalog-success/cache-failure when no missing namespace remains on disk. Retries wait five minutes and obey rolling limits of six attempts and three successful heals per hour. Successful convergence accepts its result, while configuration deletion or a target change retires the old obligation. These are local cooperative recovery rules, not proof of installed Desktop or live-provider behavior. ## Codex-home diagnostics diff --git a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts index be405b3e2de..64b2336fa25 100644 --- a/tests/codex-integration/codex-catalog-heal-reanchor.test.ts +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -269,6 +269,74 @@ for (const publishedAlias of [false, true]) { }); } +test.each([false, true])("publication identity precedes the initial comparisons (released=%s)", async released => { + const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); + fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction"); + const original = join(a, "catalog.json"), foreign = join(b, "catalog.json"), published = join(alias, "catalog.json"); + const catalog = (names: string[]) => JSON.stringify({ models: names.map(ns => ({ + slug: `${ns}/a`, description: `Routed via opencodex → ${ns}/a (owner).`, + })) }); + fs.writeFileSync(original, catalog(["ark"])); + fs.writeFileSync(foreign, catalog(["ark", "tx"])); + let listener: (event: CatalogPublicationEvent) => void = () => {}; + let converges = 0, authorized = false; + const config = { port: 10100, defaultProvider: "ark", providers: { + ark: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + tx: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + } } as OcxConfig; + const handle = startCodexCatalogSelfHeal({ deps: { + scheduleFn: () => ({ cancel() {} }), catalogPath: () => published, + subscribe: fn => { listener = fn; return () => {}; }, + converge: async (_config, lifecycle) => { + converges++; + authorized = lifecycle.beforeCommit(); + return { committed: false }; + }, + gates: { siblingOfLivePort: () => null, exiting: () => false, idle: () => true, + runtimeOwned: () => true, loadConfig: () => config, + ownsCodexHome: () => true, clientConnected: () => false, clientJournalOwner: () => false }, + log: { warn() {} }, + } }); + try { + if (released) listener({ kind: "native-released", path: original }); + const stat = fs.statSync; + let stats = 0, swapped = false; + const spy = spyOn(fs, "statSync").mockImplementation((path, options) => { + const result = stat(path, options); + // Two native comparisons use eight stat calls each. Retarget after the last + // returned A evidence, before the old handler began its independent capture. + // Capture-first must keep A evidence even when this schedule interrupts a comparison. + if (++stats === 16) { + fs.renameSync(alias, join(root, "saved-a")); + fs.symlinkSync(b, alias, "junction"); + swapped = true; + } + return result; + }); + try { listener({ kind: "published", path: published, intent: "refresh" }); } + finally { spy.mockRestore(); } + expect(swapped).toBe(true); + expect(fs.realpathSync.native(published)).toBe(fs.realpathSync.native(foreign)); + fs.writeFileSync(foreign, catalog(["ark"])); + await handle.tickForTests(); + expect(converges).toBe(0); + expect(authorized).toBe(false); + // The rejected publication preserves A's baseline and the original release fence. + fs.renameSync(alias, join(root, "saved-b")); + fs.renameSync(join(root, "saved-a"), alias); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 0 : 1); + expect(authorized).toBe(!released); + fs.writeFileSync(original, catalog(["ark"])); + listener({ kind: "published", path: published, intent: "refresh" }); + fs.writeFileSync(original, catalog([])); + await handle.tickForTests(); + expect(converges).toBe(released ? 1 : 2); + expect(authorized).toBe(true); + } finally { handle.stop(); } +}); + test.each([false, true])("a published junction cannot supply foreign bytes during an A-B-A retarget (released=%s)", async released => { const root = fixture(), a = join(root, "a"), b = join(root, "b"), alias = join(root, "alias"); fs.mkdirSync(a); fs.mkdirSync(b); fs.symlinkSync(a, alias, "junction");