From 5dcd66e46fffc4dd2373ef069d79e66064b4c358 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:14:47 +0900 Subject: [PATCH 1/6] fix(native): start owned lifecycle after ownership reprobe --- src/codex/native-profile-startup.ts | 178 +++++++++++-- src/server/index.ts | 72 +++++- src/service.ts | 8 +- tests/native-profile-startup.test.ts | 368 ++++++++++++++++++++++++++- tests/service.test.ts | 8 + 5 files changed, 588 insertions(+), 46 deletions(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index e873579197f..1a062d4fe32 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -3,6 +3,7 @@ import { clearAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; import { probeNativeProfileRecoveryState, + resolveNativeProfileContext, type NativeProfileRecoveryState, } from "./native-profile-store"; import { @@ -43,6 +44,8 @@ export interface NativeMainStartupGateDeps { probeRecoveryState?: typeof probeNativeProfileRecoveryState; owner?: NativeMainOwnerOptions; stageSweepIntervalMs?: number; + /** Test seam / activation-time revalidation for the ambient physical auth home. */ + currentHomeId?: () => string | null; } export interface NativeMainStartupLifecycle { @@ -51,6 +54,11 @@ export interface NativeMainStartupLifecycle { release(): Promise; } +export interface PreparedNativeMainStartupLifecycle { + readonly homeId: string; + start(): NativeMainStartupLifecycle; +} + let epoch = 0; let snapshot: NativeMainStartupGateSnapshot = { status: "ready", homeId: null }; let settled: Promise = Promise.resolve(snapshot); @@ -75,6 +83,7 @@ interface StartupEntry { } const startupEntries = new Map(); const serverLifecycles = new WeakMap(); +const serverLifecycleReleases = new WeakMap>(); function ready(homeId: string | null): NativeMainStartupGateSnapshot { return { status: "ready", homeId }; @@ -311,14 +320,48 @@ export function startNativeMainStartupLifecycle( }; } +/** Resolve and pin the owned lifecycle target without acquiring ownership or creating artifacts. */ +export function prepareNativeMainStartupLifecycle( + deps: NativeMainStartupGateDeps = {}, + homes?: { codexHome: string; configDir: string }, +): PreparedNativeMainStartupLifecycle | null { + let manager: NativeProfileManager; + try { + manager = deps.manager ?? new NativeProfileManager(homes); + if (homes) { + const expected = resolveNativeProfileContext(homes); + if ( + manager.context.homeId !== expected.homeId + || manager.context.instanceId !== expected.instanceId + ) return null; + } + } catch { + return null; + } + const currentHomeId = deps.currentHomeId ?? (() => { + try { return resolveNativeProfileContext().homeId; } catch { return null; } + }); + const pinnedDeps = { ...deps, manager }; + return { + homeId: manager.context.homeId, + start: () => { + if (currentHomeId() !== manager.context.homeId) { + throw new Error("The native-main startup home changed after ownership inspection."); + } + return startNativeMainStartupLifecycle(pinnedDeps); + }, + }; +} + /** * How many times a service-ownership fence will re-ask before it stops asking (#2108). * * A host that is permanently unaskable must not re-probe on every request forever, and a * host that recovers usually does so within the first few. The budget belongs to the - * REASON, not to an individual fence: raising a second fence deliberately does not hand - * out a fresh allowance, or a caller looping over fences could spin the probe forever. - * It is dropped when the last fence for that reason releases. + * live hook owner, not to an individual fence: raising a second fence deliberately does + * not hand out a fresh allowance, or a caller looping over fences could spin the probe + * forever. Spending or releasing that hook owner ends its budget generation; a later + * fence can install a new owner even if an older hookless fence is still draining. */ export const NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT = 5; @@ -327,6 +370,11 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; + readonly expectedHomeId: string; + readonly activate: () => NativeMainStartupLifecycle; + readonly adopt: (lifecycle: NativeMainStartupLifecycle) => boolean; + readonly discard: (lifecycle: NativeMainStartupLifecycle) => void; + activating: boolean; attempts: number; /** The fence that installed this hook; only its own release may drop the entry. */ readonly owner: NativeMainStartupLifecycle; @@ -334,6 +382,10 @@ interface ServiceOwnershipReprobe { readonly spend: () => void; } +function releaseUnadoptedLifecycle(lifecycle: NativeMainStartupLifecycle): Promise { + try { return Promise.resolve(lifecycle.release()).catch(() => {}); } catch { return Promise.resolve(); } +} + /** Test-only: the retry budget is module state and would otherwise leak across tests. */ export function __resetNativeMainOwnershipRetries(): void { for (const entry of serviceOwnershipReprobes.values()) entry.attempts = 0; @@ -359,23 +411,50 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): if (reason !== "ownership-unknown") return false; const entry = serviceOwnershipReprobes.get(reason); if (!entry) return false; + if (entry.activating) return false; if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; entry.attempts += 1; - let answer: NativeCodexOwnership; + let activated: NativeMainStartupLifecycle | undefined; + entry.activating = true; try { - answer = entry.probe(); + const answer = entry.probe(); + if (answer !== "owned") return false; + // Ownership becoming knowable is not itself startup completion. Install the + // normal owner/recovery lifecycle while this fence is still held, so native + // traffic cannot get ahead of owner registration, journal recovery, auth-temp + // scrubbing, or the initial stage sweep. + activated = entry.activate(); } catch { - // An inspection that throws is not evidence the host became ownable. + // Neither a failed inspection nor a failed activation is evidence that + // native-main is safe to admit. Keep the fence and retry hook intact. + return false; + } finally { + entry.activating = false; + } + if ( + !activated + || activated.homeId === null + || activated.homeId !== entry.expectedHomeId + || typeof activated.release !== "function" + ) { + if (activated && typeof activated.release === "function") entry.discard(activated); + return false; + } + if (serviceOwnershipReprobes.get(reason) !== entry || !entry.adopt(activated)) { + // Shutdown or a re-entrant release can retire this fence while activation + // runs. A lifecycle that was never attached to the server must not retain + // another owner reference in the background. + entry.discard(activated); return false; } - if (answer !== "owned") return false; // Release through the fence that installed this hook, and only that one. // // Several servers can hold a fence for the same reason while only one carries a hook, so // clearing the shared refcount here would unblock fences this probe never spoke for. // Decrementing here directly is just as wrong the other way: that fence's own release() - // would then pay a second time for one fence, leaving the count short. Delegating to the - // fence's idempotent release keeps exactly one payment per fence. + // would then pay a second time for one fence, leaving the count short. The + // fence's idempotent spend hook keeps exactly one payment per fence while the + // transitioned owned lifecycle remains attached until server shutdown. entry.spend(); return true; } @@ -395,22 +474,44 @@ function serviceOwnershipSnapshot( /** Close native-main admission without resolving or creating any CODEX_HOME artifacts. */ export function blockNativeMainStartupForUnownedServiceHome( reason: NativeMainServiceOwnershipBlockReason, - options?: { reprobe?: () => NativeCodexOwnership }, + options?: { + reprobe: () => NativeCodexOwnership; + expectedHomeId: string; + startOwnedLifecycle: () => NativeMainStartupLifecycle; + }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); - let released = false; - const lifecycle: NativeMainStartupLifecycle = { - homeId: null, - settled: Promise.resolve(serviceOwnershipSnapshot(reason)), - async release() { - if (released) return; - released = true; - const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); - if (remaining === 0) serviceOwnershipRefs.delete(reason); - else serviceOwnershipRefs.set(reason, remaining); - if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { - serviceOwnershipReprobes.delete(reason); - } + let fenceSpent = false; + let ownedLifecycle: NativeMainStartupLifecycle | undefined; + let releaseFlight: Promise | undefined; + const orphanReleaseFlights = new Set>(); + let lifecycle!: NativeMainStartupLifecycle; + const blockedSettled = Promise.resolve(serviceOwnershipSnapshot(reason)); + const spendFence = () => { + if (fenceSpent) return; + fenceSpent = true; + const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); + if (remaining === 0) serviceOwnershipRefs.delete(reason); + else serviceOwnershipRefs.set(reason, remaining); + if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { + serviceOwnershipReprobes.delete(reason); + } + }; + lifecycle = { + get homeId() { return ownedLifecycle?.homeId ?? null; }, + get settled() { return ownedLifecycle?.settled ?? blockedSettled; }, + release() { + return releaseFlight ??= (async () => { + spendFence(); + // A synchronous activator can re-enter release before its returned + // lifecycle is adopted or discarded. Let that call stack finish so the + // cleanup set is complete before this shared release flight drains it. + await Promise.resolve(); + await ownedLifecycle?.release(); + if (orphanReleaseFlights.size > 0) { + await Promise.allSettled([...orphanReleaseFlights]); + } + })(); }, }; // Do NOT reset an existing budget: keying the reprobe by reason means a caller raising @@ -418,12 +519,25 @@ export function blockNativeMainStartupForUnownedServiceHome( // the probe forever. But once the holder is gone its entry is removed above, so a LATER // fence installs its own hook — a server started after an earlier probe must not be left // needing `ocx restart`, which is the very symptom this exists to remove. - if (options?.reprobe && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { + if (options && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { serviceOwnershipReprobes.set(reason, { probe: options.reprobe, + expectedHomeId: options.expectedHomeId, + activate: options.startOwnedLifecycle, + adopt: activated => { + if (releaseFlight !== undefined || fenceSpent || ownedLifecycle !== undefined) return false; + ownedLifecycle = activated; + return true; + }, + discard: activated => { + const flight = releaseUnadoptedLifecycle(activated); + orphanReleaseFlights.add(flight); + void flight.finally(() => orphanReleaseFlights.delete(flight)); + }, + activating: false, attempts: 0, owner: lifecycle, - spend: () => { void lifecycle.release(); }, + spend: spendFence, }); } return lifecycle; @@ -434,10 +548,20 @@ export function bindNativeMainStartupLifecycle(server: object, lifecycle: Native } export async function releaseNativeMainStartupLifecycle(server: object): Promise { + const existing = serverLifecycleReleases.get(server); + if (existing) return existing; const lifecycle = serverLifecycles.get(server); if (!lifecycle) return; - serverLifecycles.delete(server); - await lifecycle.release(); + const flight = Promise.resolve().then(() => lifecycle.release()); + serverLifecycleReleases.set(server, flight); + try { + await flight; + } finally { + if (serverLifecycleReleases.get(server) === flight) { + serverLifecycleReleases.delete(server); + serverLifecycles.delete(server); + } + } } export function isNativeMainTrafficBlocked(): boolean { diff --git a/src/server/index.ts b/src/server/index.ts index 70433d845c1..ef4e4e48975 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; import { getCodexHome } from "../codex/paths"; +import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service"; import { shouldSyncCodexOnStart } from "../codex/desired-state"; import { inspectNativeCodexOwnership, @@ -174,6 +175,7 @@ import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration"; import { bindNativeMainStartupLifecycle, blockNativeMainStartupForUnownedServiceHome, + prepareNativeMainStartupLifecycle, releaseNativeMainStartupLifecycle, startNativeMainStartupLifecycle, type NativeMainStartupGateDeps, @@ -451,9 +453,25 @@ export interface StartServerDeps { readinessGate?: ReadinessGate; } -function inspectStartupOwnership(deps: StartServerDeps): OwnershipInspection { +function inspectStartupOwnership( + deps: StartServerDeps, + currentHomes: ReturnType | null, + statePaths: readonly string[] | null, +): OwnershipInspection { try { - return (deps.inspectNativeCodexOwnership ?? inspectNativeCodexOwnership)(); + if (deps.inspectNativeCodexOwnership) { + return deps.inspectNativeCodexOwnership({ + ...(currentHomes ? { currentHomes } : {}), + ...(statePaths ? { statePaths } : {}), + }); + } + if (currentHomes === null || statePaths === null) { + return { + ownership: "unknown", + reason: "startup service-home resolution failed", + }; + } + return inspectNativeCodexOwnership({ currentHomes, statePaths }); } catch { return { ownership: "unknown", @@ -540,7 +558,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; + try { startupOwnershipHomes = currentServiceHomes(); } catch { /* inspection below stays unknown */ } + const startupOwnershipStatePaths = startupOwnershipHomes + ? serviceStatePathsForOpenCodexHome(startupOwnershipHomes.opencodexHome) + : null; + const startupCacheOwnership = inspectStartupOwnership( + deps, + startupOwnershipHomes, + startupOwnershipStatePaths, + ); // Startup cache invalidation is best-effort and must never block the server from // serving. It now takes K so it cannot race a convergence commit, but both the // home resolution and the acquisition can fail on a machine with no Codex home — @@ -705,18 +732,39 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server inspectStartupOwnership(deps).ownership }, - ) + : nativeOwnership.ownership === "foreign" + ? blockNativeMainStartupForUnownedServiceHome("foreign-ownership") + : preparedNativeMainLifecycle + ? blockNativeMainStartupForUnownedServiceHome( + "ownership-unknown", + // #2108: an `unknown` verdict means the probe could not answer, not that this host + // is unownable. Hand the fence a way to re-ask so a host that becomes answerable + // after boot reopens on its own instead of needing `ocx restart`. A `foreign` + // verdict ignores this by design — that one is a fact, not a question. + { + reprobe: () => inspectStartupOwnership( + deps, + startupOwnershipHomes, + startupOwnershipStatePaths, + ).ownership, + expectedHomeId: preparedNativeMainLifecycle.homeId, + startOwnedLifecycle: preparedNativeMainLifecycle.start, + }, + ) + : blockNativeMainStartupForUnownedServiceHome("ownership-unknown") : { homeId: null, settled: Promise.resolve({ status: "ready", homeId: null }), diff --git a/src/service.ts b/src/service.ts index cf61a657fb3..3c65f42e40c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -97,13 +97,17 @@ function defaultOpenCodexHome(): string { return resolve(join(homedir(), ".opencodex")); } -function serviceStatePaths(): string[] { - const paths = [serviceStatePath()]; +export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { + const paths = [join(opencodexHome, "service-state.json")]; const defaultPath = join(defaultOpenCodexHome(), "service-state.json"); if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath); return paths; } +function serviceStatePaths(): string[] { + return serviceStatePathsForOpenCodexHome(currentOpenCodexHome()); +} + function currentCodexHome(deps: CodexHomeDeps = {}): string { // Service ownership must identify the same home as the runtime. In WSL an // unset CODEX_HOME can resolve to the single Windows Desktop home rather than diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index fe59dc2224a..c55ab9a4b60 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -30,11 +30,16 @@ import type { } from "../src/codex/native-profile-types"; import type { OcxConfig } from "../src/types"; import { + bindNativeMainStartupLifecycle, blockNativeMainStartupForUnownedServiceHome, initializeNativeMainStartupGate, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot, NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT, + prepareNativeMainStartupLifecycle, + releaseNativeMainStartupLifecycle, + type NativeMainStartupLifecycle, + waitForNativeMainStartupGate, __resetNativeMainOwnershipRetries, } from "../src/codex/native-profile-startup"; import type { NativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; @@ -47,10 +52,12 @@ import { resetLifecycleDrainStateForTests, tryAdmitTurn, } from "../src/server/lifecycle"; +import { startServer } from "../src/server"; const roots: string[] = []; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; +const OWNERSHIP_REPROBE_TEST_HOME = "ownership-reprobe-test-home"; function restoreEnv(name: "OPENCODEX_HOME" | "CODEX_HOME", value: string | undefined): void { if (value === undefined) delete process.env[name]; @@ -81,6 +88,16 @@ function envelope(accountId: string, marker: string): string { }, null, 2) + "\n"; } +/** Lightweight owned transition for retry/refcount tests that do not exercise real recovery. */ +function startReadyOwnershipRetryLifecycle(): NativeMainStartupLifecycle { + const homeId = OWNERSHIP_REPROBE_TEST_HOME; + const readySettled = initializeNativeMainStartupGate({ + manager: { context: { homeId } } as unknown as NativeProfileManager, + probeRecoveryState: () => "none", + }); + return { homeId, settled: readySettled, release: async () => {} }; +} + type Phase = "prepared" | "auth-replaced" | "vault-committed"; type Observation = "source-exact" | "source-changed" | "target-exact" | "target-changed" | "unreadable" | "third"; @@ -95,7 +112,12 @@ interface Fixture { targetProfileId: string; } -async function fixture(phase: Phase, observation: Observation, activePool = false): Promise { +async function fixture( + phase: Phase, + observation: Observation, + activePool = false, + codexAccountMode: "pool" | "direct" = "pool", +): Promise { const root = mkdtempSync(join(tmpdir(), "ocx-native-startup-")); roots.push(root); const codexHome = join(root, "codex"); @@ -182,7 +204,7 @@ async function fixture(phase: Phase, observation: Observation, activePool = fals adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", - codexAccountMode: "pool", + codexAccountMode, }, }, codexAccounts: activePool ? [{ id: "pool-a", email: "pool@test", isMain: false }] : [], @@ -649,6 +671,8 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { let answer: NativeCodexOwnership = "unknown"; const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => answer, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { expect(isNativeMainTrafficBlocked()).toBe(true); @@ -661,15 +685,312 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); + test("a later successful probe starts owned recovery before reopening admission", async () => { + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const f = await fixture("prepared", "source-exact"); + let currentHomeId: string | null = f.manager.context.homeId; + let ownedReleases = 0; + const prepared = prepareNativeMainStartupLifecycle({ + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + currentHomeId: () => currentHomeId, + }, { + codexHome: f.codexHome, + configDir: f.configDir, + }); + expect(prepared).not.toBeNull(); + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => answer, + expectedHomeId: f.manager.context.homeId, + startOwnedLifecycle: () => { + const owned = prepared!.start(); + return { + get homeId() { return owned.homeId; }, + get settled() { return owned.settled; }, + async release() { + ownedReleases += 1; + await owned.release(); + }, + }; + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + answer = "owned"; + + currentHomeId = "different-home"; + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toMatchObject({ + status: "blocked", + reason: "ownership-unknown", + }); + currentHomeId = f.manager.context.homeId; + + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(fence.homeId).toBe(f.manager.context.homeId); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + + finishRecovery(); + await fence.settled; + expect(isNativeMainTrafficBlocked()).toBe(false); + } finally { + currentHomeId = f.manager.context.homeId; + finishRecovery(); + await fence.release(); + await fence.release(); + } + expect(ownedReleases).toBe(1); + }); + + test("startServer promotes one pinned unknown scope through the owned lifecycle", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = f.codexHome; + process.env.OPENCODEX_HOME = f.configDir; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const scopes: Array<{ + currentHomes?: { codexHome: string; opencodexHome: string }; + statePaths?: readonly string[]; + }> = []; + const server = startServer(0, { + inspectNativeCodexOwnership: (scope = {}) => { + scopes.push({ + currentHomes: scope.currentHomes ? { ...scope.currentHomes } : undefined, + statePaths: scope.statePaths ? [...scope.statePaths] : undefined, + }); + return { ownership: answer, reason: "pinned startup test" }; + }, + nativeMainStartup: { + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + answer = "owned"; + + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + expect(scopes.length).toBeGreaterThanOrEqual(3); + const firstScope = scopes[0]!; + expect(firstScope.currentHomes).toEqual({ + codexHome: f.codexHome, + opencodexHome: f.configDir, + }); + expect(firstScope.statePaths?.[0]).toBe(join(f.configDir, "service-state.json")); + for (const scope of scopes.slice(1)) expect(scope).toEqual(firstScope); + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: f.manager.context.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + + test("an owned activation failure keeps the fence and retry hook intact", () => { + let activationFails = true; + let activations = 0; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + if (activationFails) throw new Error("test activation failure"); + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + activationFails = false; + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(activations).toBe(2); + } finally { + void fence.release(); + } + }); + + test("activation re-entry stays fenced and starts the owned lifecycle once", () => { + let activations = 0; + let reentrantBlocked: boolean | undefined; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + reentrantBlocked = isNativeMainTrafficBlocked(); + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(reentrantBlocked).toBe(true); + expect(activations).toBe(1); + } finally { + void fence.release(); + } + }); + + test("probe re-entry stays fenced and consumes one bounded attempt", () => { + let probes = 0; + let activations = 0; + let reentrantBlocked: boolean | undefined; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => { + probes += 1; + reentrantBlocked = isNativeMainTrafficBlocked(); + return "owned"; + }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(false); + expect(reentrantBlocked).toBe(true); + expect(probes).toBe(1); + expect(activations).toBe(1); + } finally { + void fence.release(); + } + }); + + test("preparing an injected manager rejects a different pinned home", async () => { + const one = await fixture("prepared", "source-exact"); + const other = await fixture("prepared", "source-exact"); + expect(prepareNativeMainStartupLifecycle( + { manager: one.manager }, + { codexHome: other.codexHome, configDir: other.configDir }, + )).toBeNull(); + }); + + test("stale activation cleanup is joined by the wrapper release flight", async () => { + let finishOrphanRelease!: () => void; + const orphanBarrier = new Promise(resolve => { finishOrphanRelease = resolve; }); + let orphanReleases = 0; + let fence!: NativeMainStartupLifecycle; + fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + void fence.release(); + return { + homeId: OWNERSHIP_REPROBE_TEST_HOME, + settled: Promise.resolve({ status: "ready", homeId: OWNERSHIP_REPROBE_TEST_HOME }), + release: async () => { + orphanReleases += 1; + await orphanBarrier; + }, + }; + }, + }); + expect(isNativeMainTrafficBlocked()).toBe(true); + let releaseSettled = false; + const release = fence.release().then(() => { releaseSettled = true; }); + await Bun.sleep(0); + expect(orphanReleases).toBe(1); + expect(releaseSettled).toBe(false); + finishOrphanRelease(); + await release; + expect(releaseSettled).toBe(true); + }); + + test("server lifecycle cleanup callers join one release flight", async () => { + const server = {}; + let finishRelease!: () => void; + const releaseBarrier = new Promise(resolve => { finishRelease = resolve; }); + let releases = 0; + bindNativeMainStartupLifecycle(server, { + homeId: OWNERSHIP_REPROBE_TEST_HOME, + settled: Promise.resolve({ status: "ready", homeId: OWNERSHIP_REPROBE_TEST_HOME }), + release: async () => { + releases += 1; + await releaseBarrier; + }, + }); + let secondSettled = false; + const first = releaseNativeMainStartupLifecycle(server); + const second = releaseNativeMainStartupLifecycle(server).then(() => { secondSettled = true; }); + await Bun.sleep(0); + expect(releases).toBe(1); + expect(secondSettled).toBe(false); + finishRelease(); + await Promise.all([first, second]); + expect(releases).toBe(1); + await releaseNativeMainStartupLifecycle(server); + expect(releases).toBe(1); + }); + + test("a null or mismatched owned lifecycle never spends the unknown fence", async () => { + for (const homeId of [null, "different-home"] as const) { + let releases = 0; + const gate = { status: "ready", homeId } as const; + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => ({ + homeId, + settled: Promise.resolve(gate), + release: async () => { releases += 1; }, + }), + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + await Promise.resolve(); + expect(releases).toBe(1); + expect(nativeMainStartupGateSnapshot()).toMatchObject({ + status: "blocked", + reason: "ownership-unknown", + }); + } finally { + await fence.release(); + } + } + }); + test("a foreign owner is a fact, not a question — it never retries", () => { let asked = 0; + let activations = 0; const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { reprobe: () => { asked += 1; return "owned"; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { expect(isNativeMainTrafficBlocked()).toBe(true); expect(isNativeMainTrafficBlocked()).toBe(true); expect(asked).toBe(0); + expect(activations).toBe(0); } finally { void fence.release(); } @@ -677,14 +998,21 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { test("a host that stays unaskable stops being asked", () => { let asked = 0; + let activations = 0; const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "unknown"; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + activations += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { for (let i = 0; i < 25; i++) isNativeMainTrafficBlocked(); expect(isNativeMainTrafficBlocked()).toBe(true); - expect(asked).toBeLessThanOrEqual(NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT); + expect(asked).toBe(NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT); + expect(activations).toBe(0); } finally { void fence.release(); } @@ -712,11 +1040,19 @@ describe("the retryable fence respects its own refcount (#2108)", () => { test("raising a second fence does not hand out a fresh retry budget", () => { let asked = 0; const probe = () => { asked += 1; return "unknown" as NativeCodexOwnership; }; - const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: probe, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, + }); for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); const afterFirst = asked; - const second = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: probe }); + const second = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: probe, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, + }); try { for (let i = 0; i < 20; i++) isNativeMainTrafficBlocked(); @@ -732,6 +1068,8 @@ describe("the retryable fence respects its own refcount (#2108)", () => { const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { isNativeMainTrafficBlocked(); @@ -758,6 +1096,8 @@ describe("a foreign fence is never reopened by a probe (#2108)", () => { test("a foreign fence stays closed even when the host reports owned", () => { const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { throw new Error("foreign ownership must never activate"); }, }); try { for (let i = 0; i < 10; i++) isNativeMainTrafficBlocked(); @@ -785,6 +1125,8 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { const hookless = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); const hooked = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); try { isNativeMainTrafficBlocked(); @@ -799,21 +1141,35 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { }); test("a fence raised after a spent probe still gets to re-ask", () => { + let firstStarts = 0; const first = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => "owned" as NativeCodexOwnership, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + firstStarts += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); isNativeMainTrafficBlocked(); void first.release(); let asked = 0; + let laterStarts = 0; const later = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: () => { + laterStarts += 1; + return startReadyOwnershipRetryLifecycle(); + }, }); try { isNativeMainTrafficBlocked(); // A server started after an earlier probe must not be stuck needing `ocx restart`. expect(asked).toBeGreaterThan(0); + expect(firstStarts).toBe(1); + expect(laterStarts).toBe(1); expect(isNativeMainTrafficBlocked()).toBe(false); } finally { void later.release(); @@ -828,6 +1184,8 @@ describe("a spent reprobe leaves the refcount coherent (#2108)", () => { let asked = 0; const owner = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { reprobe: () => { asked += 1; return "owned" as NativeCodexOwnership; }, + expectedHomeId: OWNERSHIP_REPROBE_TEST_HOME, + startOwnedLifecycle: startReadyOwnershipRetryLifecycle, }); const other = blockNativeMainStartupForUnownedServiceHome("ownership-unknown"); try { diff --git a/tests/service.test.ts b/tests/service.test.ts index 69ef8209db8..29712adca95 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -58,6 +58,14 @@ function expectTextToContainPath(text: string, path: string): void { } describe("service listen-port bake", () => { + test("service ownership state paths stay pinned to the captured OpenCodex home", () => { + const pinned = join(TEST_DIR, "pinned-opencodex"); + process.env.OPENCODEX_HOME = join(TEST_DIR, "ambient-opencodex"); + const paths = serviceModule.serviceStatePathsForOpenCodexHome(pinned); + expect(paths[0]).toBe(join(pinned, "service-state.json")); + expect(paths).not.toContain(join(process.env.OPENCODEX_HOME, "service-state.json")); + }); + test("resolveServiceListenPort prefers override, then OCX_BAKE_PORT, then config", () => { process.env.OPENCODEX_HOME = TEST_DIR; mkdirSync(TEST_DIR, { recursive: true }); From 3169bf93c06eb4b308a42271bb13ae9f2c242c7e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:38:35 +0900 Subject: [PATCH 2/6] fix(native): retain reprobe while service homes resolve --- src/codex/native-profile-startup.ts | 14 ++++-- src/server/index.ts | 64 ++++++++++++++++++------ tests/native-profile-startup.test.ts | 74 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 19 deletions(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index 1a062d4fe32..25f59ba23e3 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -370,7 +370,7 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; - readonly expectedHomeId: string; + readonly expectedHomeId: () => string | null; readonly activate: () => NativeMainStartupLifecycle; readonly adopt: (lifecycle: NativeMainStartupLifecycle) => boolean; readonly discard: (lifecycle: NativeMainStartupLifecycle) => void; @@ -415,10 +415,13 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): if (entry.attempts >= NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT) return false; entry.attempts += 1; let activated: NativeMainStartupLifecycle | undefined; + let expectedHomeId: string | null = null; entry.activating = true; try { const answer = entry.probe(); if (answer !== "owned") return false; + expectedHomeId = entry.expectedHomeId(); + if (expectedHomeId === null) return false; // Ownership becoming knowable is not itself startup completion. Install the // normal owner/recovery lifecycle while this fence is still held, so native // traffic cannot get ahead of owner registration, journal recovery, auth-temp @@ -434,7 +437,7 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): if ( !activated || activated.homeId === null - || activated.homeId !== entry.expectedHomeId + || activated.homeId !== expectedHomeId || typeof activated.release !== "function" ) { if (activated && typeof activated.release === "function") entry.discard(activated); @@ -476,7 +479,7 @@ export function blockNativeMainStartupForUnownedServiceHome( reason: NativeMainServiceOwnershipBlockReason, options?: { reprobe: () => NativeCodexOwnership; - expectedHomeId: string; + expectedHomeId: string | (() => string | null); startOwnedLifecycle: () => NativeMainStartupLifecycle; }, ): NativeMainStartupLifecycle { @@ -520,9 +523,12 @@ export function blockNativeMainStartupForUnownedServiceHome( // fence installs its own hook — a server started after an earlier probe must not be left // needing `ocx restart`, which is the very symptom this exists to remove. if (options && reason === "ownership-unknown" && !serviceOwnershipReprobes.has(reason)) { + const expectedHomeId = options.expectedHomeId; serviceOwnershipReprobes.set(reason, { probe: options.reprobe, - expectedHomeId: options.expectedHomeId, + expectedHomeId: typeof expectedHomeId === "function" + ? expectedHomeId + : () => expectedHomeId, activate: options.startOwnedLifecycle, adopt: activated => { if (releaseFlight !== undefined || fenceSpent || ownedLifecycle !== undefined) return false; diff --git a/src/server/index.ts b/src/server/index.ts index ef4e4e48975..397646e3214 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -27,6 +27,7 @@ import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../servi import { shouldSyncCodexOnStart } from "../codex/desired-state"; import { inspectNativeCodexOwnership, + type NativeCodexOwnership, type OwnershipInspection, } from "../integrations/native/ownership-preflight"; import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api"; @@ -445,6 +446,8 @@ export interface StartServerDeps { nativeMainStartup?: NativeMainStartupGateDeps; /** Test-only ownership evidence; production inspects the installed service state. */ inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership; + /** Test-only service-home resolver; production resolves the current homes directly. */ + resolveServiceHomes?: typeof currentServiceHomes; /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */ liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory; /** Test-only seam; production derives a fresh local-attestation secret per process. */ @@ -558,8 +561,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; - try { startupOwnershipHomes = currentServiceHomes(); } catch { /* inspection below stays unknown */ } + try { startupOwnershipHomes = resolveServiceHomes(); } catch { /* inspection below stays unknown */ } const startupOwnershipStatePaths = startupOwnershipHomes ? serviceStatePathsForOpenCodexHome(startupOwnershipHomes.opencodexHome) : null; @@ -734,35 +738,65 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + // If startup could not resolve the homes at all, preserve a bounded retry + // without guessing an authority. The first successful resolution is pinned + // together with its service-state paths before ownership is inspected. + if (retryOwnershipHomes === null || retryOwnershipStatePaths === null) { + try { + const homes = resolveServiceHomes(); + retryOwnershipHomes = homes; + retryOwnershipStatePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome); + } catch { + return "unknown"; + } + } + const homes = retryOwnershipHomes; + const statePaths = retryOwnershipStatePaths; + const answer = inspectStartupOwnership(deps, homes, statePaths).ownership; + if (answer !== "owned") return answer; + retryPreparedNativeMainLifecycle ??= prepareNativeMainStartupLifecycle( + deps.nativeMainStartup, + { codexHome: homes.codexHome, configDir: homes.opencodexHome }, + ); + // An ownership verdict without a lifecycle bound to that same home is not + // enough to reopen native-main admission. + return retryPreparedNativeMainLifecycle ? "owned" : "unknown"; + }; + const ownershipRetryOptions = preparedNativeMainLifecycle || startupOwnershipHomes === null + ? { + reprobe: reprobeNativeOwnership, + expectedHomeId: () => retryPreparedNativeMainLifecycle?.homeId ?? null, + startOwnedLifecycle: () => { + if (!retryPreparedNativeMainLifecycle) { + throw new Error("Native-main ownership became known before its startup lifecycle was prepared."); + } + return retryPreparedNativeMainLifecycle.start(); + }, + } + : undefined; const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) ? nativeOwnership.ownership === "owned" ? startNativeMainStartupLifecycle(deps.nativeMainStartup) : nativeOwnership.ownership === "foreign" ? blockNativeMainStartupForUnownedServiceHome("foreign-ownership") - : preparedNativeMainLifecycle + : ownershipRetryOptions ? blockNativeMainStartupForUnownedServiceHome( "ownership-unknown", // #2108: an `unknown` verdict means the probe could not answer, not that this host // is unownable. Hand the fence a way to re-ask so a host that becomes answerable // after boot reopens on its own instead of needing `ocx restart`. A `foreign` // verdict ignores this by design — that one is a fact, not a question. - { - reprobe: () => inspectStartupOwnership( - deps, - startupOwnershipHomes, - startupOwnershipStatePaths, - ).ownership, - expectedHomeId: preparedNativeMainLifecycle.homeId, - startOwnedLifecycle: preparedNativeMainLifecycle.start, - }, + ownershipRetryOptions, ) : blockNativeMainStartupForUnownedServiceHome("ownership-unknown") : { diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index c55ab9a4b60..0bb5437b519 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -807,6 +807,80 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); + test("startServer keeps a retry when service homes are initially unavailable", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = f.codexHome; + process.env.OPENCODEX_HOME = f.configDir; + let homesReady = false; + let homeResolutions = 0; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const scopes: Array<{ + currentHomes?: { codexHome: string; opencodexHome: string }; + statePaths?: readonly string[]; + }> = []; + const server = startServer(0, { + resolveServiceHomes: () => { + homeResolutions += 1; + if (!homesReady) throw new Error("service homes are not mounted yet"); + return { codexHome: f.codexHome, opencodexHome: f.configDir }; + }, + inspectNativeCodexOwnership: (scope = {}) => { + scopes.push({ + currentHomes: scope.currentHomes ? { ...scope.currentHomes } : undefined, + statePaths: scope.statePaths ? [...scope.statePaths] : undefined, + }); + return { ownership: answer, reason: "deferred startup scope test" }; + }, + nativeMainStartup: { + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(homeResolutions).toBe(1); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + homesReady = true; + answer = "owned"; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(homeResolutions).toBe(2); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + + const pinnedScopes = scopes.filter(scope => scope.currentHomes !== undefined); + expect(pinnedScopes.length).toBeGreaterThanOrEqual(1); + for (const scope of pinnedScopes) { + expect(scope.currentHomes).toEqual({ + codexHome: f.codexHome, + opencodexHome: f.configDir, + }); + expect(scope.statePaths?.[0]).toBe(join(f.configDir, "service-state.json")); + } + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: f.manager.context.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + test("an owned activation failure keeps the fence and retry hook intact", () => { let activationFails = true; let activations = 0; From 85fb95a24821aeedf2c9ccaa5e95f64eb3b1cdeb Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:53:12 +0900 Subject: [PATCH 3/6] fix(native): retry lifecycle preparation after home recovery --- src/server/index.ts | 40 +++++++++----------- tests/native-profile-startup.test.ts | 55 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 397646e3214..7e62ee14f85 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -772,33 +772,29 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server retryPreparedNativeMainLifecycle?.homeId ?? null, - startOwnedLifecycle: () => { - if (!retryPreparedNativeMainLifecycle) { - throw new Error("Native-main ownership became known before its startup lifecycle was prepared."); - } - return retryPreparedNativeMainLifecycle.start(); - }, - } - : undefined; + const ownershipRetryOptions = { + reprobe: reprobeNativeOwnership, + expectedHomeId: () => retryPreparedNativeMainLifecycle?.homeId ?? null, + startOwnedLifecycle: () => { + if (!retryPreparedNativeMainLifecycle) { + throw new Error("Native-main ownership became known before its startup lifecycle was prepared."); + } + return retryPreparedNativeMainLifecycle.start(); + }, + }; const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config) ? nativeOwnership.ownership === "owned" ? startNativeMainStartupLifecycle(deps.nativeMainStartup) : nativeOwnership.ownership === "foreign" ? blockNativeMainStartupForUnownedServiceHome("foreign-ownership") - : ownershipRetryOptions - ? blockNativeMainStartupForUnownedServiceHome( - "ownership-unknown", - // #2108: an `unknown` verdict means the probe could not answer, not that this host - // is unownable. Hand the fence a way to re-ask so a host that becomes answerable - // after boot reopens on its own instead of needing `ocx restart`. A `foreign` - // verdict ignores this by design — that one is a fact, not a question. - ownershipRetryOptions, - ) - : blockNativeMainStartupForUnownedServiceHome("ownership-unknown") + : blockNativeMainStartupForUnownedServiceHome( + "ownership-unknown", + // #2108: an `unknown` verdict means the probe could not answer, not that this host + // is unownable. Hand the fence a way to re-ask so a host that becomes answerable + // after boot reopens on its own instead of needing `ocx restart`. A `foreign` + // verdict ignores this by design — that one is a fact, not a question. + ownershipRetryOptions, + ) : { homeId: null, settled: Promise.resolve({ status: "ready", homeId: null }), diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index 0bb5437b519..991029cc4f6 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -881,6 +881,61 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); + test("startServer retries lifecycle preparation after a pinned home appears", async () => { + const f = await fixture("prepared", "source-exact", false, "direct"); + const lateCodexHome = join(f.root, "late-codex-home"); + process.env.CODEX_HOME = lateCodexHome; + process.env.OPENCODEX_HOME = f.configDir; + let answer: NativeCodexOwnership = "unknown"; + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const server = startServer(0, { + inspectNativeCodexOwnership: scope => ({ + ownership: answer, + reason: scope.currentHomes?.codexHome === lateCodexHome + ? "pinned missing home test" + : "unexpected startup scope", + }), + nativeMainStartup: { + beforeRecovery: () => recoveryBarrier, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + try { + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + answer = "owned"; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + mkdirSync(lateCodexHome, { recursive: true }); + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + const pending = nativeMainStartupGateSnapshot(); + expect(pending).toMatchObject({ status: "blocked", reason: "recovery-pending" }); + expect(pending.homeId).not.toBeNull(); + + finishRecovery(); + expect(await waitForNativeMainStartupGate()).toEqual({ + status: "ready", + homeId: pending.homeId, + }); + const allowed = tryAcquireNativeMainProfileClaim(); + expect(allowed).not.toBeNull(); + allowed?.release(); + } finally { + finishRecovery(); + await server.stop(true); + } + }); + test("an owned activation failure keeps the fence and retry hook intact", () => { let activationFails = true; let activations = 0; From 511ddbc0292709c171b712e18fcb136f6c7cd113 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:07:24 +0900 Subject: [PATCH 4/6] fix(native): pin initial owned startup authority --- src/server/index.ts | 19 +++++++++-------- tests/native-profile-startup.test.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 7e62ee14f85..6e694b49578 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -178,7 +178,6 @@ import { blockNativeMainStartupForUnownedServiceHome, prepareNativeMainStartupLifecycle, releaseNativeMainStartupLifecycle, - startNativeMainStartupLifecycle, type NativeMainStartupGateDeps, type NativeMainStartupLifecycle, } from "../codex/native-profile-startup"; @@ -462,18 +461,15 @@ function inspectStartupOwnership( statePaths: readonly string[] | null, ): OwnershipInspection { try { - if (deps.inspectNativeCodexOwnership) { - return deps.inspectNativeCodexOwnership({ - ...(currentHomes ? { currentHomes } : {}), - ...(statePaths ? { statePaths } : {}), - }); - } if (currentHomes === null || statePaths === null) { return { ownership: "unknown", reason: "startup service-home resolution failed", }; } + if (deps.inspectNativeCodexOwnership) { + return deps.inspectNativeCodexOwnership({ currentHomes, statePaths }); + } return inspectNativeCodexOwnership({ currentHomes, statePaths }); } catch { return { @@ -737,7 +733,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { } }); + test("startServer rejects initially owned activation after the inspected homes drift", async () => { + const inspected = await fixture("prepared", "source-exact", false, "direct"); + process.env.CODEX_HOME = inspected.codexHome; + process.env.OPENCODEX_HOME = inspected.configDir; + let inspections = 0; + let started: ReturnType | undefined; + try { + expect(() => { + started = startServer(0, { + inspectNativeCodexOwnership: scope => { + inspections += 1; + expect(scope.currentHomes).toEqual({ + codexHome: inspected.codexHome, + opencodexHome: inspected.configDir, + }); + expect(scope.statePaths?.[0]).toBe(join(inspected.configDir, "service-state.json")); + return { ownership: "owned", reason: "initially owned pinned scope test" }; + }, + nativeMainStartup: { + manager: inspected.manager, + currentHomeId: () => null, + owner: { retryMs: 10, hardenPath: async () => {} }, + }, + }); + }).toThrow("The native-main startup home changed after ownership inspection."); + } finally { + await started?.stop(true); + } + expect(inspections).toBe(2); + }); + test("startServer keeps a retry when service homes are initially unavailable", async () => { const f = await fixture("prepared", "source-exact", false, "direct"); process.env.CODEX_HOME = f.codexHome; @@ -841,6 +872,7 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { }); try { expect(homeResolutions).toBe(1); + expect(scopes).toHaveLength(0); expect(nativeMainStartupGateSnapshot()).toEqual({ status: "blocked", homeId: null, From f852d653975c604c7951bfbe1e8c038cd0dd2519 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:26:34 +0900 Subject: [PATCH 5/6] fix(native): keep startup cache invalidation on pinned home --- src/codex/catalog/parsing.ts | 15 +++++++++++ src/codex/catalog/sync.ts | 9 ++++--- src/server/index.ts | 12 ++++----- tests/codex-models-cache-invalidate.test.ts | 29 +++++++++++++++++++++ 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 74353197f7d..a47b2c6894a 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -194,6 +194,8 @@ export function shouldExposeRoutedModel(model: CatalogModel): boolean { } export function readCodexCatalogPath(): string { + const home = activeCodexHome(); + if (home) return readCodexCatalogPathForHome(home); try { const configPath = activeCodexConfigPath(); if (existsSync(configPath)) { @@ -205,6 +207,19 @@ export function readCodexCatalogPath(): string { return activeDefaultCatalogPath(); } +/** Resolve the configured catalog without consulting ambient CODEX_HOME again. */ +export function readCodexCatalogPathForHome(codexHome: string): string { + try { + const configPath = join(codexHome, "config.toml"); + if (existsSync(configPath)) { + const toml = readFileSync(configPath, "utf-8"); + const path = readRootTomlString(toml, "model_catalog_json"); + if (path) return resolve(codexHome, path); + } + } catch { /* ignore */ } + return join(codexHome, "opencodex-catalog.json"); +} + export function parseCatalogJson(raw: string): RawCatalog | null { try { const cat = JSON.parse(raw); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 70b93ee7bdb..258474447d1 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -41,7 +41,7 @@ import { } from "../model-entitlements"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata"; import { @@ -1832,11 +1832,12 @@ export function invalidateCodexModelsCacheWithPermit( // The catalog-only sync override applies here too so an explicit refresh // keeps the cache consistent with the catalog it just wrote. if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; - const catalogPath = readCodexCatalogPath(); + const catalogPath = readCodexCatalogPathForHome(owningCodexHome); + const cachePath = join(owningCodexHome, "models_cache.json"); if (!existsSync(catalogPath)) return false; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; - const currentCache = readCatalog(activeCodexModelsCachePath()); + const currentCache = readCatalog(cachePath); const existingSlugs = new Set(models.flatMap((entry: RawEntry) => typeof entry.slug === "string" ? [entry.slug] : [])); const currentConfig = loadConfig(); @@ -1864,7 +1865,7 @@ export function invalidateCodexModelsCacheWithPermit( models: [...models, ...observedAccountModels], }; replaceCodexModelsCache(permit, owningCodexHome, { - path: activeCodexModelsCachePath(), + path: cachePath, content: `${JSON.stringify(wrapper, null, 2)}\n`, }); return true; diff --git a/src/server/index.ts b/src/server/index.ts index 6e694b49578..86c334e1070 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,7 +22,6 @@ import { import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; -import { getCodexHome } from "../codex/paths"; import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service"; import { shouldSyncCodexOnStart } from "../codex/desired-state"; import { @@ -569,13 +568,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("permit-bound invalidation stays on its owning home after ambient drift", () => { + const ambientCodexHome = mkdtempSync(join(tmpdir(), "ocx-invalidate-ambient-")); + try { + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "pinned-catalog.json"\n'); + writeFileSync(join(codexHome, "pinned-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-pinned-home" }], + }, null, 2) + "\n"); + writeFileSync(join(ambientCodexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-ambient-home" }], + }, null, 2) + "\n"); + process.env.CODEX_HOME = ambientCodexHome; + + const outcome = withCatalogWriteSerialization(codexHome, permit => + invalidateCodexModelsCacheWithPermit(permit, codexHome)); + + expect(outcome).toMatchObject({ kind: "completed", value: true }); + expect(existsSync(join(ambientCodexHome, "models_cache.json"))).toBe(false); + const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { + models: Array<{ slug: string }>; + }; + expect(cache.models).toEqual([{ slug: "gpt-pinned-home" }]); + } finally { + process.env.CODEX_HOME = codexHome; + rmSync(ambientCodexHome, { recursive: true, force: true }); + } + }); + test("preserves an observed unknown native as a hidden sync observation", () => { writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ models: [{ slug: "gpt-5.5" }], From 548912b3b936f0e101348dbedecc105be405f91b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:50:41 +0900 Subject: [PATCH 6/6] fix(native): atomically pin startup ownership scope --- src/server/index.ts | 14 +++++++++----- tests/native-profile-startup.test.ts | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 86c334e1070..f59b4d0ae37 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -558,10 +558,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; - try { startupOwnershipHomes = resolveServiceHomes(); } catch { /* inspection below stays unknown */ } - const startupOwnershipStatePaths = startupOwnershipHomes - ? serviceStatePathsForOpenCodexHome(startupOwnershipHomes.opencodexHome) - : null; + let startupOwnershipStatePaths: readonly string[] | null = null; + try { + const homes = resolveServiceHomes(); + const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome); + startupOwnershipHomes = homes; + startupOwnershipStatePaths = statePaths; + } catch { /* inspection below stays unknown */ } const startupCacheOwnership = inspectStartupOwnership( deps, startupOwnershipHomes, @@ -748,8 +751,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { process.env.CODEX_HOME = f.codexHome; process.env.OPENCODEX_HOME = f.configDir; let homesReady = false; + let statePathsReady = false; let homeResolutions = 0; let answer: NativeCodexOwnership = "unknown"; let finishRecovery!: () => void; @@ -855,7 +856,10 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { resolveServiceHomes: () => { homeResolutions += 1; if (!homesReady) throw new Error("service homes are not mounted yet"); - return { codexHome: f.codexHome, opencodexHome: f.configDir }; + return { + codexHome: f.codexHome, + opencodexHome: statePathsReady ? f.configDir : (null as unknown as string), + }; }, inspectNativeCodexOwnership: (scope = {}) => { scopes.push({ @@ -883,6 +887,16 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { answer = "owned"; expect(tryAcquireNativeMainProfileClaim()).toBeNull(); expect(homeResolutions).toBe(2); + expect(scopes).toHaveLength(0); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: null, + reason: "ownership-unknown", + }); + + statePathsReady = true; + expect(tryAcquireNativeMainProfileClaim()).toBeNull(); + expect(homeResolutions).toBe(3); expect(nativeMainStartupGateSnapshot()).toEqual({ status: "blocked", homeId: f.manager.context.homeId,