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 9a603b910a..8fff31edaf 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -421,4 +421,6 @@ Codex. Seule l'exécution explicite de `ocx stop` ou `ocx service stop` restaure 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. +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. + Ne réécrivez pas un historique paginé actif ni une ligne de conversation pour forcer une migration. Fermez la conversation avant toute récupération et signalez l’erreur exacte et les versions sans publier de données privées. Une sauvegarde ou le succès d’un script ne prouve pas le rétablissement de l’affichage : vérifiez la conversation après réouverture de Codex. diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index 5b333ad44a..2394bf5e42 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif. -`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. +`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sous POSIX ou `windows_inspection_deferred` sous Windows. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache. L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 31dcb9ac94..837f1cdb9b 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -270,7 +270,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable`. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. +`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sur les systèmes POSIX. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. Comme cette étape ne consulte jamais l’état persistant, une exécution Windows pour laquelle aucun candidat d’environnement n’a été capturé signale `windows_inspection_deferred` plutôt que `candidate_unavailable` : la commande ne peut pas observer si une CLI Codex est installée, elle signale donc le report de l’inspection au lieu d’affirmer qu’aucun candidat n’existe. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés. + +Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection. ### `ocx config ...` diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index 73b3c6d065..152108fd88 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -170,6 +170,8 @@ redirections, les réponses trop volumineuses et les catalogues invalides sont r écriture locale. L'authentification est facultative et lue uniquement par référence à une variable d'environnement (`--auth-env`), jamais depuis argv. +Les requêtes HTTP en loopback sont refusées avant l’ajout des en-têtes d’authentification ou tout envoi si `HTTP_PROXY` ou `http_proxy` s’applique sans exception correspondante dans `NO_PROXY` ou `no_proxy`. `ALL_PROXY`/`all_proxy` et les paramètres limités à `HTTPS_PROXY`/`https_proxy` ne déclenchent pas cette restriction HTTP ; l’acquisition de catalogues en HTTPS reste autorisée. Le message de refus ne contient ni l’adresse du proxy ni le jeton d’authentification. Les valeurs non vides de `http_proxy` et `no_proxy` ont priorité sur `HTTP_PROXY` et `NO_PROXY`, respectivement. Pour des exceptions compatibles avec Bun, utilisez des noms d’hôte, des entrées `host:port` correspondantes, des adresses IPv6 entre crochets comme `[::1]`, ou `*`, sans URL, chemin ni préfixe `*.`. + Le catalogue et le cache sont écrits sous le verrou de catalogue Codex partagé ; un échec préserve les derniers fichiers valides connus. Des octets identiques constituent une non-opération qui préserve les mtimes. `--restart-codex`, `--restart-app-server-only` et l'alias déprécié diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 877117babe..65e1201acc 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -807,6 +807,8 @@ Catalog sync makes the selected sub-agent models available to Codex; see [Codex When a ChatGPT account is added or reauthenticated, OpenCodex normally verifies it before saving with a small streaming request to the Codex Responses backend. It waits for `response.completed`, defaults to `gpt-5.6-luna`, and retries with `gpt-5.5` on HTTP 400 or HTTP 404. Public errors contain fixed failure categories rather than raw upstream response bodies. +An HTTP 429 from an attempted warmup is reported as `codex_warmup_rate_limited`. Retry after the temporary restriction clears or the usage limit resets; signing in again does not reset these limits. A failed attempted warmup does not add a new account or replace existing credentials. This differs from quota-confirmed deferred registration below, which can save a restricted account without a model request. HTTP 401/403 failures retain the authentication guidance. + If the new OAuth credential's authenticated usage lookup confirms an exhausted 5-hour, weekly, or monthly quota, the account is saved without this model request and shows **Validation pending**. It cannot serve pool requests, even after a restart or token refresh. Once quota recovers, **Refresh quotas** finishes validation: a fresh, complete usage reading with headroom permits one small model request, and only a completed response enables the account. Failed or incomplete readings and failed validation preserve the restriction. Passive account polling does not trigger deferred validation. Unknown usage during initial registration retains the normal warmup gate. `ocx account refresh openai` and `ocx account list openai --quota --refresh` only read usage. Model validation spends quota and requires a human dashboard session: open `ocx gui` and click **Refresh quotas** after recovery. For a headless host, access its dashboard from your browser; an admin token alone does not authorize validation. Validation can complete while an account is paused without resuming or selecting it. Model authorization failures remain visible until successful validation or reauthentication clears them. @@ -875,6 +877,8 @@ When a routed preferred model may receive V2 work from a native ChatGPT parent, When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. That reason no longer refuses the Codex configuration, the reference profile, or the model catalog. `ocx sync` and `ocx start` still write those files and set `model_catalog_json`, so the Codex model picker keeps showing every OpenCodex-routed model. Only this one reason stands the conversation-history relabel down, because Codex allocates paginated rollout ordinals in its own writer and no retry changes that. Any other history preflight reason — an unreadable state database, a rollout whose identity changed, or a preflight that could not run — still refuses the whole transition and rolls it back, because those may succeed on a later attempt. OpenCodex never modifies paginated rollout files or thread rows in this state. Existing conversations keep whatever provider they are already tagged with and are not migrated; new conversations route through the proxy normally. When the relabel stands down, a `[model_providers.opencodex]` table that the home already had is kept rather than retired, even in the root-override (loopback) form, so conversations whose rows are tagged `opencodex` keep a provider id that still exists. This includes legacy rows in a migration-capable store. The CLI prints `Codex resume history: left to Codex's native writer (history_paginated_requires_native_writer)`. +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. 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 cc44ec28c3..4f6afdb37e 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -283,4 +283,6 @@ 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]` 定義を外すと、それらの会話は解決できなくなり、復元経路には互換プロバイダー表を残す手段がありません。すでにページ分割されているホームは、現状では製品からアンインストールできません。意図した動作ではなく、既知の未解決作業です。 +ルート URL 上書き方式に戻すとき、履歴の事前確認が成功していても、OpenCodex は設定を確定する前に既存の `[model_providers.opencodex]` 定義を保持します。確定後やバックグラウンドの履歴処理開始中に Codex が履歴形式を移行しても、以前の `opencodex` 会話はプロバイダーを引き続き解決できます。新しい会話は選択されたルートプロバイダーを使い、明示的な復元には従来の個別の削除チェックが適用されます。 + 会話を移行しようとして使用中のページ分割履歴やスレッド行を書き換えないでください。復元前に対象の会話を閉じ、個人の履歴を公開せず正確なエラーとバージョンを報告してください。バックアップやスクリプトの成功だけでは表示の復元は証明されません。再度開いた Codex で確認してください。 diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 1279a03713..4b4997acdf 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ 管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。 -`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 +`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable`、Windows では `windows_inspection_deferred` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。 リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 29dc8cd730..1290b65661 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -196,7 +196,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、`candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 +`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このスライスは永続化された選択状態を一切読み取らないため、環境候補がキャプチャされていない Windows 実行では `candidate_unavailable` ではなく `windows_inspection_deferred` を報告します。コマンドは Codex CLI が導入されているかどうかを観測できないので、候補が存在しないと断定せず、検査が延期されたことを報告します。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。 + +Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 74f3b3151b..ee7604688b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -172,6 +172,8 @@ Codex のローカル モデル ピッカー キャッシュを無効にし、 カタログは、ローカル書き込みの前に拒否されます。認証は任意で、環境変数参照 (`--auth-env`) から のみ読み取られ、argv からは読み取られません。 +`HTTP_PROXY` または `http_proxy` が適用され、`NO_PROXY` または `no_proxy` に一致する除外設定がない場合、ループバック HTTP リクエストは認証ヘッダーの付与や送信より前に拒否されます。`ALL_PROXY`/`all_proxy`、または `HTTPS_PROXY`/`https_proxy` だけの設定では、この HTTP 制限は適用されず、HTTPS によるカタログ取得は引き続き許可されます。拒否メッセージにプロキシのアドレスや認証トークンは含まれません。 空でない `http_proxy` と `no_proxy` は、それぞれ `HTTP_PROXY` と `NO_PROXY` より優先されます。Bun に対応する除外ルールには、ホスト名、一致する `host:port`、`[::1]` のように角括弧で囲んだ IPv6 アドレス、または `*` を使い、URL、パス、`*.` 接頭辞は使わないでください。 + カタログとキャッシュは共有の Codex カタログロックの下で書き込まれ、失敗時は last-known-good の ファイルが保持されます。バイトが同一の場合は mtime を保持する no-op です。`--restart-codex`、 `--restart-app-server-only`、非推奨エイリアス `--restart-desktop-app` は、実際の書き込みの後に 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 0d049a01fc..0d92b524e5 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -387,4 +387,6 @@ 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]` 정의를 걷어내면 그 대화를 해석할 수 없고, 복원 경로에는 호환 프로바이더 테이블을 남겨 둘 방법이 없습니다. 이미 페이지 분할된 홈은 지금은 제품으로 제거할 수 없습니다. 의도한 동작이 아니라 알려진 미해결 작업입니다. +루트 URL 재정의 방식으로 돌아갈 때 OpenCodex는 기록 사전 점검이 통과하더라도 기존 `[model_providers.opencodex]` 정의를 설정 적용 전에 유지합니다. 설정 적용 후나 백그라운드 기록 작업 시작 중에 Codex가 기록 형식을 전환해도 이전 `opencodex` 대화가 제공자를 계속 찾을 수 있습니다. 새 대화는 선택된 루트 제공자를 사용하며, 명시적 복원에는 기존의 별도 제거 검사가 적용됩니다. + 대화를 강제로 이전하려고 실행 중인 페이지 분할 대화 원본이나 스레드 행을 고치지 마세요. 복구 전에 해당 대화를 닫은 뒤, 개인 대화 내용을 올리지 말고 정확한 오류와 버전을 보고하세요. 백업이나 스크립트 성공만으로 표시 복구가 증명되지는 않으므로 Codex를 다시 열어 확인하세요. diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 3b384c20f0..f75de642c5 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s 관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다. -`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. +`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`, Windows에서는 `windows_inspection_deferred`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다. 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index be2834963a..b64ca7fc3b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -226,7 +226,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. +`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 조각은 저장된 선택 상태를 전혀 읽지 않으므로, 환경 후보가 캡처되지 않은 Windows 실행은 `candidate_unavailable`이 아니라 `windows_inspection_deferred`를 보고합니다. 명령이 Codex CLI 설치 여부를 관측할 수 없으므로, 후보가 없다고 단정하는 대신 검사가 연기되었음을 보고합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다. + +Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다. ### `ocx config ...` 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 66ab5f9829..2cac37ed24 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -256,6 +256,8 @@ Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카 자격증명, 쿼리, 프래그먼트, 리다이렉트, 크기를 넘는 응답, 잘못된 카탈로그는 로컬에 쓰기 전에 거절합니다. 인증은 선택이며 환경변수 이름(`--auth-env`)으로만 읽고 argv로는 받지 않습니다. +`HTTP_PROXY` 또는 `http_proxy`가 적용되고 `NO_PROXY` 또는 `no_proxy`에 일치하는 우회 항목이 없으면 루프백 HTTP 요청은 인증 헤더를 붙이거나 요청을 보내기 전에 거부됩니다. `ALL_PROXY`/`all_proxy` 또는 `HTTPS_PROXY`/`https_proxy`만 설정한 경우에는 이 HTTP 제한에 해당하지 않으며, HTTPS 카탈로그 취득은 계속 허용됩니다. 거부 메시지에는 프록시 주소나 인증 토큰이 포함되지 않습니다. 값이 비어 있지 않은 `http_proxy`와 `no_proxy`는 각각 `HTTP_PROXY`와 `NO_PROXY`보다 우선합니다. Bun과 호환되는 우회 규칙에는 호스트 이름, 일치하는 `host:port`, `[::1]`처럼 대괄호로 감싼 IPv6 주소 또는 `*`를 사용하고, URL·경로·`*.` 접두사는 사용하지 마세요. + 카탈로그와 캐시는 공유 Codex 카탈로그 잠금 아래에서 쓰고, 실패하면 직전까지 정상이던 파일을 그대로 둡니다. 바이트가 같으면 mtime까지 건드리지 않는 no-op입니다. `--restart-codex`, `--restart-app-server-only`, 폐기 예정 별칭 `--restart-desktop-app`은 실제로 쓴 뒤에만 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index d91e6c1865..5497199aca 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -56,7 +56,7 @@ remain report-only (`managed: false`, normally `selection_unattested`) and `sele The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +`candidate_unavailable` on POSIX or `windows_inspection_deferred` on Windows. On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; every other Windows candidate fails closed. The command does not install or repair software, execute Codex or npm, control a running process, or write configuration/cache state. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 51ada47a5b..dadd0c55b0 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -369,13 +369,18 @@ environment and persisted candidates remain report-only (`managed: false`, norma `selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context; a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report -`candidate_unavailable`. On Windows this first slice performs no candidate or configuration filesystem I/O: +`candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O: only a proof-captured absolute environment candidate can receive lexical app-bundle or version-manager labels; -every other Windows candidate fails closed. The command does not execute Codex or a package manager, repair a shim, +every other Windows candidate fails closed. Because that slice never consults persisted state, a Windows run +with no captured environment candidate reports `windows_inspection_deferred` rather than `candidate_unavailable`: +the command cannot observe whether a Codex CLI is installed, so it reports the deferral instead of asserting +that no candidate exists. The command does not execute Codex or a package manager, repair a shim, write configuration or cache state, stop a process, or install anything. App-bundled, recognized version-manager, unverified standalone, and ambiguous shim states are reported as unmanaged or unknown and are never classified as managed. +On Windows, a captured bare command such as `CODEX_CLI_PATH=codex`, a remote path, or a device path reports `candidate_path_unavailable` instead. Those cases have a captured candidate; its path is not eligible for this inspection. + ### `ocx config ...` Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index dd1f07c711..32ff382d69 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -304,7 +304,11 @@ before rebuilding the cache. It works even when the local Codex integration desi The URL must be HTTPS; loopback HTTP is accepted for local testing. Embedded URL credentials, queries, fragments, redirects, oversized responses, malformed JSON, duplicate or unsafe slugs, and -unknown `input_modalities` are refused before any local write. Authentication is optional and is +unknown `input_modalities` are refused before any local write. + +Loopback HTTP requests are refused before authentication headers are attached or any request is sent when `HTTP_PROXY` or `http_proxy` applies without a matching `NO_PROXY` or `no_proxy` bypass. `ALL_PROXY`/`all_proxy` and settings limited to `HTTPS_PROXY`/`https_proxy` do not trigger this HTTP restriction; HTTPS catalog acquisition remains allowed. The refusal message includes neither the proxy address nor the authentication token. Nonempty `http_proxy` and `no_proxy` take precedence over `HTTP_PROXY` and `NO_PROXY`, respectively. For Bun-compatible bypass rules, use hostnames, matching `host:port` entries, bracketed IPv6 addresses such as `[::1]`, or `*`; do not use URLs, paths, or `*.` prefixes. + +Authentication is optional and is read only by environment-variable reference: ```bash 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 1cca5f59fd..1394091fe9 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -415,4 +415,6 @@ 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]`, пока строки тредов на него ссылаются, сделало бы эти разговоры неразрешимыми, а путь восстановления не умеет оставлять таблицу совместимости провайдера. Домашний каталог, уже переведённый на постраничную историю, сейчас нельзя удалить средствами продукта; это известная открытая задача, а не задуманное поведение. +При возврате к режиму переопределения корневого URL OpenCodex сохраняет существующее определение `[model_providers.opencodex]` до фиксации конфигурации, даже если предварительная проверка истории успешна. Поэтому старые разговоры `opencodex` сохраняют доступ к своему провайдеру, если Codex преобразует историю после фиксации или во время запуска фоновой обработки. Новые разговоры используют выбранный корневой провайдер; явное восстановление по-прежнему выполняет отдельные проверки удаления. + Не переписывайте активную постраничную историю или строку треда, чтобы самостоятельно перенести разговоры. Закройте разговор перед восстановлением и сообщите точную ошибку и версии без публикации личной истории. Наличие резервной копии или успешный скрипт не доказывает восстановление отображения: проверьте разговор после повторного открытия Codex. diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index e25eff1afd..0901634ee2 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -31,7 +31,7 @@ runtime port и проверку identity, а не поддерживая вто явно документированные как offline-операции с конфигурацией, вместо этого могут валидировать и редактировать файл конфигурации без живого прокси. -`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. +`ocx system codex-cli-update check` не требует работающего прокси и не обращается к реестру пакетов. Команда в строго ограниченном объёме проверяет метаданные происхождения настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX или `windows_inspection_deferred` в Windows. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не устанавливает и не восстанавливает ПО, не запускает Codex или npm, не управляет работающими процессами и ничего не записывает в конфигурацию или кеш. Там, где это недвусмысленно, `list` или `status` являются действием по умолчанию. Для структурированных снимков используйте `--json`, а для потокового лога запросов — diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index f2175ec154..0b81ba9d59 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -253,7 +253,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable`. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. +`check` не обращается к реестру пакетов и в строго ограниченном объёме проверяет данные о происхождении настроенного кандидата, включая замаскированный путь к исполняемому файлу и подтверждения его принадлежности. Доверенный контекст опубликованного средства запуска подтверждает только подлинность снимка данных о кандидате, но не факт успешного запуска Codex. Поскольку команда выполняет только такую проверку и никогда не запускает Codex, кандидаты из окружения и сохранённых данных отображаются только в отчёте (`managed: false`, обычно `selection_unattested`). В выводе JSON присутствуют `candidateAvailable`, `candidateVersion`, `candidateSource` и `selectionAttested`, причём значение `selectionAttested` всегда равно `false`. Для проверки настроенного кандидата нужен доверенный контекст опубликованного средства запуска. При прямом запуске через Bun или из исходного кода такого подтверждения нет; в этом случае команда игнорирует кандидатов из окружения и сохранённых данных и может вернуть `candidate_unavailable` в POSIX-системах. В Windows этот первый этап вообще не выполняет файловый ввод-вывод по путям кандидата или конфигурации. Только абсолютный кандидат из окружения, зафиксированный доверенным средством запуска, может получить лексическую метку комплекта приложения или менеджера версий; все остальные кандидаты Windows отклоняются по принципу fail-closed. Поскольку этот этап вообще не читает сохранённое состояние выбора, запуск в Windows без захваченного кандидата из окружения возвращает `windows_inspection_deferred`, а не `candidate_unavailable`: команда не может определить, установлен ли Codex CLI, поэтому сообщает об отложенной проверке, а не утверждает, что кандидата нет. Команда не запускает Codex или менеджер пакетов, не восстанавливает shim, ничего не записывает в конфигурацию или кеш, не останавливает процессы и ничего не устанавливает. Кандидаты, входящие в комплект приложения, найденные в распознанных путях менеджеров версий, являющиеся непроверенными автономными установками или имеющие неоднозначное состояние shim, отображаются как `unmanaged` или `unknown` и никогда не классифицируются как `managed`. + +В Windows захваченная команда без полного пути, например `CODEX_CLI_PATH=codex`, удалённый путь или путь устройства возвращает `candidate_path_unavailable`. Кандидат захвачен, но его путь не подходит для этой проверки. ### `ocx config ...` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 04315f0e2a..4de2c64624 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -253,6 +253,8 @@ loopback. Учётные данные в URL, query, фрагменты, ред каталоги отклоняются до любой локальной записи. Аутентификация необязательна и читается только по имени переменной окружения (`--auth-env`), но не из argv. +HTTP-запросы к loopback отклоняются до добавления заголовков аутентификации и отправки запроса, если применяется `HTTP_PROXY` или `http_proxy`, а в `NO_PROXY` или `no_proxy` нет подходящего исключения. `ALL_PROXY`/`all_proxy` и настройки только `HTTPS_PROXY`/`https_proxy` не вызывают это ограничение для HTTP; получение каталогов по HTTPS остаётся разрешённым. Сообщение об отказе не содержит адрес прокси или токен аутентификации. Непустые значения `http_proxy` и `no_proxy` имеют приоритет над `HTTP_PROXY` и `NO_PROXY` соответственно. Для совместимых с Bun правил обхода прокси используйте имена хостов, совпадающие записи `host:port`, IPv6-адреса в квадратных скобках, например `[::1]`, или `*`; не используйте URL, пути или префикс `*.`. + Каталог и кэш пишутся под общей блокировкой каталога Codex; при сбое сохраняются last-known-good файлы. Идентичные байты — это no-op, сохраняющий mtime. `--restart-codex`, `--restart-app-server-only` и устаревший alias `--restart-desktop-app` здесь означают то же, что 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 436c995bdb..17a8630c21 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -472,4 +472,6 @@ service stop` yerel Codex'i geri yükler. 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. +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. + Konuşmaları kendiniz taşımak için etkin sayfalanmış geçmişi veya iş parçacığı satırını yeniden yazmayın. Kurtarmadan önce konuşmayı kapatın ve özel geçmişi yayımlamadan tam hatayı ve sürümleri bildirin. Yedek veya başarılı betik görüntünün düzeldiğini kanıtlamaz; Codex’i yeniden açıp konuşmayı kontrol edin. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index a36e60fed7..b3b79d854c 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -36,7 +36,7 @@ yönetim API'sine gidiş-dönüş yapar. Durdurulmuş veya erişilemeyen bir pro yapılandırma işlemleri olarak açıkça belgelenen komutlar, bunun yerine canlı bir proxy olmadan yapılandırma dosyasını doğrulayabilir ve düzenleyebilir. -`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. +`ocx system codex-cli-update check` canlı proxy gerektirmez ve paket kayıt defterine istek göndermez. Yapılandırmada belirtilen kurulum adayına ilişkin provenance meta verilerini, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik denetim Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX'te `candidate_unavailable`, Windows'ta ise `windows_inspection_deferred` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut yazılım kurmaz veya onarmaz, Codex ya da npm çalıştırmaz, çalışan bir sürece müdahale etmez ve yapılandırmaya ya da önbellek durumuna yazmaz. Belirsiz olmayan yerlerde liste veya durum varsayılandır. Yapılandırılmış anlık görüntüler için `--json` ve akışlı bir istek günlüğü akışı için `ocx observe diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index f533038cf5..75a6f63bd1 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -304,7 +304,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. +`check` paket kayıt defterine istek göndermez ve yapılandırmada belirtilen kurulum adayına ilişkin provenance kanıtını, maskelenmiş yürütülebilir dosya konumu ve sahiplik kanıtı dâhil, sınırlı biçimde inceler. Yayımlanmış başlatıcıdan gelen güvenilir bağlam aday anlık görüntüsünü doğrular; Codex'in başarıyla çalıştırıldığını doğrulamaz. Bu tek seferlik komut Codex'i hiçbir zaman çalıştırmadığından, ortamdan ve kalıcı kayıtlardan gelen adaylar yalnızca raporlanır (`managed: false`, genellikle `selection_unattested`). JSON çıktısında `candidateAvailable`, `candidateVersion` ve `candidateSource` alanları bulunur; `selectionAttested` değeri ise `false` kalır. Yapılandırmada belirtilen kurulum adayını incelemek için yayımlanmış başlatıcıdan gelen güvenilir bağlam gerekir; Bun ile veya kaynak koddan doğrudan başlatıldığında bu kanıt bulunmadığından ortamdaki ve kalıcı kayıtlardaki aday durumu yok sayılır ve POSIX sistemlerinde `candidate_unavailable` bildirilebilir. Windows'ta bu ilk parça, aday veya yapılandırma yollarında hiçbir dosya sistemi G/Ç işlemi yapmaz. Yalnızca güvenilir başlatıcının yakaladığı mutlak bir ortam adayı sözcüksel olarak uygulama paketi ya da sürüm yöneticisi etiketi alabilir; diğer tüm Windows adayları kapalı başarısızlıkla reddedilir. Bu parça kalıcı seçim durumunu hiç okumadığından, ortam adayı yakalanmamış olan Windows çalıştırmaları `candidate_unavailable` yerine `windows_inspection_deferred` bildirir: komut bir Codex CLI'nin kurulu olup olmadığını gözlemleyemez, bu yüzden aday bulunmadığını iddia etmek yerine incelemenin ertelendiğini bildirir. Komut Codex veya bir paket yöneticisi çalıştırmaz, shim'i onarmaz, yapılandırmaya ya da önbellek durumuna yazmaz, hiçbir süreci durdurmaz ve hiçbir şey kurmaz. Uygulamayla birlikte paketlenmiş adaylar, tanınan sürüm yöneticisi yollarında bulunan adaylar, doğrulanmamış bağımsız adaylar ve belirsiz shim durumları `unmanaged` veya `unknown` olarak raporlanır; hiçbir zaman `managed` olarak sınıflandırılmaz. + +Windows üzerinde `CODEX_CLI_PATH=codex` gibi yalın bir komut, uzak yol veya aygıt yolu aday olarak yakalanırsa `candidate_path_unavailable` bildirilir. Aday yakalanmıştır; ancak yolu bu inceleme için uygun değildir. ### `ocx config ...` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 4e2175bb38..ca59f39db9 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -266,6 +266,8 @@ yanıtlar ve geçersiz kataloglar, herhangi bir yerel yazma işleminden önce re doğrulama isteğe bağlıdır ve yalnızca ortam değişkeni adıyla (`--auth-env`) okunur, argv'den alınmaz. +`HTTP_PROXY` veya `http_proxy` geçerliyken `NO_PROXY` ya da `no_proxy` içinde eşleşen bir istisna yoksa loopback HTTP istekleri, kimlik doğrulama başlıkları eklenmeden ve herhangi bir istek gönderilmeden reddedilir. `ALL_PROXY`/`all_proxy` ve yalnızca `HTTPS_PROXY`/`https_proxy` ayarları bu HTTP kısıtlamasını tetiklemez; HTTPS üzerinden katalog alımına izin verilmeye devam edilir. Ret mesajı proxy adresini veya kimlik doğrulama belirtecini içermez. Boş olmayan `http_proxy` ve `no_proxy` değerleri sırasıyla `HTTP_PROXY` ve `NO_PROXY` değerlerinden önce gelir. Bun ile uyumlu proxy atlama kuralları için ana makine adları, eşleşen `host:port` girdileri, `[::1]` gibi köşeli parantez içindeki IPv6 adresleri veya `*` kullanın; URL, yol veya `*.` öneki kullanmayın. + Katalog ve önbellek, paylaşılan Codex katalog kilidi altında yazılır; bir hata durumunda last-known-good dosyalar korunur. Aynı baytlar, mtime değerlerini koruyan bir no-op'tur. `--restart-codex`, `--restart-app-server-only` ve kullanımdan kaldırılmış takma ad 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 dfdac6d98a..13612608e9 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 @@ -358,4 +358,6 @@ 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]` 定义会使这些会话无法解析,而恢复路径没有办法留下兼容提供商表。已经分页的主目录目前无法通过产品卸载;这是已知的未完成工作,而非预期行为。 +返回根 URL 覆盖模式时,即使历史预检通过,OpenCodex 也会在提交配置前保留已有的 `[model_providers.opencodex]` 定义。这样,即使 Codex 在提交后或后台历史任务启动时迁移历史格式,旧的 `opencodex` 对话仍能找到其提供商。新对话继续使用所选的根提供商;显式恢复仍执行原有的独立删除检查。 + 不要改写正在使用的分页历史文件或线程行来自行迁移这些会话。恢复前关闭相关会话,并只报告准确的错误和版本,不要公开私人历史。备份或脚本成功并不能证明显示已恢复;重新打开 Codex 后检查会话。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index e804df71af..07ba562646 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -17,7 +17,7 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 -`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 +`ocx system codex-cli-update check` 不需要实时代理,也不会向软件包注册表发起请求。它只会在限定范围内检查已配置候选项的来源元数据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性检查命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会安装或修复软件,不会运行 Codex 或 npm,不会控制正在运行的进程,也不会写入配置或缓存状态。 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index da81deadd6..2dd2fc441b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -203,7 +203,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 +`check` 不会向软件包注册表发起请求,只会在限定范围内检查已配置候选项的来源证据,包括经过脱敏的可执行文件位置和所有权证据。受信任的已发布启动器上下文只能验证该候选项快照,并不证明 Codex 已成功运行。由于这条一次性命令绝不会运行 Codex,来自环境变量和持久化记录的候选项仅用于报告(`managed: false`,通常为 `selection_unattested`);JSON 输出包含 `candidateAvailable`、`candidateVersion` 和 `candidateSource`,且 `selectionAttested` 始终为 `false`。检查已配置候选项需要受信任的已发布启动器上下文;直接使用 Bun 启动或从源码运行时没有这项证明,因此会忽略环境变量和持久化记录中的候选项状态,并可能在 POSIX 系统上报告 `candidate_unavailable`。在 Windows 上,这个首个切片不会对候选路径或配置路径执行任何文件系统 I/O。只有由受信任启动器捕获的绝对环境候选项可以获得应用捆绑或版本管理器的纯词法标签;其他所有 Windows 候选项都会以失败关闭方式处理。由于这个切片完全不读取持久化的选择状态,在未捕获任何环境候选项的 Windows 运行中会报告 `windows_inspection_deferred` 而非 `candidate_unavailable`:该命令无法观测 Codex CLI 是否已安装,因此报告检查被推迟,而不是断言不存在候选项。该命令不会运行 Codex 或软件包管理器,不会修复 shim,不会写入配置或缓存,不会停止进程,也不会安装任何内容。随应用捆绑的候选项、位于已识别版本管理器路径中的候选项、未经验证的独立候选项以及 shim 状态不明确的候选项,都会报告为 `unmanaged` 或 `unknown`,绝不会归类为 `managed`。 + +在 Windows 上,如果捕获到 `CODEX_CLI_PATH=codex` 这样的裸命令、远程路径或设备路径,则报告 `candidate_path_unavailable`。这些情况下候选项已被捕获,但其路径不适用于此检查。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 39787e18f0..2c5e3a4915 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -164,6 +164,8 @@ ocx status --json 安装由另一个 OpenCodex 实例的 `/v1/catalog` 端点提供的完整目录,然后同步 `models_cache.json`。URL 必须是 HTTPS;仅回环地址允许 HTTP。URL 内嵌凭据、查询、片段、重定向、超出大小的响应以及无效目录,都会在任何本地写入之前被拒绝。认证是可选的,并且只通过环境变量名(`--auth-env`)读取,不接受 argv 传入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中没有匹配的绕过规则,回环 HTTP 请求会在添加认证标头或发送请求之前被拒绝。`ALL_PROXY`/`all_proxy` 以及仅设置 `HTTPS_PROXY`/`https_proxy` 的情况不会触发此 HTTP 限制;仍允许通过 HTTPS 获取目录。拒绝消息不会包含代理地址或认证令牌。 非空的 `http_proxy` 和 `no_proxy` 分别优先于 `HTTP_PROXY` 和 `NO_PROXY`。要设置与 Bun 兼容的代理绕过规则,请使用主机名、匹配的 `host:port`、`[::1]` 等带方括号的 IPv6 地址或 `*`,不要使用 URL、路径或 `*.` 前缀。 + 目录和缓存在共享的 Codex 目录锁下写入;失败时保留 last-known-good 文件。字节完全相同时是保留 mtime 的空操作。`--restart-codex`、`--restart-app-server-only` 以及已弃用别名 `--restart-desktop-app` 仅在发生真实写入之后生效,含义与 `ocx sync` / `ocx sync-cache` 相同。`ETag` 条件请求不属于此命令。完整的 `--json` 信封与退出码请参见[英文参考](/reference/cli/lifecycle/)。 ## 后台服务 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 284034b54b..f7bfefac13 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 @@ -365,4 +365,6 @@ 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]` 定義會使這些對話無法解析,而復原路徑沒有辦法留下相容提供者表。已經分頁的家目錄目前無法透過產品解除安裝;這是已知的未完成工作,而非預期行為。 +返回根 URL 覆寫模式時,即使歷史預檢通過,OpenCodex 也會在提交設定前保留既有的 `[model_providers.opencodex]` 定義。如此一來,即使 Codex 在提交後或背景歷史工作啟動時遷移歷史格式,舊的 `opencodex` 對話仍能找到其提供者。新對話繼續使用所選的根提供者;明確要求的還原仍執行原有的獨立刪除檢查。 + 請勿改寫使用中的分頁歷史檔案或執行緒列來自行遷移這些對話。復原前關閉相關對話,只回報確切錯誤與版本,不要公開私人歷史。備份或指令碼成功不能證明顯示已復原;重新開啟 Codex 後確認對話。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 81121d4f51..eef78a98c9 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -28,7 +28,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 -`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 +`ocx system codex-cli-update check` 不需要執行中的代理,也不會向套件 registry 發出請求。它只會在限定範圍內檢查設定中的安裝候選項來源中繼資料,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次檢查命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 POSIX 下的 `candidate_unavailable` 或 Windows 下的 `windows_inspection_deferred`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會安裝或修復軟體、不會執行 Codex 或 npm、不會控制執行中的程序,也不會寫入設定或快取狀態。 沒有歧義時,list 或 status 是預設。使用 `--json` 取得結構化快照,並以 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 7da97b61dc..eea538f483 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -206,7 +206,9 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 +`check` 不會向套件 registry 發出請求,只會在限定範圍內檢查設定中的安裝候選項來源證據,包括經過遮罩的可執行檔位置與所有權證據。正式發布的 launcher 所提供的可信內容只會驗證該候選項快照,並不證明 Codex 已成功執行。由於這個單次命令絕不會執行 Codex,來自環境變數與持久化記錄的候選項只供報告(`managed: false`,通常為 `selection_unattested`);JSON 輸出包含 `candidateAvailable`、`candidateVersion` 與 `candidateSource`,而 `selectionAttested` 維持 `false`。檢查設定中的安裝候選項時,必須有正式發布的 launcher 所提供的可信內容;直接使用 Bun 啟動或從原始碼執行時不具備這項證明,因此會忽略來自環境與持久化記錄的候選項狀態,並可能在 POSIX 系統上報告 `candidate_unavailable`。在 Windows 上,這個首個切片不會對候選路徑或設定路徑執行任何檔案系統 I/O。只有由可信 launcher 擷取的絕對環境候選項可以取得應用程式封裝或版本管理工具的純詞彙標籤;其他所有 Windows 候選項都會以失敗關閉方式處理。由於這個切片完全不會讀取持久化的選擇狀態,在未擷取任何環境候選項的 Windows 執行中會報告 `windows_inspection_deferred` 而非 `candidate_unavailable`:該命令無法觀測 Codex CLI 是否已安裝,因此會報告檢查被延後,而不是斷言候選項不存在。此命令不會執行 Codex 或套件管理工具、不會修復 shim、不會寫入設定或快取、不會停止程序,也不會安裝任何內容。隨應用程式封裝的候選項、位於已識別版本管理工具路徑中的候選項、未經驗證的獨立候選項,以及 shim 狀態不明確的候選項,都會報告為 `unmanaged` 或 `unknown`,絕不會歸類為 `managed`。 + +在 Windows 上,如果擷取到 `CODEX_CLI_PATH=codex` 這類單純命令名稱、遠端路徑或裝置路徑,則回報 `candidate_path_unavailable`。這些情況已有擷取的候選項,但其路徑不適用於此檢查。 ### `ocx config ...` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 02421b11bd..117133bc18 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -158,6 +158,8 @@ ocx status --json 安裝由另一個 OpenCodex 執行個體的 `/v1/catalog` 端點提供的完整目錄,接著同步 `models_cache.json`。URL 必須是 HTTPS;僅回送位址允許 HTTP。URL 內嵌憑證、查詢、片段、重新導向、超出大小的回應以及無效目錄,都會在任何本機寫入之前遭拒。驗證為選用,且只透過環境變數名稱(`--auth-env`)讀取,不接受 argv 傳入。 +如果 `HTTP_PROXY` 或 `http_proxy` 生效,且 `NO_PROXY` 或 `no_proxy` 中沒有相符的略過規則,回送 HTTP 要求會在加入驗證標頭或送出要求之前遭拒。`ALL_PROXY`/`all_proxy` 以及僅設定 `HTTPS_PROXY`/`https_proxy` 的情況不會觸發此 HTTP 限制;仍允許透過 HTTPS 取得目錄。拒絕訊息不會包含代理位址或驗證權杖。 非空的 `http_proxy` 和 `no_proxy` 分別優先於 `HTTP_PROXY` 和 `NO_PROXY`。若要設定與 Bun 相容的代理略過規則,請使用主機名稱、相符的 `host:port`、`[::1]` 等含方括號的 IPv6 位址或 `*`,不要使用 URL、路徑或 `*.` 前綴。 + 目錄與快取在共用的 Codex 目錄鎖之下寫入;失敗時保留 last-known-good 檔案。位元組完全相同時是保留 mtime 的無操作。`--restart-codex`、`--restart-app-server-only` 以及已棄用別名 `--restart-desktop-app` 僅在實際寫入之後生效,含義與 `ocx sync` / `ocx sync-cache` 相同。`ETag` 條件式請求不屬於此命令。完整的 `--json` 信封與結束碼請參見[英文參考](/reference/cli/lifecycle/)。 ## 背景服務 diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index da019297d1..4cb391504a 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -750,7 +750,7 @@ JSON mode: `payload`. ### `ocx system codex-restart` -Restart the Codex app-server. +Restart the Codex desktop app and app-servers. | Method | Route | |---|---| @@ -758,7 +758,7 @@ Restart the Codex app-server. | Flag | Value | Meaning | |---|---|---| -| `--yes` | boolean | Required: restarts the operator's running Codex app-server. | +| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers. | | `--json` | boolean | Emit the restart result as JSON. | JSON mode: `payload`. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 36aa8124cc..309badbfd2 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -710,10 +710,10 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["system", "codex-restart"], - summary: "Restart the Codex app-server.", + summary: "Restart the Codex desktop app and app-servers.", routes: [{ method: "POST", path: "/api/system/codex-restart" }], flags: [ - { name: "--yes", value: "boolean", summary: "Required: restarts the operator's running Codex app-server." }, + { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers." }, { name: "--json", value: "boolean", summary: "Emit the restart result as JSON." }, ], mutates: true, diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index 03eb900887..a3b46b49d6 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -130,14 +130,14 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps = const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE); printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson); } else if (sub === "codex-restart") { - // --yes required: this restarts the user's running Codex app-server, so it is exactly the - // class of action that must not happen because an agent guessed a subcommand. + // --yes required: this fully quits and relaunches the user's Codex desktop app as well as + // restarting app-servers; an agent guessing a subcommand must not interrupt that session. const args = [...rest]; const wantsJson = takeFlag(args, "--json"); const yes = takeFlag(args, "--yes"); - if (!yes) throw new CliUsageError("system codex-restart requires --yes", USAGE); + if (!yes) throw new CliUsageError("system codex-restart requires --yes: this fully quits and relaunches the Codex desktop app and restarts its app-servers", USAGE); rejectArgs(args, USAGE); - printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex app-server restart requested."]); + printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex desktop app and app-server restart requested."]); } else if (sub === "update") await update(rest, deps); else throw new CliUsageError(`unknown system command ${sub}`, USAGE); }); diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts index 1e8885ae11..ab32e137b5 100644 --- a/src/codex/account-store.ts +++ b/src/codex/account-store.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ConfigMutationLockError, @@ -655,10 +655,43 @@ function isRefreshLockStale(path: string): boolean { const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown }; return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS; } catch { - return true; + // The owner creates the file and writes its metadata in two steps, so a live lock is + // briefly unreadable. Age the file itself instead of calling that window stale, which + // let a waiter delete a lock whose owner was still inside its critical section. + try { + return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS; + } catch { + return false; + } } } +function releaseCodexRefreshFileLock(path: string, fd: number): void { + let owned: { dev: bigint; ino: bigint } | null = null; + try { + const info = fstatSync(fd, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino }; + } catch { /* Unknown descriptor identity never authorizes unlink. */ } + try { + withConfigMutationLockSync(() => { + let current: { dev: bigint; ino: bigint } | null = null; + try { + const info = statSync(path, { bigint: true }); + if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino }; + } catch { /* Keep the lock and the callback outcome when the path probe fails. */ } + if (owned && current && current.dev === owned.dev && current.ino === owned.ino) { + try { unlinkSync(path); } catch (err) { + if (errCode(err) !== "ENOENT") throw err; + } + } + }); + } catch (err) { + // Keep the descriptor alive through comparison/unlink so its inode cannot be recycled. + // Unavailable coordination leaves the path without masking the completed refresh. + if (!(err instanceof ConfigMutationLockError)) throw err; + } finally { closeSync(fd); } +} + export async function withCodexRefreshFileLock(lockKey: string, signal: AbortSignal, fn: () => Promise): Promise { hardenConfigDir(); const dir = getConfigDir(); @@ -670,33 +703,45 @@ export async function withCodexRefreshFileLock(lockKey: string, signal: Abort while (fd == null) { if (signal.aborted) throw signal.reason; try { - fd = openSync(path, "wx", 0o600); - writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); - break; - } catch (err) { - if (errCode(err) !== "EEXIST") throw err; - if (isRefreshLockStale(path)) { + // Serialize only metadata operations, never the async refresh callback. Cooperating + // contenders cannot reclaim a successor between stale observation and path mutation. + withConfigMutationLockSync(() => { try { - unlinkSync(path); - } catch (unlinkErr) { - if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + fd = openSync(path, "wx", 0o600); + writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n"); + } catch (err) { + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ } + throw err; + } + if (errCode(err) !== "EEXIST") throw err; + if (isRefreshLockStale(path)) { + try { unlinkSync(path); } catch (unlinkErr) { + if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr; + } + } } - continue; + }); + } catch (err) { + // A failed SQLite commit can follow successful file creation; it still owns an fd. + if (fd != null) { + const failedFd = fd; + fd = null; + try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ } } - if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); - await sleep(REFRESH_LOCK_POLL_MS, signal); + if (!(err instanceof ConfigMutationLockError)) throw err; } + if (fd != null) break; + if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError(); + await sleep(REFRESH_LOCK_POLL_MS, signal); } try { return await fn(); } finally { - if (fd != null) closeSync(fd); - try { - unlinkSync(path); - } catch (err) { - if (errCode(err) !== "ENOENT") throw err; - } + releaseCodexRefreshFileLock(path, fd); } } diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts index ad384e9539..fa6d1c6fbf 100644 --- a/src/codex/auth-api/login-flow.ts +++ b/src/codex/auth-api/login-flow.ts @@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { emailMaskingEnabled, projectEmail } from "../../lib/privacy"; -import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup"; +import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup"; import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types"; import type { CatalogDisposition } from "../convergence-types"; import { isValidCodexAccountId } from "../account-id"; @@ -51,6 +51,17 @@ export async function verifyCodexAccountWarmup( return { ok: true, validatedAt: Date.now() }; } catch (err) { const reason = codexWarmupFailureReason(err); + if (err instanceof CodexWarmupError && err.code === "http_status" && err.status === 429) { + return { + ok: false, + response: jsonResponse({ + error: "Codex account warmup was rate limited. Retry later or after the account's usage limit resets.", + code: "codex_warmup_rate_limited", + reason, + accountId, + }, 429), + }; + } return { ok: false, response: jsonResponse({ @@ -322,10 +333,11 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, ? { ok: true as const, validatedAt: undefined } : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); if (!warmup.ok) { - const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string }; + const body = await warmup.response.json().catch(() => ({})) as { error?: string; code?: string; reason?: string }; setCodexLoginState(flowId, { status: "error", error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed", + code: body.code, doneAt: Date.now(), }); completed = true; diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index 232904c520..03997ea2a9 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -153,7 +153,9 @@ export function createResetCreditWhamClient(config: OcxConfig, accountId: string signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } - return safeResetCreditConsumeDto(await resp.json()); + const parsed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!parsed.ok) throw new Error("invalid upstream reset-credit consume response"); + return safeResetCreditConsumeDto(parsed.value); }), }; } @@ -383,7 +385,14 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, if (identity) markManualResetCreditOperationAmbiguous(identity); return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } - const result = safeResetCreditConsumeDto(await resp.json()); + const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (!consumed.ok) { + // The spend may already have landed upstream and its outcome code is unreadable, + // so this id must never come back as a new operation. + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: "Invalid upstream reset-credit consume response" }, 502); + } + const result = safeResetCreditConsumeDto(consumed.value); if (identity) { // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` // normalizes anything unrecognized to "unknown", and settling that diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts index 0273a19052..8f62879384 100644 --- a/src/codex/catalog/model-visibility.ts +++ b/src/codex/catalog/model-visibility.ts @@ -290,6 +290,7 @@ export function filterCatalogVisibleModels( } return models.filter(m => { if (initialModelSelectionPending(config.providers[m.provider])) return false; + if (config.providers[m.provider]?.disabled === true) return false; const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). for (const stored of disabled) { diff --git a/src/codex/catalog/remote.ts b/src/codex/catalog/remote.ts index 46576b18e4..9a6b534cda 100644 --- a/src/codex/catalog/remote.ts +++ b/src/codex/catalog/remote.ts @@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number { ? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS; } +/** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */ +function catalogRequestUsesBunHttpProxy(url: URL): boolean { + if (url.protocol !== "http:") return false; + const proxy = process.env.http_proxy || process.env.HTTP_PROXY; + if (!proxy || proxy === '""' || proxy === "''") return false; + const hostname = url.hostname.toLowerCase(); + const host = url.host.toLowerCase(); + // Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII + // whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization. + const bypasses = process.env.no_proxy || process.env.NO_PROXY || ""; + for (let entry of bypasses.split(",")) { + entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "") + .replace(/[A-Z]/g, letter => letter.toLowerCase()); + if (entry === "*") return false; + if (entry.startsWith(".")) entry = entry.slice(1); + if (!entry) continue; + const hasPort = entry.startsWith("[") + ? entry.includes("]:") + : (entry.match(/:/g)?.length ?? 0) === 1; + if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false; + } + return true; +} + export async function fetchRemoteCatalog( input: string, options: Pick = {}, ): Promise<{ document: RemoteCatalogDocument; content: string }> { const url = validateRemoteCatalogUrl(input); const token = validateToken(options.token); + if (catalogRequestUsesBunHttpProxy(url)) { + throw new RemoteCatalogError( + "insecure_http_refused", + "Loopback HTTP catalog requests must bypass outbound HTTP proxy routing", + ); + } const headers = new Headers({ Accept: "application/json" }); if (token !== undefined) headers.set("Authorization", `Bearer ${token}`); let response: Response; diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index c2da1f84f4..f57bf76efc 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -516,7 +516,8 @@ async function gatherRoutedModelsUncached( // with the same slug below, so that row's provider capability metadata is the inheritance source. const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { - const rawProvider = config.providers[cm.provider]; + const rawProvider = config.providers[cm.provider]?.disabled !== true + ? config.providers[cm.provider] : undefined; const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; // Registry routing backfills an omitted authMode on the built-in OpenAI provider to // forward. Keep the catalog projection on the same contract while still failing closed diff --git a/src/codex/cli-install-provenance.ts b/src/codex/cli-install-provenance.ts index ffca581f5f..e595205518 100644 --- a/src/codex/cli-install-provenance.ts +++ b/src/codex/cli-install-provenance.ts @@ -587,8 +587,14 @@ export async function inspectCodexCliInstall( const platform = deps.platform ?? process.platform; const candidate = observeCodexRuntimeCandidateReadOnly(deps); if (!candidate) { + // This slice reads no candidate or configuration file on Windows, so an + // absent proof-captured environment candidate does not establish that no + // Codex CLI exists: a persisted selection is simply never consulted there. + // Report the deferral that actually happened instead of the stronger claim + // that the candidate is unavailable. POSIX retains its existing result + // when no candidate is observed. return isWindowsPlatform(platform) - ? unknownWindowsReport("candidate_unavailable") + ? unknownWindowsReport("windows_inspection_deferred") : unknownReport("candidate_unavailable"); } diff --git a/src/codex/desktop-app/types.ts b/src/codex/desktop-app/types.ts index 2a4d8774b4..c90862f329 100644 --- a/src/codex/desktop-app/types.ts +++ b/src/codex/desktop-app/types.ts @@ -116,11 +116,20 @@ export interface DesktopAppAdapter { * * `root` is expected to be `realpath`-resolved by discovery already. */ +function isMembershipSeparator(character: string): boolean { + // `/` separates on every platform this runs on, and Windows accepts it wherever it + // accepts `\`. `\` is only a separator where the host says so: it is a legal + // FILENAME character on POSIX, so admitting it there would reopen the sibling hole + // this function exists to close. + return character === "/" || (sep === "\\" && character === "\\"); +} + export function isUnderRoot(executable: string, root: string): boolean { if (!executable || !root) return false; if (executable === root) return true; - const prefix = root.endsWith(sep) ? root : root + sep; - return executable.startsWith(prefix); + if (!executable.startsWith(root)) return false; + if (isMembershipSeparator(root[root.length - 1]!)) return true; + return isMembershipSeparator(executable[root.length] ?? ""); } /** diff --git a/src/codex/desktop-app/windows.ts b/src/codex/desktop-app/windows.ts index 863a20057a..c73e067e5b 100644 --- a/src/codex/desktop-app/windows.ts +++ b/src/codex/desktop-app/windows.ts @@ -29,16 +29,16 @@ const SHELL_BASENAME = "chatgpt.exe"; const POWERSHELL_PROBE_OPTIONS = { timeout: PROBE_TIMEOUT_MS, windowsHide: true } as const; /** - * isUnderRoot prefixes with the host path.sep and is case-sensitive. Windows + * isUnderRoot checks a lexical path boundary and is case-sensitive. Windows * membership is case-insensitive, and this file is executed by Unix CI against - * backslash paths, so both sides are folded onto the host separator first. + * mixed slash paths, so both slash forms are folded onto the host separator first. * The boundary itself — sibling `OpenAI.Codex-evil` must not match root * `OpenAI.Codex` — is still isUnderRoot's, which is why the PowerShell * StartsWith is only a cheap pre-filter. */ function toHostMembershipPath(windowsPath: string): string { const lowered = windowsPath.toLowerCase(); - return sep === "\\" ? lowered : lowered.replaceAll("\\", "/"); + return lowered.replace(/[\\/]/g, sep); } function isMemberExecutable(executable: string, root: string): boolean { @@ -91,10 +91,10 @@ function listPackageProcesses(exec: DesktopExec, install: DesktopAppInstall): De const literal = install.root.replace(/'/g, "''"); const script = [ "$ErrorActionPreference='SilentlyContinue'", - `$root = '${literal}'`, + `$root = '${literal}'.Replace('/', '\\')`, "$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name", "Get-CimInstance Win32_Process -Filter \"Name='ChatGPT.exe'\" |", - " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($root, 'OrdinalIgnoreCase') } |", + " Where-Object { $_.ExecutablePath -and $_.ExecutablePath.Replace('/', '\\').StartsWith($root, 'OrdinalIgnoreCase') } |", " ForEach-Object {", " $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner", " if ($o -and $o.ReturnValue -eq 0 -and $o.User) {", diff --git a/src/codex/inject.ts b/src/codex/inject.ts index ea6424e0eb..a01909a9c7 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -465,9 +465,9 @@ async function injectCodexConfigImpl( */ /* * Re-observed inside the artifact transaction. A store that migrates to paginated history - * mid-write retires the relabel unit, because the config half writes no history and rolling - * it back is what left every paginated home with no OpenCodex models. Any other reason is - * still treated as a failed transition so compensation can restore the pre-images. + * mid-write can retire the relabel unit while its already-admitted candidate leaves + * existing provider references resolvable. Existing provider definitions are retained + * before the witness; no post-commit compensation may overwrite a newer native write. */ const observeHistoryRefusalOrThrow = (known: string | null): string | null => { if (known) return known; @@ -489,12 +489,12 @@ async function injectCodexConfigImpl( /* * Rows this home may have tagged `opencodex` resolve only through a provider table. Design B - * normally retires that table because the relabel migrates those rows back to `openai` in - * the same pass; with the relabel stood down, stripping it anyway would leave every such - * conversation pointing at a provider id that no longer exists. Keep what was already - * published, and keep it BEFORE the witness so the lock admits the bytes actually written. + * selects built-in `openai` for new work, but background relabel and native publication are + * not atomic. Codex can paginate after the final check or when the worker starts. Retain + * an existing definition BEFORE the witness regardless of preflight, so worker failure + * cannot orphan old references. Explicit restoration keeps its removal and history guards. */ - if (historyRelabelRefusal && hadOcxProviderTableOnDisk && !providerTableMode) { + if (hadOcxProviderTableOnDisk && !providerTableMode) { content = applyEol( content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})), eol, @@ -787,6 +787,7 @@ async function injectCodexConfigImpl( // handed down fixed; the Worker never takes a direction from its caller. // A stood-down relabel unit spawns no Worker: the preflight it would run first has // already refused, and the config half is committed either way. + historyArtifactStageForTests?.("before-history-worker"); const historyOutcome: CodexHistoryJobOutcome = historyRelabelRefusal ? { kind: "skipped" } : await runCodexHistoryJob({ @@ -984,4 +985,3 @@ export { setBeforeRestoreConfigForTests, skippedRestoreEnvelope, } from "./inject/restore"; - diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index 7be16980c6..a8ae8c7af5 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -44,7 +44,7 @@ async function drainErrorBody(res: Response, signal: AbortSignal): Promise fatalUtf8: true, }); } catch (error) { - if (signal.aborted) { + if (signal.aborted && res.status !== 429) { throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: error, }); diff --git a/structure/catalog.md b/structure/catalog.md index bf9d6e3751..df51b86339 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -4,10 +4,14 @@ Catalog discovery remains separate from the Responses final-route [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). +## Remote catalog HTTP proxy routing + +`src/codex/catalog/remote.ts` permits loopback HTTP only when Bun fetch has no effective HTTP proxy or a matching NO_PROXY bypass. Its local matcher follows [Bun fetch semantics](https://github.com/oven-sh/bun/blob/744846f844374847c902b5e7fd59b4342a51ef99/src/dotenv/env_loader.rs#L369), including non-empty lowercase-variable priority, ASCII whitespace, literal host/port comparison and bracket-preserving IPv6. It does not normalize URL-shaped bypass entries, paths, wildcard prefixes, trailing dots or Unicode whitespace, and leaves the broader WebSocket proxy grammar unchanged. It refuses before authentication headers and fetch with a content-free `insecure_http_refused` error. ALL_PROXY and HTTPS-only settings do not affect HTTP acquisition; HTTPS and existing redirect, size, validation and coordinated-installation contracts are preserved. `tests/codex-integration/catalog-remote-pull.test.ts` covers these routing and non-disclosure boundaries. + ## Shared catalog `src/codex/catalog.ts` builds a shared Codex-shaped catalog for CLI, TUI, App, and SDK. It: @@ -37,6 +41,8 @@ Shared parsing and streaming follow the [request-copy](transports/byte-accountin native rows from the output without rewriting the pristine backup or unrelated snapshots; - invalidates `$CODEX_HOME/models_cache.json` when model visibility changes. +`src/codex/catalog/model-visibility.ts` also excludes models owned by disabled providers, including custom rows. `src/codex/catalog/routed-gather.ts` does not inherit provider configuration into custom rows while that provider is disabled. + On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. This split is required because empty or partial provider discovery must @@ -143,6 +149,8 @@ removal markers. These presentation operations do not grant routing or account e ## Startup readiness +When the desktop app is explicitly restarted to reload synchronized state, [process membership](runtime.md#codex-desktop-process-membership) is determined from its installation path; catalog model selectors do not identify restart targets. + Each `startServer` invocation owns a private, one-shot readiness gate created before the listener binds. `handleStart` supplies its gate and transitions it only after the shared catalog sync and best-effort Claude Code roster reconciliation have both settled. The catalog sync remains the @@ -249,6 +257,9 @@ Pool mode routes across main plus added Codex credentials. Key rules: generation it started from still holds; a lost race raises a generation-conflict error rather than overwriting the newer credential (`src/codex/account-store.ts`). Callers handle that error; they do not assume a silent retry. + The lock itself is identity-scoped: a not-yet-readable lock counts as held until it ages out, + and release requires a usable matching descriptor identity. Unknown identity leaves the path + for stale recovery without replacing the callback outcome when the path probe fails; confirmed-owner unlink errors other than `ENOENT` still propagate. Acquisition, stale reclamation and identity-checked release run inside the existing synchronous SQLite config-mutation transaction; the async refresh callback runs outside it. Release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Busy release coordination preserves the callback outcome and leaves the path for stale recovery. This serializes cooperating writers; stat/unlink is not atomic against non-cooperating filesystem writers. - **Authentication identity, quota domain, and cache domain are tracked separately** (`src/routing/identity-domains.ts`). `classifyCredential` returns all three with provenance: `pool.credentialGroups` supplies operator-declared quota domains, a small built-in table @@ -368,7 +379,7 @@ Provider `showThinkingSummary` is a Responses request default; it does not rewri ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 01a583c182..a8a7e5a719 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -17,6 +17,8 @@ The hub-side CLI dashboard uses the [management ingress address](../runtime.md#h Native main reauthentication follows the [CLI JSON output contract](../runtime.md#native-main-reauth-json-output). +The Codex restart command follows the [CLI restart scope contract](../runtime.md#cli-codex-restart-scope). + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/structure/codex-home.md b/structure/codex-home.md index 339358ea9b..850c7ac386 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -1,5 +1,11 @@ # Codex Home +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + +A lock in the Codex credential store is governed by [descriptor identity and age](catalog.md#accounts-namespaces-and-pool-rotation), so the mere presence of its filename is neither acquisition nor release authority. Failed path-identity probes leave the lock for stale recovery and preserve the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + +CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). + ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from @@ -232,6 +238,8 @@ to snapshot persistence instead of relying on the progress argument alone. ## Codex-home diagnostics +Desktop executable membership uses the [discovered installation root](runtime.md#codex-desktop-process-membership), independently of the Codex state directory resolved here. + Some Codex-home conditions are reported rather than repaired, because repairing them would overwrite a deliberate user choice: @@ -252,7 +260,7 @@ Plan-based automatic exclusions leave native credential files untouched and pres Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Native restore also rechecks after successful journal restoration or fallback removal, while exact config/profile/journal preimages and any coordinated remove transaction remain available for compensation. -What a detected migration does depends on which refusal it is, and on direction. On apply, `history_paginated_requires_native_writer` retires the relabel unit and the config/profile/journal write stands: it is permanent, so compensating it only produced a home with no OpenCodex models at all. 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. Restore and removal compensate on every reason, 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. +What a detected migration does depends on which refusal it is, and on direction. On apply, `history_paginated_requires_native_writer` 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. Restore and removal compensate on every reason, 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. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on that refusal, so an already-paginated home cannot yet be uninstalled through the product; apply instead writes its config and keeps a `[model_providers.opencodex]` table the home already published, so rows naming that provider keep resolving. diff --git a/structure/config.md b/structure/config.md index d00b36b06b..e7ffd45769 100644 --- a/structure/config.md +++ b/structure/config.md @@ -1,7 +1,11 @@ # Config Surface +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + +Configuration consumers retain the [refresh-lock ownership boundary](catalog.md#accounts-namespaces-and-pool-rotation); failing to establish a usable matching lock identity does not authorize deleting its path or replacing the refresh callback outcome with a path-probe error. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). Connected-client catalog diagnostics use the [terminal rendering contract](runtime.md#cli-readiness-diagnostics) on the first connection and on every `ocx sync` refresh; stored catalog values are unchanged. @@ -9,6 +13,8 @@ Hub management ingress also selects the [local dashboard address](runtime.md#hub Native main reauthentication follows the [CLI JSON output contract](runtime.md#native-main-reauth-json-output). +The Codex restart command follows the [CLI restart scope contract](runtime.md#cli-codex-restart-scope). + ## Config surface ### OpenCodex home and live process state @@ -74,6 +80,8 @@ management API. Retirement does not migrate user-selected model ids or erase usa ## Config injection +An explicit desktop restart after injection uses the [runtime process-membership contract](runtime.md#codex-desktop-process-membership); mixed Windows path spelling does not change which installation the restart targets. + `src/codex/inject.ts` writes one of two forms. The choice is not cosmetic: it decides whether Codex keeps its native provider id, which decides whether existing thread history still resolves. @@ -166,8 +174,10 @@ discovery or catalog/cache replacement. Deterministic config and ownership refus leave the existing catalog and cache untouched, and their concrete messages are emitted on stderr. Exactly one conversation-history refusal scopes the relabel unit instead of vetoing the apply transition, and only because it is permanent. Codex allocates paginated rollout ordinals inside -its own writer, so `history_paginated_requires_native_writer` is not retryable: the transition -writes config, profile, and `model_catalog_json`, the relabel job is skipped without spawning +its own writer, so `history_paginated_requires_native_writer` is not retryable: when the admitted +candidate preserves any existing provider table, the transition writes config, profile, +and `model_catalog_json`. +On successful apply, the relabel job is skipped without spawning its Worker, and the reason travels in the human message and in the structured `historyPreflightFailureReason` field *alongside* `success: true`. Every other reason — an unreadable state database, a rollout whose identity changed, a preflight that could not run — @@ -175,12 +185,14 @@ describes a store that may be relabelable on the next attempt, so those keep the and the compensating rollback. Recording them as a stand-down would mark the transition converged and suppress the relabel permanently. -Standing the relabel down changes what the routing form may retire. Rows this home tagged -`opencodex` resolve only through a `[model_providers.opencodex]` table; the loopback form -normally retires that table precisely because the relabel migrates those rows back to `openai` -in the same pass. With the relabel stood down, a table the home already published survives the -write, so those conversations keep a provider id that exists. Paginated rollout bytes and thread -rows are never modified in this state. +Rows this home tagged `opencodex` resolve through a `[model_providers.opencodex]` table. +Apply retains that existing definition before building the candidate witness, even when +history preflight passes. The root-override form still selects the built-in provider for new +conversations. Background history work is not atomic with config publication, so its future +success cannot authorize retiring the old definition first. If native pagination begins after +artifact commit or while the worker starts, the old references still resolve and any worker +failure is reported. Paginated rollout bytes and thread rows remain untouched. Explicit +restore and removal retain their separate guards below. Treating the refusal as a veto is what made every current Codex home unusable: paginated rollouts refuse unconditionally, so `model_catalog_json` never reached config.toml and both the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 4628ca5e50..0502f6d7c4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -4,11 +4,11 @@ The shared server request path follows the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). ## Dashboard serving -The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts +Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts the proxy when needed and opens `http://localhost:`, or `http://127.0.0.1:` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address). All ordinary HTTP responses (excluding successful WebSocket upgrades) include `X-Frame-Options: DENY` and @@ -241,7 +241,7 @@ unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the current state needs remediation or merely reports how routing is protected. Its warning state is derived from active Codex routing plus the actual service and launcher-shim installation state; the -`codexAutoStart` preference alone is never presented as proof of restart protection. The page shows +`codexAutoStart` preference alone is never presented as proof of restart protection. Desktop restart target selection follows the [runtime membership contract](runtime.md#codex-desktop-process-membership); finding an installed app does not establish background-service protection. The page shows copyable repair commands (`ocx service repair` for an installed service or `ocx service install` when none is registered, `ocx codex-shim install`, and `ocx restore`). On Windows it can also install an owned, per-user system tray. The resident tray owns only its icon, home-scoped singleton, and HKCU Run registration; fixed proxy actions delegate to the CLI so drain, @@ -458,7 +458,7 @@ estimated` split exists for, and why coverage is reported alongside totals. The main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. -A row also carries what its logical request cost upstream. `logicalRequestId` names the turn +A row also records the upstream cost of its logical request. `logicalRequestId` names the turn that a retry leg, a repair refetch and a combo child all belong to, and `spend` aggregates their physical sends: `sends` totals every attempt on the row, `settled` counts the sends whose attempt reached a terminal status, and `unresolved` holds the rest — an attempt abandoned in flight, or a @@ -607,7 +607,7 @@ The provider editor field policy exposes `showThinkingSummary` as a boolean prov ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 0c0ffe38d0..0fbdcefc3e 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -1,7 +1,11 @@ # Docs And Release +Catalog HTTP acquisition follows the [proxy-routing contract](../catalog.md#remote-catalog-http-proxy-routing). + +Refresh-lock validation covers fresh unreadable locks, descriptor-matched release, path-probe failures preserving callback outcomes, and confirmed-owner unlink error handling in `tests/codex-integration/codex-account-store.test.ts`; the [catalog contract](../catalog.md#accounts-namespaces-and-pool-rotation) explicitly does not promise atomic compare-and-delete. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](../runtime.md#lifecycle). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. @@ -11,6 +15,8 @@ The CLI default dashboard address follows the [management ingress bind](../runti Native main reauthentication follows the [CLI JSON output contract](../runtime.md#native-main-reauth-json-output). +The Codex restart command follows the [CLI restart scope contract](../runtime.md#cli-codex-restart-scope). + ## Public docs The public documentation site lives in `docs-site/` and is built with Astro + Starlight. English is @@ -278,6 +284,8 @@ preview has closed that stable patch line. ## Cross-platform CI +The [desktop membership contract](../runtime.md#codex-desktop-process-membership) has adapter regression coverage on every host and real PowerShell prefilter regression coverage with synthetic CIM rows on Windows in `tests/clients/desktop-app-restart.test.ts`. A skipped Windows lane does not exercise that native filter; uid-dependent POSIX cases in `tests/clients/desktop-app-restart-posix.test.ts` are skipped on Windows. + `.github/workflows/ci.yml` is the ordinary quality gate for runtime/package changes. Linux runs the suite in four shards with a separate `gates` job, and macOS runs it in two shards. Windows runs the full suite in six shards only on manual `workflow_dispatch` with `lane=all` (or an @@ -353,7 +361,7 @@ Provider configuration documents distinguish actual summaries from raw reasoning ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Private pool credential metadata follows the [quota-history publication identity contract](../providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index ab7511c3b0..1a0f8a3970 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -1,7 +1,9 @@ # OpenAI Provider Account Modes +Catalog HTTP acquisition follows the [proxy-routing contract](../catalog.md#remote-catalog-http-proxy-routing). + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](../runtime.md#lifecycle). This current contract supersedes the provider-identity and account-selection sections of `devlog/_fin/260717_openai_hardening`; that archived unit remains historical evidence for the @@ -140,6 +142,10 @@ A confirmed manual reset-credit consumption may immediately reconcile that accou eligible pre-existing ordinary reset-derived cooldown after a complete, non-exhausted usage observation started after the reset. Paused or reauthentication-required accounts and cooldowns held by another in-flight probe remain excluded; their cooldowns are retained. +Confirmation requires a readable answer. Every reset-credit read, including the consume +response on both the manual and background paths, goes through the shared bounded-body +reader, so an upstream answer past that bound is unconfirmed rather than buffered whole. +An unconfirmed manual consume leaves its operation ambiguous and reconciles nothing. Recovery owns the specific cooldown and authenticates main and added Pool accounts through their respective credential contracts. Main usage publication keeps the latest successfully published observation authoritative. Pool recovery @@ -428,9 +434,17 @@ Pool mode needs stable public names and a store that survives concurrent refresh - The credential store is generation-guarded and refresh-locked (`src/codex/account-store.ts`): a refresh persists only if the generation it started from still holds, and a lost race raises a generation-conflict error instead of overwriting the newer credential. + The lock is held and released by file identity rather than by path. A lock that exists but is + not yet readable counts as held until it ages past the stale window, because its owner creates + the file and writes its metadata as two steps, and a holder deletes the lock only while the + path still resolves to the file it created. If descriptor identity is unavailable or unusable, + release leaves the path for stale-lock recovery. Path-probe errors preserve the callback outcome; confirmed-owner unlink errors other than `ENOENT` still propagate. The stat/unlink pair is not an atomic + compare-and-delete against non-cooperating writers. Cooperating acquisition, stale reclamation and release serialize inside the synchronous config-mutation transaction, released before the async callback. Release keeps its descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. ## Sidecars, management, and UI +The desktop restart adapter uses [Windows process ownership and installation membership](../runtime.md#codex-desktop-process-membership), independently of Pool/Direct credential selection. + HTTP/SSE, Responses WebSocket, compact, images, search, and vision resolve the same account mode. There is one mode-aware `openai` forward sidecar candidate; `openai-apikey` is not a ChatGPT-forward sidecar candidate and cannot hide a failed Codex credential with separately billed API usage. @@ -472,7 +486,7 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. `src/codex/auth-api/account-list.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The [explicit model-capability contract](../config.md#explicit-per-model-capability-declarations) preserves operator declarations through provider storage and catalog capture; it does not infer upstream capability or change this surface's routing behavior. @@ -560,10 +574,12 @@ consulted, and they run in `resolveCodexAccountForThreadDetailed` ahead of it. A with no recorded refusal is deliberately not a release path on its own — stickiness until the account actually refuses is intended — but it does surrender the binding as soon as a sibling with headroom exists. Unbound assignment is untouched and still takes the coolest eligible account, -because a fresh request has no warm prefix to lose. `pool.cacheAffinity` remains the stronger -opt-in, raising the bar from the threshold to genuine exhaustion. +because a fresh request has no warm prefix to lose. `pool.cacheAffinity` is enabled by default, +raising the bar from the threshold to genuine exhaustion. Two call sites need the rule — the live path in `reevaluateAffinityQuota` and the side-effect-free `previewReusableAffinityAccount` that subagent fallback reads — and they share one helper rather than restating it, because the suite asserts the two answer identically and a preview that disagreed would hand fallback a different account than the request actually uses. + +`src/codex/auth-api/login-flow.ts` distinguishes HTTP 429 from an attempted warmup as `codex_warmup_rate_limited` and preserves that code in OAuth status. Failed attempted warmup does not persist replacement credentials; quota-confirmed deferred registration and HTTP 401/403 handling remain separate. `src/codex/warmup.ts` retains a known 429 when bounded error-body draining times out. diff --git a/structure/runtime.md b/structure/runtime.md index bd9ebbd561..dd729883f2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -3,6 +3,10 @@ Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + +OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. @@ -20,10 +24,24 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output `src/cli/account-main.ts` emits one JSON object to stdout when `ocx account main reauth --device --no-wait --json` succeeds. The human-readable `follow up:` line is emitted only without `--json`; `flowId` remains available for status polling. `tests/cli/cli-native-profile.test.ts` parses the complete captured stdout and preserves coverage of the human follow-up. +## CLI Codex restart scope + +`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. + ## Hub management dashboard address When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. +## Codex desktop process membership + +`src/codex/desktop-app/windows.ts` discovers the installed package and limits process ownership to the current Windows user. +Its PowerShell prefilter normalizes both the install root and candidate executable from `/` to `\` before a case-insensitive prefix comparison. +The adapter then folds both slash forms onto the host separator before calling `isUnderRoot()` in `src/codex/desktop-app/types.ts`. +That shared lexical boundary check rejects sibling prefixes such as `OpenAI.Codex-evil`; Windows path folding stays in the Windows adapter, so a POSIX backslash remains a filename character. +The prefilter is only an optimization, not final process-membership authority. +`tests/clients/desktop-app-restart.test.ts` covers both mixed-slash directions through the adapter and runs the real PowerShell filter against synthetic CIM rows on Windows. +`tests/clients/desktop-app-restart-posix.test.ts` keeps the POSIX separator contract covered; uid-dependent macOS/Linux cases skip on Windows. + ## Entrypoints | Path | Responsibility | @@ -156,7 +174,11 @@ package-registry request and reads bounded provenance evidence for the configure package metadata, and shim binding. The proof-bound launcher snapshot does not attest successful Codex execution; environment and persisted candidates remain report-only and cannot produce a managed classification in this one-shot command. On Windows this first slice performs no candidate/configuration filesystem I/O: it preserves only proof-captured -absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. +absolute environment candidates for lexical app-bundle/version-manager reporting and otherwise fails closed. That +fail-closed result records which observation was missing: a run with no proof-captured environment candidate reports +`windows_inspection_deferred`, because persisted selection is never consulted there and the command cannot claim that +no Codex CLI exists; a captured candidate whose path is not lexically eligible reports `candidate_path_unavailable`. +POSIX keeps `candidate_unavailable` for an unobserved candidate. This check does not attest or admit a selected runtime. The command exposes no private mutation authority and does not query a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. @@ -375,7 +397,7 @@ Responses route normalization resolves provider summary defaults from the origin ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/subagents.md b/structure/subagents.md index c0c92891f7..cb4938bf41 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -3,6 +3,12 @@ Encrypted-task and fallback request handling follow the Responses [core module ownership](transports/responses.md#core-module-ownership). This surface retains its existing behavior. +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). + +Concurrent refreshes triggered by independent agent work share the [credential refresh-lock contract](catalog.md#accounts-namespaces-and-pool-rotation); unknown lock identity remains available for stale recovery rather than immediate removal, and a failed path probe cannot mask the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. + +CLI installation inspection reason codes, including Windows deferral, follow the [runtime inspection contract](runtime.md#lifecycle). + ## Plaintext V2 agent messages `src/responses/plaintext-v2-agent-messages.ts` owns the experimental, configuration-only @@ -265,6 +271,8 @@ cause delegation. The TOML edit owns only marker-tagged values, preserves existi user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes without changing the file. +An explicit desktop restart to load those defaults follows the [runtime membership checks](runtime.md#codex-desktop-process-membership); selecting a delegation model does not authorize additional restart targets. + V2 proxy guidance uses `` for both built-in metadata and custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, effort, roster and fallback chain without prescribing delegation, spawn overrides or @@ -346,7 +354,7 @@ Final-route summary visibility is recomputed after fallback from the original Re ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates refused restore/removal transitions. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. Apply retains an existing provider definition before candidate admission even when history preflight passes, so migration after artifact commit or during worker startup cannot leave earlier conversations without their provider. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 9a11c696ac..a3d861d14a 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -20,6 +20,40 @@ import { repoPath } from "../helpers/repo-root"; type Recorded = { path: string; method: string; body: unknown }; const servers: Array> = []; +describe("ocx system codex-restart confirmation", () => { + test("names the desktop interruption before any unconfirmed request", async () => { + const { requests, deps } = fakeRuntime(); + const errors = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleSystemCommand(["codex-restart"], deps)).toBe(2); + expect(requests).toHaveLength(0); + const warning = errors.mock.calls.flat().join(" "); + expect(warning).toContain("requires --yes"); + expect(warning).toContain("fully quits and relaunches the Codex desktop app"); + } finally { errors.mockRestore(); } + }); + + test.each([false, true])("preserves requested versus completed outcomes (json=%s)", async wantsJson => { + // A skipped Desktop outcome must survive JSON output; this fixture cannot restart processes. + const result = { success: true, code: "nothing_running", requested: [], stopped: [], + desktopApp: { attempted: false, relaunch: "skipped", reason: "self_ancestry" } }; + const { requests, deps } = fakeRuntime(() => result); + const output = spyOn(console, "log").mockImplementation(() => {}); + try { + const argv = ["codex-restart", "--yes", ...(wantsJson ? ["--json"] : [])]; + expect(await handleSystemCommand(argv, deps)).toBe(0); + expect(requests).toEqual([{ path: "/api/system/codex-restart", method: "POST", body: null }]); + const text = output.mock.calls.flat().join("\n"); + if (wantsJson) expect(JSON.parse(text)).toEqual(result); + else { + expect(text).toContain("Codex desktop app"); + expect(text).toContain("restart requested."); + expect(text).not.toContain("restarted"); + } + } finally { output.mockRestore(); } + }); +}); + describe("ocx system settings client compaction", () => { test("persists the explicit boolean through the shared settings endpoint", async () => { const { requests, deps } = fakeRuntime((_req, body) => ({ ok: true, ...body })); diff --git a/tests/clients/desktop-app-restart-posix.test.ts b/tests/clients/desktop-app-restart-posix.test.ts index dd1bbd8ec6..585c86dcb7 100644 --- a/tests/clients/desktop-app-restart-posix.test.ts +++ b/tests/clients/desktop-app-restart-posix.test.ts @@ -97,9 +97,27 @@ describe("desktop restart membership is a path boundary, not a prefix", () => { expect(isUnderRoot("/usr/lib/chatgpt-evil/ChatGPT", "/usr/lib/chatgpt")).toBe(false); expect(isUnderRoot("/usr/lib/chatgpt/ChatGPT", "/usr/lib/chatgpt")).toBe(true); }); + + test("a forward slash separates on every host, a backslash only where the host says so", () => { + // Windows accepts `/` wherever it accepts `\`, and a probe can return either. Reading a + // forward-slash member as "outside the tree" is fail-closed but wrong: the restart the + // user asked for silently becomes a no-op. + expect(isUnderRoot("C:/Program Files/OpenAI.Codex/chatgpt.exe", "C:/Program Files/OpenAI.Codex")).toBe(true); + expect(isUnderRoot("C:/Program Files/OpenAI.Codex-evil/chatgpt.exe", "C:/Program Files/OpenAI.Codex")).toBe(false); + // The reverse is NOT symmetric. On POSIX a backslash is an ordinary filename + // character, so admitting it as a separator would reopen the sibling hole. + expect(isUnderRoot("/usr/lib/chatgpt\\evil", "/usr/lib/chatgpt")).toBe(process.platform === "win32"); + }); }); -describe("macOS desktop restart", () => { +/** + * The POSIX adapters scope enumeration to the current user through `process.getuid()`, + * which a Windows host does not provide. There the probe correctly reports that it could + * not run, so these cases cannot be driven from Windows at all - the shared ladder they + * exercise is covered by the Ubuntu and macOS shards. The membership and lock cases above + * have no such dependency and keep running everywhere. + */ +describe.skipIf(process.platform === "win32")("macOS desktop restart", () => { test("quits through the Apple event and relaunches by bundle id", () => { const calls: Call[] = []; const result = restartCodexDesktopApp(darwinIo({ calls })); @@ -196,7 +214,7 @@ describe("macOS desktop restart", () => { }); }); -describe("a stop is only ever claimed when the enumeration agrees (measured on Windows)", () => { +describe.skipIf(process.platform === "win32")("a stop is only ever claimed when the enumeration agrees (measured on Windows)", () => { // The defect this pins was invisible to ten rounds of code review and surfaced in the // first thirty seconds of running the ladder on a real Windows host: it reported // {"stopped":[27788],"surviving":[],"relaunch":"started"} while the app kept its @@ -325,4 +343,3 @@ describe("a restart already in flight does not start a second one", () => { expect(calls).toEqual([]); }); }); - diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index 4df9c7a6e9..fd7ae597dc 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; /** @@ -27,6 +29,53 @@ function withTrustedExes(run: () => T): T { interface Call { file: string; args: string[] } +describe.skipIf(process.platform !== "win32")("Windows membership through the real PowerShell prefilter", () => { + for (const [label, root, executable] of [ + ["forward-slash images under a backslash root", INSTALL, INSTALL.replaceAll("\\", "/") + "/ChatGPT.exe"], + ["backslash images under a forward-slash root", INSTALL.replaceAll("\\", "/"), INSTALL + "\\ChatGPT.exe"], + ] as const) { + test(label, () => { + const sibling = executable.replace(/([\\/])ChatGPT\.exe$/, "-evil$1ChatGPT.exe"); + const psLiteral = (value: string) => "'" + value.replaceAll("'", "''") + "'"; + const fixture = [ + // These functions shadow the CIM cmdlets: the generated list-only script + // sees synthetic rows and never enumerates or controls real processes. + "function Get-CimInstance {", + " param([string]$ClassName, [string]$Filter)", + " if ($ClassName -cne 'Win32_Process' -or $Filter -cne \"Name='ChatGPT.exe'\") { throw 'Unexpected fixture query' }", + " @(", + ` [pscustomobject]@{ ProcessId = 1000; ParentProcessId = 900; CreationDate = [datetime]'2026-09-15T00:00:00Z'; ExecutablePath = ${psLiteral(executable)} }`, + ` [pscustomobject]@{ ProcessId = 2000; ParentProcessId = 900; CreationDate = [datetime]'2026-09-15T00:00:00Z'; ExecutablePath = ${psLiteral(sibling)} }`, + " )", + "}", + "function Invoke-CimMethod {", + " param($InputObject, [string]$MethodName)", + " if ($MethodName -cne 'GetOwner' -or $InputObject.ProcessId -notin @(1000, 2000)) { throw 'Unexpected fixture owner query' }", + " [pscustomobject]@{ ReturnValue = 0; Domain = ''; User = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name }", + "}", + ].join("\n"); + let rawListing = ""; + const listed = withTrustedExes(() => windowsDesktopAppAdapter.listProcesses((file, args) => { + expect(file).toBe(PS); + expect(args.slice(0, 3)).toEqual(["-NoProfile", "-NonInteractive", "-Command"]); + const script = args[3]!; + rawListing = execFileSync(file, [...args.slice(0, 3), fixture + "\n" + script], { + encoding: "utf8", + timeout: 10_000, + windowsHide: true, + }); + return rawListing; + }, { id: AUMID.replace("!App", ""), root, relaunch: AUMID })); + // The real prefilter admits both lexical prefixes despite mixed slashes. + // The shared JS boundary check then removes the similarly named sibling. + expect(rawListing.trim().split(/\r?\n/).map(line => Number(line.split(" ")[0]))).toEqual([1000, 2000]); + expect(listed?.map(entry => ({ pid: entry.pid, executable: entry.executable }))).toEqual([ + { pid: 1000, executable }, + ]); + }, 15_000); + } +}); + /** Scripted exec seam: discovery, then process list, then whatever the branch does. */ /** * A lock path this case owns. The restart takes a singleton lock, so a case using the @@ -141,6 +190,34 @@ describe("Codex desktop app restart (#2292)", () => { expect(launch?.args.join(" ")).toContain(AUMID); }); + + for (const [label, root, executable, isMember] of [ + ["forward-slash executable under backslash root", INSTALL, INSTALL.replaceAll("\\", "/") + "/ChatGPT.exe", true], + ["backslash executable under forward-slash root", INSTALL.replaceAll("\\", "/"), INSTALL + "\\ChatGPT.exe", true], + ["forward-slash sibling outside backslash root", INSTALL, INSTALL.replaceAll("\\", "/") + "-evil/ChatGPT.exe", false], + ["backslash sibling outside forward-slash root", INSTALL.replaceAll("\\", "/"), INSTALL + "-evil\\ChatGPT.exe", false], + ] as const) { + test(label, () => { + const calls: Call[] = []; + const result = withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ + discovery: [AUMID.replace("!App", ""), root, AUMID].join("\n"), + processes: `1000 900 T0 ${executable}`, + calls, + aliveFor: (_pid, poll) => poll <= 2, + }))); + if (isMember) { + expect(result).toEqual({ attempted: true, stopped: [1000], surviving: [], relaunch: "started" }); + expect(calls.some(c => c.args.join(" ").includes("CloseMainWindow"))).toBe(true); + } else { + expect(result.reason).toBe("no_targets"); + expect(result.attempted).toBe(false); + expect(calls.some(c => c.args.join(" ").includes("CloseMainWindow"))).toBe(false); + expect(calls.some(c => c.args.join(" ").includes("Start-Process"))).toBe(false); + } + expect(calls.some(c => c.file === TASKKILL)).toBe(false); + }); + } + test("forces only after the graceful window elapses", () => { const calls: Call[] = []; const result = withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ diff --git a/tests/codex-integration/catalog-remote-pull.test.ts b/tests/codex-integration/catalog-remote-pull.test.ts index 02a6768f22..e92e0ccdf6 100644 --- a/tests/codex-integration/catalog-remote-pull.test.ts +++ b/tests/codex-integration/catalog-remote-pull.test.ts @@ -29,6 +29,25 @@ const response = (value: unknown, init: ResponseInit = {}) => new Response(JSON. headers: { "Content-Type": "application/json", ...init.headers }, status: init.status, }); +const proxyEnvKeys = [ + "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", + "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy", +] as const; + +async function withProxyEnv(env: Record, action: () => Promise): Promise { + const previous = proxyEnvKeys.map(key => [key, process.env[key]] as const); + try { + for (const key of proxyEnvKeys) delete process.env[key]; + for (const [key, value] of Object.entries(env)) process.env[key] = value; + await action(); + } finally { + for (const key of proxyEnvKeys) delete process.env[key]; + for (const [key, value] of previous) { + if (value !== undefined) process.env[key] = value; + } + } +} + describe("remote catalog acquisition", () => { test("accepts HTTPS and loopback HTTP but rejects credentials and insecure remote HTTP", () => { expect(validateRemoteCatalogUrl("https://hub.example.com/v1/catalog").href).toBe("https://hub.example.com/v1/catalog"); @@ -53,6 +72,175 @@ describe("remote catalog acquisition", () => { })).rejects.toMatchObject({ code: "redirect_refused", message: "Remote catalog redirect was refused" }); }); + test("refuses proxied loopback HTTP before fetch without disclosing authentication or proxy details", async () => { + const proxy = "http://proxy-user:proxy-secret@127.0.0.2:8080"; + const environments: Record[] = [ + { HTTP_PROXY: proxy }, + { http_proxy: proxy }, + { HTTP_PROXY: "", http_proxy: proxy }, + { HTTP_PROXY: proxy, NO_PROXY: "elsewhere.example" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1:9999" }, + { HTTP_PROXY: proxy, NO_PROXY: "http://127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1/path" }, + { HTTP_PROXY: proxy, NO_PROXY: "*.127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1." }, + { HTTP_PROXY: proxy, NO_PROXY: "\u00a0127.0.0.1\u00a0" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1", no_proxy: "elsewhere.example" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1", no_proxy: " " }, + ]; + for (const env of environments) { + await withProxyEnv(env, async () => { + const fetchImpl = mock(async () => response(catalog)) as typeof fetch; + const error: unknown = await fetchRemoteCatalog("http://127.0.0.1:10100/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(RemoteCatalogError); + expect(error).toMatchObject({ code: "insecure_http_refused" }); + expect(fetchImpl).not.toHaveBeenCalled(); + for (const marker of ["catalog-token-marker", "proxy-user", "proxy-secret", "127.0.0.2", "127.0.0.1"]) { + expect(String(error)).not.toContain(marker); + } + }); + } + }); + + test("permits direct loopback HTTP with matching proxy bypasses or fetch-irrelevant proxy variables", async () => { + const proxy = "http://proxy.example:8080"; + const environments: Record[] = [ + {}, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1" }, + { http_proxy: proxy, no_proxy: "127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "127.0.0.1:10100" }, + { HTTP_PROXY: proxy, NO_PROXY: "*" }, + { HTTP_PROXY: proxy, NO_PROXY: ".127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "elsewhere.example", no_proxy: "127.0.0.1" }, + { HTTP_PROXY: proxy, NO_PROXY: "\v\f127.0.0.1\r\n" }, + { HTTP_PROXY: '""' }, + { http_proxy: "''" }, + { HTTP_PROXY: proxy, http_proxy: '""' }, + { ALL_PROXY: proxy }, + { all_proxy: proxy }, + { HTTPS_PROXY: proxy }, + { https_proxy: proxy }, + ]; + for (const env of environments) { + await withProxyEnv(env, async () => { + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer catalog-token-marker"); + expect(init?.redirect).toBe("manual"); + return response(catalog); + }) as typeof fetch; + await expect(fetchRemoteCatalog("http://127.0.0.1:10100/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + })).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + } + }); + + test.each([ + ["http://127.0.0.1", false], + ["127.0.0.1", true], + ] as const)("real Bun transport respects the catalog guard for NO_PROXY=%s", async (bypass, direct) => { + let targetRequests = 0; + let proxyRequests = 0; + let authenticatedTargetRequests = 0; + const token = "synthetic-catalog-runtime-token"; + let target: ReturnType | undefined; + let proxy: ReturnType | undefined; + let child: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; + let timer: ReturnType | undefined; + let timedOut = false; + try { + target = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(req) { + targetRequests += 1; + if (req.headers.get("authorization") === `Bearer ${token}`) authenticatedTargetRequests += 1; + return response(catalog); + } }); + proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { + proxyRequests += 1; + return response(catalog); + } }); + // Inherit process-launch necessities and test provenance only, never host credentials. + const env: Record = {}; + for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", + "OCX_TEST_HOME_GUARD", "OCX_TEST_RUN_ID"]) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + for (const key of proxyEnvKeys) delete env[key]; + env.OPENCODEX_HOME = home(); + env.CODEX_HOME = home(); + env.HOME = env.USERPROFILE = home(); + // A local dotenv must not override the explicitly supplied routing fixture. + writeFileSync(join(env.OPENCODEX_HOME, ".env"), "no_proxy=*\n"); + env.HTTP_PROXY = `http://127.0.0.1:${proxy.port}`; + env.NO_PROXY = bypass; + const source = new URL("../../src/codex/catalog/remote.ts", import.meta.url).href; + const script = ` + const { fetchRemoteCatalog } = await import(${JSON.stringify(source)}); + try { + const result = await fetchRemoteCatalog(${JSON.stringify(`http://127.0.0.1:${target.port}/v1/catalog`)}, + { token: ${JSON.stringify(token)} }); + console.log(JSON.stringify({ document: result.document })); + } catch (error) { + console.log(JSON.stringify({ code: error?.code ?? "unexpected_error" })); + } + `; + child = Bun.spawn([process.execPath, "--no-env-file", "--eval", script], { cwd: env.OPENCODEX_HOME, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + timer = setTimeout(() => { timedOut = true; child?.kill("SIGKILL"); }, 10_000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + const evidence = JSON.stringify({ exitCode, timedOut, targetRequests, proxyRequests, stdout, stderr }); + expect(timedOut, evidence).toBe(false); + expect(exitCode, evidence).toBe(0); + expect(proxyRequests, evidence).toBe(0); + expect(targetRequests, evidence).toBe(direct ? 1 : 0); + expect(authenticatedTargetRequests, evidence).toBe(direct ? 1 : 0); + expect(JSON.parse(stdout)).toEqual(direct ? { document: catalog } : { code: "insecure_http_refused" }); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (child && child.exitCode === null) { child.kill("SIGKILL"); await child.exited; } + await proxy?.stop(true); + await target?.stop(true); + } + }, 15_000); + + test("keeps authenticated HTTPS acquisition available with an outbound proxy", async () => { + await withProxyEnv({ HTTP_PROXY: "http://proxy.example:8080", HTTPS_PROXY: "http://proxy.example:8080" }, async () => { + const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer catalog-token-marker"); + expect(init?.redirect).toBe("manual"); + return response(catalog); + }) as typeof fetch; + await expect(fetchRemoteCatalog("https://hub.example/v1/catalog", { + token: "catalog-token-marker", fetchImpl, + })).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + }); + + test.each([ + ["http://[::1]:10100/v1/catalog", "::1", false], + ["http://[::1]:10100/v1/catalog", "[::1]", true], + ["http://[::1]:10100/v1/catalog", "[::1]:10100", true], + ["http://[::1]:10100/v1/catalog", "[::1]:9999", false], + ["http://127.0.0.1/v1/catalog", "127.0.0.1:80", false], + ] as const)("uses Bun's literal host/port bypass for %s and %s", async (url, bypass, direct) => { + await withProxyEnv({ HTTP_PROXY: "http://proxy.example:8080", NO_PROXY: bypass }, async () => { + const fetchImpl = mock(async () => response(catalog)) as typeof fetch; + const result = fetchRemoteCatalog(url, { token: "catalog-token-marker", fetchImpl }); + if (direct) { + await expect(result).resolves.toMatchObject({ document: catalog }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + } else { + await expect(result).rejects.toMatchObject({ code: "insecure_http_refused" }); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); + }); + test("never reflects credentials, remote bodies, URLs, or transport causes", async () => { for (const fetchImpl of [ async () => new Response("remote-body-marker", { status: 401 }), diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index e44ce355e0..a71db9b875 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -624,6 +626,223 @@ describe("codex-account-store CRUD", () => { } }); + test("a refresh lock that is still being initialized is not reclaimed as stale", async () => { + const { getValidCodexToken, saveCodexAccountCredential } = await import("../../src/codex/account-store"); + saveCodexAccountCredential("refresh-empty-lock", { accessToken: "old", refreshToken: "empty-r", expiresAt: 0, chatgptAccountId: "acc" }); + // The owner creates the lock file and writes its metadata as two steps, so a live lock is + // briefly unreadable. Treating that window as stale let a waiter delete a lock whose owner + // was still inside its critical section, and both then ran the refresh. + const lockPath = refreshLockPathForToken("empty-r"); + writeFileSync(lockPath, ""); + let fetchCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ access_token: "new", expires_in: 3600 }), { status: 200 }); + }) as typeof fetch; + + try { + const pending = getValidCodexToken("refresh-empty-lock"); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(existsSync(lockPath)).toBe(true); + expect(fetchCalls).toBe(0); + unlinkSync(lockPath); + const result = await pending; + expect(result.accessToken).toBe("new"); + expect(fetchCalls).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("releasing a refresh lock leaves a lock another owner recreated in place", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = "recreated-owner"; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + await withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + // A waiter reclaimed this path and a second owner took it over while we held it. + renameSync(lockPath, `${lockPath}.reclaimed`); + writeFileSync(lockPath, JSON.stringify({ acquiredAt: Date.now(), pid: 999_001 }) + "\n"); + }); + expect(existsSync(lockPath)).toBe(true); + expect((JSON.parse(readFileSync(lockPath, "utf-8")) as { pid: number }).pid).toBe(999_001); + unlinkSync(lockPath); + unlinkSync(`${lockPath}.reclaimed`); + }); + + test.each([false, true])("refresh release prevents inode reuse before comparison (callback failure=%s)", async (callbackFails) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = `release-inode-reuse-${callbackFails}`; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const originalFstat = fs.fstatSync; + const originalStat = fs.statSync; + let fd: number | undefined; + let owned: ReturnType | undefined; + let openDuringComparison = false; + const descriptor = spyOn(fs, "fstatSync").mockImplementation((...args: Parameters) => { + fd = args[0]; + owned = originalFstat(...args); + return owned; + }); + const probe = spyOn(fs, "statSync").mockImplementation((...args: Parameters) => { + if (args[0] === path && fd !== undefined && owned) { + try { originalFstat(fd); openDuringComparison = true; } catch { /* Descriptor closed early. */ } + // Model an allocator reusing the unlinked owner's inode only after its last fd closes. + // Holding that fd alive must prevent this ABA regardless of the host filesystem. + if (!openDuringComparison) return owned; + } + return originalStat(...args); + }); + const failure = new Error("original refresh failure"); + try { + const pending = withCodexRefreshFileLock(key, new AbortController().signal, async () => { + unlinkSync(path); + writeFileSync(path, "successor"); + if (callbackFails) throw failure; + return "refreshed"; + }); + if (callbackFails) await expect(pending).rejects.toBe(failure); + else expect(await pending).toBe("refreshed"); + expect(openDuringComparison).toBe(true); + expect(readFileSync(path, "utf8")).toBe("successor"); + expect(fd).toBeDefined(); + expect(() => originalFstat(fd!)).toThrow(); + } finally { descriptor.mockRestore(); probe.mockRestore(); } + }); + + test("refresh release preserves the path when descriptor identity cannot be read", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = "unknown-owner"; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.fstatSync; + let released = false; + const probe = spyOn(fs, "fstatSync").mockImplementation((...args: Parameters) => { + if (released) throw new Error("identity probe unavailable"); + return original(...args); + }); + try { + await withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + renameSync(lockPath, `${lockPath}.reclaimed`); + writeFileSync(lockPath, "replacement-owner"); + released = true; + }); + expect(readFileSync(lockPath, "utf8")).toBe("replacement-owner"); + } finally { + probe.mockRestore(); + } + }); + + for (const code of ["EACCES", "EIO"]) { + for (const callbackFails of [false, true]) { + test(`refresh release preserves the callback outcome after ${code} path probe failure (${callbackFails})`, async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = `path-probe-${code}-${callbackFails}`; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.statSync; + const callbackError = new Error("refresh failed"); + let released = false; + const probe = spyOn(fs, "statSync").mockImplementation((...args: Parameters) => { + if (released && args[0] === lockPath) throw Object.assign(new Error("path probe unavailable"), { code }); + return original(...args); + }); + try { + const pending = withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => { + released = true; + if (callbackFails) throw callbackError; + return "refreshed"; + }); + if (callbackFails) await expect(pending).rejects.toBe(callbackError); + else expect(await pending).toBe("refreshed"); + expect(existsSync(lockPath)).toBe(true); + } finally { probe.mockRestore(); } + }); + } + } + + test.each(["ENOENT", "EACCES"])("refresh release preserves confirmed-owner unlink handling for %s", async (code) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const lockKey = `unlink-${code}`; + const lockPath = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(lockKey).digest("hex").slice(0, 32)}.lock`); + const original = fs.unlinkSync; + const unlinkError = Object.assign(new Error("unlink failed"), { code }); + let attempts = 0; + const probe = spyOn(fs, "unlinkSync").mockImplementation((path) => { + if (path === lockPath) { attempts++; throw unlinkError; } + return original(path); + }); + try { + const pending = withCodexRefreshFileLock(lockKey, new AbortController().signal, async () => "refreshed"); + if (code === "ENOENT") expect(await pending).toBe("refreshed"); + else await expect(pending).rejects.toBe(unlinkError); + expect(attempts).toBe(1); + } finally { probe.mockRestore(); } + }); + + test("refresh stale reclamation excludes a second SQLite writer until acquisition finishes", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = "serialized-stale"; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + writeFileSync(path, JSON.stringify({ acquiredAt: 0 })); + const db = new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }); + const original = fs.unlinkSync; + let blocked = false; + const probe = spyOn(fs, "unlinkSync").mockImplementation((candidate) => { + if (candidate === path && !blocked) { + try { db.exec("BEGIN IMMEDIATE"); db.exec("ROLLBACK"); } + catch (error) { blocked = (error as { code?: string }).code === "SQLITE_BUSY"; } + } + return original(candidate); + }); + try { + await withCodexRefreshFileLock(key, new AbortController().signal, async () => { + expect(blocked).toBe(true); + // The callback must not hold the metadata transaction across network/async work. + db.exec("BEGIN IMMEDIATE"); db.exec("ROLLBACK"); + }); + expect(existsSync(path)).toBe(false); + } finally { probe.mockRestore(); db.close(); } + }); + + test.each([false, true])("refresh metadata failure closes its descriptor and preserves replacement=%s", async (replacement) => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = `metadata-write-${replacement}`; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const original = fs.writeFileSync; + const failure = Object.assign(new Error("metadata write failed"), { code: "EIO" }); + let descriptor: number | undefined; + let called = false; + const probe = spyOn(fs, "writeFileSync").mockImplementation((...args: Parameters) => { + if (typeof args[0] === "number") { + descriptor = args[0]; + if (replacement) { renameSync(path, `${path}.reclaimed`); original(path, "successor"); } + throw failure; + } + return original(...args); + }); + try { + await expect(withCodexRefreshFileLock(key, new AbortController().signal, async () => { called = true; })).rejects.toBe(failure); + expect(called).toBe(false); + expect(descriptor).toBeDefined(); + expect(() => fs.fstatSync(descriptor!)).toThrow(); + expect(existsSync(path)).toBe(replacement); + if (replacement) expect(readFileSync(path, "utf8")).toBe("successor"); + } finally { probe.mockRestore(); } + }); + + test("refresh release keeps its result and lock when metadata coordination is busy", async () => { + const { withCodexRefreshFileLock } = await import("../../src/codex/account-store"); + const key = "release-coordination-busy"; + const path = join(TEST_DIR, `codex-refresh-${createHash("sha256").update(key).digest("hex").slice(0, 32)}.lock`); + const db = new Database(join(TEST_DIR, "config-mutation.sqlite"), { create: true }); + try { + expect(await withCodexRefreshFileLock(key, new AbortController().signal, async () => { + db.exec("BEGIN IMMEDIATE"); + return "refreshed"; + })).toBe("refreshed"); + expect(existsSync(path)).toBe(true); + } finally { db.exec("ROLLBACK"); db.close(); } + }); + test("same refresh grant joins a live flight", async () => { const { getCodexAccountCredential, diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index bd30621448..d02474985e 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -1,3 +1,5 @@ +import { registerWarmupRateLimitCases } from "../helpers/codex-warmup-rate-limit"; +import { registerResetCreditConsumeValidationTests } from "../helpers/reset-credit-consume-validation"; import * as usageHistoryModule from "../../src/usage/log"; import { getAccountQuotaHistory } from "../../src/codex/quota"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; @@ -2871,16 +2873,7 @@ describe("codex-auth API", () => { }); }); - test("reset-credit consume rejects invalid account ids before credential lookup", async () => { - const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "../bad" }), - }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); - expect(resp!.status).toBe(400); - expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); - }); + registerResetCreditConsumeValidationTests(makeConfig, seedPoolAccount); test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { const config = makeConfig(); @@ -5530,27 +5523,7 @@ describe("codex-auth API", () => { expect(getCodexAccountCredential("quota-unknown")).toBeNull(); }); - test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { - const config = makeConfig(); - const result = await completeMockCodexOAuth({ - config, - requestBody: { id: "oauth-race" }, - oauthAccountId: "acct-oauth-race", - email: "oauth-race@example.test", - onWarmup: () => { - config.codexAccountNamespaces = { "oauth-race": "pool-a" }; - }, - }); - - expect(result.startStatus).toBe(200); - expect(result.state).toMatchObject({ - status: "error", - error: "account id must not collide with a configured Codex account namespace", - }); - expect(config.codexAccounts).toEqual([]); - expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); - expect(getCodexAccountCredential("oauth-race")).toBeNull(); - }); + registerWarmupRateLimitCases(makeConfig, completeMockCodexOAuth); test("OAuth creation reports a durable add when catalog convergence is pending", async () => { const accountId = "oauth-picker-pending"; diff --git a/tests/codex-integration/codex-cli-install-provenance.test.ts b/tests/codex-integration/codex-cli-install-provenance.test.ts index 31635b2afd..35971c389a 100644 --- a/tests/codex-integration/codex-cli-install-provenance.test.ts +++ b/tests/codex-integration/codex-cli-install-provenance.test.ts @@ -108,7 +108,7 @@ describe("Codex CLI install provenance", () => { expect(calls).toBe(0); }); - test("Windows does not read persisted candidate state", async () => { + test("Windows defers without reading persisted candidate state", async () => { let calls = 0; const report = await inspectCodexCliInstall({ ...noFilesystemDeps(() => { calls += 1; }), @@ -116,9 +116,35 @@ describe("Codex CLI install provenance", () => { configDir: "C:\\OpenCodex", env: { PATH: "C:\\Tools" }, }); + // Persisted state is still never read on Windows, so the command cannot + // know whether a candidate exists. It must not claim that none does. + expect(report.reason).toBe("windows_inspection_deferred"); expect(report.candidateAvailable).toBe(false); - expect(report.reason).toBe("candidate_unavailable"); + expect(report.candidateSource).toBeNull(); + expect(report.candidateVersion).toBeNull(); + expect(report.location).toBeNull(); + expect(report.provenance).toBe("unknown"); + expect(report.managed).toBe(false); + expect(report.selectionAttested).toBe(false); + expect(report.versionEvidence.kind).toBe("unavailable"); expect(report.shim.status).toBe("unknown"); + expect(report.evidence).toEqual([]); + expect(calls).toBe(0); + }); + + test("a POSIX run with no observed candidate retains candidate_unavailable", async () => { + let calls = 0; + const report = await inspectCodexCliInstall({ + ...noFilesystemDeps(() => { calls += 1; }), + platform: "linux", + configDir: "relative-config-dir", + env: { PATH: "" }, + }); + // The relative configuration path is rejected without I/O. This control + // preserves the existing POSIX reason when no candidate is observed. + expect(report.reason).toBe("candidate_unavailable"); + expect(report.candidateAvailable).toBe(false); + expect(report.shim.status).toBe("not-tracked"); expect(calls).toBe(0); }); diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 5ffbc54f99..3162cc3d36 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -33,7 +33,11 @@ test("catalog readback requires a root string rather than a nested namesake", () // Full injectCodexConfig runs in a subprocess with isolated CODEX_HOME/OPENCODEX_HOME so // module-level path constants bind to the temp dirs (same pattern as codex-journal.test.ts). -function runInject(codexHome: string, ocxHome: string, configJson = "{}"): { stdout: string; status: number } { +function runInject( + codexHome: string, + ocxHome: string, + configJson = "{}", +): { stdout: string; stderr: string; status: number } { const script = ` const { injectCodexConfig } = require("./src/codex/inject"); injectCodexConfig(10100, JSON.parse(process.env.TEST_OCX_CONFIG)).then(r => { @@ -46,7 +50,11 @@ function runInject(codexHome: string, ocxHome: string, configJson = "{}"): { std encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, }); - return { stdout: result.stdout?.trim() ?? "", status: result.status ?? 1 }; + return { + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? "", + status: result.status ?? 1, + }; } function runRestore(codexHome: string, ocxHome: string, asyncRestore = false): { stdout: string; status: number } { @@ -178,6 +186,102 @@ describe("injectCodexConfig integration (Design B)", () => { }); }); + test.each([ + ["before-preflight", false, false], + ["after-preflight", false, false], + ["after-config", false, false], + ["after-artifacts", false, false], + ["after-config", true, false], + ["after-config", false, true], + ] as const)("late pagination preserves an existing provider (%s, coordinated=%s, authless=%s)", (stage, coordinated, authless) => { + const original = 'model_provider="opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\nrequires_openai_auth=true\n'; + const configPath = join(codexHome, "config.toml"); + const profilePath = join(codexHome, "opencodex.config.toml"); + writeFileSync(configPath, original); + if (!coordinated) writeFileSync(profilePath, "# original profile\n"); + if (coordinated) { + writeFileSync(configPath, 'model="test"\n'); + const seed = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(seed.status).toBe(0); + expect(JSON.parse(seed.stdout).success).toBe(true); + } + const journalPath = join(codexHome, "opencodex-journal.json"); + const before = [configPath, profilePath, journalPath].map(path => existsSync(path) ? readFileSync(path, "utf8") : null); + const script = ` + const {Database}=require("bun:sqlite"); + const {join}=require("node:path"); + const {injectCodexConfig,setBeforeHistoryArtifactCommitForTests,setHistoryArtifactStageForTests}=require("./src/codex/inject"); + const migrate=()=>{ + const db=new Database(join(process.env.CODEX_HOME,"state_5.sqlite")); + db.run("CREATE TABLE threads (rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES (\'fixture\',\'opencodex\',\'paginated\')"); + db.close(); + }; + let kind; + setBeforeHistoryArtifactCommitForTests(value=>{kind=value;if(${JSON.stringify(stage)}==="before-preflight")migrate();}); + setHistoryArtifactStageForTests(value=>{if(value===${JSON.stringify(stage)})migrate();}); + const readState=${coordinated ? 'require("./src/codex/transition-state").readCodexTransitionState' : "()=>null"}; + const before=readState(); + const result=await injectCodexConfig(10100,{codexDesktopAuthless:${authless}}); + console.log(JSON.stringify({kind,result,before,after:readState()})); + `; + const child=spawnSync(process.execPath,["--eval",script],{cwd:repoRoot,env:{...process.env,CODEX_HOME:codexHome,OPENCODEX_HOME:ocxHome},encoding:"utf8",timeout:SPAWN_BUDGET_MS-5000}); + expect(child.status, child.stderr).toBe(0); + const value = JSON.parse(child.stdout); + expect(value.kind, child.stdout).toBe(coordinated ? "coordinated" : "legacy-uncoordinated"); + expect(value.result).toMatchObject({success:true,historyPreflightFailureReason:"history_paginated_requires_native_writer"}); + const config = Bun.TOML.parse(readFileSync(configPath,"utf8")) as any; + expect(config.model_provider).toBe(authless ? "opencodex" : undefined); + expect(config.model_providers.opencodex.base_url).toBe("http://127.0.0.1:10100/v1"); + expect(readFileSync(profilePath,"utf8")).not.toBe(before[1]); + if (coordinated) expect(value.after).not.toEqual(value.before); + const db = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + try { + expect(db.query("SELECT model_provider FROM threads").get()).toEqual({model_provider:"opencodex"}); + } finally { db.close(); } + }); + + test.each([false, true])("pagination after artifact commit keeps the existing provider (coordinated=%s)", coordinated => { + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, coordinated ? 'model="test"\n' : DESIGN_B_BLOCK + "\n"); + if (coordinated) { + const seed = runInject(codexHome, ocxHome, JSON.stringify({ codexClientCompaction: true })); + expect(seed.status, seed.stderr).toBe(0); + expect(JSON.parse(seed.stdout).success).toBe(true); + } else { + writeFileSync(configPath, 'model_provider="opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'); + writeFileSync(join(codexHome, "opencodex.config.toml"), "# legacy profile\n"); + } + const script = ` + const {Database}=require("bun:sqlite"); + const {join}=require("node:path"); + const {injectCodexConfig,setHistoryArtifactStageForTests}=require("./src/codex/inject"); + let migrated=false; + setHistoryArtifactStageForTests(stage=>{ + if(stage!=="before-history-worker") return; + const db=new Database(join(process.env.CODEX_HOME,"state_5.sqlite")); + db.run("CREATE TABLE threads (rollout_path TEXT, model_provider TEXT, history_mode TEXT)"); + db.run("INSERT INTO threads VALUES ('fixture','opencodex','paginated')"); + db.close();migrated=true; + }); + const result=await injectCodexConfig(10100,{}); + console.log(JSON.stringify({migrated,result})); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(child.status, child.stderr).toBe(0); + const value = JSON.parse(child.stdout); + expect(value.migrated).toBe(true); + const parsed = Bun.TOML.parse(readFileSync(configPath, "utf8")) as any; + expect(parsed.model_providers?.opencodex?.base_url).toBe("http://127.0.0.1:10100/v1"); + expect(value.result.success).toBe(true); + const db = new Database(join(codexHome, "state_5.sqlite"), { readonly: true }); + try { expect(db.query("SELECT model_provider FROM threads").get()).toEqual({ model_provider: "opencodex" }); } + finally { db.close(); } + }); + for (const stage of ["before-preflight", "after-preflight", "after-config", "after-artifacts"]) { test.each([false,true])(`a store that migrates mid-transaction retires the relabel unit and keeps the config (${stage}, legacy=%s)`,(legacy)=>{ const original=legacy ? DESIGN_B_BLOCK+"\n" : 'model="test"\n'; @@ -207,7 +311,7 @@ describe("injectCodexConfig integration (Design B)", () => { const value=JSON.parse(child.stdout); expect(value.kind).toBe(legacy?"legacy-uncoordinated":"coordinated"); // A migration observed at ANY point in the transaction stands the relabel unit down and - // says so. It never rolls the config back: the config half writes no history, and + // says so. With no prior provider table to retire, it need not roll the config back: // rolling it back is what left every paginated home with no OpenCodex models at all. expect(value.result).toMatchObject({success:true,historyPreflightFailureReason:"history_paginated_requires_native_writer"}); expect(value.result.message).toContain("left to Codex's native writer"); @@ -533,11 +637,11 @@ describe("injectCodexConfig integration (Design B)", () => { const config = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(config).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); expect(config).toContain("# Auto-injected by opencodex"); - expect(config).not.toContain("[model_providers.opencodex]"); + expect(config).toContain("[model_providers.opencodex]"); expect(config).not.toContain('model_provider = "opencodex"'); expect(config).toContain('model = "gpt-5.5"'); - // Exactly the Design B markers survive (routing + realtime sideband) — no accumulation. - expect(config.match(/Auto-injected by opencodex/g)?.length).toBe(2); + // Routing, realtime sideband and the retained compatibility provider each have one marker. + expect(config.match(/Auto-injected by opencodex/g)?.length).toBe(3); expect(config).toContain(DESIGN_B_BLOCK); }); @@ -1261,9 +1365,10 @@ describe("injectCodexConfig integration (Design B)", () => { expect(runInject(codexHome, ocxHome).status).toBe(0); const back = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(back).toContain('openai_base_url = "http://127.0.0.1:10100/v1"'); - expect(back).not.toContain("[model_providers.opencodex]"); + expect(back).toContain("[model_providers.opencodex]"); + expect(back).toContain("requires_openai_auth = true"); expect(back).not.toContain('model_provider = "opencodex"'); - expect(back.match(/Auto-injected by opencodex/g)?.length).toBe(2); + expect(back.match(/Auto-injected by opencodex/g)?.length).toBe(3); expect(back).toContain(DESIGN_B_BLOCK); expect(runInject(codexHome, ocxHome, JSON.stringify({ codexDesktopAuthless: true })).status).toBe(0); @@ -1291,7 +1396,7 @@ describe("injectCodexConfig integration (Design B)", () => { expect(runInject(codexHome, ocxHome).status).toBe(0); const designB = readFileSync(join(codexHome, "config.toml"), "utf8"); expect(designB).toContain(DESIGN_B_BLOCK); - expect(designB).not.toContain("[model_providers.opencodex]"); + expect(designB).toContain("[model_providers.opencodex]"); expect(designB).not.toContain('model_provider = "opencodex"'); // Disabling leaves exactly one root override, not the table form's copy plus a new one. expect(designB.match(/openai_base_url/g)?.length).toBe(1); diff --git a/tests/codex-integration/codex-warmup.test.ts b/tests/codex-integration/codex-warmup.test.ts index 9339920918..200894e713 100644 --- a/tests/codex-integration/codex-warmup.test.ts +++ b/tests/codex-integration/codex-warmup.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { CodexWarmupError, warmCodexAccount } from "../../src/codex/warmup"; const originalFetch = globalThis.fetch; @@ -152,6 +152,50 @@ describe("codex warmup", () => { expect(performance.now() - startedAt).toBeLessThan(1_000); }); + test("preserves HTTP 429 classification when the error body stalls until the deadline", async () => { + let fetchCalls = 0; + let cancellations = 0; + const privateBody = "private upstream quota details"; + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCalls += 1; + const stalledBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(privateBody)); + }, + cancel() { + cancellations += 1; + return new Promise(() => {}); + }, + }); + return new Response(stalledBody, { status: 429 }); + }); + + const startedAt = performance.now(); + try { + let failure: unknown; + try { + await warmCodexAccount({ + accessToken: "a", + chatgptAccountId: "c", + timeoutMs: 20, + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(CodexWarmupError); + if (!(failure instanceof CodexWarmupError)) throw new Error("expected CodexWarmupError"); + expect(failure.code).toBe("http_status"); + expect(failure.status).toBe(429); + expect(failure.message).not.toContain(privateBody); + expect(fetchCalls).toBe(1); + expect(cancellations).toBe(1); + expect(performance.now() - startedAt).toBeLessThan(1_000); + } finally { + fetchSpy.mockRestore(); + } + }); + test("accepts a completed SSE stream at the exact byte limit", async () => { const encoder = new TextEncoder(); const terminal = 'data: {"type":"response.completed"}\n\n'; diff --git a/tests/codex-integration/selected-models.test.ts b/tests/codex-integration/selected-models.test.ts index df6aa22586..26a53e8c2b 100644 --- a/tests/codex-integration/selected-models.test.ts +++ b/tests/codex-integration/selected-models.test.ts @@ -120,4 +120,29 @@ describe("filterCatalogVisibleModels — slash-bearing ids", () => { const visible = filterCatalogVisibleModels(nested, cfg({ p: { selectedModels: ["x-y-z"] } })); expect(visible.map(v => v.id)).toEqual(["x/y/z"]); }); + + test("models belonging to a disabled provider are filtered out", () => { + const models = [m("active", "m1"), m("disabled_p", "m2"), m("active", "m3")]; + const config = cfg({ + active: { disabled: false }, + disabled_p: { disabled: true }, + }); + const visible = filterCatalogVisibleModels(models, config); + expect(visible.map(v => v.id)).toEqual(["m1", "m3"]); + }); + + test("custom models of a disabled provider are omitted by filterCatalogVisibleModels", () => { + const customModel: CatalogModel = { + id: "custom-1", + provider: "ark", + catalogKind: "custom-model-v1", + }; + const activeModel = m("openai", "gpt-5.6-sol"); + const config = cfg({ + ark: { disabled: true }, + openai: { disabled: false }, + }); + const visible = filterCatalogVisibleModels([customModel, activeModel], config); + expect(visible.map(v => v.id)).toEqual(["gpt-5.6-sol"]); + }); }); diff --git a/tests/helpers/codex-warmup-rate-limit.ts b/tests/helpers/codex-warmup-rate-limit.ts new file mode 100644 index 0000000000..bc063058a2 --- /dev/null +++ b/tests/helpers/codex-warmup-rate-limit.ts @@ -0,0 +1,126 @@ +import { expect, test } from "bun:test"; +import type { OcxConfig } from "../../src/types"; +import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; + +interface WarmupOAuthOptions { + config: OcxConfig; + requestBody: { id: string; reauth?: boolean }; + oauthAccountId: string; + email: string; + onWarmup: () => void; + warmupResponse?: () => Response; +} + +/** Registers under the calling suite's isolated home and OAuth cleanup hooks. */ +export function registerWarmupRateLimitCases( + makeConfig: (overrides?: Partial) => OcxConfig, + completeMockCodexOAuth: (options: WarmupOAuthOptions) => Promise<{ + startStatus: number; + state: { status: string; error?: string; code?: string }; + }>, +): void { + test("OAuth creation reports a rate-limited warmup without persisting the account", async () => { + const accountId = "warmup-rate-limited"; + const config = makeConfig(); + let warmupRequests = 0; + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: "acct-warmup-rate-limited", + email: "warmup-rate-limited@example.test", + onWarmup: () => { warmupRequests += 1; }, + warmupResponse: () => new Response("private upstream quota details", { status: 429 }), + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + code: "codex_warmup_rate_limited", + }); + expect(result.state.error).toContain("usage limit"); + expect(result.state.error).toContain("Retry"); + expect(JSON.stringify(result.state)).not.toContain("private upstream quota details"); + expect(warmupRequests).toBe(1); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential(accountId)).toBeNull(); + expect(readCodexAccountRecord(accountId)).toBeNull(); + }); + + test.each([401, 403])("OAuth creation keeps HTTP %s warmup failures on the authentication path", async status => { + const accountId = `warmup-auth-${status}`; + const config = makeConfig(); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: `acct-warmup-auth-${status}`, + email: `warmup-auth-${status}@example.test`, + onWarmup: () => {}, + warmupResponse: () => new Response("private upstream auth details", { status }), + }); + + expect(result.state).toMatchObject({ + status: "error", + code: "codex_warmup_failed", + }); + expect(result.state.error).toContain("Reauthenticate"); + expect(JSON.stringify(result.state)).not.toContain("private upstream auth details"); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + + test("OAuth reauth keeps the existing credential when warmup is rate limited", async () => { + const accountId = "warmup-rate-limited-reauth"; + const config = makeConfig({ + codexAccounts: [{ id: accountId, email: "existing@example.test", isMain: false }], + }); + const existingCredential = { + accessToken: "existing-access", + refreshToken: "existing-refresh", + expiresAt: Date.now() + 60_000, + chatgptAccountId: "acct-warmup-rate-limited-reauth", + }; + saveCodexAccountCredential(accountId, existingCredential); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId, reauth: true }, + oauthAccountId: existingCredential.chatgptAccountId, + email: "existing@example.test", + onWarmup: () => {}, + warmupResponse: () => new Response("private upstream quota details", { status: 429 }), + }); + + expect(result.state).toMatchObject({ + status: "error", + code: "codex_warmup_rate_limited", + }); + expect(getCodexAccountCredential(accountId)).toEqual(existingCredential); + expect(config.codexAccounts).toEqual([ + { id: accountId, email: "existing@example.test", isMain: false }, + ]); + }); + + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "oauth-race" }, + oauthAccountId: "acct-oauth-race", + email: "oauth-race@example.test", + onWarmup: () => { + config.codexAccountNamespaces = { "oauth-race": "pool-a" }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); + expect(getCodexAccountCredential("oauth-race")).toBeNull(); + }); +} diff --git a/tests/helpers/reset-credit-consume-validation.ts b/tests/helpers/reset-credit-consume-validation.ts new file mode 100644 index 0000000000..3f06e08dc8 --- /dev/null +++ b/tests/helpers/reset-credit-consume-validation.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api"; +import { BOUNDED_BODY_MAX_BYTES } from "../../src/lib/bounded-body"; +import type { OcxConfig } from "../../src/types"; + +export function registerResetCreditConsumeValidationTests( + makeConfig: () => OcxConfig, + seedPoolAccount: (config: OcxConfig, options: { id: string; email: string }) => unknown, +): void { + test("reset-credit consume rejects invalid account ids before credential lookup", async () => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "../bad" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + }); + + test("reset-credit consume refuses an upstream body past the shared bound instead of buffering it", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-oversized", email: "oversized@example.test" }); + const originalFetch = globalThis.fetch; + let usageCalls = 0; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + // A 200 with an unbounded body was read whole by resp.json() before anything + // looked at its size, unlike every other reset-credit read on this path. + const padding = "x".repeat(BOUNDED_BODY_MAX_BYTES * 2); + return new Response(`{"code":"reset","padding":"${padding}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.includes("/backend-api/wham/usage")) { + usageCalls += 1; + return Response.json({ + rate_limit: { primary_window: { used_percent: 10, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 2 }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-oversized" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(502); + expect(await resp!.json()).toEqual({ error: "Invalid upstream reset-credit consume response" }); + // The outcome is unconfirmed, so nothing downstream may treat the redeem as observed. + expect(usageCalls).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + +}