From 90e21ce6a55596c4babaf7483753824e5537b8f1 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:04:41 +0900 Subject: [PATCH 1/2] fix(codex): preserve settings when journal injection hashes are missing --- .../docs/fr/guides/codex-integration.md | 7 +- .../content/docs/guides/codex-integration.md | 24 +- .../docs/ja/guides/codex-integration.md | 4 +- .../docs/ko/guides/codex-integration.md | 4 +- .../docs/ko/reference/cli/lifecycle.md | 4 + .../content/docs/reference/cli/lifecycle.md | 4 + .../docs/ru/guides/codex-integration.md | 7 +- .../docs/tr/guides/codex-integration.md | 7 +- .../docs/zh-cn/guides/codex-integration.md | 6 +- .../docs/zh-tw/guides/codex-integration.md | 6 +- src/codex/inject.ts | 40 ++- src/codex/journal.ts | 73 +++++- structure/02_config-and-codex-home.md | 10 + tests/codex-integration/codex-journal.test.ts | 240 +++++++++++++++++- 14 files changed, 384 insertions(+), 52 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index a351adeae5..eeaf4f8af8 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -385,10 +385,9 @@ d'actualisation `chatgpt` vaut `proactive` et si `tokenGuardian.codexWarmupEnabl ## Restauration de Codex natif -opencodex ne vous enferme jamais dans sa configuration. **`ocx stop` est l'unique commande qui restaure -entièrement Codex natif** : elle arrête le proxy et le service d'arrière-plan s'il est installé, puis supprime -toutes les lignes injectées et toutes les entrées routées du catalogue. La commande `codex` fonctionne alors -exactement comme si opencodex n'avait jamais été installé : +`ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. + +Si la configuration ou le profil actuel diffère de l'original sauvegardé et que le journal ne contient pas le hash de l'état injecté de ce fichier, la récupération automatique conserve les deux fichiers et le journal sans les modifier. Un fichier déjà identique à son original n'est pas réécrit. La réinjection d'une configuration routée refuse aussi cet état incertain ; une configuration native peut créer un nouvel instantané. Voir les [règles de récupération](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2944208279..5b75a21b41 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -605,9 +605,9 @@ off by default; it runs only when Token Guardian is enabled, the `chatgpt` refre ## Restoring native Codex -opencodex never traps you. **`ocx stop` is the single command that fully reverts to native Codex** — it -stops the proxy, stops the background service if one is installed, and strips every injected line and -routed catalog entry so plain `codex` works exactly as if opencodex was never there: +`ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. + +Recovery may require manual review when the journal cannot verify the current files; see [recovery without injection hashes](#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex @@ -619,6 +619,24 @@ When opencodex runs as a managed [background service](/reference/cli/#ocx-servic `OCX_SERVICE=1` so a service-driven restart does **not** thrash the Codex config — only an explicit `ocx stop` / `ocx service stop` restores native Codex. +### Recovery without injection hashes + +The journal saves the original `config.toml` and `opencodex.config.toml` plus hashes of the state +OpenCodex injected. A legacy journal or an interruption before those hashes were recorded cannot +prove that later file contents belong to OpenCodex. If either file differs from its saved original +and lacks its own injected-state hash, automatic journal recovery and native restore report failure +without changing either file or the journal. The saved original remains available for comparison; +review it alongside the current files before choosing a manual recovery action. + +Files already equal to their saved originals are accepted without rewriting them. A missing file +is distinct from an empty file. Verified injected hashes still allow normal snapshot restoration, +and later edits in hash-backed configurations retain the existing owned-field cleanup behavior. + +Sync and `ocx restore back` also reject an existing routed configuration whose hashless journal +does not match the pre-injection baseline. This prevents a new injection hash from being attached +to an older original. A genuinely native configuration can be saved as a fresh baseline before +injection. Explicit external-provider opt-out behavior is unchanged. + ### Sub-agent fallback and V2 compatibility diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 46c33320f2..df320c660f 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -245,7 +245,9 @@ ChatGPT アカウントが Codex アカウント プールに追加されると ## ネイティブ Codexの復元 -opencodex は決してあなたを罠にはめることはありません。 **`ocx stop` は、ネイティブ Codex に完全に戻す単一のコマンドです**。プロキシを停止し、バックグラウンド サービスがインストールされている場合はそれを停止し、挿入されたすべての行とルーティングされたカタログ エントリを削除するため、プレーンな `codex` は、opencodex が存在しなかったかのように正確に動作します。 +`ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 + +現在の設定またはプロファイルが保存された元の内容と異なり、そのファイルの注入後の状態のハッシュがジャーナルにない場合、自動復元は両方のファイルとジャーナルを変更せずに残します。元の内容と同じファイルは再書き込みしません。ルーティング済み設定への再注入も、この未確認の状態では拒否されます。ネイティブ設定では新しいスナップショットを作成できます。[復元規則](/guides/codex-integration/#recovery-without-injection-hashes)を参照してください。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 41f90537cd..ea7ff40cde 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -250,7 +250,9 @@ ChatGPT 계정을 Codex account pool에 추가하면, opencodex는 이를 저장 ## 네이티브 Codex 복원 -opencodex는 절대 사용자를 가두지 않습니다. **`ocx stop`은 네이티브 Codex로 완전히 되돌리는 단일 명령입니다**. proxy를 중지하고, 설치된 background service가 있으면 그것도 중지한 뒤, 주입된 모든 라인과 라우팅된 catalog 항목을 제거해서 plain `codex`가 opencodex가 처음부터 없었던 것처럼 정확히 동작하게 합니다: +`ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. + +현재 config 또는 profile이 저장된 원본과 다르고 해당 파일의 주입 상태 해시가 저널에 없으면, 자동 snapshot 복원은 두 파일과 저널을 변경하지 않고 검토용으로 남깁니다. 이미 원본과 같은 파일은 다시 쓰지 않습니다. 기존 라우팅 설정의 재주입도 이 불확실한 원본을 사용하지 않으며, 네이티브 설정에서는 새 snapshot을 만들 수 있습니다. [자세한 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index d4c19ebd46..211fb12706 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -54,6 +54,10 @@ stop/start 대체 동작 없이 안전하게 실패합니다. 소유권을 확 프록시를 중지하지 않고 기본 Codex를 **복원**합니다. 주입된 설정 줄과 라우팅된 카탈로그 항목을 제거하므로 일반 `codex`가 다시 네이티브로 동작합니다. `eject`는 `restore`의 별칭입니다. +저장된 저널에 해당 파일의 주입 상태 해시가 없으면, 변경된 설정 파일을 덮어쓰는 대신 복원 실패를 +보고합니다. 현재 파일과 저널은 검토용으로 보존됩니다. +[해시 없는 저널의 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. + 둘 중 어느 표기든 `back`을 붙이면 이미 실행 중인 프록시를 가리키도록 일반 `codex`를 다시 연결하되, 프록시 수명 주기는 바꾸지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index a7d61a0bee..dd13df2ae6 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -59,6 +59,10 @@ Idempotently ensure a background proxy is running, then sync its live model cata Restore native Codex **without** stopping the proxy — strips the injected config lines and routed catalog entries so plain `codex` works natively again. `eject` is an alias of `restore`. +Restoration reports failure instead of replacing changed configuration files when a saved journal +lacks the corresponding injection hashes. The current files and journal remain available for +review; see [recovery without injection hashes](/guides/codex-integration/#recovery-without-injection-hashes). + Pass `back` to either spelling to re-point plain `codex` at an already-running proxy without changing the proxy lifecycle: diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 23581e56f6..01e4d8003d 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -379,10 +379,9 @@ Responses item'ов (`input: [{ type: "message", ... }]`), ждёт `response.co ## Восстановление нативного Codex -opencodex не запирает вас внутри себя. **`ocx stop` — это единственная команда, которая полностью -возвращает нативный Codex**: она останавливает прокси, останавливает фоновую службу, если она -установлена, и убирает все внедрённые строки и маршрутизируемые записи каталога, так что обычный -`codex` снова работает так, будто opencodex никогда не существовал: +`ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. + +Если текущая конфигурация или профиль отличаются от сохранённого оригинала, а журнал не содержит хеша внедрённого состояния этого файла, автоматическое восстановление сохраняет оба файла и журнал без изменений. Файл, уже совпадающий с оригиналом, не перезаписывается. Повторное внедрение в настроенную для прокси конфигурацию также отклоняет такое неподтверждённое состояние; нативная конфигурация может создать новый снимок. См. [правила восстановления](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index d13af52274..46b5f8e296 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -437,10 +437,9 @@ olduğunda ve `tokenGuardian.codexWarmupEnabled` true olduğunda çalışır. ## Yerel Codex'i geri yükleme -opencodex sizi asla tuzağa düşürmez. **`ocx stop`, yerel Codex'e tamamen geri -dönen tek komuttur** — proxy'yi durdurur, kuruluysa arka plan servisini durdurur -ve enjekte edilen her satırı ve yönlendirilen katalog girdisini kaldırır, -böylece düz `codex` sanki opencodex hiç var olmamış gibi tam olarak çalışır: +`ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. + +Mevcut yapılandırma veya profil kayıtlı özgün içerikten farklıysa ve günlükte o dosyanın enjekte edilmiş durumunun karması yoksa otomatik kurtarma iki dosyayı ve günlüğü değiştirmeden korur. Özgün içerikle zaten aynı olan dosya yeniden yazılmaz. Yönlendirilmiş bir yapılandırmaya yeniden enjeksiyon da bu belirsiz durumu reddeder; yerel yapılandırma yeni bir anlık görüntü oluşturabilir. [Kurtarma kurallarına](/guides/codex-integration/#recovery-without-injection-hashes) bakın. ```bash ocx stop # proxy'yi + servisi durdurun, yerel Codex'i geri yükleyin diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 0c3df0c0e2..1170d9207f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -321,9 +321,9 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## 恢复原生 Codex -opencodex 绝不会把你困住。**`ocx stop` 是完全恢复原生 Codex 的单一命令** —— 它会停止 proxy、 -停止后台服务(如已安装),并剥除所有注入的行和路由的目录条目,使普通的 `codex` 完全像 opencodex -从未存在过一样工作: +`ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 + +如果当前 config 或 profile 与保存的原始内容不同,且日志缺少该文件注入状态的哈希值,自动快照恢复会保留两个文件和日志,不作修改。已经与原始内容相同的文件不会重写。对已路由配置的再次注入也会拒绝使用这种未确认的基线;原生配置可以建立新的快照。详见[恢复规则](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 44ee5bbb3b..b8db326af6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -328,9 +328,9 @@ body。背景重新驗證是獨立功能,預設關閉;只有啟用 Token Gua ## 恢復原生 Codex -opencodex 絕不會把你困住。**`ocx stop` 是完整恢復原生 Codex 的單一命令**。它會停止 proxy、停止 -背景服務(若已安裝),並移除所有注入行與路由目錄條目,讓普通的 `codex` 就像從未安裝 opencodex 一樣 -運作: +`ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 + +若目前的 config 或 profile 與儲存的原始內容不同,且日誌缺少該檔案注入狀態的雜湊值,自動快照恢復會保留兩個檔案及日誌,不做修改。已與原始內容相同的檔案不會重新寫入。對已路由設定再次注入時,也會拒絕使用這種未確認的基準;原生設定可以建立新的快照。詳見[恢復規則](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # 停止 proxy + service,恢復原生 Codex diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 37e73c8e33..2241d8422b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -31,6 +31,7 @@ import { resolveEffectiveUserIdentity, } from "./user-identity"; import { + hasUnverifiedJournalBaseline, markJournalInjectedState, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, @@ -1149,6 +1150,24 @@ export async function injectCodexConfig( }; } + const journalBaselineIsNative = (): boolean => { + // Value evidence survives an app rewrite that removes the ownership comments. + const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true }); + const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true }); + const looksInjectedByValue = + (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) + || (journaledRealtimeWsBaseUrl !== null + && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); + return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue; + }; + const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH) + ? readFileSync(CODEX_PROFILE_PATH, "utf-8") + : null; + const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; + if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) { + return { success: false, message: unverifiedJournalMessage }; + } + if (options.validateOnly) { return { success: true, @@ -1157,21 +1176,14 @@ export async function injectCodexConfig( } const applyNativeArtifacts = (): void => { - // #1798 again: a Codex app rewrite keeps values and drops the ownership comments, so - // marker evidence alone would classify our own routed config as the user's native - // baseline and replace the real original snapshot. Value evidence from the journal - // (the URLs the last injection recorded writing) blocks that misclassification. - const journaledBaseUrl = journaledInjectedOpenaiBaseUrl(); - const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl(); - const looksInjectedByValue = - (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) - || (journaledRealtimeWsBaseUrl !== null - && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); writeJournal({ - currentStateIsNative: !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue, + currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, owner: options.journalOwner, }); + // A native snapshot may have been refreshed above. An older hashless routed snapshot + // must not gain the new injection's hash and later overwrite preserved user edits. + if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); atomicWriteFile(CODEX_CONFIG_PATH, content); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { @@ -1733,6 +1745,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex function restoreCodexConfigInline(): CodexRestoreConfigResult { try { const journal = restoreJournalState(); + if (journal.unverified) { + return { + state: "failed", changed: false, action: "failed", + message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", + }; + } const restored = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal." } : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); diff --git a/src/codex/journal.ts b/src/codex/journal.ts index b2ec89eef0..8aafb19202 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -62,12 +62,40 @@ export interface RestoreJournalResult { 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 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), + 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 @@ -114,7 +142,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void { const journal: Journal = { version: 1, originalConfig: Buffer.from(config).toString("base64"), - originalProfile: profile ? Buffer.from(profile).toString("base64") : null, + originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null, pid: process.pid, owner: options.owner?.kind === "client" ? { kind: "client", apiKeyId: options.owner.apiKeyId } @@ -208,22 +236,34 @@ export function journalOwner(): JournalOwner | null { export function restoreJournalState(): RestoreJournalResult { const journal = readJournal(); if (!journal) { - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false }; + return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; } - const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : ""; + 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 configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash; - const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? 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, + configChanged: !configUnchanged, + profileChanged: !profileUnchanged, + complete: false, + unverified: true, + }; + } - let configRestored = false; - let profileRestored = false; - if (configUnchanged) { - atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8")); + let configRestored = comparison.configAlreadyOriginal; + let profileRestored = comparison.profileAlreadyOriginal; + if (configUnchanged && !configRestored) { + atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig); configRestored = true; } - if (profileUnchanged) { - if (journal.originalProfile !== null) { - atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8")); + if (profileUnchanged && !profileRestored) { + if (comparison.originalProfile !== null) { + atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile); profileRestored = true; } else if (existsSync(CODEX_PROFILE_PATH)) { // "There was no profile before, so remove the one we generated." Claiming success @@ -249,6 +289,7 @@ export function restoreJournalState(): RestoreJournalResult { configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete, + unverified: false, }; } @@ -267,6 +308,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean if (owner?.kind === "client") { if (options.activeClientApiKeyId === owner.apiKeyId) return false; const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); return true; @@ -281,6 +326,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean } } const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 572c73f25f..9ab5cf2bba 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,16 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +Journal restoration compares config and profile independently against their saved originals and +recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile +pair and journal remain untouched and the result is explicitly unverified; callers must not +convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, +and absence is distinct from an empty file. The injector checks a retained hashless journal against +the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new +injected hash. Native content can establish a fresh snapshot; routed content cannot promote an +unverified older original. Existing hash-backed edit preservation and external-provider opt-out +remain separate paths. + The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and `ocx-state` respectively, retaining a read-only root. The image creates owner-only diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 3b46be997a..05d6d14e37 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -56,6 +56,224 @@ describe("codex-journal", () => { expect(out.hasPid).toBe(true); }); + test("hashless interrupted snapshot preserves later native config edits", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.config).toBe(edited); + expect(out.result.configRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless interrupted snapshot preserves a later profile", () => { + const edited = 'model_provider = "openai"\nmodel = "user-profile-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(profilePath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, profile: fs.existsSync(profilePath) ? fs.readFileSync(profilePath, "utf8") : null, + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.profile).toBe(edited); + expect(out.result.profileRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless already-original snapshot completes without rewriting config", () => { + const r = runScript(testDir, ` + const { spyOn } = require("bun:test"); + const config = require("./src/config"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const originalWrite = config.atomicWriteFile; + let writes = 0; + const spy = spyOn(config, "atomicWriteFile").mockImplementation((...args) => { + writes += 1; + return originalWrite(...args); + }); + try { console.log(JSON.stringify({ result: restoreJournalState(), writes })); } + finally { spy.mockRestore(); } + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.complete).toBe(true); + expect(out.writes).toBe(0); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + }); + + test("hashless snapshot distinguishes an empty original profile from absence", () => { + writeFileSync(join(testDir, "opencodex.config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); + const result = restoreJournalState(); + console.log(JSON.stringify({ originalProfile: journal.originalProfile, result, + profileExists: fs.existsSync(profilePath), profile: fs.readFileSync(profilePath, "utf8"), + journalExists: fs.existsSync(journalPath) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.originalProfile).toBe(""); + expect(out.result.complete).toBe(true); + expect(out.profileExists).toBe(true); + expect(out.profile).toBe(""); + expect(out.journalExists).toBe(false); + }); + + test("hashless native restore refuses instead of reporting an uncertain snapshot as restored", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { restoreNativeCodex } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.success).toBe(false); + expect(out.result.artifacts.config.state).toBe("failed"); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless routed snapshot is not promoted by reinjection after user edits", () => { + const edited = '# current user settings\nmodel = "user-selected-model"\n# Auto-injected by opencodex\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + (async () => { + const config = { port: 10200, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10200, config, { catalogPath: null, validateOnly: true }); + const result = await injectCodexConfig(10200, config, { catalogPath: null }); + console.log(JSON.stringify({ preflight, result, config: fs.readFileSync(configPath, "utf8"), + profileCreated: fs.existsSync(profilePath), journal: JSON.parse(fs.readFileSync(journalPath, "utf8")), + journalPreserved: fs.readFileSync(journalPath, "utf8") === before })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(false); + expect(out.result.success).toBe(false); + expect(out.config).toBe(edited); + expect(out.profileCreated).toBe(false); + expect(out.journal.injectedConfigHash).toBeUndefined(); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless empty config snapshot does not recreate a later deleted file", () => { + writeFileSync(join(testDir, "config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal(); + fs.unlinkSync(CODEX_CONFIG_PATH); + console.log(JSON.stringify({ result: restoreJournalState(), configExists: fs.existsSync(CODEX_CONFIG_PATH), + journalExists: fs.existsSync(JOURNAL_PATH) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.configExists).toBe(false); + expect(out.journalExists).toBe(true); + expect(out.result.complete).toBe(false); + expect(out.result.unverified).toBe(true); + }); + + test("hashless client reconcile does not report an uncertain snapshot as restored", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, reconcileJournal, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal({ owner: { kind: "client", apiKeyId: "previous-client" } }); + const before = fs.readFileSync(JOURNAL_PATH, "utf8"); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + const restored = reconcileJournal({ activeClientApiKeyId: "different-client" }); + console.log(JSON.stringify({ restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + journalPreserved: fs.existsSync(JOURNAL_PATH) && fs.readFileSync(JOURNAL_PATH, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.restored).toBe(false); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + expect(r.stderr).toContain("recovery was not verified"); + expect(r.stderr).not.toContain("was restored from the Codex journal"); + }); + + test("hashless native edits become the new snapshot before a successful injection", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const profile = 'model_provider = "openai"\nmodel = "current-user-profile"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + writeJournal(); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + fs.writeFileSync(CODEX_PROFILE_PATH, ${JSON.stringify(profile)}); + (async () => { + const config = { port: 10100, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10100, config, { catalogPath: null, validateOnly: true }); + const injected = await injectCodexConfig(10100, config, { catalogPath: null }); + const restored = restoreJournalState(); + console.log(JSON.stringify({ preflight, injected, restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + profile: fs.readFileSync(CODEX_PROFILE_PATH, "utf8") })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(true); + expect(out.injected.success).toBe(true); + expect(out.restored.complete).toBe(true); + expect(out.config).toBe(edited); + expect(out.profile).toBe(profile); + }); + test("reconcileJournal restores config when journaled PID is dead", () => { const journalPath = join(testDir, "opencodex-journal.json"); const original = "# original config\nmodel_provider = \"openai\"\n"; @@ -65,6 +283,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(modified).digest("hex"), + injectedProfileHash: null, pid: 999999, timestamp: new Date().toISOString(), }), "utf8"); @@ -136,6 +356,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999999, timestamp: new Date().toISOString(), @@ -344,6 +566,11 @@ describe("codex-journal", () => { '' ].join("\\n"), "utf8"); fs.writeFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), 'model_provider = "opencodex"\\n', "utf8"); + require("./src/codex/journal").markJournalInjectedState( + fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8"), + fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), "utf8"), + { injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null }, + ); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); `); @@ -440,20 +667,21 @@ describe("codex-journal", () => { expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); }); - test("full lifecycle: write → crash → reconcile restores", () => { + test("full lifecycle: snapshot → mark injection → crash → reconcile restores", () => { const r = runScript(testDir, ` - const { writeJournal } = require("./src/codex/journal"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); writeJournal(); + const injected = "# injected opencodex config\\n"; + require("node:fs").writeFileSync(require("./src/codex/paths").CODEX_CONFIG_PATH, injected); + markJournalInjectedState(injected, null, { + injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null, + }); console.log("written"); `); expect(r.status).toBe(0); const journalPath = join(testDir, "opencodex-journal.json"); expect(existsSync(journalPath)).toBe(true); - const journal = JSON.parse(readFileSync(journalPath, "utf8")); - - writeFileSync(join(testDir, "config.toml"), "# injected opencodex config\n", "utf8"); - const r2 = runScript(testDir, ` const { reconcileJournal } = require("./src/codex/journal"); const result = reconcileJournal(); From ffdd705561330424b65ddd4cdee2f49ff27d6366 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:22:26 +0900 Subject: [PATCH 2/2] test(codex): record injected state in normal recovery fixtures --- tests/cli/cli-start-journal-order.test.ts | 5 +++++ tests/clients/client-connect.test.ts | 9 ++++++++- tests/codex-integration/codex-catalog-restore.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index 15e6758b7a..95b5291a1a 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -81,6 +82,8 @@ function arrangeRecoverableJournal(fx: Fixture): { original: string; injected: s version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -181,6 +184,8 @@ describe("start and ensure journal ownership (#1230)", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(), diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 66bf5d4bf9..f7748914cf 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -493,6 +493,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const fingerprint = createHash("sha256").update(token).digest("hex"); const catalog = '{"models":[]}'; const catalogFingerprint = createHash("sha256").update(catalog).digest("base64url"); + const injected = 'model_provider = "opencodex"\n'; const isDisconnect = mode === "disconnect-conflict" || mode === "disconnect-process-journal"; const selectedClients = isDisconnect ? ["codex"] : ["claude"]; writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ @@ -517,13 +518,15 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); writeFileSync(join(codexHome, "config.toml"), isDisconnect - ? 'model_provider = "opencodex"\n' + ? injected : 'model_provider = "openai"\n', "utf8"); if (mode === "disconnect-conflict") { writeFileSync(join(codexHome, "opencodex-journal.json"), JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "different-key" }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -538,6 +541,8 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "process", pid: 999_999 }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -890,6 +895,8 @@ function runDesktopLifecycleScenario(mode: string) { fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model_provider = "opencodex"'); fs.writeFileSync(journal.JOURNAL_PATH, JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"').toString("base64"), originalProfile: null, + injectedConfigHash: hash(fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8")), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: owner.apiKeyId }, })); const actualRestore = journal.restoreJournalState; diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index a211701578..1b148daad1 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -39,7 +39,7 @@ describe("Codex catalog restore", () => { if (existsSync(opencodexHome)) removeTreeWithRetry(opencodexHome); }); - test("version-1 process journals restore, while matching client ownership is durable", () => { + test("version-1 process journals with injected hashes restore, while matching client ownership is durable", () => { const configPath = join(codexHome, "config.toml"); const journalPath = join(codexHome, "opencodex-journal.json"); const original = '# original\nmodel_provider = "openai"\n'; @@ -49,6 +49,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -65,6 +67,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(),