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/src/codex/catalog-self-heal.ts b/src/codex/catalog-self-heal.ts index 9fda96edd6b..6ee264f10f9 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, 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; @@ -135,14 +136,19 @@ 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 && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed; + if (!fresh && 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 }; - 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); @@ -154,18 +160,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 +193,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 +236,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()); @@ -241,33 +251,43 @@ 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"; - 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 (running) return; // Catalog-success/cache-failure must not accept a baseline. + if (running) { + 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 || !sameCatalogHealPath(event.path, path)) return; - updateTarget(path); + 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 seen = read(event.path, true); + const current = catalogPath(); + if (seen?.namespaces == null || current === null + || compareCatalogHealPaths(event.path, current) !== "same" || !stillPublished(seen.signature)) return; + clear(); + target = current; + 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 { 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 4ec119fd8ca..746baf9647b 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, 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"; @@ -9,10 +10,151 @@ export interface CatalogObservation { readonly catalog: RawCatalog | null; } -/** Canonical path equality when available; unavailable paths retain lexical equality. */ +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; resolved?: string; resolvedStat?: 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; +} + +/** 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("/"); + 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()) { + // 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)); + 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"); + } + 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; + 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()) { + 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); + continue; + } + // Keep native leaf aliases outside the macOS multi-link FD-name bug. + hardlink = process.platform === "darwin" && stat.isFile() && stat.nlink > 1n; + 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 ? "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 { /* 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 { + 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; + const resolved = link.resolved, resolvedStat = link.resolvedStat; + if (resolved !== undefined && resolvedStat !== undefined + && !unchanged(resolvedStat, read(() => statSync(resolved, { bigint: true })))) return false; + } + return unchanged(seen.parentStat, read(() => statSync(seen.parent, { bigint: true }))) + && unchanged(seen.parentStat, read(() => 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; + 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") { + 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"; + // Native normalization must not hide changed entries, links or parents on any platform. + try { + return 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"; } +} + +/** Admission-style convenience; release consumers must retain the unknown result. */ export function sameCatalogHealPath(left: string, right: string): boolean { - try { return samePath(realpathSync.native(left), realpathSync.native(right)); } - catch { return samePath(left, right); } + return compareCatalogHealPaths(left, right) === "same"; } /** Resolve authority without reading catalog bytes or cleaning journal evidence. */ @@ -32,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/src/codex/convergence.ts b/src/codex/convergence.ts index 99662a71aa9..dbc09ae9e95 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -1,7 +1,7 @@ import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families"; import { configuredUltraFastSlugs, retainedFlexPolicyBySlug } 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"; @@ -675,7 +675,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 cf1dedf5dd7..af775f8652f 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 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` 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. -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. +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-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-heal-reanchor.test.ts b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts new file mode 100644 index 00000000000..64b2336fa25 --- /dev/null +++ b/tests/codex-integration/codex-catalog-heal-reanchor.test.ts @@ -0,0 +1,485 @@ +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, observeCatalogHealFile } from "../../src/codex/catalog/heal-observation"; +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"; + +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.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)); + 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 { + 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(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(); } +}); + +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(); } +}); + +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(); } +}); + +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])("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"); + 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(); } +}); + +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(); } +}); diff --git a/tests/codex-integration/codex-catalog-self-heal.test.ts b/tests/codex-integration/codex-catalog-self-heal.test.ts index 62100f28e21..9b518d22db4 100644 --- a/tests/codex-integration/codex-catalog-self-heal.test.ts +++ b/tests/codex-integration/codex-catalog-self-heal.test.ts @@ -1,13 +1,14 @@ -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 * as fs from "node:fs"; +import { linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, 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, 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"; @@ -54,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 }; @@ -511,6 +513,552 @@ 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.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"); + 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); + 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 { + 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", async () => { + 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 { await withCatalogPlatform("darwin", () => { 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("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, "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"); + // 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 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, "MARKER-A-PRESENT"); + const link = join(a, "link.json"); + symlinkSync("missing/../catalog.json", link, "file"); + 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.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"); + 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.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"); + 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"); + expect(sameCatalogHealPath(cycle, cycle)).toBe(false); + }); +}); + +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")); + 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; } + }); +}); + +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 () => { @@ -603,6 +1151,35 @@ 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 { + 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(); + } +}); + 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(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f8c40421f3d..413716e957e 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",