diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index a351adeae5..eeaf4f8af8 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -385,10 +385,9 @@ d'actualisation `chatgpt` vaut `proactive` et si `tokenGuardian.codexWarmupEnabl ## Restauration de Codex natif -opencodex ne vous enferme jamais dans sa configuration. **`ocx stop` est l'unique commande qui restaure -entièrement Codex natif** : elle arrête le proxy et le service d'arrière-plan s'il est installé, puis supprime -toutes les lignes injectées et toutes les entrées routées du catalogue. La commande `codex` fonctionne alors -exactement comme si opencodex n'avait jamais été installé : +`ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité. + +Si la configuration ou le profil actuel diffère de l'original sauvegardé et que le journal ne contient pas le hash de l'état injecté de ce fichier, la récupération automatique conserve les deux fichiers et le journal sans les modifier. Un fichier déjà identique à son original n'est pas réécrit. La réinjection d'une configuration routée refuse aussi cet état incertain ; une configuration native peut créer un nouvel instantané. Voir les [règles de récupération](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md index e9eaa5219c..14bf8bb0d3 100644 --- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md @@ -84,8 +84,9 @@ lorsqu'un modèle préféré, une liste éligible ou une chaîne de secours est est suffisant pour afficher une invite personnalisée ; si une valeur non qualifiée ne peut pas être résolue de manière unique, `{{model}}` se développe en une chaîne vide. -Sur la v1, opencodex injecte uniquement les conseils de délégation proactive de style amont à `max` ou `ultra` -effort. Il n’ajoute aucun modèle préféré, aucune liste, aucune chaîne de repli ni aucune invite personnalisée en v1. +Sur la v1, opencodex injecte le même texte de délégation proactive que le préréglage recommandé de la v2, uniquement aux niveaux d’effort `max` ou `ultra`. +Seule la condition de déclenchement change : aucune demande de délégation distincte n’est nécessaire ; les instructions de l’utilisateur, les autorisations, le périmètre de la tâche et les règles des outils de collaboration restent applicables. +Il n’ajoute aucun modèle préféré, aucune liste, aucune chaîne de repli ni aucune invite personnalisée en v1. L'option `syncCodexSubagentDefaults` désactivée par défaut est distincte du guidage. Quand opencodex possède le routage Codex actif, la synchronisation ou le redémarrage peut écrire les valeurs sélectionnées en tant que propriété du marqueur diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2944208279..5b75a21b41 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -605,9 +605,9 @@ off by default; it runs only when Token Guardian is enabled, the `chatgpt` refre ## Restoring native Codex -opencodex never traps you. **`ocx stop` is the single command that fully reverts to native Codex** — it -stops the proxy, stops the background service if one is installed, and strips every injected line and -routed catalog entry so plain `codex` works exactly as if opencodex was never there: +`ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files. + +Recovery may require manual review when the journal cannot verify the current files; see [recovery without injection hashes](#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex @@ -619,6 +619,24 @@ When opencodex runs as a managed [background service](/reference/cli/#ocx-servic `OCX_SERVICE=1` so a service-driven restart does **not** thrash the Codex config — only an explicit `ocx stop` / `ocx service stop` restores native Codex. +### Recovery without injection hashes + +The journal saves the original `config.toml` and `opencodex.config.toml` plus hashes of the state +OpenCodex injected. A legacy journal or an interruption before those hashes were recorded cannot +prove that later file contents belong to OpenCodex. If either file differs from its saved original +and lacks its own injected-state hash, automatic journal recovery and native restore report failure +without changing either file or the journal. The saved original remains available for comparison; +review it alongside the current files before choosing a manual recovery action. + +Files already equal to their saved originals are accepted without rewriting them. A missing file +is distinct from an empty file. Verified injected hashes still allow normal snapshot restoration, +and later edits in hash-backed configurations retain the existing owned-field cleanup behavior. + +Sync and `ocx restore back` also reject an existing routed configuration whose hashless journal +does not match the pre-injection baseline. This prevents a new injection hash from being attached +to an older original. A genuinely native configuration can be saved as a fresh baseline before +injection. Explicit external-provider opt-out behavior is unchanged. + ### Sub-agent fallback and V2 compatibility diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 43c5c09d90..1a7293dab8 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -99,8 +99,10 @@ when a preferred model, eligible roster, or fallback chain resolves. A configure is sufficient to render a custom prompt; if a bare value cannot resolve uniquely, `{{model}}` expands to an empty string. -On v1, opencodex injects only the upstream-style proactive delegation guidance at `max` or `ultra` -effort. It does not add a preferred model, roster, fallback list, or custom prompt on v1. +On v1, opencodex injects the same proactive delegation guidance as the v2 recommended preset only +at `max` or `ultra` effort. Only the delegation trigger changes: no separate delegation request is +needed; user instructions, authority, task scope, and collaboration-tool rules still apply. +It does not add a preferred model, roster, fallback list, or custom prompt on v1. The default-off `syncCodexSubagentDefaults` option is separate from guidance. When opencodex owns active Codex routing, sync or restart can write the selected values as marker-owned diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 46c33320f2..df320c660f 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -245,7 +245,9 @@ ChatGPT アカウントが Codex アカウント プールに追加されると ## ネイティブ Codexの復元 -opencodex は決してあなたを罠にはめることはありません。 **`ocx stop` は、ネイティブ Codex に完全に戻す単一のコマンドです**。プロキシを停止し、バックグラウンド サービスがインストールされている場合はそれを停止し、挿入されたすべての行とルーティングされたカタログ エントリを削除するため、プレーンな `codex` は、opencodex が存在しなかったかのように正確に動作します。 +`ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。 + +現在の設定またはプロファイルが保存された元の内容と異なり、そのファイルの注入後の状態のハッシュがジャーナルにない場合、自動復元は両方のファイルとジャーナルを変更せずに残します。元の内容と同じファイルは再書き込みしません。ルーティング済み設定への再注入も、この未確認の状態では拒否されます。ネイティブ設定では新しいスナップショットを作成できます。[復元規則](/guides/codex-integration/#recovery-without-injection-hashes)を参照してください。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index d4dc59de4c..8d2be3b2e8 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -56,7 +56,9 @@ v2 ロスターの場合、適格性には 3 つの状態があります。`"v2" 組み込みの v2 ガイダンスの予算は 700 文字です。予算を超える場合、opencodex はコア スポーン命令を切り捨てるのではなく、まずロスターを削除します。組み込みガイダンスは、優先モデル、適格なロスター、またはフォールバック チェーンが解決された場合にのみ起動されます。カスタムプロンプトは `injectionModel` が設定されていれば生成され、セレクターなしの値を一意に解決できない場合は `{{model}}` が空文字列になります。 -v1 では、opencodex は、`max` または `ultra` の取り組みでアップストリーム スタイルのプロアクティブな委任ガイダンスのみを挿入します。 v1 では、優先モデル、ロスター、フォールバック リスト、カスタム プロンプトは追加されません。 +v1 では、opencodex は effort が `max` または `ultra` の場合に限り、v2 の推奨プリセットと同じプロアクティブな委任テキストを挿入します。 +変わるのは委任の開始条件だけで、委任を別途依頼する必要はなく、ユーザーの指示、権限、タスクの範囲、コラボレーションツールのルールは引き続き適用されます。 +v1 では、優先モデル、ロスター、フォールバック リスト、カスタム プロンプトは追加されません。 デフォルトでオフになっている `syncCodexSubagentDefaults` オプションは、ガイダンスとは別のものです。 opencodex がアクティブな Codex ルーティングを所有している場合、同期または再起動により、選択された値をマーカー所有の `[agents] default_subagent_model` および `default_subagent_reasoning_effort` エントリとして Codex TOML に書き込むことができます。 opencodex は、そのマーカーを持つフィールドのみを更新または削除します。いずれかのターゲット フィールドがユーザー所有の場合、ペアは部分的に書き込まれるのではなく、変更されないままになります。曖昧な TOML は書き込みなしで拒否されます。外部プロバイダー マネージャーとユーザー所有のルート ルーティングも引き続き権限を持ちます。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 41f90537cd..ea7ff40cde 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -250,7 +250,9 @@ ChatGPT 계정을 Codex account pool에 추가하면, opencodex는 이를 저장 ## 네이티브 Codex 복원 -opencodex는 절대 사용자를 가두지 않습니다. **`ocx stop`은 네이티브 Codex로 완전히 되돌리는 단일 명령입니다**. proxy를 중지하고, 설치된 background service가 있으면 그것도 중지한 뒤, 주입된 모든 라인과 라우팅된 catalog 항목을 제거해서 plain `codex`가 opencodex가 처음부터 없었던 것처럼 정확히 동작하게 합니다: +`ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다. + +현재 config 또는 profile이 저장된 원본과 다르고 해당 파일의 주입 상태 해시가 저널에 없으면, 자동 snapshot 복원은 두 파일과 저널을 변경하지 않고 검토용으로 남깁니다. 이미 원본과 같은 파일은 다시 쓰지 않습니다. 기존 라우팅 설정의 재주입도 이 불확실한 원본을 사용하지 않으며, 네이티브 설정에서는 새 snapshot을 만들 수 있습니다. [자세한 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 5baf9853ea..1372ed7c15 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -56,7 +56,9 @@ v2 로스터의 경우 적합성은 세 가지 상태로 나뉩니다. `"v2"`로 내장 v2 가이드는 700자 예산을 가집니다. 이 한도를 넘기면 opencodex는 핵심 스폰 지시를 자르는 대신 로스터를 먼저 제거합니다. 내장 가이드는 선호 모델, 적합한 로스터 또는 폴백 체인이 해석될 때만 발화합니다. 사용자 정의 프롬프트는 `injectionModel`만 설정되어 있어도 발화하며, 선택자가 없는 값을 하나로 해석할 수 없으면 `{{model}}`은 빈 문자열로 치환됩니다. -v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 업스트림 스타일의 능동 위임 가이드만 주입합니다. v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트를 추가하지 않습니다. +v1에서는 opencodex가 `max` 또는 `ultra` 추론 강도에서만 v2 권장 프리셋과 같은 능동 위임 가이드를 주입합니다. +별도의 위임 요청이 필요하지 않도록 시작 조건만 바꾸며, 사용자 지시와 권한·작업 범위·협업 도구 규칙은 계속 적용됩니다. +v1에는 선호 모델, 로스터, 폴백 목록, 사용자 정의 프롬프트를 추가하지 않습니다. 기본값이 꺼진 `syncCodexSubagentDefaults` 옵션은 가이드와 별개입니다. opencodex가 활성 Codex 라우팅을 소유하는 경우, 동기화나 재시작 시 선택한 값을 Codex TOML의 표식이 붙은 `[agents] default_subagent_model` 및 `default_subagent_reasoning_effort` 항목으로 쓸 수 있습니다. opencodex는 자신이 붙인 표식이 있는 필드만 갱신하거나 제거합니다. 대상 필드 중 하나라도 사용자 소유라면 부분 쓰기는 하지 않고 쌍을 그대로 둡니다. 애매한 TOML은 쓰기 없이 거부합니다. 외부 프로바이더 관리자와 사용자 소유 루트 라우팅도 여전히 최종 권한을 가집니다. 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 3624a2a803..76cef4bda7 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -16,6 +16,27 @@ description: 멀티 에이전트, 콤보, 관측성, 접근, 통합, 시스템, ocx agent subagents set ark/model-a,openai/gpt-5.5 ``` +### `ocx effort [status|set|clear]` + +실행 중인 프록시를 통해 메인·서브에이전트의 reasoning-effort 상한을 조회하거나 변경하며, +프록시가 없으면 로컬 설정을 사용합니다. 상한은 `low`, `medium`, `high`, `xhigh`, `max`, +`ultra`이고, `-`는 해당 상한을 해제합니다. `none`과 `minimal`은 상한 단계가 아니므로 같은 +명령의 다른 옵션이 유효하더라도 프록시 탐색이나 설정 변경 요청 전에 거부됩니다. +두 값은 상한이 아닌 별도의 injection effort를 설정하는 `--injection`에서는 그대로 사용할 수 있습니다. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +상태 조회는 저장값 또는 런타임 상한 원문을 보존하고, 지원하지 않는 값은 `warnings`에 표시합니다 +(모두 지원되는 값이면 빈 배열). 일반 출력에도 같은 경고가 나오며, 무시되는 필드와 수정 명령을 +안내합니다. 상태 조회가 기존 값을 자동으로 복구하거나 덮어쓰지는 않습니다. 서브에이전트 필드가 +무시되더라도 유효한 메인 상한이 사라지는 것은 아닙니다. `ocx effort clear`는 별도의 injection-effort +설정을 유지하면서 두 상한을 해제합니다. 상한이 적용되는 요청 surface는 +[Sub-agent surfaces](/ko/guides/sub-agent-surface/)를 참고하세요. + ### `ocx v2 |threads >` Codex `multi_agent_v2` 기능 플래그와 세 상태 멀티 에이전트 surface mode를 관리합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index d4c19ebd46..796a3d7a12 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -54,6 +54,10 @@ stop/start 대체 동작 없이 안전하게 실패합니다. 소유권을 확 프록시를 중지하지 않고 기본 Codex를 **복원**합니다. 주입된 설정 줄과 라우팅된 카탈로그 항목을 제거하므로 일반 `codex`가 다시 네이티브로 동작합니다. `eject`는 `restore`의 별칭입니다. +저장된 저널에 해당 파일의 주입 상태 해시가 없으면, 변경된 설정 파일을 덮어쓰는 대신 복원 실패를 +보고합니다. 현재 파일과 저널은 검토용으로 보존됩니다. +[해시 없는 저널의 복구 규칙](/guides/codex-integration/#recovery-without-injection-hashes)을 참고하세요. + 둘 중 어느 표기든 `back`을 붙이면 이미 실행 중인 프록시를 가리키도록 일반 `codex`를 다시 연결하되, 프록시 수명 주기는 바꾸지 않습니다. @@ -185,6 +189,10 @@ probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `fa 해당할 때 서비스 마이그레이션을 설명합니다. 이 진단에 표시되는 경로는 OS 사용자 이름을 마스킹합니다. doctor는 복구 힌트를 보여 주지만 직접 적용하지는 않습니다. +프로젝트 설정 진단은 `developer_instructions` 같은 TOML 여러 줄 문자열 안의 공급자 예시를 +무시합니다. 종료 구분자 바로 앞에 이스케이프된 따옴표가 있어도, 문자열이 끝난 뒤의 실제 +공급자 및 프로필 설정은 계속 검사합니다. + **OAuth 안정성** 섹션은 자격 증명 저장소에 쓰기 가능한지, `OPENCODEX_HOME` 아래에 refresh single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또는 Codex pool 계정(마스킹된 ID)과 복구용 `Action:`, 그리고 Codex 전달 경로가 공식 클라이언트 메타데이터를 꾸며 내지 않는다는 diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 4b95d1bcd2..ab96881d11 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -33,6 +33,27 @@ ocx agent sidecar web --list ocx agent sidecar web --model gpt-5.6-luna ``` +### `ocx effort [status|set|clear]` + +Inspect or change main and subagent reasoning-effort caps through the live proxy, or the local +configuration when no proxy is available. Cap values are `low`, `medium`, `high`, `xhigh`, `max`, +and `ultra`; `-` clears the selected cap. `none` and `minimal` are not cap levels and are rejected +before probing the proxy or submitting an update, including when another option in the same command is valid. +They remain valid for `--injection`, which sets the separate injection effort rather than a cap. + +```bash +ocx effort status --json +ocx effort set --main high --subagent low +ocx effort set --subagent - +``` + +Status preserves existing stored/runtime cap values and reports unsupported values in `warnings` +(an empty array when none are unsupported). The same warnings appear in human output and name the +field that is ignored with a correction command. Status never repairs or rewrites those values. +An ignored subagent field does not remove a valid main cap. `ocx effort clear` clears both caps +while retaining the separate injection-effort setting. See [Sub-agent surfaces](/guides/sub-agent-surface/) +for the request surfaces where caps apply. + ### `ocx v2 |keep-native-v1 |threads |mode-hint >` Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent surface mode. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index a7d61a0bee..92253e41f8 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -59,6 +59,10 @@ Idempotently ensure a background proxy is running, then sync its live model cata Restore native Codex **without** stopping the proxy — strips the injected config lines and routed catalog entries so plain `codex` works natively again. `eject` is an alias of `restore`. +Restoration reports failure instead of replacing changed configuration files when a saved journal +lacks the corresponding injection hashes. The current files and journal remain available for +review; see [recovery without injection hashes](/guides/codex-integration/#recovery-without-injection-hashes). + Pass `back` to either spelling to re-point plain `codex` at an already-running proxy without changing the proxy lifecycle: @@ -214,6 +218,10 @@ and pending history migration. The Codex app-home targeting section also detects Orca runtime-home mismatch and explains service migration when applicable. Paths shown by this diagnostic redact the OS username. Doctor prints repair hints but does not apply them. +Project-config diagnostics ignore provider examples inside TOML multiline strings, including +`developer_instructions`. Real provider and profile settings after the closing delimiter are still +checked, even when an escaped quote immediately precedes that delimiter. + The **OAuth reliability** section reports whether credential storage is writable, whether refresh single-flight/lock files can be created under `OPENCODEX_HOME`, non-healthy OAuth or Codex pool accounts (redacted ids) with a recovery `Action:`, and a static OK that the Codex forward path does diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 23581e56f6..01e4d8003d 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -379,10 +379,9 @@ Responses item'ов (`input: [{ type: "message", ... }]`), ждёт `response.co ## Восстановление нативного Codex -opencodex не запирает вас внутри себя. **`ocx stop` — это единственная команда, которая полностью -возвращает нативный Codex**: она останавливает прокси, останавливает фоновую службу, если она -установлена, и убирает все внедрённые строки и маршрутизируемые записи каталога, так что обычный -`codex` снова работает так, будто opencodex никогда не существовал: +`ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить. + +Если текущая конфигурация или профиль отличаются от сохранённого оригинала, а журнал не содержит хеша внедрённого состояния этого файла, автоматическое восстановление сохраняет оба файла и журнал без изменений. Файл, уже совпадающий с оригиналом, не перезаписывается. Повторное внедрение в настроенную для прокси конфигурацию также отклоняет такое неподтверждённое состояние; нативная конфигурация может создать новый снимок. См. [правила восстановления](/guides/codex-integration/#recovery-without-injection-hashes). ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index d8cf873514..7dfd7ea94c 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -78,8 +78,9 @@ guidance-сообщений, которые opencodex пишет сам, на о `injectionModel` достаточно, чтобы отобразить пользовательский prompt; если значение без селектора нельзя разрешить однозначно, `{{model}}` заменяется пустой строкой. -На v1 opencodex внедряет только upstream-style proactive guidance о делегировании на уровнях -effort `max` или `ultra`. Предпочитаемую модель, ростер, fallback list и custom prompt на v1 он +На v1 opencodex внедряет тот же текст о проактивном делегировании, что и рекомендуемый пресет v2, только на уровнях effort `max` или `ultra`. +Меняется только условие запуска: отдельный запрос на делегирование не требуется; инструкции пользователя, полномочия, рамки задачи и правила инструментов совместной работы остаются в силе. +Предпочитаемую модель, ростер, fallback list и custom prompt на v1 он не добавляет. Опция `syncCodexSubagentDefaults`, выключенная по умолчанию, отделена от guidance. Когда diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index d13af52274..46b5f8e296 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -437,10 +437,9 @@ olduğunda ve `tokenGuardian.codexWarmupEnabled` true olduğunda çalışır. ## Yerel Codex'i geri yükleme -opencodex sizi asla tuzağa düşürmez. **`ocx stop`, yerel Codex'e tamamen geri -dönen tek komuttur** — proxy'yi durdurur, kuruluysa arka plan servisini durdurur -ve enjekte edilen her satırı ve yönlendirilen katalog girdisini kaldırır, -böylece düz `codex` sanki opencodex hiç var olmamış gibi tam olarak çalışır: +`ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir. + +Mevcut yapılandırma veya profil kayıtlı özgün içerikten farklıysa ve günlükte o dosyanın enjekte edilmiş durumunun karması yoksa otomatik kurtarma iki dosyayı ve günlüğü değiştirmeden korur. Özgün içerikle zaten aynı olan dosya yeniden yazılmaz. Yönlendirilmiş bir yapılandırmaya yeniden enjeksiyon da bu belirsiz durumu reddeder; yerel yapılandırma yeni bir anlık görüntü oluşturabilir. [Kurtarma kurallarına](/guides/codex-integration/#recovery-without-injection-hashes) bakın. ```bash ocx stop # proxy'yi + servisi durdurun, yerel Codex'i geri yükleyin diff --git a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md index 3ce565d2cf..c03f23ba44 100644 --- a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md @@ -94,8 +94,9 @@ rehberlik yalnızca tercih edilen bir model, uygun kadro veya geri dönüş zinc istem oluşturmak için yeterlidir; yalın bir değer benzersiz şekilde çözümlenemezse `{{model}}` boş bir dizeye genişler. -v1'de opencodex yalnızca `max` veya `ultra` çabada yukarı akış tarzı proaktif -yetkilendirme rehberliğini enjekte eder. v1'de tercih edilen bir model, kadro, +v1'de opencodex, yalnızca `max` veya `ultra` çaba düzeylerinde v2'nin önerilen ön ayarıyla aynı proaktif görev devri metnini enjekte eder. +Yalnızca tetikleme koşulu değişir: ayrıca görev devri talep edilmesi gerekmez; kullanıcı talimatları, yetkiler, görev kapsamı ve iş birliği araçlarının kuralları geçerliliğini korur. +v1'de tercih edilen bir model, kadro, geri dönüş listesi veya özel istem eklemez. Varsayılan olarak kapalı olan `syncCodexSubagentDefaults` seçeneği rehberlikten diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 0c3df0c0e2..1170d9207f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -321,9 +321,9 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 ## 恢复原生 Codex -opencodex 绝不会把你困住。**`ocx stop` 是完全恢复原生 Codex 的单一命令** —— 它会停止 proxy、 -停止后台服务(如已安装),并剥除所有注入的行和路由的目录条目,使普通的 `codex` 完全像 opencodex -从未存在过一样工作: +`ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。 + +如果当前 config 或 profile 与保存的原始内容不同,且日志缺少该文件注入状态的哈希值,自动快照恢复会保留两个文件和日志,不作修改。已经与原始内容相同的文件不会重写。对已路由配置的再次注入也会拒绝使用这种未确认的基线;原生配置可以建立新的快照。详见[恢复规则](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # stop the proxy + service, restore native Codex diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index fae4b5a7e1..c8c6bcba3d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -56,7 +56,9 @@ Dashboard 上的 **Sub-agent delegation** 控件管理三个相关设置: 内置的 v2 指引有 700 字符预算。如果会超出预算,opencodex 会优先删除 roster,而不是截断核心 spawn 指令。内置指引仅在首选模型、可用 roster 或 fallback chain 解析成功时触发。只要配置了 `injectionModel`,自定义提示词就会触发;如果未限定的值无法唯一解析,`{{model}}` 会替换为空字符串。 -在 v1 上,opencodex 只会在 `max` 或 `ultra` effort 下注入上游风格的主动委派指引。它不会在 v1 上额外添加首选模型、roster、fallback list 或自定义提示词。 +在 v1 上,opencodex 只在 `max` 或 `ultra` 推理强度下注入与 v2 推荐预设相同的主动委派指引。 +仅改变委派的触发条件:不再需要单独提出委派请求;用户指示以及权限、任务范围和协作工具规则仍然适用。 +它不会在 v1 上额外添加首选模型、roster、fallback list 或自定义提示词。 默认关闭的 `syncCodexSubagentDefaults` 选项与指引是分开的。当 opencodex 拥有活跃的 Codex 路由时,同步或重启可以把所选值写入 Codex TOML 中带标记的 `[agents] default_subagent_model` 和 `default_subagent_reasoning_effort` 条目。opencodex 只会更新或移除带有其标记的字段。如果任一目标字段属于用户,整对值会保持不变,而不会部分写入;含糊不清的 TOML 会在不写入的情况下被拒绝。外部 provider 管理器和用户拥有的根路由也仍然具有最终权威。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 44ee5bbb3b..b8db326af6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -328,9 +328,9 @@ body。背景重新驗證是獨立功能,預設關閉;只有啟用 Token Gua ## 恢復原生 Codex -opencodex 絕不會把你困住。**`ocx stop` 是完整恢復原生 Codex 的單一命令**。它會停止 proxy、停止 -背景服務(若已安裝),並移除所有注入行與路由目錄條目,讓普通的 `codex` 就像從未安裝 opencodex 一樣 -運作: +`ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。 + +若目前的 config 或 profile 與儲存的原始內容不同,且日誌缺少該檔案注入狀態的雜湊值,自動快照恢復會保留兩個檔案及日誌,不做修改。已與原始內容相同的檔案不會重新寫入。對已路由設定再次注入時,也會拒絕使用這種未確認的基準;原生設定可以建立新的快照。詳見[恢復規則](/guides/codex-integration/#recovery-without-injection-hashes)。 ```bash ocx stop # 停止 proxy + service,恢復原生 Codex diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index ca74fe7dd6..59be70f24d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -61,7 +61,9 @@ opencodex 允許你為目錄中的所有模型選擇多代理協作介面。儀 內建指引只在偏好模型、合格名冊或 fallback 鏈解析成功時觸發。設定了 `injectionModel` 就足以渲染 自訂提示詞;若裸值無法唯一解析,`{{model}}` 會展開為空字串。 -在 v1 上,opencodex 只在 `max` / `ultra` effort 注入上游風格的主動委派指引。v1 不會附加偏好模型、 +在 v1 上,opencodex 只在 `max` 或 `ultra` 推理強度下注入與 v2 建議預設相同的主動委派指引。 +僅改變委派的觸發條件:不再需要另外提出委派請求;使用者指示以及權限、任務範圍與協作工具規則仍然適用。 +v1 不會附加偏好模型、 名冊、fallback 清單或自訂提示詞。 預設關閉的 `syncCodexSubagentDefaults` 選項與指引無關。當 opencodex 擁有作用中的 Codex 路由時, diff --git a/src/cli/effort.ts b/src/cli/effort.ts index 0e4ea89d72..1daed4c679 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -2,6 +2,7 @@ import { loadConfig, saveConfig } from "../config"; import { CODEX_REASONING_LEVELS, configuredReasoningEfforts, + isCodexReasoningEffort, isDeclaredReasoningEffort, mapReasoningEffort, reasoningEffortMapFor, @@ -21,7 +22,7 @@ import { export const EFFORT_USAGE = `Usage: ocx effort [status] [--json] - ocx effort [--json] + ocx effort [--json] ocx effort set [--main ] [--subagent ] [--injection ] [--json] ocx effort clear [--json] ocx effort model [--json] @@ -33,13 +34,18 @@ function clearable(value: string | undefined): string | null | undefined { return value === "-" ? null : value; } -function validateEffortLevel(level: string | null | undefined, label: string): string | null | undefined { +function validateEffortLevel( + level: string | null | undefined, + label: string, + kind: "cap" | "injection", +): string | null | undefined { if (level === undefined || level === null) return level; const trimmed = level.trim(); if (trimmed === "-" || trimmed === "") return null; - if (!isDeclaredReasoningEffort(trimmed)) { + const valid = kind === "cap" ? isCodexReasoningEffort(trimmed) : isDeclaredReasoningEffort(trimmed); + if (!valid) { throw new CliUsageError( - `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}, none, minimal, -)`, + `unknown reasoning effort "${trimmed}" for ${label} (allowed: ${CODEX_REASONING_LEVELS.map(l => l.effort).join(", ")}${kind === "injection" ? ", none, minimal" : ""}, -)`, EFFORT_USAGE, ); } @@ -114,6 +120,15 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise { data = getOfflineStatus(); } + // Report the stored/runtime value exactly as the enforcement layer evaluates it. + // An ignored subagent field does not disable a valid main cap on that child. + const warnings = ([ ["effortCap", "--main"], ["subagentEffortCap", "--subagent"] ] as const) + .flatMap(([key, flag]) => { + const value = data[key]; + if (value === null || isCodexReasoningEffort(value)) return []; + return [`${key}=${JSON.stringify(value)} is invalid and is not applied. Use: ocx effort set ${flag} <${CODEX_REASONING_LEVELS.map(l => l.effort).join("|")}|->.`]; + }); + const lines = [ `Reasoning effort status (${data.source === "runtime" ? "live proxy" : "offline config"}):`, ` Main agent effort cap: ${data.effortCap ?? "(unset — no cap)"}`, @@ -122,9 +137,10 @@ async function status(wantsJson: boolean, deps: RuntimeApiDeps): Promise { "", "Supported Codex reasoning effort ladder:", ...CODEX_REASONING_LEVELS.map(l => ` - ${l.effort.padEnd(8)} ${l.description}`), + ...(warnings.length ? ["", "Warnings:", ...warnings.map(warning => ` ${warning}`)] : []), ]; - printData(data, wantsJson, lines); + printData({ ...data, warnings }, wantsJson, lines); } async function setEffort( @@ -136,9 +152,9 @@ async function setEffort( wantsJson: boolean, deps: RuntimeApiDeps, ): Promise { - const validatedMain = validateEffortLevel(options.main, "--main"); - const validatedSubagent = validateEffortLevel(options.subagent, "--subagent"); - const validatedInjection = validateEffortLevel(options.injection, "--injection"); + const validatedMain = validateEffortLevel(options.main, "--main", "cap"); + const validatedSubagent = validateEffortLevel(options.subagent, "--subagent", "cap"); + const validatedInjection = validateEffortLevel(options.injection, "--injection", "injection"); if (validatedMain === undefined && validatedSubagent === undefined && validatedInjection === undefined) { throw new CliUsageError("at least one effort option (--main, --subagent, or --injection) is required", EFFORT_USAGE); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 37e73c8e33..2241d8422b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -31,6 +31,7 @@ import { resolveEffectiveUserIdentity, } from "./user-identity"; import { + hasUnverifiedJournalBaseline, markJournalInjectedState, journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, @@ -1149,6 +1150,24 @@ export async function injectCodexConfig( }; } + const journalBaselineIsNative = (): boolean => { + // Value evidence survives an app rewrite that removes the ownership comments. + const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true }); + const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true }); + const looksInjectedByValue = + (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) + || (journaledRealtimeWsBaseUrl !== null + && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); + return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue; + }; + const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH) + ? readFileSync(CODEX_PROFILE_PATH, "utf-8") + : null; + const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved."; + if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) { + return { success: false, message: unverifiedJournalMessage }; + } + if (options.validateOnly) { return { success: true, @@ -1157,21 +1176,14 @@ export async function injectCodexConfig( } const applyNativeArtifacts = (): void => { - // #1798 again: a Codex app rewrite keeps values and drops the ownership comments, so - // marker evidence alone would classify our own routed config as the user's native - // baseline and replace the real original snapshot. Value evidence from the journal - // (the URLs the last injection recorded writing) blocks that misclassification. - const journaledBaseUrl = journaledInjectedOpenaiBaseUrl(); - const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl(); - const looksInjectedByValue = - (journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl) - || (journaledRealtimeWsBaseUrl !== null - && rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl); writeJournal({ - currentStateIsNative: !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue, + currentStateIsNative: journalBaselineIsNative(), configContent: baselineContent, owner: options.journalOwner, }); + // A native snapshot may have been refreshed above. An older hashless routed snapshot + // must not gain the new injection's hash and later overwrite preserved user edits. + if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage); atomicWriteFile(CODEX_CONFIG_PATH, content); atomicWriteFile(CODEX_PROFILE_PATH, profileContent); markJournalInjectedState(content, profileContent, { @@ -1733,6 +1745,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex function restoreCodexConfigInline(): CodexRestoreConfigResult { try { const journal = restoreJournalState(); + if (journal.unverified) { + return { + state: "failed", changed: false, action: "failed", + message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.", + }; + } const restored = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal." } : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); diff --git a/src/codex/journal.ts b/src/codex/journal.ts index b2ec89eef0..8aafb19202 100644 --- a/src/codex/journal.ts +++ b/src/codex/journal.ts @@ -62,12 +62,40 @@ export interface RestoreJournalResult { configChanged: boolean; profileChanged: boolean; complete: boolean; + /** A changed artifact has no recorded injected hash, so snapshot ownership is unknown. */ + unverified: boolean; } function sha256(content: string | null): string | null { return content === null ? null : createHash("sha256").update(content).digest("hex"); } +function compareJournalState(journal: Journal, config: string | null, profile: string | null) { + const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8"); + const originalProfile = journal.originalProfile === null + ? null + : Buffer.from(journal.originalProfile, "base64").toString("utf-8"); + const configAlreadyOriginal = config === originalConfig; + const profileAlreadyOriginal = profile === originalProfile; + const configHashKnown = typeof journal.injectedConfigHash === "string" && journal.injectedConfigHash.length > 0; + const profileHashKnown = journal.injectedProfileHash !== undefined; + return { + originalConfig, + originalProfile, + configAlreadyOriginal, + profileAlreadyOriginal, + configUnchanged: configAlreadyOriginal || (configHashKnown && sha256(config) === journal.injectedConfigHash), + profileUnchanged: profileAlreadyOriginal || (profileHashKnown && sha256(profile) === (journal.injectedProfileHash ?? null)), + unverified: (!configHashKnown && !configAlreadyOriginal) || (!profileHashKnown && !profileAlreadyOriginal), + }; +} + +/** Read-only check of the pre-injection snapshot input, never the newly injected bytes. */ +export function hasUnverifiedJournalBaseline(config: string | null, profile: string | null): boolean { + const journal = readJournal(false); + return journal !== null && compareJournalState(journal, config, profile).unverified; +} + export interface WriteJournalOptions { /** * The caller's verdict on the config it is about to transform: false when @@ -114,7 +142,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void { const journal: Journal = { version: 1, originalConfig: Buffer.from(config).toString("base64"), - originalProfile: profile ? Buffer.from(profile).toString("base64") : null, + originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null, pid: process.pid, owner: options.owner?.kind === "client" ? { kind: "client", apiKeyId: options.owner.apiKeyId } @@ -208,22 +236,34 @@ export function journalOwner(): JournalOwner | null { export function restoreJournalState(): RestoreJournalResult { const journal = readJournal(); if (!journal) { - return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false }; + return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false }; } - const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : ""; + const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null; const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null; - const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash; - const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null); + const comparison = compareJournalState(journal, currentConfig, currentProfile); + const { configUnchanged, profileUnchanged } = comparison; + // A legacy record or interruption before markJournalInjectedState is not proof that + // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact. + if (comparison.unverified) { + return { + configRestored: comparison.configAlreadyOriginal, + profileRestored: comparison.profileAlreadyOriginal, + configChanged: !configUnchanged, + profileChanged: !profileUnchanged, + complete: false, + unverified: true, + }; + } - let configRestored = false; - let profileRestored = false; - if (configUnchanged) { - atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8")); + let configRestored = comparison.configAlreadyOriginal; + let profileRestored = comparison.profileAlreadyOriginal; + if (configUnchanged && !configRestored) { + atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig); configRestored = true; } - if (profileUnchanged) { - if (journal.originalProfile !== null) { - atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8")); + if (profileUnchanged && !profileRestored) { + if (comparison.originalProfile !== null) { + atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile); profileRestored = true; } else if (existsSync(CODEX_PROFILE_PATH)) { // "There was no profile before, so remove the one we generated." Claiming success @@ -249,6 +289,7 @@ export function restoreJournalState(): RestoreJournalResult { configChanged: !configUnchanged, profileChanged: !profileUnchanged, complete, + unverified: false, }; } @@ -267,6 +308,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean if (owner?.kind === "client") { if (options.activeClientApiKeyId === owner.apiKeyId) return false; const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`); return true; @@ -281,6 +326,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean } } const restored = restoreJournalState(); + if (restored.unverified) { + console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved."); + return false; + } if (!restored.configRestored && !restored.profileRestored) return false; console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`); return true; diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 47b6afee27..50721a689d 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -69,7 +69,9 @@ function multilineCloseIndex( backslashes += 1; } if (backslashes % 2 === 0) break; - index = line.indexOf(delimiter, index + delimiter.length); + // An escaped quote can overlap the real terminator (backslash plus four quotes). + // Keep overlapping candidates instead of skipping the entire rejected delimiter. + index = line.indexOf(delimiter, index + 1); } return index; } diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index e01a79cea8..ab6a9b55c9 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -8,6 +8,7 @@ import { } from "../../config"; import { parseRequest } from "../../responses/parser"; import { externalTaskInputContent } from "../../responses/task-input"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../codex/multi-agent-mode-policy"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state"; @@ -233,13 +234,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato -export const PROACTIVE_MULTI_AGENT_MODE_TEXT = [ - "Proactive multi-agent delegation is active.", - "Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies.", - "Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently.", - "Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself.", - "This mode remains active until a later multi-agent mode developer message changes it.", -].join(" "); +export const PROACTIVE_MULTI_AGENT_MODE_TEXT = MULTI_AGENT_MODE_HINT_RECOMMENDATION.text; const OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG = ""; const OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG = ""; @@ -491,8 +486,8 @@ export async function multiAgentGuidanceText( } const effort = parsed.options.reasoning; - // v1 keeps only the upstream-parity behavior: Proactive text at the top tier - // (ultra arrives as max on the wire). No designation/roster payload here. + // v1 changes only the delegation trigger at the top tier; other rules still apply. + // Ultra arrives as max on the wire. No designation/roster payload here. if (effort !== "max" && effort !== "ultra") return null; return `${PROACTIVE_MULTI_AGENT_MODE_TEXT}`; } diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 572c73f25f..9ab5cf2bba 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,16 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +Journal restoration compares config and profile independently against their saved originals and +recorded injected hashes. If either changed artifact lacks its injected hash, the config/profile +pair and journal remain untouched and the result is explicitly unverified; callers must not +convert that refusal into successful fallback cleanup. Already-original bytes need no rewrite, +and absence is distinct from an empty file. The injector checks a retained hashless journal against +the same `baselineContent` it snapshots, plus the current profile, before writing or assigning a new +injected hash. Native content can establish a fresh snapshot; routed content cannot promote an +unverified older original. Existing hash-backed edit preservation and external-provider opt-out +remain separate paths. + The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and `ocx-state` respectively, retaining a read-only root. The image creates owner-only diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d3f20de401..9769012e1d 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -346,6 +346,11 @@ wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhig (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. +The `ocx effort` CLI accepts only the same canonical cap ladder before live probing or persistence. +Its status output preserves unsupported legacy cap values and reports that those fields are ignored; +the read does not normalize or migrate them, and an ignored subagent field does not disable a valid +main cap. Injection-effort input remains a separate contract. + Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root `modelPinnedEfforts` resolve before applicable effort caps at the final destination. Provider model pins precede provider-wide pins, then global selector/destination pins. @@ -470,7 +475,10 @@ custom `injectionPrompt` bodies. The built-in text reports the resolved preferre effort, roster and fallback chain without prescribing delegation, spawn overrides or `fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. -V1 retains its `` proactive text at `max` or `ultra`. +V1 uses the shared `MULTI_AGENT_MODE_HINT_RECOMMENDATION.text` inside `` +at `max` or `ultra`. Only the separate explicit delegation-request trigger changes; user, +authority, task-scope and collaboration-tool rules remain applicable. This is guidance, +not an enforcement mechanism or a change to native settings or tool access. Replay deduplication compares the latest exact generated developer text separately for each tag family, preserving built-in → custom → built-in transitions without duplicating diff --git a/tests/cli/cli-effort.test.ts b/tests/cli/cli-effort.test.ts index 6e8079183b..b80028fd02 100644 --- a/tests/cli/cli-effort.test.ts +++ b/tests/cli/cli-effort.test.ts @@ -112,6 +112,112 @@ describe("ocx effort offline config operations", () => { expect(parsed.subagentEffortCap).toBeNull(); expect(parsed.efforts).toContain("low"); expect(parsed.efforts).toContain("ultra"); + expect(parsed.warnings).toEqual([]); + }); + + for (const value of ["none", "minimal"]) { + for (const target of ["shorthand", "--main", "--subagent"]) { + test(`rejects unsupported cap ${value} through ${target} before probing or saving`, async () => { + const args = target === "shorthand" ? [value] : ["set", target, value]; + const { deps, logs, errors } = fakeDeps(args); + const configBefore = readFileSync(join(tempHome!, "config.json"), "utf8"); + let probes = 0; + deps.findLiveProxy = async () => { probes += 1; return null; }; + expect(await handleEffortCommand(args, deps)).toBe(2); + expect(errors.join("\n")).toContain('unknown reasoning effort "' + value + '"'); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(probes).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(configBefore); + }); + } + + test(`offline injection still accepts ${value} without treating it as a cap`, async () => { + const { deps } = fakeDeps(); + expect(await handleEffortCommand(["set", "--injection", value], deps)).toBe(0); + expect(readTestConfig().injectionEffort).toBe(value); + expect(readTestConfig().effortCap).toBeUndefined(); + expect(readTestConfig().subagentEffortCap).toBeUndefined(); + }); + } + + test("rejects unsupported cap spelling without advertising sentinel cap values", async () => { + const { deps, errors } = fakeDeps(); + expect(await handleEffortCommand(["set", "--main", "bogus"], deps)).toBe(2); + expect(errors.join("\n")).toContain("allowed: low, medium, high, xhigh, max, ultra, -"); + expect(errors.join("\n")).not.toContain("ultra, none, minimal"); + }); + + for (const source of ["config", "runtime"] as const) { + for (const wantsJson of [false, true]) { + test(`legacy unsupported cap diagnostics preserve ${source} values (${wantsJson ? "json" : "human"})`, async () => { + const conf = { ...readTestConfig(), effortCap: "none", subagentEffortCap: "minimal", injectionEffort: "none" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const configBefore = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + const methods: string[] = []; + const main = source === "config" ? "none" : "minimal"; + const subagent = source === "config" ? "minimal" : "none"; + const runtime = source === "runtime" ? { + baseUrl: "http://127.0.0.1:10100", + fetchImpl: async (url: string | URL | Request, init?: RequestInit) => { + methods.push(init?.method ?? "GET"); + const body = new URL(url.toString()).pathname === "/api/effort-caps" + ? { effortCap: main, subagentEffortCap: subagent } + : { effort: "none" }; + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); + }, + } : {}; + const args = wantsJson ? ["status", "--json"] : ["status"]; + expect(await handleEffortCommand(args, { ...deps, ...runtime })).toBe(0); + let warnings: string[]; + if (wantsJson) { + const result = JSON.parse(logs.join("\n")); + expect(result.source).toBe(source); + expect(result.effortCap).toBe(main); + expect(result.subagentEffortCap).toBe(subagent); + expect(result.injectionEffort).toBe("none"); + expect(result.warnings).toHaveLength(2); + warnings = result.warnings; + } else { + expect(logs.join("\n")).toContain(`Main agent effort cap: ${main}`); + warnings = logs; + } + expect(warnings.join("\n")).toContain(`effortCap="${main}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain(`subagentEffortCap="${subagent}" is invalid and is not applied`); + expect(warnings.join("\n")).toContain("ocx effort set --main"); + expect(warnings.join("\n")).toContain("ocx effort set --subagent"); + expect(methods).toEqual(source === "runtime" ? ["GET", "GET"] : []); + expect(readFileSync(configPath, "utf8")).toBe(configBefore); + }); + } + } + + test("invalid legacy cap diagnostics do not normalize stored whitespace or casing", async () => { + const conf = { ...readTestConfig(), effortCap: " high ", subagentEffortCap: "HIGH" }; + const configPath = join(tempHome!, "config.json"); + writeFileSync(configPath, JSON.stringify(conf, null, 2), "utf8"); + const before = readFileSync(configPath, "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe(" high "); + expect(result.subagentEffortCap).toBe("HIGH"); + expect(result.warnings).toHaveLength(2); + expect(readFileSync(configPath, "utf8")).toBe(before); + }); + + test("an ignored subagent cap warning preserves the valid main cap", async () => { + const conf = { ...readTestConfig(), effortCap: "high", subagentEffortCap: "minimal" }; + writeFileSync(join(tempHome!, "config.json"), JSON.stringify(conf, null, 2), "utf8"); + const { deps, logs } = fakeDeps(); + expect(await handleEffortCommand(["status", "--json"], deps)).toBe(0); + const result = JSON.parse(logs.join("\n")); + expect(result.effortCap).toBe("high"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].startsWith('subagentEffortCap="minimal"')).toBe(true); + expect(readTestConfig()).toEqual(conf); }); test("ocx effort sets main effort cap offline", async () => { @@ -210,6 +316,26 @@ describe("ocx effort offline config operations", () => { }); describe("ocx effort online live-proxy integration & negative regressions", () => { + for (const value of ["none", "minimal"]) { + test(`invalid cap values reject a mixed live update before any request (${value})`, async () => { + const { deps, logs } = fakeDeps(); + const before = readFileSync(join(tempHome!, "config.json"), "utf8"); + let requests = 0; + let probes = 0; + const code = await handleEffortCommand(["set", "--main", "high", "--subagent", value, "--injection", "medium"], { + ...deps, + baseUrl: "http://127.0.0.1:10100", + findLiveProxy: async () => { probes += 1; return null; }, + fetchImpl: async () => { requests += 1; return new Response("{}"); }, + }); + expect(code).toBe(2); + expect(probes).toBe(0); + expect(requests).toBe(0); + expect(logs).toEqual([]); + expect(readFileSync(join(tempHome!, "config.json"), "utf8")).toBe(before); + }); + } + test("live status read failures never substitute offline config", async () => { const { logs, errors } = fakeDeps(["status", "--json"]); const configBefore = readTestConfig(); diff --git a/tests/cli/cli-start-journal-order.test.ts b/tests/cli/cli-start-journal-order.test.ts index 15e6758b7a..95b5291a1a 100644 --- a/tests/cli/cli-start-journal-order.test.ts +++ b/tests/cli/cli-start-journal-order.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -81,6 +82,8 @@ function arrangeRecoverableJournal(fx: Fixture): { original: string; injected: s version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -181,6 +184,8 @@ describe("start and ensure journal ownership (#1230)", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(), diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 66bf5d4bf9..3835f463bc 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -14,7 +14,7 @@ import { import { handleConnectCommand } from "../../src/cli/connect"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as findRepoRoot } from "../helpers/repo-root"; -import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; const repoRoot = findRepoRoot(); @@ -316,7 +316,10 @@ describe("remote hub client boundary", () => { /** A catalog the user already had before ever connecting. */ const PRIOR_CATALOG_BYTES = '{"models":[{"slug":"local/only-model"}]}'; -function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator") { +function runTransactionScenario( + stage: "success" | "catalog" | "preflight" | "commit" | "prior-catalog" | "coordinator", + options: { script?: string; timeoutMs?: number } = {}, +) { const opencodexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-home-")); const codexHome = mkdtempSync(join(tmpdir(), "ocx-client-connect-codex-")); const configPath = join(opencodexHome, "config.json"); @@ -335,7 +338,7 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co const { mkdirSync } = require("node:fs") as typeof import("node:fs"); mkdirSync(join(opencodexHome, "config-mutation.sqlite")); } - const script = ` + const script = options.script ?? ` const { existsSync, readFileSync } = require("node:fs"); const { createHash } = require("node:crypto"); const { connectClient, disconnectClient } = require("./src/client/connect"); @@ -396,26 +399,110 @@ function runTransactionScenario(stage: "success" | "catalog" | "preflight" | "co console.log(JSON.stringify({ connected, error, beforeDisconnect, artifacts, disconnected, catalogAfter, after: readClientConnectionState(), calls, commitFaultTriggered })); })(); `; - const result = spawnSync(process.execPath, ["--eval", script], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, - encoding: "utf8", - }); - const output = result.stdout.trim().split("\n").at(-1) ?? "{}"; - const parsed = JSON.parse(output) as Record; - return { - status: result.status, - stderr: result.stderr, - parsed, - configBytes: readFileSync(configPath, "utf8"), - cleanup: () => { - removeTreeWithRetry(opencodexHome); - removeTreeWithRetry(codexHome); - }, + const cleanup = () => { + const failures: unknown[] = []; + for (const home of [opencodexHome, codexHome]) { + try { removeTreeWithRetry(home); } + catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "Could not clean client transaction homes"); }; + try { + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: join(opencodexHome, "desktop") }, + encoding: "utf8", + timeout: options.timeoutMs ?? INTERNAL_DEADLINE_MS, + killSignal: "SIGKILL", + }); + if (result.error || result.status !== 0 || result.signal !== null) { + throw new ClientStateProbeError(result.pid, result.status, result.signal, (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"); + } + let parsed: Record; + try { parsed = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); } + catch { throw new ClientStateProbeError(result.pid, result.status, result.signal, false); } + return { status: result.status, stderr: result.stderr, parsed, configBytes: readFileSync(configPath, "utf8"), cleanup }; + } catch (error) { + try { cleanup(); } + catch (cleanupError) { throw new AggregateError([error, cleanupError], "Client transaction failed and fixture cleanup failed"); } + throw error; + } } describe("connect transaction and offline disconnect", () => { + test("transaction fixture stops a child retained after valid output", async () => { + const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); + const markerPath = join(proofHome, "child-started.json"); + const naturalExitPath = join(proofHome, "natural-exit"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ + pid: process.pid, home: process.env.OPENCODEX_HOME, codexHome: process.env.CODEX_HOME, + })); + fs.writeSync(1, '{"ok":true}\\n'); + setTimeout(() => { fs.writeFileSync(${JSON.stringify(naturalExitPath)}, "exited"); }, 5_000); + `; + let run: Awaited> | undefined; + try { + let failure: unknown; + const startedAt = performance.now(); + try { run = await runTransactionScenario("coordinator", { script, timeoutMs: 2_000 }); } + catch (error) { failure = error; } + expect(performance.now() - startedAt).toBeLessThan(10_000); + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded transaction child failure"); + expect(failure.timedOut).toBe(true); + expect(existsSync(naturalExitPath)).toBe(false); + const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; + expect(proof.pid).toBe(failure.pid); + expect(existsSync(proof.home)).toBe(false); + expect(existsSync(proof.codexHome)).toBe(false); + let exitCode: string | undefined; + try { process.kill(proof.pid, 0); } + catch (error) { exitCode = (error as NodeJS.ErrnoException).code; } + expect(exitCode).toBe("ESRCH"); + } finally { + run?.cleanup(); + removeTreeWithRetry(proofHome); + } + }, SPAWN_BUDGET_MS); + + for (const [mode, output, status] of [ + ["nonzero exit", '{"ok":true}', 7], + ["invalid JSON", "private-child-output", 0], + ] as const) { + test(`transaction fixture cleans homes after ${mode}`, () => { + const proofHome = mkdtempSync(join(tmpdir(), "ocx-transaction-child-proof-")); + const markerPath = join(proofHome, "child-started.json"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(${JSON.stringify(markerPath)}, JSON.stringify({ + pid: process.pid, home: process.env.OPENCODEX_HOME, codexHome: process.env.CODEX_HOME, + })); + fs.writeSync(1, ${JSON.stringify(output)}); + process.exit(${status}); + `; + let run: ReturnType | undefined; + try { + let failure: unknown; + try { run = runTransactionScenario("coordinator", { script }); } + catch (error) { failure = error; } + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected transaction child failure"); + expect(failure.status).toBe(status); + expect(failure.timedOut).toBe(false); + expect(failure.message).not.toContain(output); + const proof = JSON.parse(readFileSync(markerPath, "utf8")) as { pid: number; home: string; codexHome: string }; + expect(failure.pid).toBe(proof.pid); + expect(existsSync(proof.home)).toBe(false); + expect(existsSync(proof.codexHome)).toBe(false); + } finally { + try { run?.cleanup(); } + finally { removeTreeWithRetry(proofHome); } + } + }, SPAWN_BUDGET_MS); + } + test("an unavailable config coordinator refuses before issuing any hub key", () => { const run = runTransactionScenario("coordinator"); try { @@ -493,6 +580,7 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c const fingerprint = createHash("sha256").update(token).digest("hex"); const catalog = '{"models":[]}'; const catalogFingerprint = createHash("sha256").update(catalog).digest("base64url"); + const injected = 'model_provider = "opencodex"\n'; const isDisconnect = mode === "disconnect-conflict" || mode === "disconnect-process-journal"; const selectedClients = isDisconnect ? ["codex"] : ["claude"]; writeFileSync(join(opencodexHome, "config.json"), JSON.stringify({ @@ -517,13 +605,15 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c writeFileSync(join(opencodexHome, "service-api-token"), `${token}\n`, { mode: 0o600 }); writeFileSync(join(codexHome, "opencodex-catalog.json"), catalog, "utf8"); writeFileSync(join(codexHome, "config.toml"), isDisconnect - ? 'model_provider = "opencodex"\n' + ? injected : 'model_provider = "openai"\n', "utf8"); if (mode === "disconnect-conflict") { writeFileSync(join(codexHome, "opencodex-journal.json"), JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "different-key" }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -538,6 +628,8 @@ function runConnectedStateScenario(mode: "sync-401" | "sync-503" | "disconnect-c version: 1, originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "process", pid: 999_999 }, pid: 999_999, timestamp: "2026-08-28T00:00:00.000Z", @@ -890,6 +982,8 @@ function runDesktopLifecycleScenario(mode: string) { fs.writeFileSync(path.join(process.env.CODEX_HOME, "config.toml"), 'model_provider = "opencodex"'); fs.writeFileSync(journal.JOURNAL_PATH, JSON.stringify({ version: 1, originalConfig: Buffer.from('model_provider = "openai"').toString("base64"), originalProfile: null, + injectedConfigHash: hash(fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8")), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: owner.apiKeyId }, })); const actualRestore = journal.restoreJournalState; diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index a211701578..1b148daad1 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -39,7 +39,7 @@ describe("Codex catalog restore", () => { if (existsSync(opencodexHome)) removeTreeWithRetry(opencodexHome); }); - test("version-1 process journals restore, while matching client ownership is durable", () => { + test("version-1 process journals with injected hashes restore, while matching client ownership is durable", () => { const configPath = join(codexHome, "config.toml"); const journalPath = join(codexHome, "opencodex-journal.json"); const original = '# original\nmodel_provider = "openai"\n'; @@ -49,6 +49,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, pid: 999_999, timestamp: new Date().toISOString(), })); @@ -65,6 +67,8 @@ describe("Codex catalog restore", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999_999, timestamp: new Date().toISOString(), diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 3b46be997a..05d6d14e37 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -56,6 +56,224 @@ describe("codex-journal", () => { expect(out.hasPid).toBe(true); }); + test("hashless interrupted snapshot preserves later native config edits", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.config).toBe(edited); + expect(out.result.configRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless interrupted snapshot preserves a later profile", () => { + const edited = 'model_provider = "openai"\nmodel = "user-profile-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(profilePath, ${JSON.stringify(edited)}); + const result = restoreJournalState(); + console.log(JSON.stringify({ result, profile: fs.existsSync(profilePath) ? fs.readFileSync(profilePath, "utf8") : null, + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.profile).toBe(edited); + expect(out.result.profileRestored).toBe(false); + expect(out.result.complete).toBe(false); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless already-original snapshot completes without rewriting config", () => { + const r = runScript(testDir, ` + const { spyOn } = require("bun:test"); + const config = require("./src/config"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const originalWrite = config.atomicWriteFile; + let writes = 0; + const spy = spyOn(config, "atomicWriteFile").mockImplementation((...args) => { + writes += 1; + return originalWrite(...args); + }); + try { console.log(JSON.stringify({ result: restoreJournalState(), writes })); } + finally { spy.mockRestore(); } + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.complete).toBe(true); + expect(out.writes).toBe(0); + expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(false); + }); + + test("hashless snapshot distinguishes an empty original profile from absence", () => { + writeFileSync(join(testDir, "opencodex.config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + writeJournal(); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); + const result = restoreJournalState(); + console.log(JSON.stringify({ originalProfile: journal.originalProfile, result, + profileExists: fs.existsSync(profilePath), profile: fs.readFileSync(profilePath, "utf8"), + journalExists: fs.existsSync(journalPath) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.originalProfile).toBe(""); + expect(out.result.complete).toBe(true); + expect(out.profileExists).toBe(true); + expect(out.profile).toBe(""); + expect(out.journalExists).toBe(false); + }); + + test("hashless native restore refuses instead of reporting an uncertain snapshot as restored", () => { + const edited = '# current user settings\nmodel_provider = "openai"\nmodel = "user-selected-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { restoreNativeCodex } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ result, config: fs.readFileSync(configPath, "utf8"), + journalPreserved: fs.existsSync(journalPath) && fs.readFileSync(journalPath, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.result.success).toBe(false); + expect(out.result.artifacts.config.state).toBe("failed"); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless routed snapshot is not promoted by reinjection after user edits", () => { + const edited = '# current user settings\nmodel = "user-selected-model"\n# Auto-injected by opencodex\nopenai_base_url = "http://127.0.0.1:10100/v1"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const path = require("node:path"); + const { writeJournal } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + const configPath = path.join(process.env.CODEX_HOME, "config.toml"); + const profilePath = path.join(process.env.CODEX_HOME, "opencodex.config.toml"); + const journalPath = path.join(process.env.CODEX_HOME, "opencodex-journal.json"); + writeJournal(); + const before = fs.readFileSync(journalPath, "utf8"); + fs.writeFileSync(configPath, ${JSON.stringify(edited)}); + (async () => { + const config = { port: 10200, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10200, config, { catalogPath: null, validateOnly: true }); + const result = await injectCodexConfig(10200, config, { catalogPath: null }); + console.log(JSON.stringify({ preflight, result, config: fs.readFileSync(configPath, "utf8"), + profileCreated: fs.existsSync(profilePath), journal: JSON.parse(fs.readFileSync(journalPath, "utf8")), + journalPreserved: fs.readFileSync(journalPath, "utf8") === before })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(false); + expect(out.result.success).toBe(false); + expect(out.config).toBe(edited); + expect(out.profileCreated).toBe(false); + expect(out.journal.injectedConfigHash).toBeUndefined(); + expect(out.journalPreserved).toBe(true); + }); + + test("hashless empty config snapshot does not recreate a later deleted file", () => { + writeFileSync(join(testDir, "config.toml"), "", "utf8"); + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal(); + fs.unlinkSync(CODEX_CONFIG_PATH); + console.log(JSON.stringify({ result: restoreJournalState(), configExists: fs.existsSync(CODEX_CONFIG_PATH), + journalExists: fs.existsSync(JOURNAL_PATH) })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.configExists).toBe(false); + expect(out.journalExists).toBe(true); + expect(out.result.complete).toBe(false); + expect(out.result.unverified).toBe(true); + }); + + test("hashless client reconcile does not report an uncertain snapshot as restored", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH } = require("./src/codex/paths"); + const { writeJournal, reconcileJournal, JOURNAL_PATH } = require("./src/codex/journal"); + writeJournal({ owner: { kind: "client", apiKeyId: "previous-client" } }); + const before = fs.readFileSync(JOURNAL_PATH, "utf8"); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + const restored = reconcileJournal({ activeClientApiKeyId: "different-client" }); + console.log(JSON.stringify({ restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + journalPreserved: fs.existsSync(JOURNAL_PATH) && fs.readFileSync(JOURNAL_PATH, "utf8") === before })); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.restored).toBe(false); + expect(out.config).toBe(edited); + expect(out.journalPreserved).toBe(true); + expect(r.stderr).toContain("recovery was not verified"); + expect(r.stderr).not.toContain("was restored from the Codex journal"); + }); + + test("hashless native edits become the new snapshot before a successful injection", () => { + const edited = 'model_provider = "openai"\nmodel = "current-user-model"\n'; + const profile = 'model_provider = "openai"\nmodel = "current-user-profile"\n'; + const r = runScript(testDir, ` + const fs = require("node:fs"); + const { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } = require("./src/codex/paths"); + const { writeJournal, restoreJournalState } = require("./src/codex/journal"); + const { injectCodexConfig } = require("./src/codex/inject"); + writeJournal(); + fs.writeFileSync(CODEX_CONFIG_PATH, ${JSON.stringify(edited)}); + fs.writeFileSync(CODEX_PROFILE_PATH, ${JSON.stringify(profile)}); + (async () => { + const config = { port: 10100, providers: {}, defaultProvider: "openai" }; + const preflight = await injectCodexConfig(10100, config, { catalogPath: null, validateOnly: true }); + const injected = await injectCodexConfig(10100, config, { catalogPath: null }); + const restored = restoreJournalState(); + console.log(JSON.stringify({ preflight, injected, restored, config: fs.readFileSync(CODEX_CONFIG_PATH, "utf8"), + profile: fs.readFileSync(CODEX_PROFILE_PATH, "utf8") })); + })(); + `); + expect(r.status).toBe(0); + const out = JSON.parse(r.stdout); + expect(out.preflight.success).toBe(true); + expect(out.injected.success).toBe(true); + expect(out.restored.complete).toBe(true); + expect(out.config).toBe(edited); + expect(out.profile).toBe(profile); + }); + test("reconcileJournal restores config when journaled PID is dead", () => { const journalPath = join(testDir, "opencodex-journal.json"); const original = "# original config\nmodel_provider = \"openai\"\n"; @@ -65,6 +283,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(modified).digest("hex"), + injectedProfileHash: null, pid: 999999, timestamp: new Date().toISOString(), }), "utf8"); @@ -136,6 +356,8 @@ describe("codex-journal", () => { version: 1, originalConfig: Buffer.from(original).toString("base64"), originalProfile: null, + injectedConfigHash: createHash("sha256").update(injected).digest("hex"), + injectedProfileHash: null, owner: { kind: "client", apiKeyId: "client-key-1" }, pid: 999999, timestamp: new Date().toISOString(), @@ -344,6 +566,11 @@ describe("codex-journal", () => { '' ].join("\\n"), "utf8"); fs.writeFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), 'model_provider = "opencodex"\\n', "utf8"); + require("./src/codex/journal").markJournalInjectedState( + fs.readFileSync(path.join(process.env.CODEX_HOME, "config.toml"), "utf8"), + fs.readFileSync(path.join(process.env.CODEX_HOME, "opencodex.config.toml"), "utf8"), + { injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null }, + ); const result = restoreNativeCodex(); console.log(JSON.stringify({ success: result.success, message: result.message })); `); @@ -440,20 +667,21 @@ describe("codex-journal", () => { expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); }); - test("full lifecycle: write → crash → reconcile restores", () => { + test("full lifecycle: snapshot → mark injection → crash → reconcile restores", () => { const r = runScript(testDir, ` - const { writeJournal } = require("./src/codex/journal"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); writeJournal(); + const injected = "# injected opencodex config\\n"; + require("node:fs").writeFileSync(require("./src/codex/paths").CODEX_CONFIG_PATH, injected); + markJournalInjectedState(injected, null, { + injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null, + }); console.log("written"); `); expect(r.status).toBe(0); const journalPath = join(testDir, "opencodex-journal.json"); expect(existsSync(journalPath)).toBe(true); - const journal = JSON.parse(readFileSync(journalPath, "utf8")); - - writeFileSync(join(testDir, "config.toml"), "# injected opencodex config\n", "utf8"); - const r2 = runScript(testDir, ` const { reconcileJournal } = require("./src/codex/journal"); const result = reconcileJournal(); diff --git a/tests/codex-integration/codex-prompt-route.test.ts b/tests/codex-integration/codex-prompt-route.test.ts index ef862f58e2..feb38632db 100644 --- a/tests/codex-integration/codex-prompt-route.test.ts +++ b/tests/codex-integration/codex-prompt-route.test.ts @@ -15,6 +15,7 @@ import { LAYER_INVENTORY, readPromptLayers } from "../../src/codex/prompt-layers import { promptTextProbeSpawnAttemptsForTests, resetPromptTextProbeForTests, + setPromptTextProbeCloseBarrierForTests, setPromptTextProbeCommandForTests, } from "../../src/codex/prompt-text-probe"; import type { ManagementPrincipal } from "../../src/server/management-auth"; @@ -144,6 +145,33 @@ async function revision(fx: Fixture): Promise { return res.body.revision as string; } +/** Hold admission through the edit even if the fixture child has already exited. */ +async function withHeldPromptProbeClose( + fx: Fixture, + whileHeld: () => Promise, +): Promise>> { + let releaseClose!: () => void; + setPromptTextProbeCloseBarrierForTests(new Promise(resolve => { + releaseClose = resolve; + })); + // Observe an early request failure while the held-phase assertions are running. + const pending = call("GET", "/api/codex-prompt/text", fx).then( + response => ({ response }), + (error: unknown) => ({ error }), + ); + try { + await whileHeld(); + } finally { + // Clearing the seam alone cannot release a barrier the close handler captured. + releaseClose(); + setPromptTextProbeCloseBarrierForTests(null); + await pending; + } + const outcome = await pending; + if ("error" in outcome) throw outcome.error; + return outcome.response; +} + afterEach(async () => { await resetPromptTextProbeForTests(); while (roots.length) removeTreeWithRetry(roots.pop()!); @@ -880,21 +908,22 @@ describe("020 coverage completions", () => { binary: process.execPath, args: ["-e", [ `require("node:fs").writeFileSync(${JSON.stringify(startedPath)}, "started");`, - `setTimeout(() => process.stdout.write(${JSON.stringify(probeOutput)}), 200);`, + `process.stdout.write(${JSON.stringify(probeOutput)});`, ].join("")], }); - const beforeWrite = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "pre-write probe start"); - writeFileSync(fx.configPath, "include_apps_instructions = true\n", "utf8"); + const beforeWrite = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "pre-write probe start"); + writeFileSync(fx.configPath, "include_apps_instructions = true\n", "utf8"); - const afterWrite = await call("GET", "/api/codex-prompt/text", fx); - expect(afterWrite.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterWrite = await call("GET", "/api/codex-prompt/text", fx); + expect(afterWrite.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeWrite).body.ok).toBe(true); + expect(beforeWrite.body.ok).toBe(true); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.ok).toBe(true); @@ -918,27 +947,28 @@ describe("020 coverage completions", () => { `const prompt = fs.readFileSync(${JSON.stringify(selectedPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + prompt + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const revisionBeforeEdit = await revision(fx); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "selected-variant probe start"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "selected-variant probe start"); - const edited = await call("PUT", "/api/codex-prompt/base", fx, { - id, title: "New", body: "new-body", revision: revisionBeforeEdit, - }); - expect(edited.status).toBe(200); - expect(await revision(fx)).toBe(revisionBeforeEdit); + const edited = await call("PUT", "/api/codex-prompt/base", fx, { + id, title: "New", body: "new-body", revision: revisionBeforeEdit, + }); + expect(edited.status).toBe(200); + expect(await revision(fx)).toBe(revisionBeforeEdit); - const afterEdit = await call("GET", "/api/codex-prompt/text", fx); - expect(afterEdit.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterEdit = await call("GET", "/api/codex-prompt/text", fx); + expect(afterEdit.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("# Old\nold-body"); + expect(beforeEdit.body.layers.skills.text).toBe("# Old\nold-body"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("# New\nnew-body"); @@ -968,24 +998,25 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(agentsPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), `${instructionFile} probe start`); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), `${instructionFile} probe start`); - // Nothing opencodex owns has changed: no config write, no store write, so - // the transaction revision and the selected base are identical here. - writeFileSync(agentsPath, "new-agent-text", "utf8"); + // Nothing opencodex owns has changed: no config write, no store write, so + // the transaction revision and the selected base are identical here. + writeFileSync(agentsPath, "new-agent-text", "utf8"); - const afterEdit = await call("GET", "/api/codex-prompt/text", fx); - expect(afterEdit.body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const afterEdit = await call("GET", "/api/codex-prompt/text", fx); + expect(afterEdit.body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-agent-text"); + expect(beforeEdit.body.layers.skills.text).toBe("old-agent-text"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-agent-text"); @@ -1006,33 +1037,35 @@ describe("020 coverage completions", () => { `const doc = fs.existsSync(${JSON.stringify(agentsPath)}) ? fs.readFileSync(${JSON.stringify(agentsPath)}, "utf8") : "\\u0000absent";`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); // absent -> present must move the key, so a probe started with no AGENTS.md // cannot be joined once one exists. - const beforeCreate = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "absent-state probe start"); - writeFileSync(agentsPath, "created-text", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const beforeCreate = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "absent-state probe start"); + writeFileSync(agentsPath, "created-text", "utf8"); + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); }); - expect((await beforeCreate).body.layers.skills.text).toBe("\u0000absent"); + expect(beforeCreate.body.layers.skills.text).toBe("\u0000absent"); const present = await call("GET", "/api/codex-prompt/text", fx); expect(present.body.layers.skills.text).toBe("created-text"); // present -> absent is the same requirement in reverse. - const beforeDelete = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => readFileSync(startedPath, "utf8").trim().split(/\r?\n/).length === 3, "present-state probe start"); - rmSync(agentsPath); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + const beforeDelete = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => readFileSync(startedPath, "utf8").trim().split(/\r?\n/).length === 3, "present-state probe start"); + rmSync(agentsPath); + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); }); - expect((await beforeDelete).body.layers.skills.text).toBe("created-text"); + expect(beforeDelete.body.layers.skills.text).toBe("created-text"); }); /** @@ -1065,21 +1098,22 @@ describe("020 coverage completions", () => { `const doc = fs.existsSync(p) ? "present:" + fs.readFileSync(p, "utf8") : "missing";`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeTransition = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "transition probe start"); - if (transition.after === null) rmSync(agentsPath); - else writeFileSync(agentsPath, transition.after, "utf8"); + const beforeTransition = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "transition probe start"); + if (transition.after === null) rmSync(agentsPath); + else writeFileSync(agentsPath, transition.after, "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeTransition).body.layers.skills.text).toBe(`present:${transition.before}`); + expect(beforeTransition.body.layers.skills.text).toBe(`present:${transition.before}`); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe(transition.after === null ? "missing" : `present:${transition.after}`); @@ -1104,24 +1138,25 @@ describe("020 coverage completions", () => { `const doc = read(${JSON.stringify(overridePath)}) + "|" + read(${JSON.stringify(agentsPath)});`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeShift = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "framing probe start"); + const beforeShift = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "framing probe start"); - // Move the boundary: the concatenation of (name, contents) is byte-identical - // across this edit, so only a length-framed field distinguishes the two states. - writeFileSync(overridePath, "left\nAGENTS.md:right", "utf8"); - writeFileSync(agentsPath, "tail", "utf8"); + // Move the boundary: the concatenation of (name, contents) is byte-identical + // across this edit, so only a length-framed field distinguishes the two states. + writeFileSync(overridePath, "left\nAGENTS.md:right", "utf8"); + writeFileSync(agentsPath, "tail", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeShift).body.layers.skills.text).toBe("left|right\nAGENTS.md:tail"); + expect(beforeShift.body.layers.skills.text).toBe("left|right\nAGENTS.md:tail"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("left\nAGENTS.md:right|tail"); @@ -1157,20 +1192,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(externalPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "external base probe start"); - writeFileSync(externalPath, "new-external", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "external base probe start"); + writeFileSync(externalPath, "new-external", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-external"); + expect(beforeEdit.body.layers.skills.text).toBe("old-external"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-external"); @@ -1197,20 +1233,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(externalPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "relative base probe start"); - writeFileSync(externalPath, "new-relative", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "relative base probe start"); + writeFileSync(externalPath, "new-relative", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-relative"); + expect(beforeEdit.body.layers.skills.text).toBe("old-relative"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-relative"); @@ -1249,20 +1286,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(teamPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "fallback doc probe start"); - writeFileSync(teamPath, "new-team", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "fallback doc probe start"); + writeFileSync(teamPath, "new-team", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-team"); + expect(beforeEdit.body.layers.skills.text).toBe("old-team"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-team"); @@ -1297,21 +1335,22 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(parentDoc)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const nested: Fixture = { ...fx, decoyHome: nestedHome }; - const beforeEdit = call("GET", "/api/codex-prompt/text", nested); - await waitUntil(() => existsSync(startedPath), "parent doc probe start"); - writeFileSync(parentDoc, "new-parent", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(nested, async () => { + await waitUntil(() => existsSync(startedPath), "parent doc probe start"); + writeFileSync(parentDoc, "new-parent", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-parent"); + expect(beforeEdit.body.layers.skills.text).toBe("old-parent"); const fresh = await call("GET", "/api/codex-prompt/text", nested); expect(fresh.body.layers.skills.text).toBe("new-parent"); @@ -1338,21 +1377,22 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(parentDoc)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); const nested: Fixture = { ...fx, decoyHome: nestedHome }; - const beforeEdit = call("GET", "/api/codex-prompt/text", nested); - await waitUntil(() => existsSync(startedPath), "marker doc probe start"); - writeFileSync(parentDoc, "new-marker", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(nested, async () => { + await waitUntil(() => existsSync(startedPath), "marker doc probe start"); + writeFileSync(parentDoc, "new-marker", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", nested)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-marker"); + expect(beforeEdit.body.layers.skills.text).toBe("old-marker"); const fresh = await call("GET", "/api/codex-prompt/text", nested); expect(fresh.body.layers.skills.text).toBe("new-marker"); @@ -1385,20 +1425,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(teamPath)}, "utf8");`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "unparseable-config probe start"); - writeFileSync(teamPath, "new-unparseable", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "unparseable-config probe start"); + writeFileSync(teamPath, "new-unparseable", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-unparseable"); + expect(beforeEdit.body.layers.skills.text).toBe("old-unparseable"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-unparseable"); @@ -1422,20 +1463,21 @@ describe("020 coverage completions", () => { `const doc = fs.readFileSync(${JSON.stringify(manifest)}, "utf8").match(/description: (.*)/)[1];`, `fs.appendFileSync(${JSON.stringify(startedPath)}, "1\\n");`, `const output = JSON.stringify([{type:"message",role:"developer",content:[{type:"input_text",text:"" + doc + ""}]}]);`, - "setTimeout(() => process.stdout.write(output), 200);", + "process.stdout.write(output);", ].join(""); setPromptTextProbeCommandForTests({ binary: process.execPath, args: ["-e", source] }); - const beforeEdit = call("GET", "/api/codex-prompt/text", fx); - await waitUntil(() => existsSync(startedPath), "skill manifest probe start"); - writeFileSync(manifest, "---\nname: probe-skill\ndescription: new-skill-text\n---\n", "utf8"); + const beforeEdit = await withHeldPromptProbeClose(fx, async () => { + await waitUntil(() => existsSync(startedPath), "skill manifest probe start"); + writeFileSync(manifest, "---\nname: probe-skill\ndescription: new-skill-text\n---\n", "utf8"); - expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ - ok: false, - detail: "another prompt probe is still finishing; retry shortly", + expect((await call("GET", "/api/codex-prompt/text", fx)).body).toMatchObject({ + ok: false, + detail: "another prompt probe is still finishing; retry shortly", + }); + expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); }); - expect(promptTextProbeSpawnAttemptsForTests()).toBe(1); - expect((await beforeEdit).body.layers.skills.text).toBe("old-skill-text"); + expect(beforeEdit.body.layers.skills.text).toBe("old-skill-text"); const fresh = await call("GET", "/api/codex-prompt/text", fx); expect(fresh.body.layers.skills.text).toBe("new-skill-text"); diff --git a/tests/codex-integration/codex-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts index 07f6446582..f97877ed90 100644 --- a/tests/codex-integration/codex-retained-root-serialization.test.ts +++ b/tests/codex-integration/codex-retained-root-serialization.test.ts @@ -21,6 +21,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; +import { watchdogMs } from "../helpers/ci-watchdog"; const repoRoot = resolveRepoRoot(); const sandboxes: Sandbox[] = []; @@ -124,6 +125,21 @@ function sandboxChildEnv(sandbox: Sandbox): Record { return { ...sandbox.env, ...sandbox.serviceManagerEnv }; } +interface ChildResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** One consumer per pipe; barrier diagnostics and final assertions share the result. */ +function captureChildResult(child: ReturnType): Promise { + return Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]).then(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr })); +} + /** * Wait for a child to reach its barrier, failing fast with its output if it exits * first. The exit branch is a REJECTING promise, so while the race is pending an @@ -135,16 +151,30 @@ function sandboxChildEnv(sandbox: Sandbox): Record { * no-op catch attached up front marks that late rejection handled without * changing what the race sees. */ -async function raceBarrier(child: ReturnType, barrier: Promise): Promise { - const exitedEarly = child.exited.then(async exitCode => { - const stdout = await new Response(child.stdout).text(); - const stderr = await new Response(child.stderr).text(); +async function raceBarrier(result: Promise, barrier: Promise): Promise { + const exitedEarly = result.then(({ exitCode, stdout, stderr }) => { throw new Error(`sync exited before provider barrier (${exitCode})\nstdout=${stdout}\nstderr=${stderr}`); }); exitedEarly.catch(() => undefined); await Promise.race([barrier, exitedEarly]); } +test("barrier diagnostics retain both pipes when the child exits first", async () => { + const sandbox = makeSandbox("ocx-retained-early-exit-"); + const child = Bun.spawn([process.execPath, "--eval", ` + process.stdout.write("fixture-stdout\\n"); + process.stderr.write("fixture-stderr\\n"); + process.exitCode = 7; + `], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); + sandbox.children.add(child); + const result = captureChildResult(child); + + await expect(raceBarrier(result, new Promise(() => {}))).rejects.toThrow( + "sync exited before provider barrier (7)\nstdout=fixture-stdout\n\nstderr=fixture-stderr\n", + ); + expect(await result).toEqual({ exitCode: 7, stdout: "fixture-stdout\n", stderr: "fixture-stderr\n" }); +}, SPAWN_BUDGET_MS); + // A `bun --eval` child on a loaded windows-latest shard takes 8-11 s just to boot and // reach its marker (runs 33590540220 and 33605898170), so a 10 s wait was the coin flip, // not the child. Every caller passes a deadline that sits inside its own test budget so @@ -338,11 +368,13 @@ for (const publisher of ["convergence", "retained"] as const) { port: 0, fetch: async request => { if (!new URL(request.url).pathname.endsWith("/models")) return new Response("not found", { status: 404 }); - if (requests++ === 0) { + const first = requests++ === 0; + if (first) { writeFileSync(requested, "requested"); while (!existsSync(release)) await Bun.sleep(5); } - return Response.json({ data: [{ id: "race-model" }] }); + // Distinct snapshots make a stale publish observable in the final catalog. + return Response.json({ data: [{ id: first ? "race-model" : "newer-race-model" }] }); }, }); const config = { @@ -370,27 +402,32 @@ for (const publisher of ["convergence", "retained"] as const) { console.log(JSON.stringify({ status: response.status, body: await response.json() })); `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); sandbox.children.add(sync); + const syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, INTERNAL_DEADLINE_MS)); + // This real child imports the management route before reaching /models. + // Keep the CI startup floor, then leave room for the second publisher process. + await raceBarrier(syncResult, waitForPath(requested, watchdogMs(INTERNAL_DEADLINE_MS))); const published = await runPublisher(sandbox, publisher, config); if (published.exitCode !== 0) { throw new Error(`${publisher} publisher failed\nstdout=${published.stdout}\nstderr=${published.stderr}`); } const newer = readFileSync(catalogPath, "utf8"); expect(newer).not.toBe(initial); + const newerSlugs = JSON.parse(newer).models.map((model: { slug: string }) => model.slug); + expect(newerSlugs).toContain("fixture/newer-race-model"); + expect(newerSlugs).not.toContain("fixture/race-model"); writeFileSync(release, "release"); - const [exitCode, stdout, stderr] = await Promise.all([ - sync.exited, - new Response(sync.stdout).text(), - new Response(sync.stderr).text(), - ]); + // Exercise the losing exit branch before the successful caller reads output. + await sync.exited; + const { exitCode, stdout, stderr } = await syncResult; expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); + expect(JSON.parse(stdout).status).toBe(200); expect(readFileSync(catalogPath, "utf8")).toBe(newer); } finally { provider.stop(true); } - }, SPAWN_BUDGET_MS); + }, SPAWN_BUDGET_MS * 2); } /** @@ -447,8 +484,9 @@ test("a persisted runtime selection moved by another process during the await bl console.log(JSON.stringify(await syncCatalogModels(config))); `], sandbox.preloadPath)], { cwd: repoRoot, env: sandboxChildEnv(sandbox), stdout: "pipe", stderr: "pipe" }); sandbox.children.add(sync); + const syncResult = captureChildResult(sync); - await raceBarrier(sync, waitForPath(requested, INTERNAL_DEADLINE_MS)); + await raceBarrier(syncResult, waitForPath(requested, INTERNAL_DEADLINE_MS)); // Another process selects a different Codex runtime. No catalog byte changes. writeFileSync(runtimeStatePath, `${JSON.stringify({ @@ -460,11 +498,8 @@ test("a persisted runtime selection moved by another process during the await bl }, null, 2)}\n`); writeFileSync(release, "release"); - const [exitCode, stdout, stderr] = await Promise.all([ - sync.exited, - new Response(sync.stdout).text(), - new Response(sync.stderr).text(), - ]); + await sync.exited; + const { exitCode, stdout, stderr } = await syncResult; expect({ exitCode, stderr }).toMatchObject({ exitCode: 0 }); expect(JSON.parse(stdout.trim())).toMatchObject({ catalogWritten: false }); expect(readFileSync(catalogPath, "utf8")).toBe(initial); diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index 6b3d1374b2..181bcbc32c 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { injectDeveloperMessage, multiAgentGuidanceText, sanitizeEncryptedContentInPlace } from "../../src/server/responses"; +import { MULTI_AGENT_MODE_HINT_RECOMMENDATION } from "../../src/codex/multi-agent-mode-policy"; import { parseRequest } from "../../src/responses/parser"; import type { OcxParsedRequest } from "../../src/types"; import { CODEX_ACCOUNT_BOUND_CATALOG_KIND, effectiveSubagentRoster } from "../../src/codex/catalog"; @@ -85,6 +86,14 @@ function catalogFixture(dir: string, models: CatalogFixtureModel[]): void { const V2_ON = "[features.multi_agent_v2]\nenabled = true\n"; const V2_OFF = "[features]\nmulti_agent = true\n"; +const TRIGGER_ONLY_RECOMMENDATION = [ + "Proactive multi-agent delegation is active.", + "Only the delegation trigger changes: a separate explicit request is no longer required.", + "All existing user, authority, task-scope, and collaboration-tool rules continue to apply.", + "Delegate eligible independent work when parallel execution could materially improve speed or quality.", + "User requests override this hint.", + "This mode remains active until a later multi-agent mode developer message changes it.", +].join(" "); function parsedFixture(over: { reasoning?: string; @@ -104,14 +113,14 @@ function parsedFixture(over: { } describe("multiAgentGuidanceText", () => { - test("v1 tool surface + max injects the tagged Proactive text", async () => { + test.each(["max", "ultra"])("v1 %s uses the trigger-only proactive recommendation", async reasoning => { codexHomeFixture(V2_OFF); // guidance fires regardless of v2 flag const text = await multiAgentGuidanceText(parsedFixture({ - reasoning: "max", + reasoning, tools: [{ name: "spawn_agent", namespace: "agents" }, { name: "send_input", namespace: "agents" }], })); - expect(text).toContain(""); - expect(text).toContain("Proactive multi-agent delegation is active"); + expect(text).toBe(`${TRIGGER_ONLY_RECOMMENDATION}`); + expect(MULTI_AGENT_MODE_HINT_RECOMMENDATION.text).toBe(TRIGGER_ONLY_RECOMMENDATION); }); test("v1 tool surface below the top tier stays silent", async () => { @@ -956,6 +965,43 @@ describe("injectDeveloperMessage", () => { && (part as Record).text === text; }).length; + test("upgrades historical v1 wording once and preserves replayed guidance", async () => { + codexHomeFixture(V2_OFF); + // Released bytes are independent of today's recommendation and remain in the conversation. + const legacyText = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Delegate independent sub-tasks to sub-agents whenever parallel work would materially improve speed or quality — do not serialize work that can run concurrently. Each sub-agent runs in its own context and can use all available tools; prefer spawning specialists over doing everything yourself. This mode remains active until a later multi-agent mode developer message changes it."; + const produce = () => multiAgentGuidanceText(parsedFixture({ + reasoning: "max", tools: [{ name: "spawn_agent", namespace: "agents" }], + })); + const text = await produce(); + expect(text).toBe(`${TRIGGER_ONLY_RECOMMENDATION}`); + const history = [generatedItem(legacyText), + { type: "message", role: "user", content: "previous turn" }, + { type: "message", role: "assistant", content: "done" }]; + const firstInput = [...structuredClone(history), { type: "message", role: "user", content: "new turn" }]; + const first = parseRequest({ model: "gpt-5.5", input: firstInput, previous_response_id: "resp_old_v1" }); + first._replayPrefixLen = history.length; + first._continuationConversationMessageIndex = history.length; + injectDeveloperMessage(first, text!); + expect(firstInput.slice(0, history.length)).toEqual(history); + expect(firstInput.slice(history.length)).toEqual([generatedItem(text!), + { type: "message", role: "user", content: "new turn" }]); + expect(countExact(firstInput, legacyText)).toBe(1); + expect(countExact(firstInput, text!)).toBe(1); + + const nextHistory = [...firstInput, { type: "message", role: "assistant", content: "done again" }]; + const secondInput = [...structuredClone(nextHistory), { type: "message", role: "user", content: "next turn" }]; + const second = parseRequest({ model: "gpt-5.5", input: secondInput, previous_response_id: "resp_new_v1" }); + second._replayPrefixLen = nextHistory.length; + second._continuationConversationMessageIndex = nextHistory.length; + const nextText = await produce(); + expect(nextText).toBe(text); + injectDeveloperMessage(second, nextText!); + expect(secondInput.slice(0, nextHistory.length)).toEqual(nextHistory); + expect(secondInput).toHaveLength(nextHistory.length + 1); + expect(countExact(secondInput, legacyText)).toBe(1); + expect(countExact(secondInput, text!)).toBe(1); + }); + test("inserts after leading developer metadata and before conversation", () => { const parsed = parseRequest({ model: "gpt-5.5", diff --git a/tests/codex-integration/native-profile-processes.test.ts b/tests/codex-integration/native-profile-processes.test.ts index 0a5c449d12..133f97ff9d 100644 --- a/tests/codex-integration/native-profile-processes.test.ts +++ b/tests/codex-integration/native-profile-processes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { @@ -157,27 +157,19 @@ describe("native profile process probe", () => { }); test("kills and settles a timed-out child", async () => { - const directory = mkdtempSync(join(tmpdir(), "ocx-native-probe-")); - const survived = join(directory, "survived"); - const script = [ - `setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(survived)}, 'alive'), 300);`, - "setInterval(() => {}, 1_000);", - ].join(" "); - try { - await expect(executeNativeProcess(process.execPath, ["-e", script], { - encoding: "utf8", - timeout: 100, - maxBuffer: 1024, - windowsHide: true, - shell: false, - killSignal: "SIGKILL", - })).rejects.toThrow(); - await Bun.sleep(600); - expect(existsSync(survived)).toBe(false); - } finally { - removeTreeWithRetry(directory); - } - }); + // A child marker races the parent's timeout when its event loop is busy. + // Check the observed exit signal instead. Normal exit is a finite fuse, so + // disabling the executor timeout fails this assertion without orphaning a child. + const script = "setTimeout(() => process.exit(0), 10_000);"; + await expect(executeNativeProcess(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 100, + maxBuffer: 1024, + windowsHide: true, + shell: false, + killSignal: "SIGKILL", + })).rejects.toMatchObject({ killed: true, signal: "SIGKILL" }); + }, 15_000); test("rejects output above the configured byte cap", async () => { await expect(executeNativeProcess(process.execPath, [ diff --git a/tests/codex-integration/project-config-warnings.test.ts b/tests/codex-integration/project-config-warnings.test.ts index 16f9dd258d..1767ee655a 100644 --- a/tests/codex-integration/project-config-warnings.test.ts +++ b/tests/codex-integration/project-config-warnings.test.ts @@ -129,6 +129,49 @@ describe("parseTomlDocument", () => { const valid = parseTomlDocument('model_provider = "provider\\\\name"'); expect(valid.root.model_provider).toBe("provider\\name"); }, 2_000); + + for (const scenario of [ + { name: "root override", sameLine: false, tail: ['model_provider = "custom"'], + code: "model_provider_root", via: "root", profileName: null }, + { name: "same-line string", sameLine: true, tail: ['model_provider = "custom"'], + code: "model_provider_root", via: "root", profileName: null }, + { name: "selected profile", sameLine: false, + tail: ['profile = "work"', '[profiles.work]', 'model_provider = "custom"'], + code: "profile_selector", via: "profile", profileName: "work" }, + { name: "selected provider table", sameLine: false, + tail: ['model_provider = "custom"', '[model_providers.custom]', 'name = "Custom"'], + code: "model_providers_table", via: "root", profileName: null }, + ] as const) { + test(`overlapping multiline terminator preserves ${scenario.name} diagnostics`, () => { + const text = ['developer_instructions = """' + (scenario.sameLine ? "" : "\n") + + "foo" + "\\" + '"'.repeat(4), ...scenario.tail].join("\n"); + // Independent TOML parsing proves the escaped quote is followed by a real terminator. + expect(Bun.TOML.parse(text).developer_instructions).toBe('foo"'); + expect(resolveEffectiveProjectModelProvider(text)).toEqual({ + provider: "custom", profileName: scenario.profileName, via: scenario.via, + }); + const warnings = analyzeProjectCodexConfig(text, "fixture/.codex/config.toml"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ code: scenario.code, detail: "custom" }); + expect(warnings[0]!.profileName).toBe(scenario.profileName ?? undefined); + if (scenario.code === "model_providers_table") { + expect(parseTomlDocument(text).sections.get("model_providers.custom")?.name).toBe("Custom"); + } + }); + } + + test("escaped three quotes keep fake routing inside the multiline body", () => { + const text = ['developer_instructions = """', "foo" + "\\" + '"'.repeat(3), + 'model_provider = "custom"', '[model_providers.custom]', 'name = "Custom"', + '"""', 'model_provider = "openai"'].join("\n"); + const parsedByBun = Bun.TOML.parse(text); + expect(parsedByBun.model_provider).toBe("openai"); + expect(parsedByBun.developer_instructions).toContain('[model_providers.custom]'); + const parsed = parseTomlDocument(text); + expect(parsed.root.model_provider).toBe("openai"); + expect(parsed.sections.has("model_providers.custom")).toBe(false); + expect(analyzeProjectCodexConfig(text, "fixture/.codex/config.toml")).toEqual([]); + }); }); describe("parseTrustedProjectPathsFromCodexConfig", () => { diff --git a/tests/lib/abort-idle-deadline.test.ts b/tests/lib/abort-idle-deadline.test.ts index 9c8422ba53..d72f09488e 100644 --- a/tests/lib/abort-idle-deadline.test.ts +++ b/tests/lib/abort-idle-deadline.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { idleDeadline } from "../../src/lib/abort"; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -17,18 +17,59 @@ test("idleDeadline fires once after the idle window with no reset", async () => expect(fired).toBe(1); }); -test("idleDeadline reset() re-arms and postpones firing", async () => { +test("idleDeadline reset() re-arms and postpones firing", () => { + // Keep this boundary check synchronous: real sleeps can resume after the idle window. + // The other cases below still exercise Bun's real timers. + type TimerHandle = ReturnType; + let now = 0; + let nextHandle = 0; + const timers = new Map void }>(); + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, delay = 0, ...args: unknown[] + ) => { + const handle = ++nextHandle as unknown as TimerHandle; + timers.set(handle, { at: now + delay, fire: () => callback(...args) }); + return handle; + }) as typeof setTimeout); + const clearSpy = spyOn(globalThis, "clearTimeout").mockImplementation(handle => { + timers.delete(handle as TimerHandle); + }); + const advanceBy = (ms: number) => { + const target = now + ms; + for (;;) { + const due = [...timers].filter(([, timer]) => timer.at <= target) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + timers.delete(due[0]); + now = due[1].at; + due[1].fire(); + } + now = target; + }; let fired = 0; - const idle = idleDeadline(120, () => { fired += 1; }); - idle.reset(); - for (let i = 0; i < 4; i++) { - await sleep(40); - idle.reset(); // keep-alive: total elapsed (160ms) exceeds 120ms but silence never does + let idle: ReturnType | undefined; + try { + idle = idleDeadline(120, () => { fired += 1; }); + idle.reset(); + for (let i = 0; i < 4; i++) { + advanceBy(40); + idle.reset(); // total elapsed exceeds 120 ms, but each silent interval does not + } + expect(fired).toBe(0); + advanceBy(119); + expect(fired).toBe(0); + advanceBy(1); + expect(fired).toBe(1); + advanceBy(240); + expect(fired).toBe(1); + } finally { + try { + idle?.cancel(); + } finally { + clearSpy.mockRestore(); + timeoutSpy.mockRestore(); + } } - expect(fired).toBe(0); - await sleep(220); - expect(fired).toBe(1); - idle.cancel(); }); test("idleDeadline pause() disarms without retiring; reset() re-arms after pause", async () => { diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index 316ee56b91..8a2298972c 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -12,6 +12,7 @@ import { startServer } from "../../src/server"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; const RESPONSES_ENDPOINT = `${XAI_GROK_CLI_BASE_URL}/responses`; const encoder = new TextEncoder(); @@ -20,8 +21,34 @@ let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let originalFetch: typeof fetch; +let activeRoutedCase: { controller: AbortController; settled: Promise } | null = null; + +function runRoutedCase(body: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const result = body(controller.signal); + // Observe the entire body, including its server-stop finally, even after a test timeout. + activeRoutedCase = { controller, settled: result.then(() => {}, () => {}) }; + return result; +} + +async function drainRoutedCase(): Promise { + const active = activeRoutedCase; + if (!active) return; + active.controller.abort(new DOMException("xAI fixture cleanup", "AbortError")); + await active.settled; + if (activeRoutedCase === active) activeRoutedCase = null; +} + +function startXaiTestServer() { + return startServer(0, { + // This wire fixture does not exercise native Codex service ownership. Avoid + // unrelated Windows service queries and native-main recovery during setup. + inspectNativeCodexOwnership: () => ({ ownership: "foreign", reason: "xAI wire fixture" }), + }); +} beforeEach(async () => { + if (activeRoutedCase) throw new Error("previous routed-parent fixture has not finished cleanup"); originalFetch = globalThis.fetch; previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-xai-responses-codex-"); @@ -36,14 +63,15 @@ beforeEach(async () => { }); }); -afterEach(() => { +afterEach(async () => { + await drainRoutedCase(); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; if (testDir) removeTreeWithRetry(testDir); -}); +}, SERVER_BUDGET_MS); function config(): OcxConfig { return { @@ -72,7 +100,43 @@ function sse(payload: unknown): Uint8Array { } describe("xAI OAuth Responses streaming opt-in", () => { - test.each([true, false])("continues a routed parent after a string child result (stream=%s)", async stream => { + test("routed-case cleanup waits for the entire aborted body finally", async () => { + let markFinally!: () => void; + const enteredFinally = new Promise(resolve => { markFinally = resolve; }); + let releaseFinally!: () => void; + const finallyGate = new Promise(resolve => { releaseFinally = resolve; }); + let finallyFinished = false; + const running = runRoutedCase(async signal => { + try { + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } finally { + markFinally(); + await finallyGate; + finallyFinished = true; + } + }); + const outcome = running.then(() => null, (error: unknown) => error); + let drained = false; + const draining = drainRoutedCase().then(() => { drained = true; }); + try { + await enteredFinally; + await Promise.resolve(); + // Awaiting outcome first would hide a drain helper that returned too early. + expect(drained).toBe(false); + expect(finallyFinished).toBe(false); + } finally { + releaseFinally(); + await draining; + await outcome; + } + expect(await outcome).toMatchObject({ name: "AbortError" }); + expect(finallyFinished).toBe(true); + expect(activeRoutedCase).toBeNull(); + }, SERVER_BUDGET_MS); + + test.each([true, false])("continues a routed parent after a string child result (stream=%s)", stream => runRoutedCase(async signal => { const captured: Array> = []; let privateItemRejections = 0; const childText = " Synthetic worker result\nAll requested observations returned.\n "; @@ -112,9 +176,11 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig({ ...config(), multiAgentMode: "v2" }); - const server = startServer(0); + const server = startXaiTestServer(); const send = async (session: string, input: unknown[], parentSession?: string) => { + signal.throwIfAborted(); const response = await originalFetch(new URL("/v1/responses", server.url), { + signal, method: "POST", headers: { "content-type": "application/json", "session-id": session, ...(parentSession ? { "x-codex-parent-thread-id": parentSession } : {}), }, @@ -163,7 +229,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { } finally { await server.stop(true); } - }, 10_000); + }), 10_000); test("uses the native Responses wire and relays the first delta before completion", async () => { let releaseCompletion!: () => void; @@ -258,7 +324,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); let reader: ReadableStreamDefaultReader | undefined; try { const response = await originalFetch(new URL("/v1/responses", server.url), { @@ -373,7 +439,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", @@ -468,7 +534,7 @@ describe("xAI OAuth Responses streaming opt-in", () => { }) as typeof fetch; saveConfig(config()); - const server = startServer(0); + const server = startXaiTestServer(); try { const response = await originalFetch(new URL("/v1/responses", server.url), { method: "POST",