diff --git a/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md new file mode 100644 index 00000000000..a3db4957992 --- /dev/null +++ b/devlog/_plan/260920_round2_followups/010_r1_paginated_history_guard.md @@ -0,0 +1,91 @@ +# R1 — the paginated-history guard, from activation and from recovery + +Scope: #5321 (activation) and #4812 (recovery). Branch `codex/260920-r1-paginated-history-guard`. + +## What the guard was actually protecting + +`preflightCodexHistoryInjection` returns `history_paginated_openai_requires_native_writer` when a +provider-table transition finds a thread row that is both `model_provider = 'openai'` and +`history_mode = 'paginated'`. The reasoning is sound. The transition takes the root +`openai_base_url` out, a paginated row cannot be relabeled, and Codex builds its provider map as +`merge_configured_model_providers(built_in_model_providers(openai_base_url), model_providers)`, so +without that root line the built-in `openai` entry is `api.openai.com`. The conversation would +resume outside the proxy. + +What made it a lockout is that 2.60.0 classified it alongside "something is wrong with this +store". `src/codex/inject.ts` refuses every reason that is not exactly `HISTORY_RELABEL_STANDS_DOWN`, +so nothing was written at all: no config, no profile, no `model_catalog_json`, integration +disabled. Before 2.60.0 the same home returned the plain stand-down, and the routing and catalog +half landed while the relabel stood down. + +## The state that was already in the tree + +The injector already builds the safe state for one routing form. `keepRootOverrideAlongsideTable` +keeps the marker-owned root override beside the provider table for client compaction, for exactly +this reason, and passes `resumeHistory: false` so the relabel never runs. Authless was excluded +deliberately — its point is `requires_openai_auth = false` — on the assumption that it could +always forward-tag resume history instead. On a paginated home that assumption is false, and the +refusal is where that showed up. + +So the fix is not a new mechanism. `src/codex/inject/paginated-openai-compat.ts` selects the +existing one from the preflight verdict rather than from the routing form: when the reason is the +paginated-openai code and the target can own a root key, retain the override, downgrade the reason +to the stand-down constant, and let the transition complete. The paginated row is never read or +written; it simply keeps resolving to this proxy. + +Two cases cannot reach that state, and both are honest outcomes rather than traps: + +- An admission-token form cannot use the root key at all, because Codex's built-in `openai` entry + carries no `x-opencodex-api-key` header. It keeps the refusal, and the message now names + `unauthenticatedLoopbackListener` and `syncResumeHistory` instead of "do not retry". +- A root line the user owns is left alone. The conversation follows the destination they chose, + which is the guarantee the injector already makes everywhere else about a line it does not own, + and the journal correctly records the line as not ours. + +## Where it had to live + +`src/codex/inject.ts` was at 984 of its 987-line ratchet cap, so the decision could not be +inlined. The new module costs the injector one import and one net line; the file now sits at +exactly 987. The refusal code became an exported constant in `src/codex/history-provider.ts` +because the same literal in two files is how the stand-down pair drifted the first time. + +## #4812, checked rather than assumed + +The recovery half is already closed on `dev`: `resolveRestoreHistoryDisposition` stands down on +`HISTORY_RELABEL_STANDS_DOWN` and removal retains the provider table. The new code cannot reach +restore at all — it is only set under `providerTableMode`, and restore preflights with +`providerTableMode = false`, whose row predicate is `model_provider = 'opencodex'`. + +Two things were still wrong on that side. `ocx restore --remove-codex-provider-table` existed but +appeared in no usage or help text, so the escape hatch was reachable only by reading the parser; +it is now in the command registry and top-level usage, bound by a test that reads the flag out of +`dispatch.ts` rather than restating it. And the public guide in all eight locales still said +restore and removal refuse on paginated history and that such a home cannot be uninstalled, which +has not been true since 2026-09-17. + +## Verification + +Static review plus exact-head hosted CI. Per the lane constraints, NOT RUN locally: `bun test`, +any individual test file, `bun run typecheck`, any build, any install, live `ocx`, service +restart, and credential or configuration changes. + +Regression coverage added: + +- `tests/codex-integration/history-paginated-openai-compat.test.ts` — the resolver itself: root + override retained and placed before the first table, CRLF preserved, a user-owned line left + untouched and not claimed, the admission-token refusal naming both remedies as keys that are + asserted to exist in `src/types/config.ts`, every other reason passing through unchanged, and a + source-oracle check that the refusal code is defined once. +- `tests/codex-integration/codex-inject-integration.test.ts` — the end-to-end regression, rewritten + from "refuses" to the full transition: config carries both the table and the marker-owned root + override, the rollout bytes and the thread row are unchanged, and `ocx restore` afterwards takes + the retained override back out. That last assertion is the one that keeps this from trading + #5321 for a new #4812. +- `tests/cli/cli-restore-back.test.ts` — the removal flag is discoverable in both help surfaces. + +## Not in this lane + +The other hard-refusal reasons on the recovery side still have no named repair command: a missing +state database with pending manifest entries, and a backup manifest that is unreadable, foreign, +or schema-invalid. Those are a different failure family from the guard and are left open rather +than folded in here. 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 b4467db2701..f5aada09618 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -419,7 +419,7 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure ## Refus de sécurité pour l’historique paginé -Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` et la suppression de la configuration Codex refusent toujours sur `history_paginated_requires_native_writer`. Retirer la définition `[model_providers.opencodex]` alors que des lignes de conversation la référencent encore rendrait ces conversations irrésolubles, et le chemin de restauration n’a aucun moyen de conserver une table de fournisseur de compatibilité. Un répertoire d’accueil déjà paginé ne peut pas actuellement être désinstallé par le produit ; c’est un travail ouvert connu, et non le comportement voulu. +Une transition de fournisseur peut renvoyer `history_paginated_requires_native_writer` si le stockage concerné prend en charge la pagination, même pour ses lignes legacy. Cette raison ne refuse plus la configuration Codex, le profil de référence ni le catalogue de modèles. `ocx sync` et `ocx start` écrivent toujours ces fichiers et définissent `model_catalog_json`, afin que le sélecteur de modèles Codex continue d’afficher tous les modèles routés par OpenCodex. Seule cette raison interrompt le réétiquetage de l’historique des conversations, car Codex attribue les numéros d’historique paginé dans son propre processus d’écriture et aucune nouvelle tentative n’y change rien. Toute autre raison de contrôle préalable de l’historique — une base d’état illisible, un historique dont l’identité a changé, ou un contrôle préalable qui n’a pas pu s’exécuter — refuse encore toute la transition et l’annule, car ces cas peuvent réussir plus tard. Dans cet état, OpenCodex ne modifie jamais les fichiers d’historique paginé ni les lignes de conversation. Les conversations existantes conservent le fournisseur déjà associé et ne sont pas migrées ; les nouvelles conversations passent par le proxy. Lorsque le réétiquetage est interrompu, une table `[model_providers.opencodex]` déjà présente dans le répertoire d’accueil est conservée plutôt que retirée, y compris sous la forme root-override (loopback), afin que les conversations dont les lignes sont étiquetées `opencodex` gardent un identifiant de fournisseur qui existe encore. Le CLI affiche `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` et `ocx uninstall` ne refusent plus sur `history_paginated_requires_native_writer`. Ils retirent toutes les clés de routage racine d'OpenCodex et conservent la définition `[model_providers.opencodex]` sur le disque : les conversations dont les lignes nomment encore ce fournisseur restent résolubles, tandis que `codex` seul cesse de pointer vers le proxy. Le résultat est signalé comme une restauration partielle qui nomme les lignes conservées, et `ocx restore --remove-codex-provider-table` les supprime aussi, après quoi ces conversations ne s'ouvrent plus. Par ailleurs, activer l'intégration sous sa forme table de fournisseur sur un répertoire d'accueil dont les conversations marquées `openai` ont déjà été paginées par Codex était auparavant refusé d'emblée avec `history_paginated_openai_requires_native_writer` : rien n'était écrit et l'intégration restait désactivée. OpenCodex termine désormais cette transition en conservant la redéfinition racine gérée `openai_base_url` à côté de la table `[model_providers.opencodex]`. Codex fusionne cette redéfinition avec son fournisseur `openai` intégré, donc ces conversations continuent d'atteindre le proxy sans être réétiquetées, et aucun octet d'historique ni ligne de conversation n'est modifié. Seule une forme de routage exigeant l'en-tête d'admission `x-opencodex-api-key` refuse encore, car le fournisseur intégré de Codex ne peut pas porter cet en-tête ; son message nomme les deux réglages qui résolvent la situation — router Codex par l'écouteur loopback pour conserver la redéfinition, ou mettre `syncResumeHistory` à `false` en acceptant que ces conversations reprennent sur le point de terminaison OpenAI propre à Codex. Lors du retour au mode de remplacement de l’URL racine, OpenCodex conserve la définition `[model_providers.opencodex]` existante avant de valider la configuration, même si la vérification préalable de l’historique réussit. Les anciennes conversations `opencodex` peuvent ainsi toujours retrouver leur fournisseur si Codex migre l’historique après cette validation ou pendant le démarrage du traitement en arrière-plan. Les nouvelles conversations utilisent le fournisseur racine sélectionné ; la restauration explicite conserve ses contrôles de suppression distincts. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 3afb7af916f..6513400b4a6 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -919,7 +919,9 @@ When an affected history store supports paginated records, a provider transition When returning to the root-override form, OpenCodex retains an existing `[model_providers.opencodex]` definition before committing the configuration, even if history preflight currently passes. This keeps older `opencodex` conversations resolvable if Codex migrates history after that commit or while the background worker starts. New conversations still use the selected root provider; explicit restore keeps its separate removal guards. -`ocx restore` and Codex config removal still refuse on `history_paginated_requires_native_writer`. Stripping the `[model_providers.opencodex]` definition while thread rows still reference it would make those conversations unresolvable, and the restore path has no way to keep a compatibility provider table. A home that is already paginated cannot currently be uninstalled through the product; that is known open work rather than intended behaviour. +`ocx restore`, `ocx stop` and `ocx uninstall` no longer refuse on `history_paginated_requires_native_writer`. They take every OpenCodex root routing key out and keep the `[model_providers.opencodex]` definition on disk, so conversations whose rows still name that provider keep resolving while plain `codex` stops pointing at the proxy. The result is reported as a partial restore that names the retained lines, and `ocx restore --remove-codex-provider-table` removes them too, after which those conversations stop opening. + +Enabling the integration in its provider-table form on a home whose `openai`-tagged conversations Codex has already paginated used to be refused outright with `history_paginated_openai_requires_native_writer`: nothing was written and the integration stayed disabled. OpenCodex now completes that transition by keeping the managed root `openai_base_url` override beside the `[model_providers.opencodex]` table. Codex merges the override onto its built-in `openai` provider, so those conversations keep reaching the proxy without being relabeled and no rollout byte or thread row is touched. Only a routing form that requires the `x-opencodex-api-key` admission header still refuses, because Codex's built-in provider cannot carry that header; its message names the two settings that resolve it — route Codex through the loopback listener so the override can be retained, or set `syncResumeHistory` to `false` to accept that those conversations resume against Codex's own OpenAI endpoint. Do not rewrite an active paginated rollout or thread row to migrate those conversations yourself. Close the affected conversation before any recovery, and report the exact error and versions without uploading private history. A backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. 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 31134ea6a38..4bd4530decc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -281,7 +281,7 @@ opencodex が管理対象 [バックグラウンドサービス](/reference/cli/ ## ページ分割履歴の保護による拒否 -対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore` と Codex 設定の削除は、いまも `history_paginated_requires_native_writer` で拒否されます。スレッド行がまだ参照しているのに `[model_providers.opencodex]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 +対象の履歴ストアがページ分割をサポートする場合、プロバイダー変更は `history_paginated_requires_native_writer` を返すことがあります。この理由では、Codex の設定、参照プロファイル、モデルカタログは拒否されません。`ocx sync` と `ocx start` はこれらのファイルを書き込み、`model_catalog_json` を設定するため、Codex のモデル選択には OpenCodex 経由のモデルがすべて表示され続けます。会話履歴の再ラベル付けを控えるのはこの理由だけの場合です。ページ分割された履歴の番号は Codex 自身の書き込み処理が割り当て、再試行しても変わりません。読み取れない状態データベース、識別子が変わった履歴、実行できなかった事前検査など、それ以外の履歴事前検査の理由では、後から成功する可能性があるため、遷移全体を拒否してロールバックします。この状態では OpenCodex はページ分割された履歴ファイルやスレッド行を変更しません。既存の会話はすでに付いているプロバイダーのまま移行されず、新しい会話は通常どおりプロキシ経由でルーティングされます。再ラベル付けを控えるとき、ホームに既にある `[model_providers.opencodex]` テーブルは廃止せず残します。ルート上書き(loopback)形式でも同じで、行が `opencodex` と付いている会話は、まだ存在するプロバイダー id を保てます。移行可能なストアの legacy 行も対象です。CLI は `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)` と表示します。`ocx restore`、`ocx stop`、`ocx uninstall` は `history_paginated_requires_native_writer` で拒否しなくなりました。OpenCodex が書いたルートのルーティングキーをすべて取り除き、`[model_providers.opencodex]` の定義はディスクに残します。そのプロバイダーを指している会話は解決でき、素の `codex` はプロキシを向かなくなります。結果は残した行を示す部分復元として報告され、`ocx restore --remove-codex-provider-table` を使えばその行も削除できます。そのときは該当の会話が開かなくなります。また、`openai` と付いた会話を Codex がすでにページ分割したホームでプロバイダーテーブル形式の統合を有効にすると、以前は `history_paginated_openai_requires_native_writer` で全体が拒否され、何も書かれず統合も無効のままでした。現在は、管理対象のルート `openai_base_url` 上書きを `[model_providers.opencodex]` テーブルと一緒に残す形で移行を完了します。Codex はこの上書きを組み込みの `openai` プロバイダーに統合するため、それらの会話は再ラベル付けなしでプロキシに届き、履歴ファイルやスレッド行は変更されません。`x-opencodex-api-key` の受け入れヘッダーを必要とするルーティング形式だけは今も拒否されます。組み込みプロバイダーがそのヘッダーを運べないためで、そのメッセージは解決策を二つ名指しします。ループバックリスナー経由で Codex を接続して上書きを維持するか、`syncResumeHistory` を `false` にして、それらの会話が Codex 自身の OpenAI エンドポイントに向かうことを受け入れるかです。 ルート URL 上書き方式に戻すとき、履歴の事前確認が成功していても、OpenCodex は設定を確定する前に既存の `[model_providers.opencodex]` 定義を保持します。確定後やバックグラウンドの履歴処理開始中に Codex が履歴形式を移行しても、以前の `opencodex` 会話はプロバイダーを引き続き解決できます。新しい会話は選択されたルートプロバイダーを使い、明示的な復元には従来の個別の削除チェックが適用されます。 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 72fef21fea1..1aefaeb8b75 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -391,7 +391,7 @@ opencodex가 managed [background service](/reference/cli/#ocx-service)로 실행 ## 페이지 분할 기록 보호에 따른 거부 -영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`와 Codex 설정 제거는 여전히 `history_paginated_requires_native_writer`로 거부됩니다. 스레드 행이 아직 참조하는데 `[model_providers.opencodex]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. +영향받는 기록 저장소가 페이지 분할을 지원하면 프로바이더 전환이 `history_paginated_requires_native_writer`를 반환할 수 있습니다. 이 이유로는 Codex 설정, 참조 프로필, 모델 카탈로그를 더 이상 거부하지 않습니다. `ocx sync`와 `ocx start`는 해당 파일과 `model_catalog_json`을 계속 쓰므로 Codex 모델 선택기에는 OpenCodex가 라우팅하는 모델이 모두 그대로 보입니다. 대화 기록의 프로바이더 재지정을 건너뛰는 것은 이 이유뿐이며, 페이지 분할 순번은 Codex 자체의 네이티브 기록 작성자가 할당하고 재시도해도 달라지지 않기 때문입니다. 읽을 수 없는 상태 데이터베이스, 식별자가 바뀐 대화 원본, 실행하지 못한 사전 검사처럼 다른 기록 사전 검사 이유는 나중에 성공할 수 있으므로 전환 전체를 거부하고 되돌립니다. 이 상태에서 OpenCodex는 페이지 분할 대화 원본이나 스레드 행을 수정하지 않습니다. 기존 대화는 이미 붙어 있는 프로바이더를 유지하고 이전되지 않으며, 새 대화는 평소처럼 프록시를 통해 라우팅됩니다. 재지정을 건너뛸 때 홈에 이미 있던 `[model_providers.opencodex]` 테이블은 폐기하지 않고 유지합니다. root-override(loopback) 형식에서도 같아서, 행이 `opencodex`로 표시된 대화는 아직 존재하는 프로바이더 id를 유지합니다. 변환 가능한 저장소의 `legacy` 행도 포함됩니다. CLI는 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`를 출력합니다. `ocx restore`, `ocx stop`, `ocx uninstall`은 이제 `history_paginated_requires_native_writer`로 거부하지 않습니다. OpenCodex가 넣은 루트 라우팅 키를 모두 걷어내고 `[model_providers.opencodex]` 정의는 디스크에 남기므로, 그 프로바이더를 가리키는 대화는 계속 열리고 plain `codex`는 더 이상 프록시를 향하지 않습니다. 결과는 남겨 둔 줄을 함께 알려 주는 부분 복원으로 보고되며, `ocx restore --remove-codex-provider-table`을 쓰면 그 줄까지 지웁니다. 대신 해당 대화는 열리지 않게 됩니다. 한편 `openai`로 표시된 대화를 Codex가 이미 페이지 분할한 홈에서 프로바이더 테이블 형식으로 통합을 켜면, 예전에는 `history_paginated_openai_requires_native_writer`로 전체가 거부되어 아무것도 쓰이지 않고 통합도 꺼진 채로 남았습니다. 지금은 관리 대상 루트 `openai_base_url` 재정의를 `[model_providers.opencodex]` 테이블과 함께 남겨 두는 방식으로 전환을 끝냅니다. Codex가 이 재정의를 내장 `openai` 프로바이더에 합치므로 해당 대화는 재지정 없이 계속 프록시에 닿고, 대화 원본이나 스레드 행은 건드리지 않습니다. `x-opencodex-api-key` 승인 헤더가 필요한 라우팅 형식만 여전히 거부합니다. 내장 프로바이더가 그 헤더를 실을 수 없기 때문이며, 이때 메시지는 해결 방법 두 가지를 이름으로 알려 줍니다. 루프백 리스너로 Codex를 연결해 재정의를 유지하거나, `syncResumeHistory`를 `false`로 두어 해당 대화가 Codex 자체 OpenAI 엔드포인트로 이어지는 것을 받아들이는 것입니다. 루트 URL 재정의 방식으로 돌아갈 때 OpenCodex는 기록 사전 점검이 통과하더라도 기존 `[model_providers.opencodex]` 정의를 설정 적용 전에 유지합니다. 설정 적용 후나 백그라운드 기록 작업 시작 중에 Codex가 기록 형식을 전환해도 이전 `opencodex` 대화가 제공자를 계속 찾을 수 있습니다. 새 대화는 선택된 루트 제공자를 사용하며, 명시적 복원에는 기존의 별도 제거 검사가 적용됩니다. 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 5e1a62ec71b..056f4b42917 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -413,7 +413,7 @@ ocx restore back # point plain Codex at the running proxy again ## Защитный отказ для постраничной истории -Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` и удаление конфигурации Codex по-прежнему отказывают по `history_paginated_requires_native_writer`. Удаление определения `[model_providers.opencodex]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. +Если затронутое хранилище поддерживает постраничную историю, смена провайдера может вернуть `history_paginated_requires_native_writer`, в том числе для строк legacy. По этой причине больше не отклоняются конфигурация Codex, опорный профиль и каталог моделей. `ocx sync` и `ocx start` по-прежнему записывают эти файлы и задают `model_catalog_json`, поэтому выбор модели Codex продолжает показывать все модели, маршрутизируемые через OpenCodex. Переразметку истории разговоров останавливает только эта причина: порядковые номера постраничной истории выделяет собственный процесс записи Codex, и повторная попытка этого не меняет. Любая другая причина предварительной проверки истории — нечитаемая база состояния, история со сменившейся идентификацией или проверка, которую не удалось запустить, — по-прежнему отклоняет весь переход и откатывает его, потому что такие случаи могут пройти позже. В этом состоянии OpenCodex не изменяет постраничные файлы истории и строки тредов. Существующие разговоры сохраняют уже назначенного провайдера и не мигрируют; новые разговоры идут через прокси как обычно. Когда переразметка останавливается, таблица `[model_providers.opencodex]`, уже бывшая в домашнем каталоге, сохраняется, а не снимается, в том числе в форме root-override (loopback), чтобы разговоры со строками, помеченными `opencodex`, сохраняли существующий идентификатор провайдера. CLI выводит `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` и `ocx uninstall` больше не отказывают по причине `history_paginated_requires_native_writer`. Они убирают все корневые ключи маршрутизации OpenCodex и оставляют определение `[model_providers.opencodex]` на диске, поэтому разговоры, строки которых всё ещё называют этого провайдера, продолжают разрешаться, а обычный `codex` перестаёт указывать на прокси. Результат сообщается как частичное восстановление с перечислением оставленных строк; `ocx restore --remove-codex-provider-table` удаляет и их, после чего такие разговоры перестают открываться. Кроме того, включение интеграции в форме таблицы провайдера в домашнем каталоге, где разговоры с меткой `openai` Codex уже перевёл на постраничную историю, раньше отклонялось целиком с `history_paginated_openai_requires_native_writer`: ничего не записывалось, а интеграция оставалась выключенной. Теперь OpenCodex завершает этот переход, сохраняя управляемое корневое переопределение `openai_base_url` рядом с таблицей `[model_providers.opencodex]`. Codex объединяет это переопределение со своим встроенным провайдером `openai`, поэтому такие разговоры продолжают попадать в прокси без переразметки, а файлы истории и строки тредов не изменяются. Отказ сохраняется только для формы маршрутизации, требующей заголовок допуска `x-opencodex-api-key`, потому что встроенный провайдер Codex не может его нести; в этом случае сообщение называет две настройки, которые решают задачу: направить Codex через loopback-слушатель, чтобы переопределение можно было сохранить, или задать `syncResumeHistory` значение `false`, приняв, что такие разговоры пойдут к собственной конечной точке OpenAI в Codex. При возврате к режиму переопределения корневого URL OpenCodex сохраняет существующее определение `[model_providers.opencodex]` до фиксации конфигурации, даже если предварительная проверка истории успешна. Поэтому старые разговоры `opencodex` сохраняют доступ к своему провайдеру, если 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 4f7b49328ac..a7ccadae455 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -470,7 +470,7 @@ service stop` yerel Codex'i geri yükler. ## Sayfalanmış geçmiş için güvenlik reddi -Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore` ve Codex yapılandırmasının kaldırılması `history_paginated_requires_native_writer` nedeniyle hâlâ reddedilir. İş parçacığı satırları hâlâ ona başvuruyken `[model_providers.opencodex]` tanımını kaldırmak o konuşmaları çözülemez yapar ve geri yükleme yolu uyumluluk sağlayıcı tablosunu tutamaz. Zaten sayfalanmış bir ev dizini şu anda ürün üzerinden kaldırılamaz; bu amaçlanan davranış değil, bilinen açık iştir. +Etkilenen geçmiş deposu sayfalamayı destekliyorsa sağlayıcı değişimi `history_paginated_requires_native_writer` döndürebilir; legacy satırlar da buna dahildir. Bu neden artık Codex yapılandırmasını, başvuru profilini veya model kataloğunu reddetmez. `ocx sync` ve `ocx start` bu dosyaları yazmaya ve `model_catalog_json` yolunu ayarlamaya devam eder; böylece Codex model seçicisi OpenCodex üzerinden yönlendirilen her modeli göstermeyi sürdürür. Konuşma geçmişinin yeniden etiketlenmesini durduran yalnızca bu nedendir, çünkü sayfalanmış geçmiş sıra numaralarını Codex’in kendi yerel yazıcısı atar ve yeniden denemek bunu değiştirmez. Okunamayan bir durum veritabanı, kimliği değişmiş bir geçmiş veya çalıştırılamayan bir ön kontrol gibi diğer geçmiş ön kontrol nedenleri, daha sonra başarılı olabilecekleri için hâlâ tüm değişimi reddeder ve geri alır. Bu durumda OpenCodex sayfalanmış geçmiş dosyalarını veya iş parçacığı satırlarını değiştirmez. Mevcut konuşmalar zaten etiketlendikleri sağlayıcıda kalır ve taşınmaz; yeni konuşmalar proxy üzerinden normal şekilde yönlendirilir. Yeniden etiketleme durduğunda, ev dizininde zaten bulunan bir `[model_providers.opencodex]` tablosu kaldırılmaz, kök-override (loopback) biçimde bile tutulur; böylece satırları `opencodex` olarak etiketlenmiş konuşmalar hâlâ var olan bir sağlayıcı kimliğini korur. CLI şunu yazdırır: `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. `ocx restore`, `ocx stop` ve `ocx uninstall` artık `history_paginated_requires_native_writer` nedeniyle reddetmez. OpenCodex'in yazdığı tüm kök yönlendirme anahtarlarını kaldırır ve `[model_providers.opencodex]` tanımını diskte bırakır; böylece satırları hâlâ o sağlayıcıyı adlandıran konuşmalar çözülmeye devam ederken düz `codex` proxy'yi göstermeyi bırakır. Sonuç, bırakılan satırları adlandıran kısmi bir geri yükleme olarak raporlanır; `ocx restore --remove-codex-provider-table` onları da kaldırır ve ardından o konuşmalar açılmaz. Ayrıca, `openai` etiketli konuşmaları Codex'in zaten sayfaladığı bir ev dizininde entegrasyonu sağlayıcı tablosu biçiminde açmak eskiden `history_paginated_openai_requires_native_writer` ile tümüyle reddediliyordu: hiçbir şey yazılmıyor ve entegrasyon devre dışı kalıyordu. OpenCodex bu geçişi artık yönetilen kök `openai_base_url` geçersiz kılmasını `[model_providers.opencodex]` tablosunun yanında tutarak tamamlar. Codex bu geçersiz kılmayı yerleşik `openai` sağlayıcısıyla birleştirdiği için o konuşmalar yeniden etiketlenmeden proxy'ye ulaşmayı sürdürür ve hiçbir geçmiş baytı veya iş parçacığı satırı değişmez. Yalnızca `x-opencodex-api-key` kabul başlığını gerektiren yönlendirme biçimi hâlâ reddeder, çünkü Codex'in yerleşik sağlayıcısı bu başlığı taşıyamaz; mesajı bunu çözen iki ayarı adlandırır: geçersiz kılmanın korunabilmesi için Codex'i loopback dinleyicisi üzerinden yönlendirin ya da `syncResumeHistory` değerini `false` yaparak o konuşmaların Codex'in kendi OpenAI uç noktasına gitmesini kabul edin. Kök URL geçersiz kılma biçimine dönülürken OpenCodex, geçmiş ön kontrolü başarılı olsa bile yapılandırmayı kaydetmeden önce mevcut `[model_providers.opencodex]` tanımını korur. Böylece Codex, kayıttan sonra veya arka plan geçmiş işlemi başlarken geçmiş biçimini değiştirirse eski `opencodex` konuşmaları sağlayıcılarını bulmaya devam eder. Yeni konuşmalar seçili kök sağlayıcıyı kullanır; açıkça istenen geri yükleme, mevcut ayrı kaldırma kontrollerini korur. 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 982a734304b..94a00079d97 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 @@ -356,7 +356,7 @@ ocx restore back # point plain Codex at the running proxy again ## 分页历史记录安全拒绝 -如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 和移除 Codex 配置仍会因 `history_paginated_requires_native_writer` 被拒绝。线程行仍在引用时撤掉 `[model_providers.opencodex]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 +如果受影响的历史存储支持分页,提供商切换可能返回 `history_paginated_requires_native_writer`。该原因不再拒绝写入 Codex 配置、参考配置档和模型目录。`ocx sync` 与 `ocx start` 仍会写入这些文件并设置 `model_catalog_json`,因此 Codex 模型选择器会继续显示所有经 OpenCodex 路由的模型。只有这一条原因会让会话历史的重新标记停手,因为分页历史序号由 Codex 自己的写入器分配,重试也不会改变。无法读取的状态数据库、身份已变的历史文件、未能运行的预检等其他历史预检原因仍会拒绝整个切换并回滚,因为那些情况以后可能成功。在此状态下,OpenCodex 不会修改分页历史文件或线程行。现有会话保留已标记的提供商,不会被迁移;新会话仍正常经代理路由。重新标记停手时,主目录里已有的 `[model_providers.opencodex]` 表会保留而不是撤下,即便是 root-override(loopback)形式也一样,这样行上标记为 `opencodex` 的会话仍能对应到还存在的提供商 id。可迁移存储中的 legacy 记录也适用。CLI 会打印 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 和 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒绝。它们会移除 OpenCodex 写入的全部根路由键,并把 `[model_providers.opencodex]` 定义保留在磁盘上,因此行上仍指向该提供商的会话依旧可以解析,而裸 `codex` 不再指向代理。结果会报告为部分恢复并列出保留的行;`ocx restore --remove-codex-provider-table` 会连这些行一并删除,之后那些会话将无法打开。另外,在 Codex 已把 `openai` 标记会话迁移为分页历史的主目录上启用提供商表形式的集成,过去会以 `history_paginated_openai_requires_native_writer` 整体拒绝:什么都不写,集成保持关闭。现在 OpenCodex 会保留受管的根 `openai_base_url` 覆盖,与 `[model_providers.opencodex]` 表并存,从而完成这次切换。Codex 会把该覆盖合并到内置 `openai` 提供商上,所以那些会话无需重新标记即可继续到达代理,历史文件与线程行都不会被改动。只有需要 `x-opencodex-api-key` 准入标头的路由形式仍会拒绝,因为 Codex 内置提供商无法携带该标头;此时消息会点名两个可行设置——让 Codex 走回环监听器以便保留该覆盖,或把 `syncResumeHistory` 设为 `false`,接受那些会话转向 Codex 自己的 OpenAI 端点。 返回根 URL 覆盖模式时,即使历史预检通过,OpenCodex 也会在提交配置前保留已有的 `[model_providers.opencodex]` 定义。这样,即使 Codex 在提交后或后台历史任务启动时迁移历史格式,旧的 `opencodex` 对话仍能找到其提供商。新对话继续使用所选的根提供商;显式恢复仍执行原有的独立删除检查。 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 c0ca280658c..15b740e5743 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 @@ -363,7 +363,7 @@ ocx restore back # 讓普通 Codex 再次指向仍在執行的 proxy ## 分頁歷史記錄安全拒絕 -如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore` 與移除 Codex 設定仍會因 `history_paginated_requires_native_writer` 被拒絕。執行緒列仍在參照時撤掉 `[model_providers.opencodex]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 +如果受影響的歷史儲存區支援分頁,提供者切換可能傳回 `history_paginated_requires_native_writer`。此原因不再拒絕寫入 Codex 設定、參考設定檔與模型目錄。`ocx sync` 與 `ocx start` 仍會寫入這些檔案並設定 `model_catalog_json`,因此 Codex 模型選擇器會繼續顯示所有經 OpenCodex 路由的模型。只有這一條原因會讓對話歷史的重新標記停手,因為分頁歷史序號由 Codex 自己的寫入器分配,重試也不會改變。無法讀取的狀態資料庫、身分已變的歷史檔案、未能執行的預檢等其他歷史預檢原因仍會拒絕整個切換並回復,因為那些情況以後可能成功。在此狀態下,OpenCodex 不會修改分頁歷史檔案或執行緒列。既有對話保留已標記的提供者,不會被遷移;新對話仍正常經代理路由。重新標記停手時,家目錄裡既有的 `[model_providers.opencodex]` 表會保留而不是撤下,即便是 root-override(loopback)形式也一樣,這樣列上標記為 `opencodex` 的對話仍能對應到還存在的提供者 id。可遷移儲存區中的 legacy 記錄也適用。CLI 會印出 `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`。`ocx restore`、`ocx stop` 與 `ocx uninstall` 不再因 `history_paginated_requires_native_writer` 被拒絕。它們會移除 OpenCodex 寫入的所有根路由鍵,並把 `[model_providers.opencodex]` 定義留在磁碟上,因此列上仍指向該提供者的對話依舊可以解析,而純 `codex` 不再指向代理。結果會回報為部分復原並列出保留的列;`ocx restore --remove-codex-provider-table` 會連這些列一併刪除,之後那些對話將無法開啟。另外,在 Codex 已把 `openai` 標記對話遷移為分頁歷史的家目錄上啟用提供者表形式的整合,過去會以 `history_paginated_openai_requires_native_writer` 整體拒絕:什麼都不寫,整合維持關閉。現在 OpenCodex 會保留受管的根 `openai_base_url` 覆寫,與 `[model_providers.opencodex]` 表並存,藉此完成這次切換。Codex 會把該覆寫合併到內建 `openai` 提供者上,所以那些對話無需重新標記即可繼續抵達代理,歷史檔案與執行緒列都不會被更動。只有需要 `x-opencodex-api-key` 准入標頭的路由形式仍會拒絕,因為 Codex 內建提供者無法攜帶該標頭;此時訊息會點名兩個可行設定——讓 Codex 走回送監聽器以便保留該覆寫,或把 `syncResumeHistory` 設為 `false`,接受那些對話轉向 Codex 自己的 OpenAI 端點。 返回根 URL 覆寫模式時,即使歷史預檢通過,OpenCodex 也會在提交設定前保留既有的 `[model_providers.opencodex]` 定義。如此一來,即使 Codex 在提交後或背景歷史工作啟動時遷移歷史格式,舊的 `opencodex` 對話仍能找到其提供者。新對話繼續使用所選的根提供者;明確要求的還原仍執行原有的獨立刪除檢查。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a8d43bc015c..00c63bfc13d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -825,6 +825,7 @@ "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", + "history-paginated-openai-compat.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers", diff --git a/src/cli/help.ts b/src/cli/help.ts index 38a5c18da19..67003ebca65 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -26,6 +26,8 @@ Usage: ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) ocx restore back Re-point codex at the running proxy (undo restore) + ocx restore --remove-codex-provider-table + Also drop [model_providers.opencodex] that a paginated restore kept ocx recover-history --legacy-openai --yes Force all user-message opencodex rows to OpenAI (legacy recovery) ocx recover-history --ocx-compaction --yes diff --git a/src/cli/registry.ts b/src/cli/registry.ts index b6df952a156..af044066308 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -35,12 +35,18 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ aliases: ["eject"], usage: "ocx restore [back]", summary: "Restore native Codex config without stopping the proxy; `restore back` re-points codex at the running proxy.", + details: [ + "--remove-codex-provider-table Also remove [model_providers.opencodex] when a paginated home made restore keep it. Conversations tagged opencodex stop opening.", + ], }, { name: "eject", aliases: [], usage: "ocx eject [back]", summary: "Restore native Codex config without stopping the proxy; `eject back` re-points codex at the running proxy.", + details: [ + "--remove-codex-provider-table Also remove [model_providers.opencodex] when a paginated home made restore keep it. Conversations tagged opencodex stop opening.", + ], }, { name: "recover-history", diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 15a7e3b8344..426e70624c2 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -335,6 +335,17 @@ class CodexHistoryIntegrityError extends Error { */ export const HISTORY_RELABEL_STANDS_DOWN = "history_paginated_requires_native_writer"; +/** + * The narrower reason: a provider-table transition found an `openai`-tagged row Codex has + * already paginated. It is not a plain stand-down, because the transition also takes the root + * `openai_base_url` out, and that combination would send the conversation to Codex's built-in + * OpenAI endpoint rather than this proxy. + * + * A constant for the same reason as the one above: `src/codex/inject/paginated-openai-compat.ts` + * decides what to do about it, and a literal repeated in two files is how the pair drifts apart. + */ +export const HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE = "history_paginated_openai_requires_native_writer"; + function assertLegacyHistoryRecord(line: string): void { let value: unknown; try { value = JSON.parse(line); } catch { throw new CodexHistoryIntegrityError("history_rollout_record_invalid"); } @@ -459,7 +470,7 @@ export function preflightCodexHistoryInjection( } assertLegacyHistoryWritable(row.rollout_path); } - if (foundPaginatedOpenaiRow) return "history_paginated_openai_requires_native_writer"; + if (foundPaginatedOpenaiRow) return HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE; return foundPaginatedRow ? HISTORY_RELABEL_STANDS_DOWN : null; } catch (error) { return error instanceof CodexHistoryIntegrityError diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 54c83105898..f88edfe67b0 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -41,6 +41,7 @@ import { writeJournal, } from "./journal"; import { HISTORY_RELABEL_STANDS_DOWN, preflightCodexHistoryInjection } from "./history-provider"; +import { applyPaginatedOpenaiCompat } from "./inject/paginated-openai-compat"; import { describeHistoryJobFailure, deriveCodexHistoryOperation, @@ -347,12 +348,13 @@ async function injectCodexConfigImpl( // first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and // codex re-appends that stale first line whenever it writes git or memory-mode metadata. // - // Authless is excluded on purpose: its whole point is a provider that carries - // requires_openai_auth = false, and admission-token forms cannot use the root key at all. - // Those two forms therefore keep their existing behaviour, forward-tagging resume history with - // originals backed up, and that includes the case where a user enables authless and client - // compaction together. Only the compaction-only form skips the history unit. - const keepRootOverrideAlongsideTable = providerTableMode + // Authless is excluded here on purpose: its whole point is a provider that carries + // requires_openai_auth = false, so it forward-tags resume history with originals backed up + // instead, and that includes the case where a user enables authless and client compaction + // together. Only the compaction-only form skips the history unit up front. When forward + // tagging turns out to be impossible because Codex already paginated those rows, the same + // retention is selected below from the preflight verdict rather than from the routing form. + let keepRootOverrideAlongsideTable = providerTableMode && routingTarget.clientCompaction === true && routingTarget.desktopAuthless !== true && routingTarget.requiresAdmissionToken !== true; @@ -472,14 +474,15 @@ async function injectCodexConfigImpl( if (observed && observed !== HISTORY_RELABEL_STANDS_DOWN) throw new CodexHistoryPreflightRefusal(observed); return observed; }; - const observedHistoryRefusal = historyPreflight(); + const compat = applyPaginatedOpenaiCompat(historyPreflight(), routingTarget, content, eol); + content = compat.content; + keepRootOverrideAlongsideTable ||= compat.retainedRootOverride; + const observedHistoryRefusal = compat.refusal; if (observedHistoryRefusal && observedHistoryRefusal !== HISTORY_RELABEL_STANDS_DOWN) { return { success: false, historyPreflightFailureReason: observedHistoryRefusal, - message: `Codex config injection refused: ${observedHistoryRefusal}. ` - + "Existing provider definitions and conversation files were preserved. " - + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.", + message: compat.message, }; } let historyRelabelRefusal = observedHistoryRefusal; @@ -607,11 +610,11 @@ async function injectCodexConfigImpl( atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { // A root override is ours whenever we wrote one and no user-owned value won. That is - // loopback Design B, and now also the client-compaction form, which keeps the same - // marker-owned root line beside its provider table. Journaling it matters because the - // marker comment is not durable: the Codex app can reserialize config.toml and drop - // comments, and restore then has only the journaled value to tell our line from a user's - // (#1798). The other table forms never write the key, so they still record null. + // loopback Design B, the client-compaction form, and any table form that retained the + // root line for a paginated openai row, all of which keep the marker-owned line beside + // the table. Journaling it matters because the marker comment is not durable: the Codex + // app can reserialize config.toml and drop comments, and restore then has only the + // journaled value to tell our line from a user's (#1798). Other table forms record null. injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl ? null : rootTomlString(content, "openai_base_url"), diff --git a/src/codex/inject/paginated-openai-compat.ts b/src/codex/inject/paginated-openai-compat.ts new file mode 100644 index 00000000000..eb4e3301f11 --- /dev/null +++ b/src/codex/inject/paginated-openai-compat.ts @@ -0,0 +1,90 @@ +/** + * The way forward for a provider-table transition that finds an `openai`-tagged row + * Codex has already migrated to paginated history. + * + * The refusal this resolves is correct about the danger and wrong about the remedy. + * A provider-table transition takes the root `openai_base_url` out, and a paginated row + * cannot be relabeled, so the transition as planned would send that conversation to + * Codex's built-in OpenAI endpoint. Refusing to relabel is right. Refusing the whole + * transition left #5321's reporter with 173 conversations and two unsupported exits: + * delete them, or downgrade. + * + * There is a third state, and the injector already builds it for the client-compaction + * form: keep the marker-owned root override beside the provider table. Codex merges the + * override onto its built-in `openai` entry when it builds the provider map, so the + * paginated row keeps reaching this proxy while never being rewritten. The transition + * completes, the relabel stands down, and no rollout byte or thread row is touched. + * + * Two cases cannot reach that state. An admission-token form cannot use the root key at + * all, because Codex's built-in `openai` entry carries no `x-opencodex-api-key` header; + * that one keeps the refusal and names what the operator can actually do. A root line the + * user owns is left alone, and the conversation follows their configuration rather than + * this proxy — the same guarantee the injector already makes everywhere else about a line + * it does not own. + */ +import { HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, HISTORY_RELABEL_STANDS_DOWN } from "../history-provider"; +import { applyEol, setRootOpenaiBaseUrlForTarget, stripInjectedOpenaiBaseUrl } from "./config-toml"; +import type { CodexRoutingTarget } from "./routing-target"; + +export interface PaginatedOpenaiCompatDecision { + /** The refusal that survives. `null` only when the preflight raised none. */ + readonly refusal: string | null; + /** Candidate config bytes, carrying the retained root override when one was written. */ + readonly content: string; + /** True only when OpenCodex wrote the retained line and must journal it as its own. */ + readonly retainedRootOverride: boolean; + /** What the caller reports when `refusal` is not the stand-down reason. */ + readonly message: string; +} + +function genericRefusal(refusal: string): string { + return `Codex config injection refused: ${refusal}. ` + + "Existing provider definitions and conversation files were preserved. " + + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly."; +} + +/** + * Named next actions, because "do not retry" is what trapped the reporter. Both are + * configuration the operator already owns: the loopback listener is what makes the root + * override usable, and `syncResumeHistory` is the existing opt-out from history remapping. + */ +function admissionTokenRefusal(): string { + return `Codex config injection refused: ${HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE}. ` + + "Existing provider definitions and conversation files were preserved. " + + "This home has conversations tagged openai whose history Codex has already migrated to its paginated " + + "format, and they cannot be relabeled. Keeping them on this proxy needs a root openai_base_url override, " + + "which this routing form cannot use: Codex's built-in openai provider carries no x-opencodex-api-key header. " + + "To complete the transition, either route Codex through the loopback listener " + + "(unauthenticatedLoopbackListener.enabled, or a loopback hostname) so the override can be retained, " + + "or set syncResumeHistory to false to accept that those conversations resume against Codex's own OpenAI endpoint."; +} + +/** + * Resolve the transition, retaining the root override when this routing form can own one. + * + * `content` is the fully assembled provider-table candidate: OpenCodex's own root override + * has already been stripped and not re-added, so a root `openai_base_url` still present in it + * belongs to the user. + */ +export function applyPaginatedOpenaiCompat( + refusal: string | null, + target: CodexRoutingTarget, + content: string, + eol: "\r\n" | "\n", +): PaginatedOpenaiCompatDecision { + if (refusal !== HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE) { + return { refusal, content, retainedRootOverride: false, message: refusal ? genericRefusal(refusal) : "" }; + } + if (target.requiresAdmissionToken === true) { + return { refusal, content, retainedRootOverride: false, message: admissionTokenRefusal() }; + } + const written = setRootOpenaiBaseUrlForTarget(stripInjectedOpenaiBaseUrl(applyEol(content, "\n")), target); + // The relabel still stands down either way: the row stays tagged openai and paginated, and + // the caller uses this reason to skip the history unit rather than let it start and refuse. + return { + refusal: HISTORY_RELABEL_STANDS_DOWN, + content: written.keptUserBaseUrl ? content : applyEol(written.content, eol), + retainedRootOverride: !written.keptUserBaseUrl, + message: "", + }; +} diff --git a/structure/codex-home.md b/structure/codex-home.md index c7a6ef5248f..47177b946ae 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -321,6 +321,8 @@ What a detected migration does depends on which refusal it is, and on direction. On apply, that reason retires the relabel unit and the config/profile/journal write stands when the admitted candidate preserves any existing provider table. Retention is decided before witness construction and does not depend on history preflight passing: apply keeps any existing provider definition while selecting the requested root provider. This also protects references when native migration begins after artifact commit or during worker startup, without compensating over newer native writes. Background worker failures remain reported, and candidate bytes never change after admission. Any other reason there — an unreadable state database, a changed rollout identity, a preflight that could not run — may succeed on a later attempt, so it still restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. +`history_paginated_openai_requires_native_writer` is the one apply-side reason that is neither of those. It means a provider-table transition found an `openai`-tagged row already paginated, and the transition as planned would take the root `openai_base_url` out from under it. `applyPaginatedOpenaiCompat` in `src/codex/inject/paginated-openai-compat.ts` resolves it in the same window as the provider-table retention above, before the witness: it keeps the marker-owned root override beside the table and downgrades the reason to the stand-down constant, so the relabel unit never starts and the paginated row is neither read nor written. The retained line is journaled as OpenCodex's own, which is what lets restore remove it later; a line the user owns is left in place and journaled as theirs. Only an admission-token form still refuses, because Codex's built-in `openai` entry cannot carry `x-opencodex-api-key`, and that refusal names the configuration that resolves it rather than telling the operator not to retry (#5321). + On restore and removal, that same reason no longer refuses the config half. It selects a degraded restore: every OpenCodex root routing key comes out, `[model_providers.opencodex]` is retained verbatim including its ownership marker, and the history relabel is skipped rather than attempted. The retained table is captured from the pre-transform bytes and re-appended into the same buffer, so the write is one atomic transformation — a config carrying root `model_provider = "opencodex"` without a matching table fails the whole Codex config load, not one thread, which makes that intermediate state strictly worse than the routing it replaces. `resolveRestoreHistoryDisposition` in `src/codex/inject/restore.ts` is the single place that reads the preflight reason and answers the separate question of whether routing may come out. Every other reason keeps the hard refusal and compensates on every artifact, because retiring a provider definition its thread rows still name would orphan them. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. It no longer costs the home its ability to be uninstalled: synchronous and asynchronous restore, inline journal restore, and direct config removal all take routing down on that reason while keeping the provider table, so an already-paginated home can be stopped and uninstalled and plain `codex` returns to the built-in provider. Rows naming `opencodex` still resolve through the retained table; their requests reach a proxy that is gone and fail with an ordinary connection error, which is a per-conversation failure rather than a broken config. `ocx restore --remove-codex-provider-table` removes the table for a user who accepts that those conversations stop opening; nothing selects it implicitly. diff --git a/structure/config.md b/structure/config.md index 18e805c284b..12cf9acbd10 100644 --- a/structure/config.md +++ b/structure/config.md @@ -231,9 +231,24 @@ converged and suppress the relabel permanently. That stand-down applies only when the provider tags left in place still resolve through the resulting configuration. A provider-table transition that finds a paginated `openai` row returns -`history_paginated_openai_requires_native_writer` and refuses the artifact transaction: removing -the root `openai_base_url` without relabeling that row would route a resumed conversation through -Codex's built-in OpenAI provider instead of this proxy. +`history_paginated_openai_requires_native_writer`, because removing the root `openai_base_url` +without relabeling that row would route a resumed conversation through Codex's built-in OpenAI +provider instead of this proxy. That reason selects a third state rather than a refusal: +`src/codex/inject/paginated-openai-compat.ts` keeps the marker-owned root override beside the +provider table, exactly as the client-compaction form already does, and the transition completes +with the relabel standing down. Codex merges the override onto its built-in `openai` entry when +it builds the provider map, so the row keeps reaching this proxy while never being rewritten, and +the retained value is journaled as OpenCodex's own so restore can still take it out. + +Two cases cannot reach that state. An admission-token form cannot use the root key at all — +Codex's built-in entry carries no `x-opencodex-api-key` header — so it keeps the refusal, and the +message names the two configuration keys that resolve it (`unauthenticatedLoopbackListener`, +`syncResumeHistory`) instead of saying only "do not retry". A root line the user owns is left +alone and the conversation follows the destination they chose, which is the same guarantee the +injector makes everywhere else about a line it does not own. Refusing the whole transition with +no named way forward was the 2.60.0 regression in #5321: nothing was written, the integration +stayed disabled, and the only exits a reporter could find were deleting the affected +conversations or downgrading. Rows this home tagged `opencodex` resolve through a `[model_providers.opencodex]` table. Apply retains that existing definition before building the candidate witness, even when diff --git a/tests/cli/cli-restore-back.test.ts b/tests/cli/cli-restore-back.test.ts index a750ae8ee92..a25461bd3c6 100644 --- a/tests/cli/cli-restore-back.test.ts +++ b/tests/cli/cli-restore-back.test.ts @@ -185,6 +185,14 @@ describe("ocx restore back", () => { const restoreHelp = runCli(["help", "restore"], env); expect(restoreHelp.status).toBe(0); expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain("ocx restore [back]"); + // #4812 left the escape hatch undiscoverable: the flag worked, but no help text named + // it, so a user whose paginated restore kept the provider table had nothing to read. + // Read the flag out of the parser rather than restating it, so a rename cannot pass. + const parsed = readFileSync(join(repoRoot, "src", "cli", "dispatch.ts"), "utf8") + .match(/const removeProviderTable = takeFlag\(restoreArgs, "(--[a-z-]+)"\)/)?.[1]; + expect(parsed).toBeTruthy(); + expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain(parsed!); + expect(`${usage.stdout}\n${usage.stderr}`).toContain(parsed!); } finally { removeTreeWithRetry(codexHome); removeTreeWithRetry(ocxHome); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index e88b4446d4d..0615fb2d374 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -785,14 +785,19 @@ describe("injectCodexConfig integration (Design B)", () => { expect(restoredRowBytes).toBe(rowBytes); }); - test("a provider-table transition refuses rather than strand a paginated openai thread", () => { - const original = 'model_provider = "openai"\n# >>> opencodex managed openai_base_url >>>\nopenai_base_url = "http://127.0.0.1:10100/v1"\n# <<< opencodex managed openai_base_url <<<\n'; + test("a provider-table transition keeps a paginated openai thread on the proxy instead of refusing", () => { + // #5321. The transition used to be refused outright, so nothing was written and the + // integration stayed disabled. It now completes by keeping the marker-owned root override + // beside the table: the row is never relabeled, and it still resolves to this proxy. + // The reporter's shape: a loopback root-override home turning on codexDesktopAuthless. + const original = `${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "http://127.0.0.1:10100/v1"\nmodel = "gpt-5.5"\n`; const configPath = join(codexHome, "config.toml"); writeFileSync(configPath, original); const rollout = join(codexHome, "openai-paginated.jsonl"); const bytes = JSON.stringify({ ordinal: 0, type: "session_meta", payload: { id: "fixture", history_mode: "paginated", model_provider: "openai" } }) + "\n"; writeFileSync(rollout, bytes); - const db = new Database(join(codexHome, "state_5.sqlite")); + const dbPath = join(codexHome, "state_5.sqlite"); + const db = new Database(dbPath); db.run("CREATE TABLE threads (id TEXT, rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); db.run("INSERT INTO threads VALUES ('fixture', ?, 'openai', 'paginated')", rollout); db.close(); @@ -800,10 +805,27 @@ describe("injectCodexConfig integration (Design B)", () => { const result = runInject(codexHome, ocxHome, JSON.stringify({ codexDesktopAuthless: true })); expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ - success: false, - historyPreflightFailureReason: "history_paginated_openai_requires_native_writer", + success: true, + historyPreflightFailureReason: "history_paginated_requires_native_writer", }); - expect(readFileSync(configPath, "utf8")).toBe(original); + const written = readFileSync(configPath, "utf8"); + expect(written).toContain("[model_providers.opencodex]"); + expect(written).toContain(`${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "http://127.0.0.1:10100/v1"`); + // The safety property the refusal existed to protect: the paginated row is untouched and + // still tagged openai, and the retained override is what keeps it reaching the proxy. + expect(readFileSync(rollout, "utf8")).toBe(bytes); + const after = new Database(dbPath, { readonly: true }); + expect(after.query("SELECT model_provider, history_mode FROM threads WHERE id = 'fixture'").all()) + .toEqual([{ model_provider: "openai", history_mode: "paginated" }]); + after.close(); + + // The other half of the trap (#4812): unblocking the transition is worth nothing if the + // retained override then cannot come back out. Restore journals it as ours, so it does. + const restored = JSON.parse(runRestore(codexHome, ocxHome).stdout); + expect(restored.success).toBe(true); + const native = readFileSync(configPath, "utf8"); + expect(native).not.toContain("openai_base_url"); + expect(native).not.toContain('model_provider = "opencodex"'); expect(readFileSync(rollout, "utf8")).toBe(bytes); }); diff --git a/tests/codex-integration/history-paginated-openai-compat.test.ts b/tests/codex-integration/history-paginated-openai-compat.test.ts new file mode 100644 index 00000000000..7a7f10b8901 --- /dev/null +++ b/tests/codex-integration/history-paginated-openai-compat.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { applyPaginatedOpenaiCompat } from "../../src/codex/inject/paginated-openai-compat"; +import { + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, + HISTORY_RELABEL_STANDS_DOWN, +} from "../../src/codex/history-provider"; +import { OCX_ROUTING_MARKER_LINE } from "../../src/codex/injected-marker"; +import type { CodexRoutingTarget } from "../../src/codex/inject/routing-target"; +import { repoPath } from "../helpers/repo-root"; + +const BASE_URL = "http://127.0.0.1:10100/v1"; +const loopback: CodexRoutingTarget = { + baseUrl: BASE_URL, + requiresAdmissionToken: false, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", + desktopAuthless: true, +}; +const admissionToken: CodexRoutingTarget = { + baseUrl: "https://proxy.example/v1", + requiresAdmissionToken: true, + tokenEnv: "OPENCODEX_API_AUTH_TOKEN", +}; + +// The provider-table candidate as the injector hands it over: root model_provider plus the +// table, and no root openai_base_url, because a table transition takes that key out. +const TABLE_CANDIDATE = [ + 'model_provider = "opencodex"', + 'model = "vendor/routed-model"', + "", + "[model_providers.opencodex]", + `base_url = "${BASE_URL}"`, + "", +].join("\n"); + +describe("paginated openai compatibility (#5321)", () => { + test("a loopback table transition retains the root override and stands the relabel down", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE, "\n", + ); + // Standing down rather than clearing the reason is what keeps the caller from starting the + // relabel unit at all: the paginated row must not be rewritten, only kept resolvable. + expect(decision.refusal).toBe(HISTORY_RELABEL_STANDS_DOWN); + expect(decision.retainedRootOverride).toBe(true); + expect(decision.content).toContain(`${OCX_ROUTING_MARKER_LINE}\nopenai_base_url = "${BASE_URL}"`); + // The table has to survive alongside it, or new authless threads lose their provider. + expect(decision.content).toContain("[model_providers.opencodex]"); + expect(decision.content).toContain('model_provider = "opencodex"'); + }); + + test("the retained override is written before the first table, where Codex reads root keys", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE, "\n", + ); + const lines = decision.content.split("\n"); + expect(lines.findIndex(line => line.startsWith("openai_base_url"))) + .toBeLessThan(lines.findIndex(line => line.startsWith("["))); + }); + + test("CRLF config keeps its line endings through the retention", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, TABLE_CANDIDATE.replace(/\n/g, "\r\n"), "\r\n", + ); + expect(decision.content).toContain("\r\n"); + expect(decision.content.replace(/\r\n/g, "")).not.toContain("\n"); + }); + + test("a root line the user owns is left alone and never journaled as ours", () => { + const userOwned = `openai_base_url = "https://my-gateway.example/v1"\n${TABLE_CANDIDATE}`; + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, loopback, userOwned, "\n", + ); + // The transition still completes — the conversation follows the destination the user chose, + // which is the same guarantee the injector makes everywhere else about a line it does not own. + expect(decision.refusal).toBe(HISTORY_RELABEL_STANDS_DOWN); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(userOwned); + }); + + test("an admission-token form keeps the refusal and names both ways out", () => { + const decision = applyPaginatedOpenaiCompat( + HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE, admissionToken, TABLE_CANDIDATE, "\n", + ); + expect(decision.refusal).toBe(HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(TABLE_CANDIDATE); + // The regression was not the refusal, it was "do not retry" with nowhere to go. Both named + // remedies must be real configuration keys, so they are asserted against the config type. + const configSource = readFileSync(repoPath("src", "types", "config.ts"), "utf8"); + for (const key of ["syncResumeHistory", "unauthenticatedLoopbackListener"]) { + expect(decision.message).toContain(key); + expect(configSource).toContain(`${key}?:`); + } + expect(decision.message).not.toContain("do not run legacy recovery"); + }); + + test("every other reason passes through with the refusal text it always had", () => { + const decision = applyPaginatedOpenaiCompat("history_rollout_identity_changed", loopback, TABLE_CANDIDATE, "\n"); + expect(decision.refusal).toBe("history_rollout_identity_changed"); + expect(decision.retainedRootOverride).toBe(false); + expect(decision.content).toBe(TABLE_CANDIDATE); + expect(decision.message).toContain("do not run legacy recovery or retry this transition blindly"); + // A plain stand-down is not this resolver's business and must reach the caller untouched. + expect(applyPaginatedOpenaiCompat(HISTORY_RELABEL_STANDS_DOWN, loopback, TABLE_CANDIDATE, "\n")) + .toMatchObject({ refusal: HISTORY_RELABEL_STANDS_DOWN, retainedRootOverride: false, content: TABLE_CANDIDATE }); + expect(applyPaginatedOpenaiCompat(null, loopback, TABLE_CANDIDATE, "\n")) + .toMatchObject({ refusal: null, retainedRootOverride: false, content: TABLE_CANDIDATE }); + }); + + test("the refusal code is defined once and read from that definition", () => { + // The pair drifted once already between apply and restore. A literal in a second file is + // how it drifts again, so the only occurrences allowed are the constant and its consumers. + const provider = readFileSync(repoPath("src", "codex", "history-provider.ts"), "utf8"); + const occurrences = provider.split(HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE).length - 1; + expect(occurrences).toBe(1); + expect(readFileSync(repoPath("src", "codex", "inject", "paginated-openai-compat.ts"), "utf8")) + .not.toContain(`"${HISTORY_PAGINATED_OPENAI_NEEDS_ROOT_OVERRIDE}"`); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53fc4cbab3d..309a38d223a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -652,6 +652,7 @@ "health-scoring.test.ts": "server", "history-migration-guardian.test.ts": "codex-integration", "history-ocx-compaction-recovery.test.ts": "codex-integration", + "history-paginated-openai-compat.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", "hyperbolic-provider.test.ts": "providers",