import { beginCodexWriteSection, publishCodexArtifact } from "./inject/config-write-section"; import { createHash } from "node:crypto"; import type { AtomicWriteHooks } from "../config/atomic-write"; import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, withConfigMutationLockSync } from "../config"; import { assertCodexHomeOwner, codexHomeIsAbsent, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner"; import { hasInjectedCodexRouting } from "./injected-marker"; import { configWriteLockFailureMessage, withConfigWriteLockHeld } from "./config-write-lock"; import type { LockHandle } from "./config-write-lock"; import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths"; import { transformManagedSubagentDefaults, type WrittenSubagentDefaults } from "./subagent-defaults"; /** * Exported so that anything reasoning ABOUT the journal points at the journal. * * The Codex admission snapshot re-derived this path by hand and got it wrong in * both halves — wrong directory and wrong filename — so its "journal identity" * watched a file nothing writes. The fixture re-derived it the same wrong way, * agreed with the producer, and the pair stayed green. One exported constant * removes the opportunity. */ export const JOURNAL_PATH = join(CODEX_HOME, CODEX_HOME_JOURNAL_FILE); export type JournalOwner = | { kind: "process"; pid: number } | { kind: "client"; apiKeyId: string }; interface Journal { version: 1; originalConfig: string; originalProfile: string | null; injectedConfigHash?: string; injectedProfileHash?: string | null; /** * The exact root `openai_base_url` this injection wrote, when it wrote one. * * #1798: ownership used to be inferred from a marker COMMENT on the preceding line, * which a reserializing Codex app deletes while keeping the value. Recording the value * we actually wrote makes ownership provable from evidence rather than from formatting, * and it is what lets restore tell OUR loopback URL apart from a gateway the user set. */ injectedOpenaiBaseUrl?: string | null; /** * The root `experimental_realtime_ws_base_url` this injection wrote, when it wrote one. * Recorded on its own rather than inferred from `injectedOpenaiBaseUrl`: a user can own a * realtime override whose value happens to equal the proxy URL, and restore must not treat * that as ours. Null when the key was preserved or not injected. */ injectedRealtimeWsBaseUrl?: string | null; /** * The root `web_search` value this injection wrote, when it wrote one. * * Same reasoning as {@link injectedOpenaiBaseUrl}, for the one other root key whose value is a * mode Codex reads: the marker comment above `web_search = "disabled"` does not survive a Codex * app reserialize, and without value evidence the next injection cannot tell our line from the * operator's. The failure that buys is quiet: a re-enabled sidecar whose client still has the * native tool switched off has nothing to intercept, which is the empty state the switch exists * to avoid. */ injectedRootWebSearch?: string | null; /** * The user-owned root `web_search` line this injection REMOVED while the sidecar was off. * * Two root keys of the same name are invalid TOML, so the operator's mode has to leave the file * for as long as the switch is off. Recording the exact line is what puts it back when the * sidecar is switched on again — including for a line the snapshot predates, which is the case * `ocx restore` alone cannot cover. */ replacedRootWebSearch?: string | null; /** * The catalog path this injection actually wrote to. * * #1798: restore re-resolves the catalog from the CURRENT config, so a Codex app rewrite * that dropped `model_catalog_json` sends restore to the default catalog while the * proxy-written one is left routed. The injected path is the only durable record of which * file we actually touched. */ injectedCatalogPath?: string | null; /** * The marker-owned `[agents]` default values this injection wrote, keyed by TOML key. * * #7425: the marker comment proves OpenCodex wrote a line once, not that the value is still * ours. A user (or a comment-preserving Codex settings writer) can change the value and keep * the comment, and the next sync used to revert it. The recorded value is what tells an * untouched managed line apart from an edited one. Null when no managed default was written. */ injectedSubagentDefaults?: WrittenSubagentDefaults | null; /** Injection binding survives native snapshot replacement and is released by native restore. */ opencodexHome?: string; pid: number; owner?: JournalOwner; timestamp: string; } export interface RestoreJournalResult { /** The shared config write lock was held by another opencodex writer; restore did not run. */ lockBusy?: true; lockUnsafe?: string; /** An unchanged generated profile could not be restored; distinct from a preserved user edit. */ profileRestoreFailed?: true; ownershipRefusal?: CodexHomeOwnerRefusalReason; configRestored: boolean; profileRestored: boolean; /** True only when restore rewrote or removed a file (not when state was already original). */ configRewritten: boolean; profileRewritten: boolean; configChanged: boolean; profileChanged: boolean; complete: boolean; /** A changed artifact has no recorded injected hash, so snapshot ownership is unknown. */ unverified: boolean; } function sha256(content: string | null): string | null { return content === null ? null : createHash("sha256").update(content).digest("hex"); } function defaultOwnershipAllowsSnapshot(journal: Journal, config: string | null): boolean { const written = parseInjectedSubagentDefaults(journal.injectedSubagentDefaults); if (!written || config === null) return true; // A first-injection hash may match after a later managed write. If the newer ownership // evidence preserves a value that bare markers would remove, use field-level restore. // An ambiguous transform likewise cannot authorize replaying the whole snapshot. const byRecord = transformManagedSubagentDefaults(config, null, written); const byMarker = transformManagedSubagentDefaults(config, null); return byRecord.ok && byMarker.ok && byRecord.content === byMarker.content; } function compareJournalState(journal: Journal, config: string | null, profile: string | null) { const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8"); const originalProfile = journal.originalProfile === null ? null : Buffer.from(journal.originalProfile, "base64").toString("utf-8"); const configAlreadyOriginal = config === originalConfig; const profileAlreadyOriginal = profile === originalProfile; const configHashKnown = typeof journal.injectedConfigHash === "string" && journal.injectedConfigHash.length > 0; const profileHashKnown = journal.injectedProfileHash !== undefined; return { originalConfig, originalProfile, configAlreadyOriginal, profileAlreadyOriginal, configUnchanged: configAlreadyOriginal || ( configHashKnown && sha256(config) === journal.injectedConfigHash && defaultOwnershipAllowsSnapshot(journal, config) ), profileUnchanged: profileAlreadyOriginal || (profileHashKnown && sha256(profile) === (journal.injectedProfileHash ?? null)), unverified: (!configHashKnown && !configAlreadyOriginal) || (!profileHashKnown && !profileAlreadyOriginal), }; } /** Read-only check of the pre-injection snapshot input, never the newly injected bytes. */ export function hasUnverifiedJournalBaseline(config: string | null, profile: string | null): boolean { const journal = readJournal(false); return journal !== null && compareJournalState(journal, config, profile).unverified; } export interface WriteJournalOptions { /** * The caller's verdict on the config it is about to transform: false when * `hasInjectedCodexRouting` matched. This does NOT decide whether the content * may be journaled — that is checked below, from the bytes themselves. It only * authorizes REPLACING an existing snapshot, which is why omitting it still * allows a first snapshot but never an overwrite. */ currentStateIsNative?: boolean; /** * The exact bytes the caller classified. Journaling these rather than re-reading * the file keeps the snapshot and the verdict describing the same content when * another process rewrites config.toml mid-flight. */ configContent?: string; owner?: { kind: "process" } | { kind: "client"; apiKeyId: string }; } /** * Snapshot the pre-injection Codex state. * * Only native (non-opencodex-owned) config may be journaled, and native config * always supersedes an older snapshot. The first half stops a re-inject from * recording opencodex's own routing as the user's original — which would survive * `ocx stop` and make the injection unremovable. The second half is the #477 fix: * without it the first snapshot a machine ever takes is the only one it ever has, * so an unclean shutdown days later replays a day-one config over the user's * plugins, model choice, and trusted projects. */ export function writeJournal(options: WriteJournalOptions = {}, hooks: AtomicWriteHooks = {}): void { assertCodexHomeOwner(CODEX_HOME); if (!existsSync(CODEX_CONFIG_PATH)) return; const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8"); // Ownership is decided HERE, from the bytes about to be journaled — never taken // on the caller's word. A caller that says "native" about injected content would // otherwise make opencodex's own routing the user's permanent "original". if (hasInjectedCodexRouting(config)) return; // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than // the check above (it may describe bytes read a moment earlier), so an // unclassified call creates a first snapshot but never overwrites one. const inspection = readCodexHomeJournal(JOURNAL_PATH); if (inspection.kind === "unknown") { throw new CodexHomeOwnerRefusal("owner-unknown"); } const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; if (previous && options.currentStateIsNative !== true) return; const profile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; const journal: Journal = { version: 1, originalConfig: Buffer.from(config).toString("base64"), originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null, opencodexHome: opencodexHomeForInjection(previous?.opencodexHome), pid: process.pid, owner: options.owner?.kind === "client" ? { kind: "client", apiKeyId: options.owner.apiKeyId } : { kind: "process", pid: process.pid }, timestamp: new Date().toISOString(), }; atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks); } export interface InjectedJournalOwnership { injectedOpenaiBaseUrl: string | null; injectedRealtimeWsBaseUrl: string | null; injectedCatalogPath: string | null; /** Omitted by callers that inject no `web_search` line, which is the value it then records. */ injectedRootWebSearch?: string | null; /** Omitted by callers that removed no user `web_search` line. */ replacedRootWebSearch?: string | null; /** Omitted by callers that wrote no managed `[agents]` default, which is the value it then records. */ injectedSubagentDefaults?: WrittenSubagentDefaults | null; } export function markJournalInjectedState( config: string, profile: string | null, ownership: InjectedJournalOwnership, hooks: AtomicWriteHooks = {}, ): void { assertCodexHomeOwner(CODEX_HOME); const inspection = readCodexHomeJournal(JOURNAL_PATH); if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null; if (!journal) return; // The first exact injected config is the only safe whole-snapshot restore boundary for // the first native snapshot. A later reinjection may preserve user edits made while routed; // hashing those newer bytes and restoring the first snapshot would delete those edits. // Keep the first hash so changed/reinjected configs take the owned-field fallback path. journal.injectedConfigHash ??= sha256(config) ?? undefined; // The profile file is wholly generated by OpenCodex, so its latest exact hash remains safe // to refresh and lets restore remove the latest generated profile after a port change. journal.injectedProfileHash = sha256(profile); // Only the caller knows which values it actually owns. Deriving these from the final TOML // would mistake a preserved user override for injected routing. journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl; journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl; journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null; journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null; journal.injectedSubagentDefaults = ownership.injectedSubagentDefaults ?? null; journal.injectedCatalogPath = ownership.injectedCatalogPath; journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome); atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal), undefined, hooks); } /** * The root `openai_base_url` the last injection wrote, or null when it wrote none. * * #1798: the fallback strip recognizes an injected URL by the marker COMMENT above it, * and a Codex app rewrite keeps values while dropping comments. This is the evidence that * survives such a rewrite, so restore can still prove the URL is ours -- and, just as * importantly, prove that a DIFFERENT URL is not. */ export function journaledInjectedOpenaiBaseUrl(options: { readOnly?: boolean } = {}): string | null { return readJournal(options.readOnly !== true)?.injectedOpenaiBaseUrl ?? null; } /** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */ export function journaledInjectedRealtimeWsBaseUrl(options: { readOnly?: boolean } = {}): string | null { return readJournal(options.readOnly !== true)?.injectedRealtimeWsBaseUrl ?? null; } /** The root `web_search` value the last injection wrote, or null when it wrote none. */ export function journaledInjectedRootWebSearch(options: { readOnly?: boolean } = {}): string | null { return readJournal(options.readOnly !== true)?.injectedRootWebSearch ?? null; } /** The user-owned root `web_search` line the last injection removed, or null when there was none. */ export function journaledReplacedRootWebSearch(options: { readOnly?: boolean } = {}): string | null { return readJournal(options.readOnly !== true)?.replacedRootWebSearch ?? null; } /** The catalog path the last injection wrote to, or null when none was recorded. */ export function journaledInjectedCatalogPath(): string | null { return readJournal()?.injectedCatalogPath ?? null; } /** * The managed `[agents]` default values the last injection wrote, or null when there is no * record. The journal is read from disk, so only string values for the two known keys count. */ export function journaledInjectedSubagentDefaults(options: { readOnly?: boolean } = {}): WrittenSubagentDefaults | null { return parseInjectedSubagentDefaults(readJournal(options.readOnly !== true)?.injectedSubagentDefaults); } function parseInjectedSubagentDefaults(recorded: unknown): WrittenSubagentDefaults | null { if (typeof recorded !== "object" || recorded === null) return null; const written: WrittenSubagentDefaults = {}; for (const key of ["default_subagent_model", "default_subagent_reasoning_effort"] as const) { const value = (recorded as Record)[key]; if (typeof value === "string") written[key] = value; } return Object.keys(written).length > 0 ? written : null; } export function removeJournal(hooks: AtomicWriteHooks = {}): void { assertCodexHomeOwner(CODEX_HOME); hooks.validateBeforeRename?.(JOURNAL_PATH); try { unlinkSync(JOURNAL_PATH); } catch { return; } hooks.afterRename?.(JOURNAL_PATH); } /** Successful field-level native restore releases ownership while retaining recovery evidence. */ export function releaseJournalHomeBinding(hooks: AtomicWriteHooks = {}): void { assertCodexHomeOwner(CODEX_HOME); const inspection = readCodexHomeJournal(JOURNAL_PATH); if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return; delete inspection.journal.opencodexHome; atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal), undefined, hooks); } // Kept compatible with existing read-only callers; invalid evidence is never cleanup authority. function readJournal(_cleanInvalid = true): Journal | null { const inspected = readCodexHomeJournal(JOURNAL_PATH); return inspected.kind === "read" ? inspected.journal as unknown as Journal : null; } /** * Who owns the journal. `readOnly` never deletes an unreadable journal: a background reader (the * routing healer, `routing-healer.ts`) must not destroy recovery evidence it only looked at. */ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner | null { const journal = readJournal(options.readOnly !== true); if (!journal) return null; if (journal.owner?.kind === "client" && typeof journal.owner.apiKeyId === "string" && journal.owner.apiKeyId) { return { kind: "client", apiKeyId: journal.owner.apiKeyId }; } if (journal.owner?.kind === "process" && Number.isSafeInteger(journal.owner.pid) && journal.owner.pid > 0) { return { kind: "process", pid: journal.owner.pid }; } return Number.isSafeInteger(journal.pid) && journal.pid > 0 ? { kind: "process", pid: journal.pid } : null; } export function restoreJournalState( options: { heldConfigWriteLock?: LockHandle } = {}, ): RestoreJournalResult { if (codexHomeIsAbsent(CODEX_HOME)) return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, configChanged: false, profileChanged: false, complete: false, unverified: false, }; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, options.heldConfigWriteLock, held => { try { assertCodexHomeOwner(CODEX_HOME); } catch (error) { if (!(error instanceof CodexHomeOwnerRefusal)) throw error; return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, configChanged: false, profileChanged: false, complete: false, unverified: true, ownershipRefusal: error.reason }; } beginCodexWriteSection(held); const journal = readJournal(); if (!journal) { return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; } const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null; const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; const comparison = compareJournalState(journal, currentConfig, currentProfile); const { configUnchanged, profileUnchanged } = comparison; // A legacy record or interruption before markJournalInjectedState is not proof that // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact. if (comparison.unverified) { return { configRestored: comparison.configAlreadyOriginal, profileRestored: comparison.profileAlreadyOriginal, configRewritten: false, profileRewritten: false, configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete: false, unverified: true, }; } // A matching byte hash does not authorize following a newly substituted profile link. // Refuse the complete pair before restoring config or deleting its recovery journal. let profileIsSymlink = false; if (profileUnchanged && (!comparison.profileAlreadyOriginal || comparison.originalProfile === null)) { try { profileIsSymlink = lstatSync(CODEX_PROFILE_PATH).isSymbolicLink(); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } if (profileIsSymlink) { return { configRestored: comparison.configAlreadyOriginal, profileRestored: false, configRewritten: false, profileRewritten: false, configChanged: !configUnchanged, profileChanged: false, complete: false, unverified: false, profileRestoreFailed: true as const, }; } assertCodexHomeOwner(CODEX_HOME); let configRestored = comparison.configAlreadyOriginal; let profileRestored = comparison.profileAlreadyOriginal; let configRewritten = false; let profileRewritten = false; if (configUnchanged && !configRestored) { publishCodexArtifact(CODEX_CONFIG_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalConfig, undefined, hooks)); configRestored = true; configRewritten = true; } if (profileUnchanged && !profileRestored) { if (comparison.originalProfile !== null) { publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => atomicWriteFile(path, comparison.originalProfile!, undefined, hooks)); profileRestored = true; profileRewritten = true; } else if (existsSync(CODEX_PROFILE_PATH)) { // "There was no profile before, so remove the one we generated." Claiming success // without checking is how a caller ends up deleting the journal, reporting a clean // restore, and leaving our profile on disk with nothing left that records it should // not be there. ENOENT is the one benign outcome: the file is already gone, which is // the state we wanted. try { publishCodexArtifact(CODEX_PROFILE_PATH, held, (path, hooks) => { hooks.validateBeforeRename?.(path); unlinkSync(path); hooks.afterRename?.(path); }); profileRestored = true; profileRewritten = true; } catch (error) { profileRestored = (error as NodeJS.ErrnoException).code === "ENOENT"; } } else { profileRestored = true; } } let complete = configRestored && profileRestored; if (complete) { try { publishCodexArtifact(JOURNAL_PATH, held, (_path, hooks) => removeJournal(hooks)); } catch { complete = false; } } return { configRestored, profileRestored, configRewritten, profileRewritten, configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete, unverified: false, ...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}), }; }); if (!locked.ok) { // Busy is not a verdict on the journal — report it as its own fact so a // caller cannot mistake it for a failed or unneeded restore. Nothing was // read yet, so the changed-file fields cannot be computed. return { configRestored: false, profileRestored: false, configRewritten: false, profileRewritten: false, configChanged: false, profileChanged: false, complete: false, unverified: false, ...(locked.error === "unsafe" ? { lockUnsafe: configWriteLockFailureMessage(locked) } : { lockBusy: true as const }), }; } return locked.value; } export function restoreJournal(): boolean { return restoreJournalState().complete; } /** A no-rewrite recovery is quiet only when the stale journal is actually gone. */ function warnIfJournalRetained(): void { if (existsSync(JOURNAL_PATH)) console.error("⚠️ Codex journal recovery found nothing to restore, but the journal could not be removed; it will be retried on the next start."); } export interface ReconcileJournalOptions { activeClientApiKeyId?: string; /** * #7423: an update stopped the previous proxy and handed its Codex routing to this one. * A process-owned journal whose owner is gone is then adopted rather than replayed, so * startup does not restore native config in the seconds before its own sync re-injects. */ adoptUpdateHandover?: boolean; } /** * Called by `connectClient` right after its connection commits, when its injection ran: the * client takes over a journal another owner wrote (a standalone proxy's injection it re-injected * over), keeping the original snapshot. Before the commit the journal stays its writer's, so a * runtime starting mid-connect sees a live process owner and leaves it alone. Without the takeover * the first start after that process exited (a supervisor relaunch after `ocx connect --link` and * `ocx restart`) read it as a crashed session and restored native Codex under the new link. * Only a process-written journal is taken: another client's journal belongs to a later connection * (a stale sync must never claim it), and `beforeWrite` re-runs the caller's connection guard under * the Codex config lock and the config mutation lock together with the write, so a connection that * changed since its injection writes nothing and none can change before the write lands. * Outcomes: `adopted`, `owned` (already this client's), `missing` (no journal), `foreign` (another * client's journal, left alone; see {@link journalHandoverIncomplete}). */ export type JournalAdoption = "adopted" | "owned" | "missing" | "foreign"; /** * Whether routing is still at risk after a handover. No journal (external providers leave none) * gives startup recovery nothing to restore; only another client's journal is restored over this * connection on the next start. */ export function journalHandoverIncomplete(adoption: JournalAdoption): boolean { return adoption === "foreign"; } export function adoptJournalForClient(apiKeyId: string, beforeWrite: () => void = () => {}): JournalAdoption { if (codexHomeIsAbsent(CODEX_HOME)) return "missing"; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, (handle): JournalAdoption => { assertCodexHomeOwner(CODEX_HOME); beginCodexWriteSection(handle); // Only a confirmed absence is safe; an unreadable journal may still hold a restorable owner. const inspection = readCodexHomeJournal(JOURNAL_PATH); if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown"); if (inspection.kind === "missing") return "missing"; const journal = inspection.journal as unknown as Journal; if (journal.owner?.kind === "client") return journal.owner.apiKeyId === apiKeyId ? "owned" : "foreign"; // Codex lock, then the mutation lock (the injection's order): guard and write as one step. return withConfigMutationLockSync(() => { beforeWrite(); const adopted = JSON.stringify({ ...journal, owner: { kind: "client", apiKeyId } }); publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); return "adopted" as const; }); }); if (!locked.ok) throw new Error(locked.error === "unsafe" ? configWriteLockFailureMessage(locked) : "the Codex config lock is busy"); return locked.value; } export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean { if (codexHomeIsAbsent(CODEX_HOME)) return false; const locked = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, handle => { try { assertCodexHomeOwner(CODEX_HOME); } catch (error) { if (!(error instanceof CodexHomeOwnerRefusal)) throw error; console.error(error.message); return false; } beginCodexWriteSection(handle); const journal = readJournal(); if (!journal) return false; const owner = journal.owner ?? (Number.isSafeInteger(journal.pid) && journal.pid > 0 ? { kind: "process" as const, pid: journal.pid } : null); if (owner?.kind === "client") { if (options.activeClientApiKeyId === owner.apiKeyId) return false; const restored = restoreJournalState({ heldConfigWriteLock: handle }); if (restored.unverified) { console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); return false; } if (!restored.complete) { console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files."); return false; } // Warn only when restore actually rewrote state; an already-original // snapshot still cleans itself up but has nothing to report. if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; } console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); return true; } const pid = owner?.kind === "process" ? owner.pid : journal.pid; try { process.kill(pid, 0); return false; } catch (e: unknown) { if ((e as NodeJS.ErrnoException).code === "EPERM") { return false; } } if (options.adoptUpdateHandover) { // The original baseline and injected-state hashes stay as written; only ownership moves. // Published like every other journal write, so a replaced Codex home is refused rather // than overwritten; a refusal preserves both the journal and the config. const adopted = JSON.stringify({ ...journal, pid: process.pid, owner: { kind: "process", pid: process.pid } }); try { publishCodexArtifact(JOURNAL_PATH, handle, (path, hooks) => atomicWriteFile(path, adopted, undefined, hooks)); } catch { console.error("⚠️ The Codex journal handed over by an update could not be adopted; the journal and configuration were preserved."); } return false; } const restored = restoreJournalState({ heldConfigWriteLock: handle }); if (restored.unverified) { console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); return false; } if (!restored.complete) { console.error("⚠️ Codex journal recovery was incomplete; the journal was preserved — check the Codex files."); return false; } // Warn only when restore actually rewrote state. A stale journal whose // artifacts are already original still cleans itself up (complete removes // it) but has nothing to report — claiming a restore then cries wolf. if (!restored.configRewritten && !restored.profileRewritten) { warnIfJournalRetained(); return false; } console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; }); if (!locked.ok && locked.error === "unsafe") console.error(configWriteLockFailureMessage(locked)); return locked.ok ? locked.value : false; }