diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9019fa1e..624cd7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,9 @@ jobs: with: python-version: ${{ matrix.python }} - run: python -m pip install '.[relay,mcp]' --requirement .github/requirements/build.txt - - run: python -m unittest discover -s tests/relay -t . -v + # Preserve Python stacks if a native dependency/process segfaults; + # this is diagnostic only, not a workaround for an unexplained crash. + - run: python -X faulthandler -m unittest discover -s tests/relay -t . -v - run: python -m build - name: Verify optional transport packaging env: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index beb9023f..394b00d4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -483,13 +483,27 @@ prove the queue can accept it. ### SQLite storage boundary +Standalone `list` remains metadata-only: it does not execute Codex or query its version, and `--codex-bin` does not establish the version for discovery. On macOS, discovery therefore conservatively checks ordinary as well as forced preferences before opening a candidate SQLite database. This blocks stale-home results for older loaders, but can also refuse a valid newer environment whose native loader ignores ordinary values. A refusal is an error, never a successful empty list or a switch to another database; valid results from other agents remain available with the incomplete-discovery error. The diagnostic identifies the storage-configuration ambiguity and asks the operator to review known configuration layers, including effective `com.openai.codex` configuration/requirements and the intended home/storage layout. Changing `--codex-home` alone does not resolve global preference ambiguity. Do not automatically remove preferences, managed profiles or databases to suppress the error. If the native loader legitimately ignores the value, discovery remains conservatively unsupported for that configuration; precise version-aware discovery is a separate improvement. Internal send dry-run discovery reuses the already selected send policy without another probe. + +For sends and their internal dry-run checks, the macOS preference policy depends on the selected native executable. The exact reviewed Codex 0.162.1 loader accepts only forced values; Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/config/src/loader/macos.rs) reads ordinary as well as forced preferences through `CFPreferencesCopyAppValue`. On macOS, ordinary queue sends and dry-runs run one bounded `--version` child in the selected home and working directory: three seconds for the query and two for cleanup, with 1 MiB stdout and 64 KiB diagnostic limits. Output is never reflected; malformed, unavailable or unreviewed versions conservatively include ordinary preferences rather than refusing solely for their version. Uncertain process cleanup refuses before queue submission, and interruption is propagated after the bounded cleanup attempt. Only the exact reviewed `codex-cli 0.162.1` selects forced-only checks; later versions are not assumed equivalent. Handoff reuses its existing version query and original deadline, then retains the selected policy through submission. The supported 0.154.0 wake path includes ordinary preferences even when an active writer only receives a queue submission. Storage and custom-marker checks repeat before the budget and child boundaries. No new version probe is added outside macOS, no results are cached, and wake support is not expanded. A refusal after known queue submission preserves that submission without resending. These are source-based guards and synthetic regressions, not new native preference or MDM validation. + +Owned queue and wake children use an existing, strictly resolved physical working directory, including aliases in any ancestor. Project scanning, child `cwd`, POSIX `PWD` and wake `thread/resume.cwd` use that directory. For a saved consumer directory that still exists, both physical and distinct lexical project spellings are checked with the same marker boundaries and custom-marker fallback; this can conservatively refuse an alias configuration. A missing consumer directory still has no layers to inspect. This does not establish an already running writer's startup configuration, and alias/configuration changes after inspection remain possible. The `wake.cwd` result uses this physical spelling too; it can differ from the saved alias. + +The native loader's layer-list comment mentions `/config.toml`, but its pinned [project-layer construction](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/config/src/loader/mod.rs#L1652) loads `.codex/config.toml` instead. This guard follows that implementation, not the stale comment. A successful Windows Known Folder lookup with an invalid or unbounded path is refused rather than silently falling back to a different directory. + +Wake preflight checks known storage before reserving its budget, but a later recheck can still refuse after reservation if configuration or writer evidence changes. The translated-native-home route retains its inherited cwd and does not inspect the Windows consumer directory. + The 2026-10-09 source investigation examined native Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/core/src/config/mod.rs) and [0.159.0](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/mod.rs). Both resolve SQLite storage from configured `sqlite_home`, then nonempty `CODEX_SQLITE_HOME`, then `CODEX_HOME`; relative environment paths use the native resolved working directory. [Managed requirements](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/requirements.rs) can replace configured storage, including command-line overrides. The [native queue path](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/tui/src/session_queue_commands.rs) can reuse a running shared server, whose storage is not established by the new client's environment. These are source findings, not new live-delivery or platform validation. -session-peer refuses a nonempty inherited `CODEX_SQLITE_HOME` or a root `sqlite_home` setting in any inspected home's configuration before discovery, dry-run, or queue submission. This includes quoted/escaped root keys and root dotted/table forms, even when the configured path equals the selected home. Unselected nested keys are not root storage settings. Empty environment overrides are removed from native child environments; environment names are case-sensitive on POSIX and case-insensitive on Windows. The guard retains the selected native-home translation where already supported and does not add relocated-storage support. +The 2026-10-15 investigation of native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) found the storage precedence to be managed requirements, then the merged configuration layers, then `CODEX_SQLITE_HOME`, then `CODEX_HOME`. Layers that can set `sqlite_home` include system files, macOS managed profiles, the user's `config.toml`, a profile file selected with `--profile`, and the project layers of the native process working directory (`.codex/config.toml` from the working directory up to the nearest default `.git` root, or only the working directory when no root is found; enabled only for trusted projects). Legacy `[profiles.*]` tables have no storage key. `codex queue` submits through a running app-server daemon when one accepts a connection on the home's `app-server-control/app-server-control.sock`, and otherwise through an embedded server. Native TUIs start such a daemon automatically by default unless excluded, for example with `--no-daemon`. A daemon's QueueStore keeps the database chosen at its startup, while `config/read` rereads the current disk layers; `initialize` reports the home and platform, but the reviewed `initialize`, `config/read` and queue APIs offer no stable attestation contract for the startup SQLite identity. A matching thread UUID or the same server connection does not establish storage identity (a copied database can hold the same UUID), and the queue API has no expected-storage or incarnation precondition. A process that consumes queued messages polls the queue in its own storage. These are source findings, not live-delivery or platform validation. + +session-peer refuses a nonempty inherited `CODEX_SQLITE_HOME` or a root `sqlite_home` setting in any inspected home's configuration before discovery, dry-run, or queue submission. This includes quoted/escaped root keys and root dotted/table forms, even when the configured path equals the selected home. Unselected nested keys are not root storage settings. Empty environment overrides are removed from native child environments; environment names are case-sensitive on POSIX and case-insensitive on Windows. The guard retains the selected native-home translation where already supported and does not add relocated-storage support. It also refuses a root `sqlite_home` in the system configuration and requirements files (`/etc/codex/config.toml`, `/etc/codex/managed_config.toml`, `/etc/codex/requirements.toml`, or the Known Folder API's ProgramData directory followed by `OpenAI\Codex\config.toml` and `requirements.toml` on Windows) and in macOS managed-profile values, regardless of project trust. A queue submission starts the native process in the inspected home, so the caller's directory adds no project layers. Dry-run checks the same execution plan (home, child working directory, environment, known layers and consumer directory) without queue/app-server children (wake dry-run and ordinary macOS queue dry-run may run the native version probe), and a real send repeats the checks immediately before its effect; neither removes the time gap before native Codex reads its configuration, so a passing dry-run does not guarantee storage agreement for a later send. By user policy, a daemon control socket, live or stale, is neither a refusal reason nor probed: native Codex selects the daemon or embedded server as before, and no storage authority is claimed. Outside the translated-native-home route, dry-run, queue and wake also inspect the project layers of the saved thread directory, where the consuming writer or wake server normally starts; unreadable or malformed thread metadata is refused. Wake runs these storage checks before reserving its per-target budget. Refusal reasons are `sqlite_environment_override`, `sqlite_home_configuration`, `sqlite_home_system_configuration`, `sqlite_home_managed_configuration`, `sqlite_home_project_configuration`, `consumer_directory_unverifiable`, `storage_config_unreadable` and `storage_config_unverifiable`. + +Each inspected TOML file must be a regular, non-symlink UTF-8 file of at most 1 MiB. On Windows, the system directory is obtained through `SHGetKnownFolderPath(FOLDERID_ProgramData)`, with native Codex's fixed `C:\ProgramData` fallback on API failure; the `ProgramData` environment variable is not used. On macOS, CoreFoundation synchronizes the Codex preference domain and releases owned values. The forced-only policy rechecks forced status after copying and ignores ordinary user preferences; the version-selected conservative policy also reads ordinary values, as described above. Selected base64 strings are bounded to 2 MiB and decode to UTF-8 TOML of at most 1 MiB; synchronization, type, decoding or size errors refuse. Missing TOML files or preference values are allowed. Default project markers stop at the nearest non-directory `.git` entry (metadata only) or a directory containing `HEAD`; symlink markers are conservatively refused; without a marker only cwd applies. `/config.toml` is not a project layer. A known root `project_root_markers` override retains a conservative ancestor superset instead of claiming exact custom-marker interpretation. These APIs are covered by mocks; relocated Windows Known Folders and real forced MDM profiles have not been tested. Unreadable, changed, oversized, or lexically unclassifiable files are refused without printing their contents. The dependency-free key classifier supports Python 3.9 and is not a general TOML/schema validator; native Codex still validates other values. Queue and wake recheck this boundary before starting a child. A refusal has `status=refused`, `submitted=false`, and `retryAllowed=false`; valid ordinary outputs and writer-ownership checks remain unchanged. SSH performs this check on the destination, not on the sender's environment. -The home's `config.toml` must be a regular, non-symlink UTF-8 file of at most 1 MiB; missing files are allowed. Unreadable, changed, oversized, or lexically unclassifiable files are refused without printing their contents. The dependency-free key classifier supports Python 3.9 and is not a general TOML/schema validator; native Codex still validates other values. Queue and wake recheck this boundary before starting a child. A refusal has `status=refused`, `submitted=false`, and `retryAllowed=false`; valid ordinary outputs and writer-ownership checks remain unchanged. SSH performs this check on the destination, not on the sender's environment. +Translated native homes (a WSL relay receiver queueing to native Windows Codex) keep their existing submission contract, but storage verification is not supported for them: this POSIX process cannot inspect the Windows-side home spelling, case rules, environment, or system, managed and project layers. A future strict mode could refuse such paths before any effect; it is not implemented. -This is bounded hardening, not proof of effective native storage for every configuration. Project/system/profile layers, managed requirements, and an existing shared server are not resolved or attested by this guard; configuration can also change after the final check. Do not infer storage agreement from a successful check alone. [#268](https://github.com/abruption/session-peer/issues/268) remains open for that evidence gate and version/platform acceptance. Fake-child fixtures show refusal and environment behavior only; they do not establish actual wrong-target delivery, a security exploit, or new native compatibility. The wake version matrix remains unchanged. +This is bounded hardening, not proof of effective native storage for every configuration. Not inspected: package-supplied defaults; enterprise cloud configuration bundles; the startup storage of a running shared daemon; a `--profile` selection, `-c` overrides or the environment of a writer that is already running; a writer that started outside the saved thread directory or whose directory no longer exists; and, for a translated native home, the Windows-side system, managed and project layers. Configuration can also change after the final check. Do not infer storage agreement from a successful check alone. [#268](https://github.com/abruption/session-peer/issues/268) remains open for native platform validation of this contract. Fake-child fixtures show refusal and environment behavior only; they do not establish actual wrong-target delivery, a security exploit, or new native compatibility. The wake version matrix remains unchanged. ### Orca and multiple Codex homes diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index a6d71d2a..d1318c8c 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "7fbc82834883a13e91f3a31b058296eefca7117bd7dd08409e44faccc82e1f03", + "docs/cli-reference.md": "4f5e228e914ba1b1a7277e27688288a93cc9fa1dad261fa0511a895663dd6d5d", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 36c4fcad..e7de6c0b 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -472,13 +472,27 @@ Codex の検出は、読み取り専用の SQLite 接続を使用して `state_5 ### SQLite ストレージの境界 +単独の `list` はメタデータだけを調べます。Codex の実行やバージョン照会は行わず、`--codex-bin` を指定しても検出用のバージョンが確認されたことにはなりません。そのため macOS では候補の SQLite DB を開く前に、通常の設定と強制設定の両方を保守的に検査します。旧ローダーで既定ホームの古い DB を誤って読むことを防ぎますが、通常の設定を無視する新しいローダーの正常な環境も拒否する場合があります。拒否はエラーとして返し、成功した空一覧や別 DB への切り替えとして扱いません。他のエージェントの正常な結果は検出不完全のエラーとともに保持します。診断ではストレージ設定の曖昧さを示し、運用者に既知の設定層と有効な `com.openai.codex` の設定・要件と意図したホーム・ストレージ構成の確認を求めます。`--codex-home` の変更だけでは全体設定の曖昧さは解消しません。エラーを隠すために設定、管理プロファイルや DB を自動削除しないでください。native ローダーが実際に値を無視していても、この構成の検出は保守的に未対応とし、正確なバージョン別検出は別の改善で扱います。send の事前検証内の検出では、すでに選択した送信方針を再利用し、追加のバージョン照会をしません。 + +送信とその事前検証では、選択した native 実行ファイルによって macOS 設定の検査方針が異なります。確認した Codex 0.162.1 は強制設定だけを読みますが、Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/config/src/loader/macos.rs) は `CFPreferencesCopyAppValue` で通常の設定も読みます。macOS の通常のキュー送信と事前検証は、選択したホームと作業ディレクトリで `--version` 子プロセスを一度実行します。照会は3秒、後処理は2秒、標準出力は1 MiB、診断出力は64 KiBに制限します。出力内容を外部に返しません。形式が不正、取得不能、未調査のバージョンでは、バージョンだけを理由に拒否せず通常の設定も保守的に調べます。後処理が不確実なら投入前に拒否し、中断は期限内の後処理を試みた後に伝播します。厳密に `codex-cli 0.162.1` の場合だけ強制設定専用にし、後のバージョンも同じとは仮定しません。handoff は既存のバージョン照会と元の期限を再利用し、投入まで方針を保持します。対応する0.154.0の wake 経路は、稼働中の writer にキューだけを投入する場合も通常の設定を調べます。予算予約と子プロセス起動の境界でストレージと独自マーカーを再確認します。macOS 以外では新たなバージョン照会をせず、結果をキャッシュせず、wake の対応範囲も増やしません。確認済みのキュー投入後に拒否しても投入の事実を保持し、再送しません。これはソースに基づく検査と模擬回帰テストであり、新たな native 設定や MDM の実測ではありません。 + +所有するキューと wake の子プロセスには、祖先の別名も含めて厳密に解決した、存在する物理的な作業ディレクトリを使います。プロジェクト検索、子の `cwd`、POSIX の `PWD`、wake の `thread/resume.cwd` は同じディレクトリです。保存済みの consumer ディレクトリが存在する場合、物理的な表記と異なる論理的な表記の両方を、同じマーカー境界と独自マーカーの代替規則で調べるため、別名設定を保守的に拒否する場合があります。失われた consumer ディレクトリには検査する層がないという既存の扱いを維持します。実行中の writer の起動設定を確認するものではなく、検査後の別名や設定の変更も起こり得ます。 結果の `wake.cwd` もこの物理的な表記を使うため、保存された別名と異なる場合があります。 + +native ローダーの層一覧コメントには `/config.toml` とありますが、固定した[プロジェクト層の構成コード](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/config/src/loader/mod.rs#L1652)は `.codex/config.toml` を読み込みます。この検査は古いコメントではなく実装に従います。Windows Known Folder の取得が成功しても、返されたパスが不正または上限超過の場合、別のディレクトリへ黙って切り替えず拒否します。 + +wake は予算予約前に既知のストレージ設定を調べますが、予約後に設定や書き込みプロセスの証拠が変わると、後続の再検査で拒否される場合があります。変換された native ホームの経路は継承した cwd を維持し、Windows の消費側ディレクトリは調べません。 + 2026-10-09 のソース調査では native Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/core/src/config/mod.rs) と [0.159.0](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/mod.rs) を確認しました。両版は設定済みの `sqlite_home`、空でない `CODEX_SQLITE_HOME`、`CODEX_HOME` の順で SQLite ストレージを選択します。相対的な環境変数のパスは native が解決した作業ディレクトリを基準にします。[管理要件](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/requirements.rs) はコマンドライン指定を含むストレージ設定を置き換えられます。[native のキュー経路](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/tui/src/session_queue_commands.rs) は稼働中の共有サーバーを再利用できるため、新しいクライアントの環境だけではサーバーのストレージを確認できません。これはソース上の確認であり、新たな実送信やプラットフォーム検証ではありません。 -session-peer は継承された `CODEX_SQLITE_HOME` が空でない場合、または調べるホームの設定にルートの `sqlite_home` がある場合、検出、事前検証、キュー投入の前に拒否します。設定パスが選択したホームと同じ場合も拒否し、引用符・エスケープを含むルートキー、ルートのドット区切りキーやテーブルも対象です。選択されていない入れ子のキーはルートのストレージ設定と見なしません。空の環境変数指定は native 子プロセスの環境から除去します。環境変数名は POSIX では大文字と小文字を区別し、Windows では区別しません。既存の native ホームのパス変換は維持し、ストレージの移動には対応しません。 +2026-10-15 の native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) の調査では、ストレージの優先順位は管理要件、統合された設定層、`CODEX_SQLITE_HOME`、`CODEX_HOME` の順でした。`sqlite_home` を設定できる層は、システムファイル、macOS の管理プロファイル、ユーザーの `config.toml`、`--profile` で選択したプロファイルファイル、native プロセスの作業ディレクトリのプロジェクト層(作業ディレクトリから最も近い既定の `.git` ルートまでの `.codex/config.toml`。ルートがなければ作業ディレクトリのみ。信頼済みプロジェクトでのみ有効)です。従来の `[profiles.*]` テーブルにはストレージのキーがありません。`codex queue` は、ホームの `app-server-control/app-server-control.sock` で接続を受け付ける app-server デーモンが稼働中であればそのデーモン経由で、そうでなければ内蔵サーバー経由で投入します。native TUI は `--no-daemon` などで除外しない限り、既定でこのデーモンを自動起動します。デーモンの QueueStore は起動時に選んだデータベースを保持しますが、`config/read` は現在のディスク上の設定層を読み直します。`initialize` はホームとプラットフォームを返しますが、確認した `initialize`、`config/read`、キュー API には起動時の SQLite の同一性を証明する安定した契約がありません。スレッド UUID の一致や同じサーバーへの接続はストレージの同一性を意味せず(コピーしたデータベースにも同じ UUID があり得ます)、キュー API には期待するストレージやプロセス世代の前提条件がありません。キューのメッセージを消費するプロセスは自身のストレージのキューをポーリングします。これはソース上の確認であり、実送信やプラットフォーム検証ではありません。 + +session-peer は継承された `CODEX_SQLITE_HOME` が空でない場合、または調べるホームの設定にルートの `sqlite_home` がある場合、検出、事前検証、キュー投入の前に拒否します。設定パスが選択したホームと同じ場合も拒否し、引用符・エスケープを含むルートキー、ルートのドット区切りキーやテーブルも対象です。選択されていない入れ子のキーはルートのストレージ設定と見なしません。空の環境変数指定は native 子プロセスの環境から除去します。環境変数名は POSIX では大文字と小文字を区別し、Windows では区別しません。既存の native ホームのパス変換は維持し、ストレージの移動には対応しません。 また、システムの設定・要件ファイル(`/etc/codex/config.toml`、`/etc/codex/managed_config.toml`、`/etc/codex/requirements.toml`、Windows では Known Folder API の ProgramData ディレクトリ配下の `OpenAI\Codex\config.toml` と `requirements.toml`)と macOS の管理プロファイルの値にルートの `sqlite_home` がある場合、プロジェクトの信頼状態に関係なく拒否します。キュー投入は調べたホームで native プロセスを起動するため、呼び出し元のディレクトリはプロジェクト層を追加しません。事前検証は キュー・app-server の子プロセスを起動せずに(wake と macOS の通常のキューの事前検証では native のバージョン確認プロセスを実行する場合があります)同じ実行計画(ホーム、子プロセスの作業ディレクトリ、環境、既知の設定層、消費ディレクトリ)を調べ、実際の送信は効果の直前に再確認します。どちらも native Codex が設定を読むまでの時間差をなくさないため、事前検証の成功は次の送信でのストレージ一致を保証しません。ユーザーの方針により、デーモンの制御ソケットは稼働中か残存かに関係なく拒否理由にならず、session-peer は接続も試みません。デーモンと内蔵サーバーの選択は従来どおり native Codex が行い、ストレージの権限は主張しません。変換された native ホームの経路を除き、事前検証、キュー投入、wake は、消費する書き込みプロセスや wake サーバーが通常起動する、保存済みスレッドのディレクトリのプロジェクト層も調べ、スレッドのメタデータを読み取れない場合や形式が不正な場合は拒否します。wake は対象ごとの予算を予約する前にこれらのストレージ検査を行います。拒否理由は `sqlite_environment_override`、`sqlite_home_configuration`、`sqlite_home_system_configuration`、`sqlite_home_managed_configuration`、`sqlite_home_project_configuration`、`consumer_directory_unverifiable`、`storage_config_unreadable`、`storage_config_unverifiable` です。 + +調べる TOML ファイルは最大 1 MiB の UTF-8 通常ファイルであり、シンボリックリンクであってはなりません。Windows のシステムディレクトリは `SHGetKnownFolderPath(FOLDERID_ProgramData)` で取得し、API 失敗時は native Codex と同じ固定値 `C:\ProgramData` を使います。`ProgramData` 環境変数は使用しません。macOS では CoreFoundation で Codex の設定ドメインを同期し、所有値を解放します。強制設定専用の方針ではコピー後に強制状態を再確認し、通常のユーザー設定を無視します。バージョンで選ぶ保守的な方針では、上記のとおり通常の設定も読みます。選択する base64 文字列は最大 2 MiB、デコード後の UTF-8 TOML は最大 1 MiB です。同期・型・デコード・サイズのエラーは拒否します。TOML ファイルや設定値の欠落は許可します。既定の探索は最も近い非ディレクトリの `.git` 項目(metadata のみ確認)、または `HEAD` を持つ .git ディレクトリで止まります。シンボリックリンクのマーカーは保守的に拒否し、ルートがなければ作業ディレクトリだけを調べます。`/config.toml` はプロジェクト層ではありません。既知の設定にルートの `project_root_markers` 指定があれば、カスタムマーカーを正確に解釈したとは主張せず、上位ディレクトリ全体を保守的に調べます。API はモックで検証しており、移動された実際の Windows Known Folder や強制 MDM プロファイルは未検証です。読み取り不能、変更、サイズ超過、キー構文を分類できない場合は内容を表示せず拒否します。外部依存のないキー分類器は Python 3.9 に対応しますが、完全な TOML・スキーマ検証器ではありません。他の値は引き続き native Codex が検証します。キュー投入と wake は子プロセスの起動前にこの境界を再確認します。拒否結果は `status=refused`、`submitted=false`、`retryAllowed=false` となり、通常の既存出力と書き込みプロセスの所有権検査は変わりません。SSH では送信元の環境ではなく送信先で検査します。 -ホームの `config.toml` は最大 1 MiB の UTF-8 通常ファイルであり、シンボリックリンクであってはなりません。ファイルが存在しなくても構いません。読み取り不能、変更、サイズ超過、キー構文を分類できない場合は内容を表示せず拒否します。外部依存のないキー分類器は Python 3.9 に対応しますが、完全な TOML・スキーマ検証器ではありません。他の値は引き続き native Codex が検証します。キュー投入と wake は子プロセスの起動前にこの境界を再確認します。拒否結果は `status=refused`、`submitted=false`、`retryAllowed=false` となり、通常の既存出力と書き込みプロセスの所有権検査は変わりません。SSH では送信元の環境ではなく送信先で検査します。 +変換された native ホーム(WSL の relay 受信側から native Windows Codex へのキュー投入)は既存の投入契約を維持しますが、ストレージ検証には対応していません。この POSIX プロセスは Windows 側のホーム表記、大文字小文字の規則、環境、システム・管理・プロジェクト層を調べられません。このような経路を効果の前に拒否する厳格モードは将来可能ですが、実装していません。 -これは範囲を限定した防御の強化であり、すべての設定で native ストレージが一致する証明ではありません。プロジェクト・システム・プロファイルの設定層、管理要件、既存の共有サーバーをこの検査で解決・証明することはありません。最後の検査後に設定が変わる場合もあります。検査の成功だけからストレージの一致を推測しないでください。[#268](https://github.com/abruption/session-peer/issues/268) は、この証拠とバージョン・プラットフォームの受け入れ検証のため未完了のまま維持します。擬似子プロセスのテストが示すのは拒否と環境処理のみであり、実際の誤送信、セキュリティ上の悪用、新たな native 互換性を証明しません。wake の対応バージョン範囲は変わりません。 +これは範囲を限定した防御の強化であり、すべての設定で native ストレージが一致する証明ではありません。調べないものは、パッケージ既定の設定、エンタープライズのクラウド設定バンドル、実行中の共有デーモンの起動時ストレージ、すでに実行中の書き込みプロセスの `--profile` による選択・`-c` による上書き・環境変数、保存済みスレッドのディレクトリ以外で起動したかディレクトリが失われた書き込みプロセス、変換された native ホームの Windows 側のシステム・管理・プロジェクト層です。最後の検査後に設定が変わる場合もあります。検査の成功だけからストレージの一致を推測しないでください。[#268](https://github.com/abruption/session-peer/issues/268) は、この契約の native プラットフォーム検証のため未完了のまま維持します。擬似子プロセスのテストが示すのは拒否と環境処理のみであり、実際の誤送信、セキュリティ上の悪用、新たな native 互換性を証明しません。wake の対応バージョン範囲は変わりません。 ### Orca と複数の Codex ホーム diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 0865cf65..58885779 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -469,13 +469,27 @@ Codex 검색은 읽기 전용 SQLite 연결을 사용하여 `state_5.sqlite`를 ### SQLite 저장소 경계 +독립적인 `list`는 메타데이터만 조회합니다. Codex를 실행하거나 버전을 조회하지 않으며, `--codex-bin`을 지정해도 목록 조회에 사용할 버전이 확인되는 것은 아닙니다. 따라서 macOS에서는 후보 SQLite DB를 열기 전에 일반 설정과 강제 설정을 모두 보수적으로 검사합니다. 구버전 로더에서 기본 홈의 오래된 DB를 잘못 조회하는 것을 막지만, 일반 설정을 무시하는 최신 로더의 정상 환경도 거부할 수 있습니다. 거부는 오류로 반환하며, 성공한 빈 목록이나 다른 DB로의 우회로 바꾸지 않습니다. 다른 에이전트의 정상 결과는 조회 불완전 오류와 함께 유지합니다. 진단은 저장소 설정의 모호함을 알리고, 운영자가 알려진 설정 계층과 유효한 `com.openai.codex` 설정·요건과 의도한 홈·저장소 구성을 확인하도록 안내합니다. `--codex-home`만 바꿔서는 전역 설정의 모호함이 해소되지 않습니다. 오류를 없애려고 설정, 관리 프로필이나 DB를 자동 삭제하지 마세요. native 로더가 실제로 해당 값을 무시하더라도 이 구성의 목록 조회는 보수적으로 미지원이며, 정확한 버전별 조회 지원은 별도 개선으로 다룹니다. send dry-run 내부의 목록 확인은 이미 선택한 전송 정책을 재사용하며 추가 버전 조회를 하지 않습니다. + +전송과 그 내부 dry-run 검사에서는 선택한 native 실행 파일에 따라 macOS 설정 검사 정책이 달라집니다. 소스를 확인한 Codex 0.162.1은 강제 설정만 읽지만, Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/config/src/loader/macos.rs)은 `CFPreferencesCopyAppValue`로 일반 설정도 읽습니다. macOS의 일반 큐 전송과 dry-run은 선택한 홈과 작업 디렉터리에서 `--version` 자식 프로세스를 한 번 실행합니다. 조회 3초와 정리 2초로 제한하고 표준 출력은 1 MiB, 진단 출력은 64 KiB까지 받습니다. 출력 내용은 반사하지 않으며, 형식이 잘못됐거나 확인할 수 없거나 소스를 검토하지 않은 버전은 버전만으로 거부하지 않고 일반 설정도 보수적으로 검사합니다. 프로세스 정리가 불확실하면 큐 제출 전에 거부하고, 중단 요청은 제한된 정리를 시도한 뒤 전달합니다. 정확히 `codex-cli 0.162.1`일 때만 강제 설정 전용 검사를 선택하며 이후 버전을 같다고 추정하지 않습니다. handoff는 기존 버전 조회와 원래 기한을 재사용하고 제출까지 선택한 정책을 유지합니다. 지원하는 0.154.0 wake 경로는 활성 writer에게 큐만 제출할 때도 일반 설정을 검사합니다. 예산 예약 및 자식 프로세스 실행 경계에서 저장소와 사용자 지정 마커를 다시 검사합니다. macOS 이외에는 새 버전 조회를 추가하지 않고 결과를 캐시하지 않으며 wake 지원 범위도 늘리지 않습니다. 확인된 큐 제출 이후 거부되더라도 제출 사실을 유지하고 다시 보내지 않습니다. 소스 기반 검사와 모의 회귀 테스트일 뿐 새로운 native 설정이나 MDM 실측은 아닙니다. + +직접 시작하는 큐 및 wake 자식 프로세스는 상위 경로의 별칭까지 해석한, 실제로 존재하는 물리적 작업 디렉터리를 사용합니다. 프로젝트 검색, 자식 `cwd`, POSIX `PWD`, wake의 `thread/resume.cwd`가 같은 디렉터리를 사용합니다. 저장된 consumer 디렉터리가 존재하면 물리적 경로와 서로 다른 논리적 경로를 같은 마커 경계 및 사용자 지정 마커 대체 규칙으로 모두 검사하므로 별칭 설정을 보수적으로 거부할 수 있습니다. 사라진 consumer 디렉터리는 검사할 계층이 없다는 기존 처리를 유지합니다. 실행 중인 writer의 시작 설정이 확인되는 것은 아니며, 검사 후 별칭이나 설정이 바뀔 가능성도 남습니다. 결과의 `wake.cwd`도 이 물리적 경로를 사용하므로 저장된 별칭 표기와 다를 수 있습니다. + +native 로더의 계층 목록 주석에는 `/config.toml`이 있지만, 고정된 [프로젝트 계층 구성 코드](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/config/src/loader/mod.rs#L1652)는 `.codex/config.toml`을 불러옵니다. 이 검사는 오래된 주석이 아니라 실제 구현을 따릅니다. Windows Known Folder 조회가 성공했는데 반환 경로가 잘못됐거나 크기 제한을 넘으면 다른 디렉터리로 조용히 대체하지 않고 거부합니다. + +wake는 예산 예약 전에 알려진 저장소 설정을 검사하지만, 예약 후 설정이나 쓰기 프로세스 증거가 바뀌면 후속 재검사에서 거부될 수 있습니다. 변환된 native 홈 경로는 상속된 cwd를 유지하며 Windows 소비자 디렉터리는 검사하지 않습니다. + 2026-10-09 소스 조사에서는 native Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/core/src/config/mod.rs)과 [0.159.0](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/mod.rs)을 확인했습니다. 두 버전 모두 설정된 `sqlite_home`, 비어 있지 않은 `CODEX_SQLITE_HOME`, `CODEX_HOME` 순으로 SQLite 저장소를 선택합니다. 상대 환경 변수 경로의 기준은 native에서 결정한 작업 디렉터리입니다. [관리자 요구사항](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/requirements.rs)은 명령행 재정의를 포함한 저장소 설정을 바꿀 수 있습니다. [native 큐 경로](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/tui/src/session_queue_commands.rs)는 이미 실행 중인 공유 서버를 재사용할 수 있으므로 새 클라이언트의 환경만으로 서버의 저장소를 확인할 수 없습니다. 이는 소스 조사 결과이며 새로운 실제 전송이나 플랫폼 검증이 아닙니다. -session-peer는 상속된 `CODEX_SQLITE_HOME`이 비어 있지 않거나 검사하는 홈의 설정에 루트 `sqlite_home`이 있으면 발견, 사전 검증, 큐 제출 전에 거부합니다. 설정 경로가 선택한 홈과 같아도 거부하며, 따옴표·이스케이프가 있는 루트 키와 루트의 점 구분 키·테이블도 포함합니다. 선택되지 않은 하위 키는 루트 저장소 설정으로 취급하지 않습니다. 빈 환경 변수 재정의는 native 자식 프로세스의 환경에서 제거합니다. 환경 변수 이름은 POSIX에서 대소문자를 구분하고 Windows에서는 구분하지 않습니다. 기존에 지원하는 native 홈 경로 변환은 유지하며 저장소 위치 변경 지원을 추가하지 않습니다. +2026-10-15 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 조사 결과, 저장소 우선순위는 관리자 요구사항, 병합된 설정 계층, `CODEX_SQLITE_HOME`, `CODEX_HOME` 순입니다. `sqlite_home`을 설정할 수 있는 계층은 시스템 파일, macOS 관리 프로필, 사용자의 `config.toml`, `--profile`로 선택한 프로필 파일, native 프로세스 작업 디렉터리의 프로젝트 계층(작업 디렉터리부터 가장 가까운 기본 `.git` 루트까지의 `.codex/config.toml`, 루트가 없으면 작업 디렉터리만 해당하며 신뢰한 프로젝트에서만 활성화)입니다. 기존 `[profiles.*]` 테이블에는 저장소 키가 없습니다. `codex queue`는 홈의 `app-server-control/app-server-control.sock`에서 연결을 받는 app-server 데몬이 실행 중이면 그 데몬을 통해, 그렇지 않으면 내장 서버를 통해 제출합니다. native TUI는 `--no-daemon` 등으로 제외하지 않으면 기본적으로 이런 데몬을 자동으로 시작합니다. 데몬의 QueueStore는 시작할 때 선택한 데이터베이스를 유지하지만 `config/read`는 현재 디스크의 계층을 다시 읽습니다. `initialize`는 홈과 플랫폼을 알려주지만, 검토한 `initialize`, `config/read`, 큐 API에는 시작 시점 SQLite 정체를 증명하는 안정적인 계약이 없습니다. 스레드 UUID가 일치하거나 같은 서버에 연결되었다고 해서 저장소가 같다는 뜻은 아니며(복사한 데이터베이스에도 같은 UUID가 있을 수 있음), 큐 API에는 기대 저장소나 프로세스 세대 전제조건이 없습니다. 큐에 쌓인 메시지를 소비하는 프로세스는 자기 저장소의 큐를 폴링합니다. 이는 소스 조사 결과이며 실제 전송이나 플랫폼 검증이 아닙니다. + +session-peer는 상속된 `CODEX_SQLITE_HOME`이 비어 있지 않거나 검사하는 홈의 설정에 루트 `sqlite_home`이 있으면 발견, 사전 검증, 큐 제출 전에 거부합니다. 설정 경로가 선택한 홈과 같아도 거부하며, 따옴표·이스케이프가 있는 루트 키와 루트의 점 구분 키·테이블도 포함합니다. 선택되지 않은 하위 키는 루트 저장소 설정으로 취급하지 않습니다. 빈 환경 변수 재정의는 native 자식 프로세스의 환경에서 제거합니다. 환경 변수 이름은 POSIX에서 대소문자를 구분하고 Windows에서는 구분하지 않습니다. 기존에 지원하는 native 홈 경로 변환은 유지하며 저장소 위치 변경 지원을 추가하지 않습니다. 또한 시스템 설정·요구사항 파일(`/etc/codex/config.toml`, `/etc/codex/managed_config.toml`, `/etc/codex/requirements.toml`, Windows에서는 Known Folder API의 ProgramData 디렉터리 아래 `OpenAI\Codex\config.toml`과 `requirements.toml`)과 macOS 관리 프로필 값에 루트 `sqlite_home`이 있으면 프로젝트 신뢰 여부와 관계없이 거부합니다. 큐 제출은 검사한 홈에서 native 프로세스를 시작하므로 호출한 위치의 디렉터리가 프로젝트 계층을 추가하지 않습니다. 사전 검증은 큐·app-server 자식 프로세스를 시작하지 않고(wake 및 macOS 일반 큐 사전 검증은 native 버전 확인 프로세스를 실행할 수 있음) 같은 실행 계획(홈, 자식 프로세스 작업 디렉터리, 환경, 알려진 계층, 소비 디렉터리)을 검사하고, 실제 전송은 효과 직전에 다시 검사합니다. 어느 쪽도 native Codex가 설정을 읽기 전의 시간차를 없애지 못하므로 사전 검증 통과가 다음 전송의 저장소 일치를 보장하지 않습니다. 사용자 정책에 따라 데몬 제어 소켓은 활성 여부와 관계없이 거부 사유가 아니며 session-peer가 연결을 시도하지도 않습니다. 데몬과 내장 서버 선택은 기존처럼 native Codex가 하며 저장소 권한을 주장하지 않습니다. 변환된 native 홈 경로를 제외하고, 사전 검증, 큐 제출, wake는 소비하는 쓰기 프로세스나 wake 서버가 보통 시작하는 저장된 스레드 디렉터리의 프로젝트 계층도 검사하며, 스레드 메타데이터를 읽을 수 없거나 형식이 잘못되었으면 거부합니다. wake는 대상별 예산을 예약하기 전에 이 저장소 검사를 수행합니다. 거부 사유는 `sqlite_environment_override`, `sqlite_home_configuration`, `sqlite_home_system_configuration`, `sqlite_home_managed_configuration`, `sqlite_home_project_configuration`, `consumer_directory_unverifiable`, `storage_config_unreadable`, `storage_config_unverifiable`입니다. + +검사하는 TOML 파일은 최대 1 MiB의 UTF-8 일반 파일이어야 하며 심볼릭 링크는 허용하지 않습니다. Windows 시스템 디렉터리는 `SHGetKnownFolderPath(FOLDERID_ProgramData)`로 확인하며 API 실패 시 native Codex와 같이 고정된 `C:\ProgramData`를 사용합니다. `ProgramData` 환경 변수는 사용하지 않습니다. macOS에서는 CoreFoundation으로 Codex 설정 도메인을 동기화하고 소유한 값을 해제합니다. 강제 설정 전용 정책은 복사 후 강제 여부를 다시 확인하고 일반 사용자 설정은 무시합니다. 버전에 따라 선택한 보수적 정책은 위 설명대로 일반 설정도 읽습니다. 선택한 base64 문자열은 최대 2 MiB이며 최대 1 MiB의 UTF-8 TOML로 디코딩합니다. 동기화·형식·디코딩·크기 오류는 거부합니다. TOML 파일이나 설정값이 없어도 됩니다. 기본 프로젝트 탐색은 디렉터리가 아닌 가장 가까운 `.git` 항목(metadata만 확인) 또는 `HEAD`가 있는 .git 디렉터리에서 멈춥니다. 심볼릭 링크 marker는 보수적으로 거부하며, 루트가 없으면 작업 디렉터리만 검사합니다. `/config.toml`은 프로젝트 계층이 아닙니다. 알려진 설정에 루트 `project_root_markers` 재정의가 있으면 사용자 지정 표식을 정확히 해석한다고 주장하지 않고 모든 상위 디렉터리를 보수적으로 검사합니다. API는 모의 테스트로 검증했으며 실제로 재배치한 Windows Known Folder와 강제 MDM 프로필은 시험하지 않았습니다. 읽을 수 없거나 변경·크기 초과·키 구문 판별 불가 상태인 파일은 내용을 출력하지 않고 거부합니다. 외부 의존성이 없는 키 판별기는 Python 3.9를 지원하지만 완전한 TOML·스키마 검증기는 아닙니다. 다른 값은 native Codex가 계속 검증합니다. 큐 제출과 wake는 자식 프로세스를 시작하기 전에 이 경계를 다시 확인합니다. 거부 결과는 `status=refused`, `submitted=false`, `retryAllowed=false`이며 정상적인 기존 출력과 쓰기 프로세스 소유권 검사는 바뀌지 않습니다. SSH에서는 발신자의 환경이 아니라 대상 기기에서 검사합니다. -홈의 `config.toml`은 최대 1 MiB의 UTF-8 일반 파일이어야 하며 심볼릭 링크는 허용하지 않습니다. 파일이 없어도 됩니다. 읽을 수 없거나 변경·크기 초과·키 구문 판별 불가 상태인 파일은 내용을 출력하지 않고 거부합니다. 외부 의존성이 없는 키 판별기는 Python 3.9를 지원하지만 완전한 TOML·스키마 검증기는 아닙니다. 다른 값은 native Codex가 계속 검증합니다. 큐 제출과 wake는 자식 프로세스를 시작하기 전에 이 경계를 다시 확인합니다. 거부 결과는 `status=refused`, `submitted=false`, `retryAllowed=false`이며 정상적인 기존 출력과 쓰기 프로세스 소유권 검사는 바뀌지 않습니다. SSH에서는 발신자의 환경이 아니라 대상 기기에서 검사합니다. +변환된 native 홈(WSL relay 수신자가 native Windows Codex에 큐 제출)은 기존 제출 계약을 유지하지만 저장소 검증은 지원하지 않습니다. 이 POSIX 프로세스는 Windows 쪽 홈 표기, 대소문자 규칙, 환경, 시스템·관리·프로젝트 계층을 검사할 수 없습니다. 이런 경로를 효과 전에 거부하는 엄격 모드는 향후 가능하지만 구현하지 않았습니다. -이 변경은 한정된 방어 강화이며 모든 설정에서 native 저장소가 일치함을 증명하지 않습니다. 프로젝트·시스템·프로필 계층, 관리자 요구사항, 기존 공유 서버는 이 검사로 해석하거나 증명하지 않습니다. 마지막 검사 후 설정이 바뀔 수도 있습니다. 검사 성공만으로 저장소 일치를 추론하지 마세요. [#268](https://github.com/abruption/session-peer/issues/268)은 이 증거와 버전·플랫폼 수락 검증을 위해 열린 상태로 유지합니다. 가짜 자식 프로세스 테스트는 거부와 환경 처리만 보여주며 실제 오대상 전송, 보안 악용, 새로운 native 호환성을 입증하지 않습니다. wake의 버전 지원 범위는 바뀌지 않습니다. +이 변경은 한정된 방어 강화이며 모든 설정에서 native 저장소가 일치함을 증명하지 않습니다. 검사하지 않는 대상은 패키지 기본 설정, 엔터프라이즈 클라우드 설정 번들, 실행 중인 공유 데몬의 시작 시점 저장소, 이미 실행 중인 쓰기 프로세스의 `--profile` 선택·`-c` 재정의·환경 변수, 저장된 스레드 디렉터리가 아닌 곳에서 시작했거나 디렉터리가 사라진 쓰기 프로세스, 그리고 변환된 native 홈의 Windows 측 시스템·관리·프로젝트 계층입니다. 마지막 검사 후 설정이 바뀔 수도 있습니다. 검사 성공만으로 저장소 일치를 추론하지 마세요. [#268](https://github.com/abruption/session-peer/issues/268)은 이 계약의 native 플랫폼 검증을 위해 열린 상태로 유지합니다. 가짜 자식 프로세스 테스트는 거부와 환경 처리만 보여주며 실제 오대상 전송, 보안 악용, 새로운 native 호환성을 입증하지 않습니다. wake의 버전 지원 범위는 바뀌지 않습니다. ### Orca 및 여러 Codex 홈 diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index 3e7b457e..adda94dd 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -335,13 +335,27 @@ Codex 发现使用只读 SQLite 连接读取 `state_5.sqlite`。此内部模式 ### SQLite 存储边界 +独立的 `list` 只查询元数据,不执行 Codex 或查询其版本;指定 `--codex-bin` 也不表示已确认发现流程所用的版本。因此,macOS 会在打开候选 SQLite DB 之前保守检查普通设置和强制设置。这能阻止旧版加载器下误读默认目录中的旧 DB,但也可能拒绝新版加载器实际忽略普通设置的正常环境。拒绝会作为错误返回,不会变成成功的空列表,也不会切换到其他 DB;其他代理的正常结果仍与发现不完整错误一同保留。诊断会说明存储配置存在歧义,并请操作人员核查已知配置层,包括有效的 `com.openai.codex` 配置、要求以及预期目录和存储布局。只更改 `--codex-home` 无法消除全局设置歧义。不要为消除错误而自动删除设置、管理配置或 DB。即使 native 加载器确实忽略该值,这种配置的发现流程仍保守视为不支持;准确的版本感知发现另作改进。send dry-run 内部发现复用已选择的发送策略,不新增版本查询。 + +发送及其内部 dry-run 检查所用的 macOS 设置检查策略取决于选定的 native 可执行文件。已审查的 Codex 0.162.1 仅读取强制设置,而 Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/config/src/loader/macos.rs) 通过 `CFPreferencesCopyAppValue` 也读取普通设置。macOS 上的普通队列发送和 dry-run 会在选定目录和工作目录中运行一次 `--version` 子进程:查询限3秒,清理限2秒,标准输出限1 MiB,诊断输出限64 KiB。不回显输出内容;版本格式错误、不可获取或尚未审查时,保守检查普通设置,而非仅因版本而拒绝。无法确定清理完成时在提交队列前拒绝,中断在尝试限时清理后传播。仅精确匹配 `codex-cli 0.162.1` 时才只检查强制设置,不假定后续版本相同。handoff 重用现有版本查询和原始期限,并将所选策略保留到提交阶段。受支持的0.154.0 wake 路径即使仅向活动 writer 提交队列,也检查普通设置。在预留额度和启动子进程的边界再次检查存储和自定义标记。macOS 以外不新增版本查询,不缓存结果,也不扩大 wake 支持范围。已确认队列提交之后发生拒绝时保留已提交事实且不重发。这些是源码检查和模拟回归测试,并非新的 native 设置或 MDM 实测。 + +自行启动的队列和 wake 子进程使用严格解析后确实存在的物理工作目录,包括解析所有上级路径中的别名。项目搜索、子进程 `cwd`、POSIX `PWD` 和 wake 的 `thread/resume.cwd` 使用该目录。已保存的 consumer 目录仍存在时,使用相同的标记边界和自定义标记回退规则检查物理路径以及不同的逻辑路径,因此可能保守拒绝别名配置。consumer 目录已不存在时,仍保持没有配置层可检查的现有处理。这不证明运行中 writer 的启动配置,检查后别名或配置仍可能变化。 结果中的 `wake.cwd` 也采用该物理路径,因此可能不同于保存的别名。 + +native 加载器的层级列表注释提到 `/config.toml`,但固定版本的[项目层构建代码](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/config/src/loader/mod.rs#L1652)实际加载 `.codex/config.toml`。本检查遵循实现,而非过时注释。Windows Known Folder 查询成功后,如果返回路径无效或超出长度上限,将拒绝而不是静默改用其他目录。 + +wake 在预留预算前检查已知存储设置,但如果预留后配置或写入进程证据发生变化,后续复查仍可能拒绝。转换后的 native 目录路径保持继承的 cwd,且不检查 Windows 消费端目录。 + 2026-10-09 的源代码调查核对了 native Codex [0.154.0](https://github.com/openai/codex/blob/6b9826e3aa83b1a5947db50f4332cb9c65f1b340/codex-rs/core/src/config/mod.rs) 和 [0.159.0](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/mod.rs)。两版都依次从已配置的 `sqlite_home`、非空的 `CODEX_SQLITE_HOME`、`CODEX_HOME` 选择 SQLite 存储。环境变量中的相对路径以 native 解析后的工作目录为基准。[管理员要求](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/core/src/config/requirements.rs) 可以替换存储设置,包括命令行覆盖。[native 队列路径](https://github.com/openai/codex/blob/687a119f0fcaace47e1f1abcc77cec6c813fd6da/codex-rs/tui/src/session_queue_commands.rs) 可能复用正在运行的共享服务器,因此新客户端的环境不能证明服务器使用的存储。这些是源码调查结果,并非新的实际投递或平台验证。 -如果继承的 `CODEX_SQLITE_HOME` 非空,或任何被检查目录的配置中存在根级 `sqlite_home`,session-peer 会在发现、预检或队列提交之前拒绝。即使配置路径等于所选目录也会拒绝,包括带引号或转义的根级键以及根级点分键和表。未选中的嵌套键不被视为根级存储设置。空环境变量覆盖会从 native 子进程环境中移除;POSIX 区分环境变量名称的大小写,Windows 不区分。现有的 native 目录路径转换保持不变,不新增迁移存储位置的支持。 +2026-10-15 对 native Codex [0.162.1](https://github.com/openai/codex/tree/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs) 的调查发现,存储优先级依次为管理员要求、合并后的配置层、`CODEX_SQLITE_HOME`、`CODEX_HOME`。可以设置 `sqlite_home` 的配置层包括系统文件、macOS 托管配置描述文件、用户的 `config.toml`、通过 `--profile` 选择的配置档文件,以及 native 进程工作目录的项目层(从工作目录到最近的默认 `.git` 根目录之间的 `.codex/config.toml`;未找到根目录时只检查工作目录,仅对受信任项目启用)。旧版 `[profiles.*]` 表没有存储键。如果目录中的 `app-server-control/app-server-control.sock` 上有接受连接的 app-server 守护进程在运行,`codex queue` 会通过该守护进程提交,否则通过内嵌服务器提交。除非通过 `--no-daemon` 等方式排除,native TUI 默认会自动启动此类守护进程。守护进程的 QueueStore 保留启动时选择的数据库,而 `config/read` 会重新读取当前磁盘上的配置层;`initialize` 会报告目录和平台,但已审查的 `initialize`、`config/read` 和队列 API 都没有证明启动时 SQLite 身份的稳定约定。线程 UUID 一致或连接到同一服务器并不代表存储相同(复制的数据库也可能包含相同的 UUID),队列 API 也没有预期存储或进程代次前置条件。消费队列消息的进程会轮询其自身存储中的队列。这些是源码调查结果,并非实际投递或平台验证。 + +如果继承的 `CODEX_SQLITE_HOME` 非空,或任何被检查目录的配置中存在根级 `sqlite_home`,session-peer 会在发现、预检或队列提交之前拒绝。即使配置路径等于所选目录也会拒绝,包括带引号或转义的根级键以及根级点分键和表。未选中的嵌套键不被视为根级存储设置。空环境变量覆盖会从 native 子进程环境中移除;POSIX 区分环境变量名称的大小写,Windows 不区分。现有的 native 目录路径转换保持不变,不新增迁移存储位置的支持。此外,如果系统配置和要求文件(`/etc/codex/config.toml`、`/etc/codex/managed_config.toml`、`/etc/codex/requirements.toml`,Windows 上为 Known Folder API 返回的 ProgramData 目录下的 `OpenAI\Codex\config.toml` 和 `requirements.toml`)或 macOS 托管配置值中存在根级 `sqlite_home`,无论项目是否受信任都会拒绝。队列提交会在被检查的目录中启动 native 进程,因此调用方所在目录不会增加项目层。预检在不启动队列或 app-server 子进程(wake 和 macOS 普通队列的预检仍可能运行 native 版本检查进程)的情况下检查相同的执行计划(目录、子进程工作目录、环境、已知配置层和消费目录),实际发送则在产生效果之前再次检查;两者都无法消除 native Codex 读取配置之前的时间差,因此预检通过并不保证之后发送时存储一致。根据用户策略,无论守护进程控制套接字是活动的还是遗留的,都不会成为拒绝原因,session-peer 也不会尝试连接;守护进程与内嵌服务器的选择仍由 native Codex 决定,且不声称具有存储权威。除转换后的 native 目录路径外,预检、队列提交和 wake 还会检查已保存线程目录的项目层,消费消息的写入进程或 wake 服务器通常从该目录启动;线程元数据无法读取或格式错误时会拒绝。wake 会在预留单目标预算之前执行这些存储检查。拒绝原因包括 `sqlite_environment_override`、`sqlite_home_configuration`、`sqlite_home_system_configuration`、`sqlite_home_managed_configuration`、`sqlite_home_project_configuration`、`consumer_directory_unverifiable`、`storage_config_unreadable` 和 `storage_config_unverifiable`。 + +被检查的 TOML 文件必须是不超过 1 MiB 的 UTF-8 常规文件,不能是符号链接。Windows 系统目录通过 `SHGetKnownFolderPath(FOLDERID_ProgramData)` 获取;API 失败时与 native Codex 一样使用固定的 `C:\ProgramData`,不使用 `ProgramData` 环境变量。macOS 通过 CoreFoundation 同步 Codex 配置域并释放所拥有的值。强制设置专用策略在复制后再次检查强制状态并忽略普通用户设置;按版本选择的保守策略也读取普通值,如上所述。选定的 base64 字符串最多为 2 MiB,解码后的 UTF-8 TOML 最多为 1 MiB。同步、类型、解码或大小错误均拒绝;TOML 文件或配置值不存在时允许继续。默认项目搜索在最近的非目录 `.git` 条目(仅检查 metadata)或含有 `HEAD` 的 .git 目录处停止;保守拒绝符号链接标记;没有根目录时只检查工作目录。`/config.toml` 不是项目配置层。已知配置中存在根级 `project_root_markers` 覆盖时,保守检查所有上级目录,而不声称精确解释了自定义标记。API 已通过模拟测试验证;实际重新定位的 Windows Known Folder 和强制 MDM 配置描述文件尚未测试。无法读取、发生变化、超出大小限制或键语法无法分类的文件会被拒绝,且不会输出其内容。不依赖外部库的键分类器支持 Python 3.9,但不是完整的 TOML 或模式验证器;其他值仍由 native Codex 验证。队列和 wake 会在启动子进程前重新检查该边界。拒绝结果包含 `status=refused`、`submitted=false` 和 `retryAllowed=false`;正常的既有输出及写入进程所有权检查保持不变。SSH 在目标端检查,而不是检查发送端的环境。 -目录中的 `config.toml` 必须是不超过 1 MiB 的 UTF-8 常规文件,不能是符号链接;文件不存在时允许继续。无法读取、发生变化、超出大小限制或键语法无法分类的文件会被拒绝,且不会输出其内容。不依赖外部库的键分类器支持 Python 3.9,但不是完整的 TOML 或模式验证器;其他值仍由 native Codex 验证。队列和 wake 会在启动子进程前重新检查该边界。拒绝结果包含 `status=refused`、`submitted=false` 和 `retryAllowed=false`;正常的既有输出及写入进程所有权检查保持不变。SSH 在目标端检查,而不是检查发送端的环境。 +转换后的 native 目录(WSL relay 接收端向 native Windows Codex 提交队列)保持现有提交约定,但不支持存储验证:此 POSIX 进程无法检查 Windows 侧的目录写法、大小写规则、环境以及系统、托管和项目层。今后可以由严格模式在产生效果前拒绝此类路径,但目前未实现。 -这只是有限的防护增强,不能证明所有配置下的 native 存储一致。该检查不会解析或证明项目、系统、配置档层、管理员要求及现有共享服务器的有效存储;最后一次检查后配置仍可能变化。不要仅凭检查成功推断存储一致。[#268](https://github.com/abruption/session-peer/issues/268) 保持开放,等待此证据门槛和版本、平台验收。伪子进程测试只说明拒绝和环境处理,不能证明实际误投递、安全利用或新的 native 兼容性。wake 的版本支持范围保持不变。 +这只是有限的防护增强,不能证明所有配置下的 native 存储一致。不检查的内容包括:软件包默认配置、企业云配置包、正在运行的共享守护进程启动时的存储、已在运行的写入进程的 `--profile` 选择、`-c` 覆盖和环境变量、在已保存线程目录以外启动或其目录已不存在的写入进程,以及转换后的 native 目录在 Windows 侧的系统、托管和项目层。最后一次检查后配置仍可能变化。不要仅凭检查成功推断存储一致。[#268](https://github.com/abruption/session-peer/issues/268) 保持开放,等待此约定的 native 平台验证。伪子进程测试只说明拒绝和环境处理,不能证明实际误投递、安全利用或新的 native 兼容性。wake 的版本支持范围保持不变。 ### Orca 与多个 Codex 目录 diff --git a/session_peer.py b/session_peer.py index 0b327dab..d789f287 100644 --- a/session_peer.py +++ b/session_peer.py @@ -235,6 +235,36 @@ def codex_storage_refused(reason: str) -> CcPeerError: "retryAllowed": False, "reason": reason}) +def _check_codex_discovery_storage(root: Path, *, include_ordinary_preferences: bool) -> None: + """Metadata-only discovery cannot verify the selected native loader. + + Public listing conservatively checks ordinary macOS preferences without + launching a version child. A send dry-run can reuse its already established + preference policy privately; it must not run a second version query here. + """ + try: + check_codex_storage(root, include_ordinary_preferences=include_ordinary_preferences) + except CcPeerError as exc: + reason = exc.details.get("reason") + if (sys.platform == "darwin" and include_ordinary_preferences and + reason in ("sqlite_home_managed_configuration", "storage_config_unverifiable")): + cause = ("macOS preference values contain an unsupported sqlite_home setting. " + if reason == "sqlite_home_managed_configuration" else + "A known storage configuration layer could not be classified; " + "this need not originate in preferences. ") + raise CcPeerError( + "Cannot establish the selected Codex SQLite storage: " + reason + ". " + cause + + "This discovery step performs no additional native version query. Standalone metadata-only " + "listing checks ordinary as well as forced com.openai.codex preferences without querying " + "Codex; it can conservatively refuse values ignored by a 0.162.1 configuration. " + "Ask the operator to review known configuration layers, including effective com.openai.codex " + "config_toml_base64 and requirements_toml_base64 preferences, and ensure the intended home and storage. " + "--codex-home alone does not resolve " + "global preferences. Do not automatically remove preferences or switch databases. Nothing submitted.", + dict(exc.details)) from exc + raise + + def _codex_config_tokens(text: str) -> list[tuple[str, str]]: """Bounded lexical classification, NOT a general TOML parser. @@ -320,7 +350,7 @@ def unicode_escape(match): return keys -def _codex_config_has_storage(text: str) -> bool: +def _codex_config_has_storage(text: str, root_key: str = "sqlite_home") -> bool: table = [] statement = [] brackets = [] @@ -333,14 +363,14 @@ def _codex_config_has_storage(text: str) -> bool: if [item[0] for item in statement[-count:]] != ["]"] * count: raise ValueError("unclassifiable configuration table") table = _codex_config_key(statement[count:-count]) - if table[0] == "sqlite_home": + if table[0] == root_key: return True else: split = next((i for i, token in enumerate(statement) if token[0] == "="), -1) if split <= 0 or split == len(statement) - 1: raise ValueError("unclassifiable configuration assignment") key = _codex_config_key(statement[:split]) - if not table and key[0] == "sqlite_home": + if not table and key[0] == root_key: return True statement = [] continue @@ -356,23 +386,18 @@ def _codex_config_has_storage(text: str) -> bool: return False -def check_codex_storage(root: Path) -> None: - """Reject known relocation without reading native accounts or transcripts. +def _read_codex_config_bytes(path: Path, unreadable: str = "storage_config_unreadable") -> bytes | None: + """Return one bounded regular file, None when absent, or refuse. - This checks inherited SQLite overrides and this home's config only. It does - not pretend to resolve native project/system/managed requirement layers. + The file is pinned by device/inode and size/mtime across the read so a + concurrent replacement fails closed instead of being half-classified. """ - for name, value in os.environ.items(): - match = name.upper() == "CODEX_SQLITE_HOME" if IS_WINDOWS else name == "CODEX_SQLITE_HOME" - if match and value.strip(): - raise codex_storage_refused("sqlite_environment_override") - path = root / "config.toml" try: before = path.lstat() - except FileNotFoundError: - return + except (FileNotFoundError, NotADirectoryError): + return None except OSError as exc: - raise codex_storage_refused("storage_config_unreadable") from exc + raise codex_storage_refused(unreadable) from exc descriptor = None try: if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_CODEX_CONFIG_BYTES: @@ -394,24 +419,423 @@ def check_codex_storage(root: Path) -> None: if (len(data) > MAX_CODEX_CONFIG_BYTES or snapshot(before) != snapshot(after) or snapshot(current) != snapshot(after)): raise ValueError("configuration file changed or exceeded limit") - relocated = _codex_config_has_storage(data.decode("utf-8", errors="strict")) - except (OSError, ValueError, UnicodeError, RecursionError) as exc: + return data + except (OSError, ValueError) as exc: raise codex_storage_refused("storage_config_unverifiable") from exc finally: if descriptor is not None: os.close(descriptor) - if relocated: - raise codex_storage_refused("sqlite_home_configuration") -def codex_process_environment(root: Path, native_home: str | None = None) -> dict: - check_codex_storage(root) +def _codex_config_text_has_storage(data: bytes) -> bool: + try: + return _codex_config_has_storage(data.decode("utf-8", errors="strict")) + except (ValueError, UnicodeError, RecursionError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def _refuse_configured_storage(path: Path, reason: str) -> None: + data = _read_codex_config_bytes(path) + if data is not None and _codex_config_text_has_storage(data): + raise codex_storage_refused(reason) + + +def codex_windows_program_data() -> str: + """Native Known Folder lookup; never trust the ProgramData environment.""" + import ctypes + class GUID(ctypes.Structure): + _fields_ = [("Data1", ctypes.c_uint32), ("Data2", ctypes.c_uint16), + ("Data3", ctypes.c_uint16), ("Data4", ctypes.c_ubyte * 8)] + pointer = ctypes.c_void_p() + free = None + lookup_succeeded = False + try: + shell = ctypes.WinDLL("shell32", use_last_error=True) + ole = ctypes.WinDLL("ole32", use_last_error=True) + lookup = shell.SHGetKnownFolderPath + lookup.argtypes = [ctypes.POINTER(GUID), ctypes.c_uint32, ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p)] + lookup.restype = ctypes.c_int32 + free = ole.CoTaskMemFree + free.argtypes = [ctypes.c_void_p] + free.restype = None + identifier = GUID(0x62AB5D82, 0xFDC1, 0x4DC3, + (ctypes.c_ubyte * 8)(0xA9, 0xDD, 0x07, 0x0D, 0x1D, 0x49, 0x5D, 0x97)) + if lookup(ctypes.byref(identifier), 0, None, ctypes.byref(pointer)) != 0 or not pointer.value: + return r"C:\ProgramData" + lookup_succeeded = True + # Windows WCHAR is UTF-16, independently of the host's wchar_t width. + units = ctypes.cast(pointer, ctypes.POINTER(ctypes.c_uint16)) + encoded = bytearray() + for index in range(32768): + unit = units[index] + if unit == 0: + # Match native OsString::from_wide without substituting another + # directory when a Windows path contains an unpaired surrogate. + value = encoded.decode("utf-16-le", errors="surrogatepass") + import ntpath + if not value or not ntpath.splitdrive(value)[0] or not ntpath.isabs(value): + raise ValueError("invalid native directory") + return value + encoded.extend(unit.to_bytes(2, "little")) + raise ValueError("unbounded native directory") + except (AttributeError, OSError, ValueError, UnicodeError) as exc: + if lookup_succeeded: + raise codex_storage_refused("storage_config_unverifiable") from exc + finally: + if pointer.value and free is not None: + free(pointer) + return r"C:\ProgramData" + + +def codex_system_config_paths() -> list[Path]: + """System config and requirements layers that native Codex 0.162.1 reads. + + Requirements (exact `sqlite_home`) outrank every config layer. Unix still + loads the legacy managed_config.toml as requirements. + """ + if IS_WINDOWS: + base = Path(codex_windows_program_data()) / "OpenAI" / "Codex" + return [base / "config.toml", base / "requirements.toml"] + base = Path("/etc/codex") + return [base / "config.toml", base / "managed_config.toml", base / "requirements.toml"] + + +CODEX_MANAGED_PREFERENCES_DOMAIN = "com.openai.codex" +CODEX_MANAGED_PREFERENCES_KEYS = ("config_toml_base64", "requirements_toml_base64") + + +def codex_managed_preference_values(*, forced_only: bool = True) -> list[str]: + """Read native preferences, releasing owned CF values. + + Codex 0.162.1 accepts only forced values. The supported wake version, + 0.154.0, used CopyAppValue without that gate, including ordinary values. + """ + if sys.platform != "darwin": + return [] + import ctypes + cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") + def api(name, args, result): + function = getattr(cf, name) + function.argtypes, function.restype = args, result + return function + ptr = ctypes.c_void_p + create = api("CFStringCreateWithCString", [ptr, ctypes.c_char_p, ctypes.c_uint32], ptr) + release = api("CFRelease", [ptr], None) + sync = api("CFPreferencesAppSynchronize", [ptr], ctypes.c_ubyte) + forced = api("CFPreferencesAppValueIsForced", [ptr, ptr], ctypes.c_ubyte) + copy = api("CFPreferencesCopyAppValue", [ptr, ptr], ptr) + type_id = api("CFGetTypeID", [ptr], ctypes.c_ulong) + string_type = api("CFStringGetTypeID", [], ctypes.c_ulong) + extract = api("CFStringGetCString", [ptr, ptr, ctypes.c_long, ctypes.c_uint32], ctypes.c_ubyte) + owned = [] + try: + domain = create(None, CODEX_MANAGED_PREFERENCES_DOMAIN.encode("ascii"), 0x08000100) + if not domain: + raise ValueError("missing domain") + owned.append(domain) + if not sync(domain): + raise ValueError("preference synchronization failed") + values = [] + for name in CODEX_MANAGED_PREFERENCES_KEYS: + key = create(None, name.encode("ascii"), 0x08000100) + if not key: + raise ValueError("missing key") + owned.append(key) + if forced_only and not forced(key, domain): + continue + value = copy(key, domain) + if not value: + continue + try: + if forced_only and not forced(key, domain): + continue + if type_id(value) != string_type(): + raise ValueError("non-string preference") + buffer = ctypes.create_string_buffer(MAX_CODEX_CONFIG_BYTES * 2 + 1) + if not extract(value, buffer, len(buffer), 0x08000100): + raise ValueError("unbounded preference") + values.append(buffer.value.decode("utf-8", errors="strict")) + finally: + release(value) + return values + finally: + for value in reversed(owned): + release(value) + + +def _refuse_managed_preference_storage(*, include_ordinary_preferences: bool = False) -> None: + try: + values = (codex_managed_preference_values(forced_only=False) if include_ordinary_preferences + else codex_managed_preference_values()) + for encoded in values: + if not isinstance(encoded, str) or len(encoded) > MAX_CODEX_CONFIG_BYTES * 2: + raise ValueError("unbounded preference") + decoded = base64.b64decode(encoded.strip(), validate=True) + if len(decoded) > MAX_CODEX_CONFIG_BYTES: + raise ValueError("unbounded preference") + if _codex_config_text_has_storage(decoded): + raise codex_storage_refused("sqlite_home_managed_configuration") + except CcPeerError: + raise + except (AttributeError, OSError, ValueError, TypeError, OverflowError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_physical_cwd(cwd: Path) -> Path: + """The existing directory chdir will reach, not its lexical alias.""" + try: + physical = Path(cwd).resolve(strict=True) + if not physical.is_dir(): + raise ValueError("not a directory") + return physical + except (OSError, RuntimeError, ValueError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_project_config_paths(cwd: Path, *, root: Path | None = None, + include_ordinary_preferences: bool = False) -> list[Path]: + """Native cwd/tree/repo project layers for a process started in `cwd`. + + Default markers stop at the closest .git root; without a marker only cwd + applies. A custom marker in any known layer retains a conservative superset. + """ + return _codex_project_config_paths_from(codex_physical_cwd(cwd), root=root, + include_ordinary_preferences=include_ordinary_preferences) + + +def _codex_project_config_paths_from(base: Path, *, root: Path | None = None, + include_ordinary_preferences: bool = False) -> list[Path]: + """Apply the same marker boundary to an already selected spelling.""" + directories = (base, *base.parents) + if len(directories) > 256: + raise codex_storage_refused("storage_config_unverifiable") + candidates = [directory / ".codex" / "config.toml" for directory in directories] + selected = root or Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") + known = [selected / "config.toml", *codex_system_config_paths()] + custom = False + for path in known: + data = _read_codex_config_bytes(path) + if data is not None: + try: + custom |= _codex_config_has_storage(data.decode("utf-8", errors="strict"), "project_root_markers") + except (ValueError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + try: + values = (codex_managed_preference_values(forced_only=False) if include_ordinary_preferences + else codex_managed_preference_values()) + for encoded in values: + if not isinstance(encoded, str) or len(encoded) > MAX_CODEX_CONFIG_BYTES * 2: + raise ValueError("unbounded preference") + data = base64.b64decode(encoded.strip(), validate=True) + if len(data) > MAX_CODEX_CONFIG_BYTES: + raise ValueError("unbounded preference") + custom |= _codex_config_has_storage(data.decode("utf-8", errors="strict"), "project_root_markers") + except (AttributeError, OSError, ValueError, TypeError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + if custom: + return candidates + for index, directory in enumerate(directories): + try: + marker = directory / ".git" + info = marker.lstat() + if stat.S_ISLNK(info.st_mode): + raise codex_storage_refused("storage_config_unverifiable") + head_present = False + if stat.S_ISDIR(info.st_mode): + try: + (marker / "HEAD").stat() + head_present = True + except (FileNotFoundError, NotADirectoryError): + pass + # Native accepts any existing non-directory marker by metadata; + # never open FIFO/device markers or silently scan less than native. + if not stat.S_ISDIR(info.st_mode) or head_present: + return candidates[:index + 1] + except (FileNotFoundError, NotADirectoryError): + continue + except OSError as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + return candidates[:1] + + +def check_codex_storage(root: Path, *, cwd: Path | None = None, + include_ordinary_preferences: bool = False) -> None: + """Refuse known storage relocation before any effect. + + Covered native layers (codex-cli 0.162.1, rust-v0.162.1 092d3acd): an + inherited CODEX_SQLITE_HOME, this home's config, system config and + requirements, macOS managed profiles and, when `cwd` is given, the + project layers of a process started there. Passing is not an attestation + of effective storage: package defaults, enterprise cloud bundles and a + running shared app-server daemon (whose QueueStore keeps the database + chosen at its startup, while config/read rereads current disk layers) are + not inspected. Message bodies, accounts and transcripts are never read. + Version-selected queue paths additionally check ordinary macOS values + when the reviewed 0.162.1 forced-only loader has not been established. + """ + for name, value in os.environ.items(): + match = name.upper() == "CODEX_SQLITE_HOME" if IS_WINDOWS else name == "CODEX_SQLITE_HOME" + if match and value.strip(): + raise codex_storage_refused("sqlite_environment_override") + _refuse_configured_storage(root / "config.toml", "sqlite_home_configuration") + for path in codex_system_config_paths(): + _refuse_configured_storage(path, "sqlite_home_system_configuration") + _refuse_managed_preference_storage(include_ordinary_preferences=include_ordinary_preferences) + if cwd is not None: + _refuse_codex_project_paths(root, codex_project_config_paths(cwd, root=root, + include_ordinary_preferences=include_ordinary_preferences)) + + +def _refuse_codex_project_paths(root: Path, paths: list[Path]) -> None: + try: + selected_home = os.path.normcase(str(root.resolve())) + seen = set() + for path in paths: + project_home = os.path.normcase(str(path.parent.resolve())) + if project_home != selected_home and project_home not in seen: + seen.add(project_home) + _refuse_configured_storage(path, "sqlite_home_project_configuration") + except (OSError, RuntimeError, ValueError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_process_environment(root: Path, native_home: str | None = None, *, + cwd: Path | None = None, include_ordinary_preferences: bool = False) -> dict: + check_codex_storage(root, cwd=cwd, include_ordinary_preferences=include_ordinary_preferences) env = {name: value for name, value in os.environ.items() if (name.upper() if IS_WINDOWS else name) not in ("CODEX_HOME", "CODEX_SQLITE_HOME")} env["CODEX_HOME"] = native_home or str(root) + if cwd is not None and not IS_WINDOWS: + # Keep a shell-style PWD consistent with the inspected project layers. + env["PWD"] = str(codex_physical_cwd(cwd)) return env +def codex_preferences_for_version(version: str | None) -> bool: + """Unknown loader semantics conservatively include ordinary preferences. + + Only the exact reviewed 0.162.1 loader is known to require forced values; + do not infer the same semantics for an older, future or vendor build. + """ + return version != "codex-cli 0.162.1" + + +def codex_queue_preference_policy(executable: str, root: Path, queue_cwd: Path, + native_home: str | None = None) -> bool: + """One bounded, diagnostic-only macOS version query for this queue plan. + + Reuse the owned-process primitive (1 MiB stdout/64 KiB stderr caps), with + three seconds for the query and two for cleanup. Unrecognized/unavailable + output selects a conservative preference scan, not a version refusal. + Output and diagnostics are never reflected. The helper is called only + after the generated runtime has loaded all of its source segments. + """ + if sys.platform != "darwin": + return False + env = codex_process_environment(root, native_home, cwd=queue_cwd) + now = time.monotonic() + done = handoff_stream_child([executable, "--version"], b"", now + 3, now + 5, + time.monotonic, env=env, cwd=queue_cwd) + if done.interrupted: + raise KeyboardInterrupt + if done.cleanup_failed or done.spawned is None: + raise codex_storage_refused("storage_config_unverifiable") + version = None + if not done.reason and done.returncode == 0 and not done.stdout_overflow and len(done.stdout) <= 4096: + try: + version = done.stdout.decode("utf-8", errors="strict").strip() + except UnicodeError: + pass + return codex_preferences_for_version(version) + + +def _codex_thread_directory(root: Path, thread_id: str) -> Path | None: + """The saved thread directory (metadata only), or None when it has none. + + The consuming writer normally started there, so its project layers can + have selected storage that differs from the inspected home. Unreadable or + malformed metadata refuses; a missing row or a directory that no longer + exists has no layers to inspect. + """ + try: + with contextlib.closing(sqlite3.connect((root / "state_5.sqlite").as_uri() + "?mode=ro", + uri=True, timeout=3)) as conn: + conn.execute("PRAGMA query_only=ON") + row = conn.execute("SELECT cwd FROM threads WHERE id=?", (thread_id,)).fetchone() + except sqlite3.Error as exc: + raise codex_storage_refused("consumer_directory_unverifiable") from exc + if row is None or row[0] is None or row[0] == "": + return None + if not isinstance(row[0], str) or "\0" in row[0] or not Path(row[0]).is_absolute(): + raise codex_storage_refused("consumer_directory_unverifiable") + path = Path(row[0]) + try: + return path if stat.S_ISDIR(path.stat().st_mode) else None + except (FileNotFoundError, NotADirectoryError): + return None + except OSError as exc: + raise codex_storage_refused("consumer_directory_unverifiable") from exc + + +def codex_thread_cwd(root: Path, thread_id: str) -> Path | None: + path = _codex_thread_directory(root, thread_id) + return None if path is None else codex_physical_cwd(path) + + +def check_codex_consumer_storage(root: Path, thread_id: str, *, native_home: str | None = None, + include_ordinary_preferences: bool = False) -> None: + if native_home and not IS_WINDOWS: + # A translated Windows receiver has Windows cwd metadata. POSIX + # paths cannot inspect its configuration; preserve the explicitly + # unverified legacy route rather than classifying that cwd as invalid. + return + saved = _codex_thread_directory(root, thread_id) + if saved is not None: + cwd = codex_physical_cwd(saved) + check_codex_storage(root, cwd=cwd, include_ordinary_preferences=include_ordinary_preferences) + lexical = Path(os.path.abspath(saved)) + if lexical != cwd: + # A running consumer may have selected explicit lexical layers; + # check both spellings with native-style boundaries, not every + # lexical ancestor. This can conservatively over-refuse an alias. + _refuse_codex_project_paths(root, _codex_project_config_paths_from(lexical, root=root, + include_ordinary_preferences=include_ordinary_preferences)) + + +def codex_queue_cwd(root: Path, native_home: str | None) -> Path: + """Working directory whose project config layers the queue process loads. + + Native queue resolves project layers from its own working directory, so + pin it to the inspected home. A translated native home (WSL relay receiver + to native Windows) keeps the inherited directory. Storage verification is + NOT supported for that path: this POSIX process cannot see the Windows + home spelling, case rules, environment, or system/managed/project layers, + so only POSIX-visible layers are checked and the submission contract is + otherwise unchanged. + """ + return Path(os.getcwd()) if native_home else codex_physical_cwd(root) + + +def codex_queue_storage_preflight(args: argparse.Namespace, root: Path, thread_id: str) -> Path: + """Initial home, cwd and consumer checks for the selected queue plan. + + This helper starts no child. Ordinary macOS callers subsequently run the + bounded version query and apply its preference policy, including during + dry-run; dry-run never submits a queue item. Real submission repeats the + version-selected checks before its effect. Wake runs this initial guard + before reserving a budget, but later changes can still refuse after a + reservation. These checks do not remove the configuration-read time gap. + Returns the physical queue cwd (except the unverified translated route). + """ + queue_cwd = codex_queue_cwd(root, getattr(args, "codex_native_home", None)) + include_ordinary_preferences = bool(getattr(args, "wake", False)) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=getattr(args, "codex_native_home", None), + include_ordinary_preferences=include_ordinary_preferences) + return queue_cwd + + def codex_home(args: argparse.Namespace) -> Path: return Path(getattr(args, "codex_home", None) or os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser().resolve() @@ -488,11 +912,15 @@ def collect_codex_listing(args: argparse.Namespace) -> dict: homes, errors = codex_listing_candidates(args) sessions = [] diagnostics = [] + # Listing remains metadata-only: no executable/version query, even when + # --codex-bin is supplied. Never trust a caller's private send-policy flag. + include_ordinary_preferences = sys.platform == "darwin" for candidate in homes: item = {key: value for key, value in candidate.items() if key != "required"} db = Path(item["stateDb"]) try: - check_codex_storage(Path(item["codexHome"])) + _check_codex_discovery_storage(Path(item["codexHome"]), + include_ordinary_preferences=include_ordinary_preferences) try: mode = db.stat().st_mode except (FileNotFoundError, NotADirectoryError): @@ -508,6 +936,7 @@ def collect_codex_listing(args: argparse.Namespace) -> dict: continue home_args = argparse.Namespace(**vars(args)) home_args.codex_home = item["codexHome"] + home_args._codex_discovery_include_ordinary_preferences = include_ordinary_preferences rows = discover_codex(home_args) if any(not isinstance(row["updatedAt"], (int, float)) or not isinstance(row["id"], str) or not row["id"] for row in rows): @@ -1123,7 +1552,9 @@ def codex_executable(args: argparse.Namespace) -> str: def discover_codex(args: argparse.Namespace) -> list[dict]: """Experimental saved-session discovery; never write Codex's internal DB.""" root = codex_home(args) - check_codex_storage(root) + policy = getattr(args, "_codex_discovery_include_ordinary_preferences", None) + include_ordinary_preferences = policy if type(policy) is bool else sys.platform == "darwin" + _check_codex_discovery_storage(root, include_ordinary_preferences=include_ordinary_preferences) db = root / "state_5.sqlite" if not db.is_file(): raise CcPeerError(f"Codex state_5.sqlite not found in {root}; check --codex-home (tested with CLI 0.154.0)") @@ -1185,7 +1616,14 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "consumptionConfirmed": False}) selected = codex_home(args) root, home_resolution = resolve_codex_home(args, selected, thread_id) - check_codex_storage(root) + native_home = getattr(args, "codex_native_home", None) + # Dry-run applies the same storage boundary as the real submission. + queue_cwd = codex_queue_storage_preflight(args, root, thread_id) + include_ordinary_preferences = (True if getattr(args, "wake", False) else + codex_queue_preference_policy(executable, root, queue_cwd, native_home)) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) result = {"ok": True, "target": {"agent": "codex", "id": thread_id}, "chars": len(text), "dryRun": args.dry_run, "codexHome": str(root), "submitted": not args.dry_run, @@ -1193,7 +1631,8 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "status": "validated" if args.dry_run else "queued", "codexHomeResolution": home_resolution} if args.dry_run: - discovery_args = argparse.Namespace(codex_home=str(root), all=True) + discovery_args = argparse.Namespace(codex_home=str(root), all=True, + _codex_discovery_include_ordinary_preferences=include_ordinary_preferences) if not any(s["id"] == thread_id for s in discover_codex(discovery_args)): raise NoTargetError(f"No saved Codex thread {thread_id} in {root}") return result @@ -1202,11 +1641,13 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: # mounted POSIX path, but the native codex.exe process needs the Windows # spelling of that same home. This private adapter value is derived from # operator policy; it is never accepted from a paired request. - process_home = getattr(args, "codex_native_home", None) or str(root) - env = codex_process_environment(root, process_home) + process_home = native_home or str(root) + env = codex_process_environment(root, process_home, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) try: # Keep leading dashes inside the option value, including with --no-from. - done = subprocess.run(argv, + done = subprocess.run(argv, cwd=None if native_home else str(queue_cwd), env=env, capture_output=True, encoding="utf-8", errors="replace", timeout=CODEX_QUEUE_TIMEOUT) except subprocess.TimeoutExpired as exc: @@ -1247,6 +1688,9 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: raise wake_refused("version_unavailable", "Cannot establish native Codex wake support") from exc if version not in CODEX_WAKE_VERSIONS: raise wake_refused("unsupported_version", f"Codex wake is not validated for {version}") + # Validated 0.154.0 wake always includes ordinary CF preferences. Ordinary + # queue paths select their own version policy; this does not expand wake. + check_codex_storage(root, include_ordinary_preferences=True) try: with contextlib.closing(sqlite3.connect((root / "state_5.sqlite").as_uri() + "?mode=ro", uri=True, timeout=3)) as conn: conn.execute("PRAGMA query_only=ON") @@ -1260,6 +1704,9 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: raise wake_refused("archived_thread", "Archived Codex threads cannot be woken") if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir(): raise wake_refused("cwd_unavailable", "Original Codex working directory is unavailable") + cwd = str(codex_physical_cwd(Path(cwd))) + # The wake app-server starts in the thread's directory and loads its layers. + check_codex_storage(root, cwd=Path(cwd), include_ordinary_preferences=True) if not isinstance(rollout, str) or not Path(rollout).is_file() or Path(rollout).stat().st_size == 0: raise wake_refused("uninitialized_thread", "Codex wake requires an initialized rollout") writer = inspect_codex_writer(root, thread_id) @@ -1344,6 +1791,7 @@ def signal_group(number): def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float, *, wake_context: dict | None = None) -> dict: """Resume one thread, await one turn, and own/clean up the native process.""" + cwd = str(codex_physical_cwd(Path(cwd))) process = None def cancelled(signum, frame): raise KeyboardInterrupt @@ -1356,7 +1804,7 @@ def cancelled(signum, frame): # into CLI JSON. Only lifecycle states and bounded error text escape. with tempfile.TemporaryFile() as diagnostics: process = subprocess.Popen([executable, "app-server"], cwd=cwd, - env={**codex_process_environment(root), + env={**codex_process_environment(root, cwd=Path(cwd), include_ordinary_preferences=True), **(wake_chain_environment(wake_context) if wake_context else {})}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostics, start_new_session=True) selector = selectors.DefaultSelector() @@ -1464,6 +1912,8 @@ def queue_codex(args: argparse.Namespace, text: str) -> dict: # A known active writer only receives the existing queue submission; # it does not start another native process or consume an activation. initially_active = preflight.get("writer", {}).get("activity") == "live_writer" + # Storage refusals must not consume a wake reservation. + codex_queue_storage_preflight(selected, root, thread_id) if not initially_active: wake_target_rate_reserve(guard_fd) result = _queue_codex(selected, text) @@ -6159,7 +6609,7 @@ def handoff_group_zombies_only(pid, native_total): return valid -def handoff_stream_child(argv, source_bytes, cutoff, total, clock, *, env=None): +def handoff_stream_child(argv, source_bytes, cutoff, total, clock, *, env=None, cwd=None): """Stream trusted source on stdin and drain both output pipes concurrently. ``cutoff`` and ``total`` use the caller's original clock, including its @@ -6308,7 +6758,7 @@ def read_stream(key): return result(False) construction_started = True process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, env=env, close_fds=True, + stderr=subprocess.PIPE, env=env, cwd=cwd, close_fds=True, start_new_session=True) selector = selectors.DefaultSelector() for stream, events, kind in ((process.stdin, selectors.EVENT_WRITE, "stdin"), @@ -7546,11 +7996,11 @@ def handoff_native_context(args): return context -def handoff_child(argv, cutoff, total, env=None): +def handoff_child(argv, cutoff, total, env=None, *, cwd=None): """Use the shared owned-process primitive; never inspect a transcript.""" if IS_WINDOWS or handoff_now() >= cutoff: raise handoff_error("deadline_before_effect") - done = handoff_stream_child(argv, b"", cutoff, total, handoff_now, env=env) + done = handoff_stream_child(argv, b"", cutoff, total, handoff_now, env=env, cwd=cwd) reasons = {"process_deadline": "native_deadline", "stderr_capacity": "native_diagnostic_capacity", "process_not_started": "native_process_failed", @@ -7566,10 +8016,15 @@ def handoff_codex_context(args, binding, cutoff, total): root, resolution = args._handoff_codex_selection if str(root) != binding["home"]: raise handoff_error("native_context_changed") + # Inspect the ordinary queue's execution plan before any native child or + # attempted intent. Prepared ledger history may already exist here. + queue_cwd = codex_queue_storage_preflight(args, root, binding["target"]) + native_home = getattr(args, "codex_native_home", None) executable = codex_executable(args) # A bounded harmless version query proves the selected queue executable is # callable. It does not certify all native versions or a writer generation. - probe = handoff_child([executable, "--version"], cutoff, total, codex_process_environment(root)) + probe = handoff_child([executable, "--version"], cutoff, total, + codex_process_environment(root, native_home, cwd=queue_cwd), cwd=queue_cwd) if probe["interrupted"]: raise KeyboardInterrupt try: @@ -7578,23 +8033,32 @@ def handoff_codex_context(args, binding, cutoff, total): raise handoff_error("native_version_unavailable") from exc if probe["reason"] or probe["returncode"] not in (0, -signal.SIGKILL) or re.fullmatch(r"codex-cli [0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?", version) is None: raise handoff_error("native_version_unavailable") + include_ordinary_preferences = codex_preferences_for_version(version) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, binding["target"], native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) revalidate_codex_home(root, binding["target"], resolution) - return root, resolution, executable + return root, resolution, executable, queue_cwd, include_ordinary_preferences def handoff_codex_submit(args, text, binding, context, cutoff, total): - root, resolution, executable = context + root, resolution, executable, queue_cwd, include_ordinary_preferences = context check_codex_message(text) # Reuse the original selected-home evidence, not a fresh auto-selection # after durable intent. This remains a pre-queue guard, not atomic identity. try: revalidate_codex_home(root, binding["target"], resolution) + native_home = getattr(args, "codex_native_home", None) + env = codex_process_environment(root, native_home, cwd=queue_cwd, + include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, binding["target"], native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) except CcPeerError as exc: raise CcPeerError("Codex handoff refused before queue", {"status": "refused", "retryAllowed": False}) from exc if handoff_now() >= cutoff: raise CcPeerError("Codex handoff refused before queue", {"status": "refused", "retryAllowed": False}) - env = codex_process_environment(root) - done = handoff_child(codex_queue_argv(executable, binding["target"], text), cutoff, total, env) + done = handoff_child(codex_queue_argv(executable, binding["target"], text), cutoff, total, env, + cwd=queue_cwd) # Overflow is never salvaged from an apparently valid retained prefix. if done["reason"] == "native_output_capacity": if done["interrupted"]: @@ -7765,7 +8229,7 @@ def cmd_handoff_send(args): check_message(text, remote=False) if adapter.name == "codex": check_codex_message(text) - root, resolution, _ = codex_context + root, resolution, _, _, _ = codex_context # Size a private prospective profile without claiming submission. # Reserve escaped queue-ID space before the only queue invocation. prospective = {"ok": True, "target": {"agent": "codex", "id": binding["target"]}, "chars": len(text), diff --git a/session_peer_core/codex.py b/session_peer_core/codex.py index 261239ff..861539ae 100644 --- a/session_peer_core/codex.py +++ b/session_peer_core/codex.py @@ -16,6 +16,36 @@ def codex_storage_refused(reason: str) -> CcPeerError: "retryAllowed": False, "reason": reason}) +def _check_codex_discovery_storage(root: Path, *, include_ordinary_preferences: bool) -> None: + """Metadata-only discovery cannot verify the selected native loader. + + Public listing conservatively checks ordinary macOS preferences without + launching a version child. A send dry-run can reuse its already established + preference policy privately; it must not run a second version query here. + """ + try: + check_codex_storage(root, include_ordinary_preferences=include_ordinary_preferences) + except CcPeerError as exc: + reason = exc.details.get("reason") + if (sys.platform == "darwin" and include_ordinary_preferences and + reason in ("sqlite_home_managed_configuration", "storage_config_unverifiable")): + cause = ("macOS preference values contain an unsupported sqlite_home setting. " + if reason == "sqlite_home_managed_configuration" else + "A known storage configuration layer could not be classified; " + "this need not originate in preferences. ") + raise CcPeerError( + "Cannot establish the selected Codex SQLite storage: " + reason + ". " + cause + + "This discovery step performs no additional native version query. Standalone metadata-only " + "listing checks ordinary as well as forced com.openai.codex preferences without querying " + "Codex; it can conservatively refuse values ignored by a 0.162.1 configuration. " + "Ask the operator to review known configuration layers, including effective com.openai.codex " + "config_toml_base64 and requirements_toml_base64 preferences, and ensure the intended home and storage. " + "--codex-home alone does not resolve " + "global preferences. Do not automatically remove preferences or switch databases. Nothing submitted.", + dict(exc.details)) from exc + raise + + def _codex_config_tokens(text: str) -> list[tuple[str, str]]: """Bounded lexical classification, NOT a general TOML parser. @@ -101,7 +131,7 @@ def unicode_escape(match): return keys -def _codex_config_has_storage(text: str) -> bool: +def _codex_config_has_storage(text: str, root_key: str = "sqlite_home") -> bool: table = [] statement = [] brackets = [] @@ -114,14 +144,14 @@ def _codex_config_has_storage(text: str) -> bool: if [item[0] for item in statement[-count:]] != ["]"] * count: raise ValueError("unclassifiable configuration table") table = _codex_config_key(statement[count:-count]) - if table[0] == "sqlite_home": + if table[0] == root_key: return True else: split = next((i for i, token in enumerate(statement) if token[0] == "="), -1) if split <= 0 or split == len(statement) - 1: raise ValueError("unclassifiable configuration assignment") key = _codex_config_key(statement[:split]) - if not table and key[0] == "sqlite_home": + if not table and key[0] == root_key: return True statement = [] continue @@ -137,23 +167,18 @@ def _codex_config_has_storage(text: str) -> bool: return False -def check_codex_storage(root: Path) -> None: - """Reject known relocation without reading native accounts or transcripts. +def _read_codex_config_bytes(path: Path, unreadable: str = "storage_config_unreadable") -> bytes | None: + """Return one bounded regular file, None when absent, or refuse. - This checks inherited SQLite overrides and this home's config only. It does - not pretend to resolve native project/system/managed requirement layers. + The file is pinned by device/inode and size/mtime across the read so a + concurrent replacement fails closed instead of being half-classified. """ - for name, value in os.environ.items(): - match = name.upper() == "CODEX_SQLITE_HOME" if IS_WINDOWS else name == "CODEX_SQLITE_HOME" - if match and value.strip(): - raise codex_storage_refused("sqlite_environment_override") - path = root / "config.toml" try: before = path.lstat() - except FileNotFoundError: - return + except (FileNotFoundError, NotADirectoryError): + return None except OSError as exc: - raise codex_storage_refused("storage_config_unreadable") from exc + raise codex_storage_refused(unreadable) from exc descriptor = None try: if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_CODEX_CONFIG_BYTES: @@ -175,24 +200,423 @@ def check_codex_storage(root: Path) -> None: if (len(data) > MAX_CODEX_CONFIG_BYTES or snapshot(before) != snapshot(after) or snapshot(current) != snapshot(after)): raise ValueError("configuration file changed or exceeded limit") - relocated = _codex_config_has_storage(data.decode("utf-8", errors="strict")) - except (OSError, ValueError, UnicodeError, RecursionError) as exc: + return data + except (OSError, ValueError) as exc: raise codex_storage_refused("storage_config_unverifiable") from exc finally: if descriptor is not None: os.close(descriptor) - if relocated: - raise codex_storage_refused("sqlite_home_configuration") -def codex_process_environment(root: Path, native_home: str | None = None) -> dict: - check_codex_storage(root) +def _codex_config_text_has_storage(data: bytes) -> bool: + try: + return _codex_config_has_storage(data.decode("utf-8", errors="strict")) + except (ValueError, UnicodeError, RecursionError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def _refuse_configured_storage(path: Path, reason: str) -> None: + data = _read_codex_config_bytes(path) + if data is not None and _codex_config_text_has_storage(data): + raise codex_storage_refused(reason) + + +def codex_windows_program_data() -> str: + """Native Known Folder lookup; never trust the ProgramData environment.""" + import ctypes + class GUID(ctypes.Structure): + _fields_ = [("Data1", ctypes.c_uint32), ("Data2", ctypes.c_uint16), + ("Data3", ctypes.c_uint16), ("Data4", ctypes.c_ubyte * 8)] + pointer = ctypes.c_void_p() + free = None + lookup_succeeded = False + try: + shell = ctypes.WinDLL("shell32", use_last_error=True) + ole = ctypes.WinDLL("ole32", use_last_error=True) + lookup = shell.SHGetKnownFolderPath + lookup.argtypes = [ctypes.POINTER(GUID), ctypes.c_uint32, ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p)] + lookup.restype = ctypes.c_int32 + free = ole.CoTaskMemFree + free.argtypes = [ctypes.c_void_p] + free.restype = None + identifier = GUID(0x62AB5D82, 0xFDC1, 0x4DC3, + (ctypes.c_ubyte * 8)(0xA9, 0xDD, 0x07, 0x0D, 0x1D, 0x49, 0x5D, 0x97)) + if lookup(ctypes.byref(identifier), 0, None, ctypes.byref(pointer)) != 0 or not pointer.value: + return r"C:\ProgramData" + lookup_succeeded = True + # Windows WCHAR is UTF-16, independently of the host's wchar_t width. + units = ctypes.cast(pointer, ctypes.POINTER(ctypes.c_uint16)) + encoded = bytearray() + for index in range(32768): + unit = units[index] + if unit == 0: + # Match native OsString::from_wide without substituting another + # directory when a Windows path contains an unpaired surrogate. + value = encoded.decode("utf-16-le", errors="surrogatepass") + import ntpath + if not value or not ntpath.splitdrive(value)[0] or not ntpath.isabs(value): + raise ValueError("invalid native directory") + return value + encoded.extend(unit.to_bytes(2, "little")) + raise ValueError("unbounded native directory") + except (AttributeError, OSError, ValueError, UnicodeError) as exc: + if lookup_succeeded: + raise codex_storage_refused("storage_config_unverifiable") from exc + finally: + if pointer.value and free is not None: + free(pointer) + return r"C:\ProgramData" + + +def codex_system_config_paths() -> list[Path]: + """System config and requirements layers that native Codex 0.162.1 reads. + + Requirements (exact `sqlite_home`) outrank every config layer. Unix still + loads the legacy managed_config.toml as requirements. + """ + if IS_WINDOWS: + base = Path(codex_windows_program_data()) / "OpenAI" / "Codex" + return [base / "config.toml", base / "requirements.toml"] + base = Path("/etc/codex") + return [base / "config.toml", base / "managed_config.toml", base / "requirements.toml"] + + +CODEX_MANAGED_PREFERENCES_DOMAIN = "com.openai.codex" +CODEX_MANAGED_PREFERENCES_KEYS = ("config_toml_base64", "requirements_toml_base64") + + +def codex_managed_preference_values(*, forced_only: bool = True) -> list[str]: + """Read native preferences, releasing owned CF values. + + Codex 0.162.1 accepts only forced values. The supported wake version, + 0.154.0, used CopyAppValue without that gate, including ordinary values. + """ + if sys.platform != "darwin": + return [] + import ctypes + cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") + def api(name, args, result): + function = getattr(cf, name) + function.argtypes, function.restype = args, result + return function + ptr = ctypes.c_void_p + create = api("CFStringCreateWithCString", [ptr, ctypes.c_char_p, ctypes.c_uint32], ptr) + release = api("CFRelease", [ptr], None) + sync = api("CFPreferencesAppSynchronize", [ptr], ctypes.c_ubyte) + forced = api("CFPreferencesAppValueIsForced", [ptr, ptr], ctypes.c_ubyte) + copy = api("CFPreferencesCopyAppValue", [ptr, ptr], ptr) + type_id = api("CFGetTypeID", [ptr], ctypes.c_ulong) + string_type = api("CFStringGetTypeID", [], ctypes.c_ulong) + extract = api("CFStringGetCString", [ptr, ptr, ctypes.c_long, ctypes.c_uint32], ctypes.c_ubyte) + owned = [] + try: + domain = create(None, CODEX_MANAGED_PREFERENCES_DOMAIN.encode("ascii"), 0x08000100) + if not domain: + raise ValueError("missing domain") + owned.append(domain) + if not sync(domain): + raise ValueError("preference synchronization failed") + values = [] + for name in CODEX_MANAGED_PREFERENCES_KEYS: + key = create(None, name.encode("ascii"), 0x08000100) + if not key: + raise ValueError("missing key") + owned.append(key) + if forced_only and not forced(key, domain): + continue + value = copy(key, domain) + if not value: + continue + try: + if forced_only and not forced(key, domain): + continue + if type_id(value) != string_type(): + raise ValueError("non-string preference") + buffer = ctypes.create_string_buffer(MAX_CODEX_CONFIG_BYTES * 2 + 1) + if not extract(value, buffer, len(buffer), 0x08000100): + raise ValueError("unbounded preference") + values.append(buffer.value.decode("utf-8", errors="strict")) + finally: + release(value) + return values + finally: + for value in reversed(owned): + release(value) + + +def _refuse_managed_preference_storage(*, include_ordinary_preferences: bool = False) -> None: + try: + values = (codex_managed_preference_values(forced_only=False) if include_ordinary_preferences + else codex_managed_preference_values()) + for encoded in values: + if not isinstance(encoded, str) or len(encoded) > MAX_CODEX_CONFIG_BYTES * 2: + raise ValueError("unbounded preference") + decoded = base64.b64decode(encoded.strip(), validate=True) + if len(decoded) > MAX_CODEX_CONFIG_BYTES: + raise ValueError("unbounded preference") + if _codex_config_text_has_storage(decoded): + raise codex_storage_refused("sqlite_home_managed_configuration") + except CcPeerError: + raise + except (AttributeError, OSError, ValueError, TypeError, OverflowError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_physical_cwd(cwd: Path) -> Path: + """The existing directory chdir will reach, not its lexical alias.""" + try: + physical = Path(cwd).resolve(strict=True) + if not physical.is_dir(): + raise ValueError("not a directory") + return physical + except (OSError, RuntimeError, ValueError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_project_config_paths(cwd: Path, *, root: Path | None = None, + include_ordinary_preferences: bool = False) -> list[Path]: + """Native cwd/tree/repo project layers for a process started in `cwd`. + + Default markers stop at the closest .git root; without a marker only cwd + applies. A custom marker in any known layer retains a conservative superset. + """ + return _codex_project_config_paths_from(codex_physical_cwd(cwd), root=root, + include_ordinary_preferences=include_ordinary_preferences) + + +def _codex_project_config_paths_from(base: Path, *, root: Path | None = None, + include_ordinary_preferences: bool = False) -> list[Path]: + """Apply the same marker boundary to an already selected spelling.""" + directories = (base, *base.parents) + if len(directories) > 256: + raise codex_storage_refused("storage_config_unverifiable") + candidates = [directory / ".codex" / "config.toml" for directory in directories] + selected = root or Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") + known = [selected / "config.toml", *codex_system_config_paths()] + custom = False + for path in known: + data = _read_codex_config_bytes(path) + if data is not None: + try: + custom |= _codex_config_has_storage(data.decode("utf-8", errors="strict"), "project_root_markers") + except (ValueError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + try: + values = (codex_managed_preference_values(forced_only=False) if include_ordinary_preferences + else codex_managed_preference_values()) + for encoded in values: + if not isinstance(encoded, str) or len(encoded) > MAX_CODEX_CONFIG_BYTES * 2: + raise ValueError("unbounded preference") + data = base64.b64decode(encoded.strip(), validate=True) + if len(data) > MAX_CODEX_CONFIG_BYTES: + raise ValueError("unbounded preference") + custom |= _codex_config_has_storage(data.decode("utf-8", errors="strict"), "project_root_markers") + except (AttributeError, OSError, ValueError, TypeError, UnicodeError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + if custom: + return candidates + for index, directory in enumerate(directories): + try: + marker = directory / ".git" + info = marker.lstat() + if stat.S_ISLNK(info.st_mode): + raise codex_storage_refused("storage_config_unverifiable") + head_present = False + if stat.S_ISDIR(info.st_mode): + try: + (marker / "HEAD").stat() + head_present = True + except (FileNotFoundError, NotADirectoryError): + pass + # Native accepts any existing non-directory marker by metadata; + # never open FIFO/device markers or silently scan less than native. + if not stat.S_ISDIR(info.st_mode) or head_present: + return candidates[:index + 1] + except (FileNotFoundError, NotADirectoryError): + continue + except OSError as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + return candidates[:1] + + +def check_codex_storage(root: Path, *, cwd: Path | None = None, + include_ordinary_preferences: bool = False) -> None: + """Refuse known storage relocation before any effect. + + Covered native layers (codex-cli 0.162.1, rust-v0.162.1 092d3acd): an + inherited CODEX_SQLITE_HOME, this home's config, system config and + requirements, macOS managed profiles and, when `cwd` is given, the + project layers of a process started there. Passing is not an attestation + of effective storage: package defaults, enterprise cloud bundles and a + running shared app-server daemon (whose QueueStore keeps the database + chosen at its startup, while config/read rereads current disk layers) are + not inspected. Message bodies, accounts and transcripts are never read. + Version-selected queue paths additionally check ordinary macOS values + when the reviewed 0.162.1 forced-only loader has not been established. + """ + for name, value in os.environ.items(): + match = name.upper() == "CODEX_SQLITE_HOME" if IS_WINDOWS else name == "CODEX_SQLITE_HOME" + if match and value.strip(): + raise codex_storage_refused("sqlite_environment_override") + _refuse_configured_storage(root / "config.toml", "sqlite_home_configuration") + for path in codex_system_config_paths(): + _refuse_configured_storage(path, "sqlite_home_system_configuration") + _refuse_managed_preference_storage(include_ordinary_preferences=include_ordinary_preferences) + if cwd is not None: + _refuse_codex_project_paths(root, codex_project_config_paths(cwd, root=root, + include_ordinary_preferences=include_ordinary_preferences)) + + +def _refuse_codex_project_paths(root: Path, paths: list[Path]) -> None: + try: + selected_home = os.path.normcase(str(root.resolve())) + seen = set() + for path in paths: + project_home = os.path.normcase(str(path.parent.resolve())) + if project_home != selected_home and project_home not in seen: + seen.add(project_home) + _refuse_configured_storage(path, "sqlite_home_project_configuration") + except (OSError, RuntimeError, ValueError) as exc: + raise codex_storage_refused("storage_config_unverifiable") from exc + + +def codex_process_environment(root: Path, native_home: str | None = None, *, + cwd: Path | None = None, include_ordinary_preferences: bool = False) -> dict: + check_codex_storage(root, cwd=cwd, include_ordinary_preferences=include_ordinary_preferences) env = {name: value for name, value in os.environ.items() if (name.upper() if IS_WINDOWS else name) not in ("CODEX_HOME", "CODEX_SQLITE_HOME")} env["CODEX_HOME"] = native_home or str(root) + if cwd is not None and not IS_WINDOWS: + # Keep a shell-style PWD consistent with the inspected project layers. + env["PWD"] = str(codex_physical_cwd(cwd)) return env +def codex_preferences_for_version(version: str | None) -> bool: + """Unknown loader semantics conservatively include ordinary preferences. + + Only the exact reviewed 0.162.1 loader is known to require forced values; + do not infer the same semantics for an older, future or vendor build. + """ + return version != "codex-cli 0.162.1" + + +def codex_queue_preference_policy(executable: str, root: Path, queue_cwd: Path, + native_home: str | None = None) -> bool: + """One bounded, diagnostic-only macOS version query for this queue plan. + + Reuse the owned-process primitive (1 MiB stdout/64 KiB stderr caps), with + three seconds for the query and two for cleanup. Unrecognized/unavailable + output selects a conservative preference scan, not a version refusal. + Output and diagnostics are never reflected. The helper is called only + after the generated runtime has loaded all of its source segments. + """ + if sys.platform != "darwin": + return False + env = codex_process_environment(root, native_home, cwd=queue_cwd) + now = time.monotonic() + done = handoff_stream_child([executable, "--version"], b"", now + 3, now + 5, + time.monotonic, env=env, cwd=queue_cwd) + if done.interrupted: + raise KeyboardInterrupt + if done.cleanup_failed or done.spawned is None: + raise codex_storage_refused("storage_config_unverifiable") + version = None + if not done.reason and done.returncode == 0 and not done.stdout_overflow and len(done.stdout) <= 4096: + try: + version = done.stdout.decode("utf-8", errors="strict").strip() + except UnicodeError: + pass + return codex_preferences_for_version(version) + + +def _codex_thread_directory(root: Path, thread_id: str) -> Path | None: + """The saved thread directory (metadata only), or None when it has none. + + The consuming writer normally started there, so its project layers can + have selected storage that differs from the inspected home. Unreadable or + malformed metadata refuses; a missing row or a directory that no longer + exists has no layers to inspect. + """ + try: + with contextlib.closing(sqlite3.connect((root / "state_5.sqlite").as_uri() + "?mode=ro", + uri=True, timeout=3)) as conn: + conn.execute("PRAGMA query_only=ON") + row = conn.execute("SELECT cwd FROM threads WHERE id=?", (thread_id,)).fetchone() + except sqlite3.Error as exc: + raise codex_storage_refused("consumer_directory_unverifiable") from exc + if row is None or row[0] is None or row[0] == "": + return None + if not isinstance(row[0], str) or "\0" in row[0] or not Path(row[0]).is_absolute(): + raise codex_storage_refused("consumer_directory_unverifiable") + path = Path(row[0]) + try: + return path if stat.S_ISDIR(path.stat().st_mode) else None + except (FileNotFoundError, NotADirectoryError): + return None + except OSError as exc: + raise codex_storage_refused("consumer_directory_unverifiable") from exc + + +def codex_thread_cwd(root: Path, thread_id: str) -> Path | None: + path = _codex_thread_directory(root, thread_id) + return None if path is None else codex_physical_cwd(path) + + +def check_codex_consumer_storage(root: Path, thread_id: str, *, native_home: str | None = None, + include_ordinary_preferences: bool = False) -> None: + if native_home and not IS_WINDOWS: + # A translated Windows receiver has Windows cwd metadata. POSIX + # paths cannot inspect its configuration; preserve the explicitly + # unverified legacy route rather than classifying that cwd as invalid. + return + saved = _codex_thread_directory(root, thread_id) + if saved is not None: + cwd = codex_physical_cwd(saved) + check_codex_storage(root, cwd=cwd, include_ordinary_preferences=include_ordinary_preferences) + lexical = Path(os.path.abspath(saved)) + if lexical != cwd: + # A running consumer may have selected explicit lexical layers; + # check both spellings with native-style boundaries, not every + # lexical ancestor. This can conservatively over-refuse an alias. + _refuse_codex_project_paths(root, _codex_project_config_paths_from(lexical, root=root, + include_ordinary_preferences=include_ordinary_preferences)) + + +def codex_queue_cwd(root: Path, native_home: str | None) -> Path: + """Working directory whose project config layers the queue process loads. + + Native queue resolves project layers from its own working directory, so + pin it to the inspected home. A translated native home (WSL relay receiver + to native Windows) keeps the inherited directory. Storage verification is + NOT supported for that path: this POSIX process cannot see the Windows + home spelling, case rules, environment, or system/managed/project layers, + so only POSIX-visible layers are checked and the submission contract is + otherwise unchanged. + """ + return Path(os.getcwd()) if native_home else codex_physical_cwd(root) + + +def codex_queue_storage_preflight(args: argparse.Namespace, root: Path, thread_id: str) -> Path: + """Initial home, cwd and consumer checks for the selected queue plan. + + This helper starts no child. Ordinary macOS callers subsequently run the + bounded version query and apply its preference policy, including during + dry-run; dry-run never submits a queue item. Real submission repeats the + version-selected checks before its effect. Wake runs this initial guard + before reserving a budget, but later changes can still refuse after a + reservation. These checks do not remove the configuration-read time gap. + Returns the physical queue cwd (except the unverified translated route). + """ + queue_cwd = codex_queue_cwd(root, getattr(args, "codex_native_home", None)) + include_ordinary_preferences = bool(getattr(args, "wake", False)) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=getattr(args, "codex_native_home", None), + include_ordinary_preferences=include_ordinary_preferences) + return queue_cwd + + def codex_home(args: argparse.Namespace) -> Path: return Path(getattr(args, "codex_home", None) or os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser().resolve() @@ -269,11 +693,15 @@ def collect_codex_listing(args: argparse.Namespace) -> dict: homes, errors = codex_listing_candidates(args) sessions = [] diagnostics = [] + # Listing remains metadata-only: no executable/version query, even when + # --codex-bin is supplied. Never trust a caller's private send-policy flag. + include_ordinary_preferences = sys.platform == "darwin" for candidate in homes: item = {key: value for key, value in candidate.items() if key != "required"} db = Path(item["stateDb"]) try: - check_codex_storage(Path(item["codexHome"])) + _check_codex_discovery_storage(Path(item["codexHome"]), + include_ordinary_preferences=include_ordinary_preferences) try: mode = db.stat().st_mode except (FileNotFoundError, NotADirectoryError): @@ -289,6 +717,7 @@ def collect_codex_listing(args: argparse.Namespace) -> dict: continue home_args = argparse.Namespace(**vars(args)) home_args.codex_home = item["codexHome"] + home_args._codex_discovery_include_ordinary_preferences = include_ordinary_preferences rows = discover_codex(home_args) if any(not isinstance(row["updatedAt"], (int, float)) or not isinstance(row["id"], str) or not row["id"] for row in rows): @@ -904,7 +1333,9 @@ def codex_executable(args: argparse.Namespace) -> str: def discover_codex(args: argparse.Namespace) -> list[dict]: """Experimental saved-session discovery; never write Codex's internal DB.""" root = codex_home(args) - check_codex_storage(root) + policy = getattr(args, "_codex_discovery_include_ordinary_preferences", None) + include_ordinary_preferences = policy if type(policy) is bool else sys.platform == "darwin" + _check_codex_discovery_storage(root, include_ordinary_preferences=include_ordinary_preferences) db = root / "state_5.sqlite" if not db.is_file(): raise CcPeerError(f"Codex state_5.sqlite not found in {root}; check --codex-home (tested with CLI 0.154.0)") @@ -966,7 +1397,14 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "consumptionConfirmed": False}) selected = codex_home(args) root, home_resolution = resolve_codex_home(args, selected, thread_id) - check_codex_storage(root) + native_home = getattr(args, "codex_native_home", None) + # Dry-run applies the same storage boundary as the real submission. + queue_cwd = codex_queue_storage_preflight(args, root, thread_id) + include_ordinary_preferences = (True if getattr(args, "wake", False) else + codex_queue_preference_policy(executable, root, queue_cwd, native_home)) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) result = {"ok": True, "target": {"agent": "codex", "id": thread_id}, "chars": len(text), "dryRun": args.dry_run, "codexHome": str(root), "submitted": not args.dry_run, @@ -974,7 +1412,8 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: "status": "validated" if args.dry_run else "queued", "codexHomeResolution": home_resolution} if args.dry_run: - discovery_args = argparse.Namespace(codex_home=str(root), all=True) + discovery_args = argparse.Namespace(codex_home=str(root), all=True, + _codex_discovery_include_ordinary_preferences=include_ordinary_preferences) if not any(s["id"] == thread_id for s in discover_codex(discovery_args)): raise NoTargetError(f"No saved Codex thread {thread_id} in {root}") return result @@ -983,11 +1422,13 @@ def _queue_codex(args: argparse.Namespace, text: str) -> dict: # mounted POSIX path, but the native codex.exe process needs the Windows # spelling of that same home. This private adapter value is derived from # operator policy; it is never accepted from a paired request. - process_home = getattr(args, "codex_native_home", None) or str(root) - env = codex_process_environment(root, process_home) + process_home = native_home or str(root) + env = codex_process_environment(root, process_home, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, thread_id, native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) try: # Keep leading dashes inside the option value, including with --no-from. - done = subprocess.run(argv, + done = subprocess.run(argv, cwd=None if native_home else str(queue_cwd), env=env, capture_output=True, encoding="utf-8", errors="replace", timeout=CODEX_QUEUE_TIMEOUT) except subprocess.TimeoutExpired as exc: @@ -1028,6 +1469,9 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: raise wake_refused("version_unavailable", "Cannot establish native Codex wake support") from exc if version not in CODEX_WAKE_VERSIONS: raise wake_refused("unsupported_version", f"Codex wake is not validated for {version}") + # Validated 0.154.0 wake always includes ordinary CF preferences. Ordinary + # queue paths select their own version policy; this does not expand wake. + check_codex_storage(root, include_ordinary_preferences=True) try: with contextlib.closing(sqlite3.connect((root / "state_5.sqlite").as_uri() + "?mode=ro", uri=True, timeout=3)) as conn: conn.execute("PRAGMA query_only=ON") @@ -1041,6 +1485,9 @@ def codex_wake_preflight(root: Path, thread_id: str, executable: str) -> dict: raise wake_refused("archived_thread", "Archived Codex threads cannot be woken") if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir(): raise wake_refused("cwd_unavailable", "Original Codex working directory is unavailable") + cwd = str(codex_physical_cwd(Path(cwd))) + # The wake app-server starts in the thread's directory and loads its layers. + check_codex_storage(root, cwd=Path(cwd), include_ordinary_preferences=True) if not isinstance(rollout, str) or not Path(rollout).is_file() or Path(rollout).stat().st_size == 0: raise wake_refused("uninitialized_thread", "Codex wake requires an initialized rollout") writer = inspect_codex_writer(root, thread_id) @@ -1125,6 +1572,7 @@ def signal_group(number): def run_codex_wake(executable: str, root: Path, thread_id: str, cwd: str, timeout: float, *, wake_context: dict | None = None) -> dict: """Resume one thread, await one turn, and own/clean up the native process.""" + cwd = str(codex_physical_cwd(Path(cwd))) process = None def cancelled(signum, frame): raise KeyboardInterrupt @@ -1137,7 +1585,7 @@ def cancelled(signum, frame): # into CLI JSON. Only lifecycle states and bounded error text escape. with tempfile.TemporaryFile() as diagnostics: process = subprocess.Popen([executable, "app-server"], cwd=cwd, - env={**codex_process_environment(root), + env={**codex_process_environment(root, cwd=Path(cwd), include_ordinary_preferences=True), **(wake_chain_environment(wake_context) if wake_context else {})}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostics, start_new_session=True) selector = selectors.DefaultSelector() @@ -1245,6 +1693,8 @@ def queue_codex(args: argparse.Namespace, text: str) -> dict: # A known active writer only receives the existing queue submission; # it does not start another native process or consume an activation. initially_active = preflight.get("writer", {}).get("activity") == "live_writer" + # Storage refusals must not consume a wake reservation. + codex_queue_storage_preflight(selected, root, thread_id) if not initially_active: wake_target_rate_reserve(guard_fd) result = _queue_codex(selected, text) diff --git a/session_peer_core/handoff.py b/session_peer_core/handoff.py index 2c4cec53..7847a1dd 100644 --- a/session_peer_core/handoff.py +++ b/session_peer_core/handoff.py @@ -1108,11 +1108,11 @@ def handoff_native_context(args): return context -def handoff_child(argv, cutoff, total, env=None): +def handoff_child(argv, cutoff, total, env=None, *, cwd=None): """Use the shared owned-process primitive; never inspect a transcript.""" if IS_WINDOWS or handoff_now() >= cutoff: raise handoff_error("deadline_before_effect") - done = handoff_stream_child(argv, b"", cutoff, total, handoff_now, env=env) + done = handoff_stream_child(argv, b"", cutoff, total, handoff_now, env=env, cwd=cwd) reasons = {"process_deadline": "native_deadline", "stderr_capacity": "native_diagnostic_capacity", "process_not_started": "native_process_failed", @@ -1128,10 +1128,15 @@ def handoff_codex_context(args, binding, cutoff, total): root, resolution = args._handoff_codex_selection if str(root) != binding["home"]: raise handoff_error("native_context_changed") + # Inspect the ordinary queue's execution plan before any native child or + # attempted intent. Prepared ledger history may already exist here. + queue_cwd = codex_queue_storage_preflight(args, root, binding["target"]) + native_home = getattr(args, "codex_native_home", None) executable = codex_executable(args) # A bounded harmless version query proves the selected queue executable is # callable. It does not certify all native versions or a writer generation. - probe = handoff_child([executable, "--version"], cutoff, total, codex_process_environment(root)) + probe = handoff_child([executable, "--version"], cutoff, total, + codex_process_environment(root, native_home, cwd=queue_cwd), cwd=queue_cwd) if probe["interrupted"]: raise KeyboardInterrupt try: @@ -1140,23 +1145,32 @@ def handoff_codex_context(args, binding, cutoff, total): raise handoff_error("native_version_unavailable") from exc if probe["reason"] or probe["returncode"] not in (0, -signal.SIGKILL) or re.fullmatch(r"codex-cli [0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?", version) is None: raise handoff_error("native_version_unavailable") + include_ordinary_preferences = codex_preferences_for_version(version) + check_codex_storage(root, cwd=queue_cwd, include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, binding["target"], native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) revalidate_codex_home(root, binding["target"], resolution) - return root, resolution, executable + return root, resolution, executable, queue_cwd, include_ordinary_preferences def handoff_codex_submit(args, text, binding, context, cutoff, total): - root, resolution, executable = context + root, resolution, executable, queue_cwd, include_ordinary_preferences = context check_codex_message(text) # Reuse the original selected-home evidence, not a fresh auto-selection # after durable intent. This remains a pre-queue guard, not atomic identity. try: revalidate_codex_home(root, binding["target"], resolution) + native_home = getattr(args, "codex_native_home", None) + env = codex_process_environment(root, native_home, cwd=queue_cwd, + include_ordinary_preferences=include_ordinary_preferences) + check_codex_consumer_storage(root, binding["target"], native_home=native_home, + include_ordinary_preferences=include_ordinary_preferences) except CcPeerError as exc: raise CcPeerError("Codex handoff refused before queue", {"status": "refused", "retryAllowed": False}) from exc if handoff_now() >= cutoff: raise CcPeerError("Codex handoff refused before queue", {"status": "refused", "retryAllowed": False}) - env = codex_process_environment(root) - done = handoff_child(codex_queue_argv(executable, binding["target"], text), cutoff, total, env) + done = handoff_child(codex_queue_argv(executable, binding["target"], text), cutoff, total, env, + cwd=queue_cwd) # Overflow is never salvaged from an apparently valid retained prefix. if done["reason"] == "native_output_capacity": if done["interrupted"]: @@ -1327,7 +1341,7 @@ def cmd_handoff_send(args): check_message(text, remote=False) if adapter.name == "codex": check_codex_message(text) - root, resolution, _ = codex_context + root, resolution, _, _, _ = codex_context # Size a private prospective profile without claiming submission. # Reserve escaped queue-ID space before the only queue invocation. prospective = {"ok": True, "target": {"agent": "codex", "id": binding["target"]}, "chars": len(text), diff --git a/session_peer_core/handoff_process.py b/session_peer_core/handoff_process.py index 67d32f08..0920bcc5 100644 --- a/session_peer_core/handoff_process.py +++ b/session_peer_core/handoff_process.py @@ -116,7 +116,7 @@ def handoff_group_zombies_only(pid, native_total): return valid -def handoff_stream_child(argv, source_bytes, cutoff, total, clock, *, env=None): +def handoff_stream_child(argv, source_bytes, cutoff, total, clock, *, env=None, cwd=None): """Stream trusted source on stdin and drain both output pipes concurrently. ``cutoff`` and ``total`` use the caller's original clock, including its @@ -265,7 +265,7 @@ def read_stream(key): return result(False) construction_started = True process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, env=env, close_fds=True, + stderr=subprocess.PIPE, env=env, cwd=cwd, close_fds=True, start_new_session=True) selector = selectors.DefaultSelector() for stream, events, kind in ((process.stdin, selectors.EVENT_WRITE, "stdin"), diff --git a/tests/codex/support.py b/tests/codex/support.py index 13c2d22b..d5ac1fa8 100644 --- a/tests/codex/support.py +++ b/tests/codex/support.py @@ -1,4 +1,41 @@ -"""Shared deterministic identifiers for Codex test fixtures.""" +"""Shared deterministic identifiers and isolation for Codex test fixtures.""" +import os +from unittest import mock THREAD = "01900000-0000-7000-8000-000000000001" OTHER = "01900000-0000-7000-8000-000000000002" + + +def isolate_native_layers(case, project_root=None): + """Keep machine-wide and ancestor Codex configuration out of a fixture. + + System, requirements and macOS managed-profile layers are emptied; project + layers are limited to paths inside `project_root` (none when omitted), so + offline results never depend on the developer's real configuration. + """ + import session_peer as peer + real_project_paths = peer.codex_project_config_paths + real_project_paths_from = peer._codex_project_config_paths_from + base = None if project_root is None else os.path.abspath(project_root) + + def contained(cwd, **kwargs): + if base is None: + return [] + return [path for path in real_project_paths(cwd, **kwargs) + if os.path.abspath(path).startswith(base + os.sep)] + + def contained_from(cwd, **kwargs): + # Existing consumers also inspect a distinct lexical spelling through + # this private helper; isolating only the public physical path misses it. + if base is None: + return [] + return [path for path in real_project_paths_from(cwd, **kwargs) + if os.path.abspath(path).startswith(base + os.sep)] + + for patcher in (mock.patch.object(peer, "codex_system_config_paths", return_value=[]), + mock.patch.object(peer, "codex_queue_preference_policy", return_value=False), + mock.patch.object(peer, "codex_managed_preference_values", return_value=[]), + mock.patch.object(peer, "codex_project_config_paths", side_effect=contained), + mock.patch.object(peer, "_codex_project_config_paths_from", side_effect=contained_from)): + patcher.start() + case.addCleanup(patcher.stop) diff --git a/tests/codex/test_dispatch.py b/tests/codex/test_dispatch.py index 0828496d..bea3a960 100644 --- a/tests/codex/test_dispatch.py +++ b/tests/codex/test_dispatch.py @@ -15,20 +15,21 @@ import session_peer as peer -from tests.codex.support import OTHER, THREAD +from tests.codex.support import OTHER, THREAD, isolate_native_layers class Codex(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) + self.root = Path(self.directory.name).resolve() + isolate_native_layers(self, self.root) self.db = self.root / "state_5.sqlite" conn = sqlite3.connect(self.db) conn.execute("CREATE TABLE threads (id TEXT, name TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") conn.executemany("INSERT INTO threads VALUES (?,?,?,?,?,?,?)", [ - (THREAD, None, "title", "/project", 10, 0, "/missing-rollout"), - (OTHER, "named", "fallback", "/other", 20, 1, "/missing-rollout")]) + (THREAD, None, "title", str(self.root / "missing-project"), 10, 0, "/missing-rollout"), + (OTHER, "named", "fallback", str(self.root / "missing-other"), 20, 1, "/missing-rollout")]) conn.commit() conn.close() self.args = argparse.Namespace(codex_home=str(self.root), codex_bin=None, all=False, diff --git a/tests/codex/test_homes.py b/tests/codex/test_homes.py index 45d54659..cc2552d1 100644 --- a/tests/codex/test_homes.py +++ b/tests/codex/test_homes.py @@ -14,7 +14,7 @@ import session_peer as peer -from tests.codex.support import OTHER, THREAD +from tests.codex.support import OTHER, THREAD, isolate_native_layers class CodexHomes(unittest.TestCase): @@ -24,6 +24,7 @@ def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.user = Path(directory.name).resolve() + isolate_native_layers(self, self.user) self.default = self.user / ".codex" self.account = self.user / "Library/Application Support/orca/codex-accounts/account-a/home" self.make_home(self.default) @@ -46,7 +47,8 @@ def make_home(self, home, thread=THREAD, archived=0): home.mkdir(parents=True, exist_ok=True) with contextlib.closing(sqlite3.connect(home / "state_5.sqlite")) as conn: conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") - conn.execute("INSERT INTO threads VALUES (?, 'copy', '/project', 1, ?, '/missing-rollout')", (thread, archived)) + conn.execute("INSERT INTO threads VALUES (?, 'copy', ?, 1, ?, '/missing-rollout')", + (thread, str(self.user / "missing-project"), archived)) conn.commit() def send(self, *options): diff --git a/tests/codex/test_listing_storage_policy.py b/tests/codex/test_listing_storage_policy.py new file mode 100644 index 00000000..6f82a5fd --- /dev/null +++ b/tests/codex/test_listing_storage_policy.py @@ -0,0 +1,269 @@ +"""Metadata-only listing policy fixtures; no native CLI, CF or model is used.""" +import argparse +import base64 +import contextlib +import io +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex.support import THREAD, OTHER, isolate_native_layers + + +class ListingStoragePolicy(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.user = Path(temporary.name).resolve() + self.default = self.user / ".codex" + self.account = self.user / "Library/Application Support/orca/codex-accounts/account-a/home" + self.make_home(self.default, THREAD) + self.make_home(self.account, OTHER) + isolate_native_layers(self, self.user) + self.patch(mock.patch.object(Path, "home", return_value=self.user)) + self.patch(mock.patch.dict(os.environ, {}, clear=True)) + self.patch(mock.patch.object(peer.sys, "platform", "darwin")) + self.ordinary = False + self.preferences = self.patch(mock.patch.object(peer, "codex_managed_preference_values", + side_effect=self.preference_values)) + self.args = argparse.Namespace(codex_home=str(self.default), codex_bin=None, + all=False, agent="codex") + + def patch(self, patcher): + value = patcher.start() + self.addCleanup(patcher.stop) + return value + + def make_home(self, home, thread): + home.mkdir(parents=True) + with contextlib.closing(sqlite3.connect(home / "state_5.sqlite")) as conn: + conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") + conn.execute("INSERT INTO threads VALUES (?, 'fixture', ?, 1, 0, '/missing-rollout')", + (thread, str(self.user / "missing-project"))) + conn.commit() + + def preference_values(self, *, forced_only=True): + if self.ordinary and not forced_only: + return [base64.b64encode(b'sqlite_home="private-listing-location"').decode("ascii")] + return [] + + @contextlib.contextmanager + def no_native(self): + # Listing must remain usable without a CLI installation and must not + # run either a normal subprocess or the owned version-query primitive. + with mock.patch.object(peer.subprocess, "run") as run, \ + mock.patch.object(peer.subprocess, "Popen") as start, \ + mock.patch.object(peer, "codex_executable") as executable, \ + mock.patch.object(peer, "codex_queue_preference_policy") as policy, \ + mock.patch.object(peer, "handoff_stream_child") as owned: + yield + for operation in (run, start, executable, policy, owned): + operation.assert_not_called() + + def assert_diagnostic(self, value): + text = str(value) + for marker in ("metadata-only", "0.162.1", "operator", "com.openai.codex", + "config_toml_base64", "requirements_toml_base64", + "intended home and storage", "--codex-home alone", + "Do not automatically remove preferences or switch databases"): + self.assertIn(marker.lower(), text.lower()) + self.assertNotIn("private-listing-location", text) + self.assertNotIn(base64.b64encode(b'sqlite_home="private-listing-location"').decode("ascii"), text) + + def cli(self, *options): + output, errors = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors): + code = peer.main(["list", "--no-update-notice", *options]) + return code, output.getvalue(), errors.getvalue() + + def test_direct_discovery_refuses_ordinary_storage_before_opening_stale_db(self): + self.ordinary = True + # Labels represent the two loader scenarios, not a claimed executable + # version proof: metadata-only listing does not execute either binary. + for requested in ("fixture-codex-0.154.0", "fixture-codex-0.162.1"): + self.args.codex_bin = requested + with self.subTest(requested=requested), self.no_native(), \ + mock.patch.object(peer.sqlite3, "connect") as connect: + with self.assertRaises(peer.CcPeerError) as caught: + peer.discover_codex(self.args) + self.assertEqual(caught.exception.details["reason"], "sqlite_home_managed_configuration") + self.assert_diagnostic(caught.exception) + connect.assert_not_called() + + def test_collect_refuses_all_ambiguous_homes_without_database_fallback(self): + self.ordinary = True + self.args.codex_home = None + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + result = peer.collect_codex_listing(self.args) + self.assertEqual(result["sessions"], []) + self.assertEqual(result["discovery"]["status"], "error") + self.assertEqual(len(result["discovery"]["homes"]), 2) + for home in result["discovery"]["homes"]: + self.assertEqual(home["status"], "error") + self.assert_diagnostic(home["error"]) + connect.assert_not_called() + + def test_malformed_ordinary_preference_refuses_before_db_with_operator_guidance(self): + def malformed(*, forced_only=True): + return [] if forced_only else ["private-invalid-base64!"] + + with self.no_native(), \ + mock.patch.object(peer, "codex_managed_preference_values", side_effect=malformed), \ + mock.patch.object(peer.sqlite3, "connect") as connect: + with self.assertRaises(peer.CcPeerError) as caught: + peer.discover_codex(self.args) + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + self.assert_diagnostic(caught.exception) + self.assertNotIn("private-invalid-base64!", str(caught.exception)) + connect.assert_not_called() + + def test_malformed_home_configuration_does_not_misidentify_preferences_as_cause(self): + (self.default / "config.toml").write_text( + 'sqlite_home="private-listing-location\n', encoding="utf-8") + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + with self.assertRaises(peer.CcPeerError) as caught: + peer.discover_codex(self.args) + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + self.assert_diagnostic(caught.exception) + self.assertIn("A known storage configuration layer could not be classified", str(caught.exception)) + self.assertIn("this need not originate in preferences", str(caught.exception)) + self.assertNotIn("macOS preference values contain an unsupported sqlite_home setting", str(caught.exception)) + self.preferences.assert_not_called() + connect.assert_not_called() + + def test_internal_true_policy_does_not_deny_an_enclosing_version_query(self): + self.ordinary = True + # Represents an enclosing send that already established a loader policy; + # this discovery step must not imply that the enclosing probe never ran. + self.args._codex_discovery_include_ordinary_preferences = True + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + with self.assertRaises(peer.CcPeerError) as caught: + peer.discover_codex(self.args) + self.assertEqual(caught.exception.details["reason"], "sqlite_home_managed_configuration") + self.assert_diagnostic(caught.exception) + self.assertIn("macOS preference values contain an unsupported sqlite_home setting", str(caught.exception)) + self.assertIn("This discovery step performs no additional native version query", str(caught.exception)) + self.assertNotIn("Metadata-only discovery does not determine the native Codex version", str(caught.exception)) + connect.assert_not_called() + + def test_collect_does_not_accept_a_caller_supplied_internal_policy_override(self): + self.ordinary = True + self.args._codex_discovery_include_ordinary_preferences = False + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + result = peer.collect_codex_listing(self.args) + self.assertEqual(result["discovery"]["status"], "error") + self.assertEqual(result["sessions"], []) + connect.assert_not_called() + + def test_invalid_internal_discovery_policy_remains_conservative(self): + self.ordinary = True + for value in (None, 0, "false", []): + self.args._codex_discovery_include_ordinary_preferences = value + with self.subTest(kind=type(value).__name__), self.no_native(), \ + mock.patch.object(peer.sqlite3, "connect") as connect: + with self.assertRaises(peer.CcPeerError): + peer.discover_codex(self.args) + connect.assert_not_called() + + def test_cli_json_is_failure_not_empty_success(self): + self.ordinary = True + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + code, output, _ = self.cli("--agent", "codex", "--json") + result = json.loads(output) + self.assertEqual(code, 1) + self.assertIs(result["ok"], False) + self.assertEqual(result["sessions"], []) + self.assertEqual(result["discovery"]["codex"]["status"], "error") + self.assert_diagnostic(output) + connect.assert_not_called() + + def test_cli_text_explains_cause_and_operator_remedy(self): + self.ordinary = True + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect: + code, output, errors = self.cli("--agent", "codex", "--codex-home", str(self.account)) + self.assertEqual(code, 1) + self.assertIn("discovery failed", output) + self.assert_diagnostic(output + errors) + connect.assert_not_called() + + def test_explicit_codex_bin_does_not_enable_a_listing_version_probe(self): + self.ordinary = True + for output_options in (("--json",), ()): + with self.subTest(json_output=bool(output_options)), self.no_native(), \ + mock.patch.object(peer.sqlite3, "connect") as connect: + code, output, errors = self.cli("--agent", "codex", "--codex-bin", + "fixture-codex-0.162.1", *output_options) + self.assertEqual(code, 1) + self.assert_diagnostic(output + errors) + if output_options: + self.assertIs(json.loads(output)["ok"], False) + connect.assert_not_called() + + def test_failed_codex_discovery_preserves_healthy_claude_rows(self): + self.ordinary = True + row = {"pid": 4242, "name": "fixture-claude", "cwd": str(self.user), + "alive": True, "reachable": True} + with self.no_native(), mock.patch.object(peer.sqlite3, "connect") as connect, \ + mock.patch.object(peer.AGENTS, "names", return_value=["claude", "codex"]), \ + mock.patch.object(peer, "discover", return_value=[row]): + code, output, _ = self.cli("--json") + result = json.loads(output) + self.assertEqual(code, 1) + self.assertIs(result["ok"], False) + self.assertEqual([entry["agent"] for entry in result["sessions"]], ["claude"]) + self.assertEqual(result["discovery"]["claude"]["status"], "ok") + self.assertEqual(result["discovery"]["codex"]["status"], "error") + connect.assert_not_called() + + def test_no_ambiguous_preference_preserves_missing_cli_multi_home_metadata(self): + self.args.codex_home = None + with self.no_native(), mock.patch.object(peer.shutil, "which", return_value=None): + result = peer.collect_codex_listing(self.args) + self.assertEqual(result["discovery"]["status"], "ok") + self.assertEqual({row["id"] for row in result["sessions"]}, {THREAD, OTHER}) + self.assertTrue(all(home["status"] == "ok" for home in result["discovery"]["homes"])) + + def test_absent_default_home_does_not_hide_healthy_orca_metadata(self): + (self.default / "state_5.sqlite").unlink() + self.args.codex_home = None + with self.no_native(): + result = peer.collect_codex_listing(self.args) + self.assertEqual(result["discovery"]["status"], "ok") + self.assertEqual([row["id"] for row in result["sessions"]], [OTHER]) + self.assertEqual([home["status"] for home in result["discovery"]["homes"]], ["absent", "ok"]) + + def test_one_home_config_refusal_keeps_other_home_rows_when_no_global_ambiguity(self): + (self.default / "config.toml").write_text('sqlite_home="private-listing-location"\n', encoding="utf-8") + self.args.codex_home = None + with self.no_native(): + result = peer.collect_codex_listing(self.args) + self.assertEqual(result["discovery"]["status"], "error") + self.assertEqual([row["id"] for row in result["sessions"]], [OTHER]) + self.assertEqual([home["status"] for home in result["discovery"]["homes"]], ["error", "ok"]) + self.assertNotIn("private-listing-location", json.dumps(result)) + + def test_internal_queue_dry_run_reuses_its_proven_policy_without_second_probe(self): + self.ordinary = True + self.args.to = "codex:" + THREAD + self.args.dry_run = True + self.args.allow_inactive_codex_home = True + self.args.wake = False + with mock.patch.object(peer, "resolve_codex_home", return_value=(self.default, {})), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer, "codex_queue_preference_policy", return_value=False) as policy, \ + mock.patch.object(peer.subprocess, "run") as run, \ + mock.patch.object(peer.subprocess, "Popen") as start, \ + mock.patch.object(peer, "handoff_stream_child") as owned: + result = peer._queue_codex(self.args, "fixture") + self.assertEqual(result["status"], "validated") + self.assertIs(result["submitted"], False) + self.assertEqual(policy.call_count, 1) + run.assert_not_called() + start.assert_not_called() + owned.assert_not_called() + self.assertTrue(all(call.kwargs.get("forced_only", True) for call in self.preferences.call_args_list)) diff --git a/tests/codex/test_multi_home_list.py b/tests/codex/test_multi_home_list.py index 0275ff60..e6d651c2 100644 --- a/tests/codex/test_multi_home_list.py +++ b/tests/codex/test_multi_home_list.py @@ -1,6 +1,7 @@ """Multi-home inventory, partial discovery and authorization boundaries.""" import argparse import contextlib +import ctypes import io import json import os @@ -14,6 +15,7 @@ import session_peer as peer import session_peer_mcp as adapter +from tests.codex.support import isolate_native_layers THREAD = '01900000-0000-7000-8000-000000000001' @@ -23,6 +25,13 @@ def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) self.root = Path(directory.name).resolve() + # Mocking Darwin here selects an Orca inventory layout, not permission + # to load CoreFoundation on Linux/Windows or read host configuration. + isolate_native_layers(self, self.root) + platform_library = mock.patch.object(ctypes, 'CDLL', side_effect=AssertionError( + 'inventory fixtures must not load native platform libraries')) + platform_library.start() + self.addCleanup(platform_library.stop) self.default = self.root/'.codex' self.orca = self.root/'Library/Application Support/orca/codex-accounts' self.args = argparse.Namespace(codex_home=None, agent='codex', all=False) diff --git a/tests/codex/test_native_storage_platform.py b/tests/codex/test_native_storage_platform.py new file mode 100644 index 00000000..977e171d --- /dev/null +++ b/tests/codex/test_native_storage_platform.py @@ -0,0 +1,362 @@ +"""Mocked platform APIs and root discovery; no native delivery/MDM evidence.""" +import argparse +import base64 +import contextlib +import ctypes +import os +from pathlib import Path +import tempfile +import sqlite3 +import subprocess +from types import SimpleNamespace +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex.support import THREAD, isolate_native_layers + +REAL_QUEUE_PREFERENCE_POLICY = peer.codex_queue_preference_policy + + +class QueuePreferenceVersions(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name).resolve() + isolate_native_layers(self, self.root) + self.args = argparse.Namespace(to="codex:" + THREAD, codex_home=str(self.root), + codex_bin="fixture", dry_run=False, wake=False, all=False, + allow_inactive_codex_home=True) + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as db: + db.execute("CREATE TABLE threads(id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,archived INTEGER,rollout_path TEXT)") + db.execute("INSERT INTO threads VALUES(?, 'fixture', ?, 1, 0, '')", (THREAD, str(self.root))) + db.commit() + + def probe(self, stdout=b"codex-cli 0.162.1\n", **changes): + fields = dict(stdout=stdout, stderr=b"diagnostic-fixture", returncode=0, reason=None, + stdout_overflow=False, cleanup_failed=False, spawned=True, interrupted=False) + fields.update(changes) + return SimpleNamespace(**fields) + + def ordinary_values(self, *, forced_only=True): + return [] if forced_only else [base64.b64encode(b'sqlite_home="ordinary-fixture"').decode("ascii")] + + def test_policy_is_exact_reviewed_version_not_an_ordering_rule(self): + for version in ("codex-cli 0.154.0", "codex-cli 0.162.0", "codex-cli 0.162.2", "codex-cli 0.162.1-vendor", None): + with self.subTest(version=version): + self.assertTrue(peer.codex_preferences_for_version(version)) + self.assertFalse(peer.codex_preferences_for_version("codex-cli 0.162.1")) + + def test_mac_probe_uses_selected_plan_bounded_runner_and_never_echoes_output(self): + cases = ((self.probe(), False), (self.probe(b"codex-cli 0.154.0"), True), + (self.probe(b"unexpected-fixture"), True), (self.probe(b"\xff"), True), + (self.probe(returncode=1), True), (self.probe(reason="process_deadline"), True), + # Even complete reviewed-version output with abnormal exit + # uses the conservative mode, not an unproven clean query. + (self.probe(returncode=-9), True), # POSIX SIGKILL, mocked on every OS + (self.probe(stdout_overflow=True), True), (self.probe(b"x" * 4097), True)) + for done, expected in cases: + with self.subTest(expected=expected), mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(peer.time, "monotonic", return_value=10), \ + mock.patch.object(peer, "handoff_stream_child", return_value=done) as child: + result = REAL_QUEUE_PREFERENCE_POLICY("fixture", self.root, self.root) + self.assertIs(result, expected) + self.assertEqual(child.call_args.args[:4], (["fixture", "--version"], b"", 13, 15)) + self.assertEqual(child.call_args.kwargs["cwd"], self.root) + self.assertEqual(child.call_args.kwargs["env"]["CODEX_HOME"], str(self.root)) + + def test_unclean_probe_refuses_and_interruption_propagates(self): + for options in ({"cleanup_failed": True}, {"spawned": None}, {"interrupted": True}): + with self.subTest(options=options), mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(peer, "handoff_stream_child", return_value=self.probe(**options)): + with self.assertRaises(KeyboardInterrupt if options.get("interrupted") else peer.CcPeerError) as caught: + REAL_QUEUE_PREFERENCE_POLICY("fixture", self.root, self.root) + self.assertNotIn("diagnostic-fixture", str(caught.exception)) + + def test_non_mac_does_not_probe(self): + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(peer, "handoff_stream_child") as child: + self.assertFalse(REAL_QUEUE_PREFERENCE_POLICY("fixture", self.root, self.root)) + child.assert_not_called() + + def test_queue_and_dry_run_use_version_policy_without_rejecting_unknown_version(self): + for version in (b"codex-cli 0.154.0", b"codex-cli 0.162.1", b"unknown-fixture"): + for dry_run in (True, False): + self.args.dry_run = dry_run + expected_refusal = version != b"codex-cli 0.162.1" + with self.subTest(version=version, dry_run=dry_run), \ + mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(peer, "codex_queue_preference_policy", wraps=REAL_QUEUE_PREFERENCE_POLICY), \ + mock.patch.object(peer, "codex_managed_preference_values", side_effect=self.ordinary_values), \ + mock.patch.object(peer, "handoff_stream_child", return_value=self.probe(version)) as probe, \ + mock.patch.object(peer, "resolve_codex_home", return_value=(self.root, {})), \ + mock.patch.object(peer, "revalidate_codex_home"), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, + "Queued message fixture-id for thread " + THREAD + ".\n", "")) as queue: + if expected_refusal: + with self.assertRaises(peer.CcPeerError) as caught: + peer._queue_codex(self.args, "fixture") + self.assertEqual(caught.exception.details["reason"], "sqlite_home_managed_configuration") + queue.assert_not_called() + else: + result = peer._queue_codex(self.args, "fixture") + self.assertEqual(result["status"], "validated" if dry_run else "queued") + self.assertEqual(queue.call_count, 0 if dry_run else 1) + probe.assert_called_once() + # An unknown version is not itself a refusal in the absence of a + # relevant ordinary setting; no new native compatibility is claimed. + with mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(peer, "handoff_stream_child", return_value=self.probe(b"unknown-fixture")): + mode = REAL_QUEUE_PREFERENCE_POLICY("fixture", self.root, self.root) + peer.check_codex_storage(self.root, cwd=self.root, include_ordinary_preferences=mode) + + +class NativeStoragePlatforms(unittest.TestCase): + def windows_api(self, value=r"D:\NativeData", hr=0, null=False, error=False): + raw = value.encode("utf-16-le", errors="surrogatepass") + b"\x00\x00" + buffer = ctypes.create_string_buffer(raw) + shell, ole = mock.Mock(), mock.Mock() + def lookup(guid, flags, token, output): + self.assertEqual((flags, token), (0, None)) + self.assertEqual(guid._obj.Data1, 0x62AB5D82) + self.assertEqual((guid._obj.Data2, guid._obj.Data3), (0xFDC1, 0x4DC3)) + self.assertEqual(tuple(guid._obj.Data4), (0xA9, 0xDD, 0x07, 0x0D, 0x1D, 0x49, 0x5D, 0x97)) + if error: + raise OSError("fixture") + if not null: + ctypes.cast(output, ctypes.POINTER(ctypes.c_void_p))[0] = ctypes.addressof(buffer) + return hr + shell.SHGetKnownFolderPath.side_effect = lookup + ole.expected_pointer = ctypes.addressof(buffer) + ole.lookup = shell.SHGetKnownFolderPath + loader = mock.patch.object(ctypes, "WinDLL", side_effect=[shell, ole], create=True) + return loader, ole + + def test_known_folder_ignores_environment_and_frees_allocation(self): + loader, ole = self.windows_api() + with loader, mock.patch.dict(os.environ, {"ProgramData": "untrusted"}): + self.assertEqual(peer.codex_windows_program_data(), r"D:\NativeData") + ole.CoTaskMemFree.assert_called_once() + self.assertEqual(ole.CoTaskMemFree.call_args.args[0].value, ole.expected_pointer) + self.assertIs(ole.lookup.restype, ctypes.c_int32) + self.assertEqual(ole.lookup.argtypes[1:], [ctypes.c_uint32, ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p)]) + + def test_known_folder_failure_null_and_error_fallback(self): + for options in ({"hr": -1}, {"null": True}, {"error": True}): + with self.subTest(options=list(options)): + loader, ole = self.windows_api(**options) + with loader: + self.assertEqual(peer.codex_windows_program_data(), r"C:\ProgramData") + self.assertEqual(ole.CoTaskMemFree.call_count, + 0 if options.get("null") or options.get("error") else 1) + + def test_known_folder_success_does_not_fallback_on_invalid_path(self): + for value in ("", "relative", r"\NativeData", "x" * 32768): + loader, ole = self.windows_api(value=value) + with loader: + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_windows_program_data() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + ole.CoTaskMemFree.assert_called_once() + + def test_known_folder_preserves_native_utf16_path(self): + value = "D:\\Native-" + chr(0xd800) + loader, ole = self.windows_api(value=value) + with loader: + self.assertEqual(peer.codex_windows_program_data(), value) + ole.CoTaskMemFree.assert_called_once() + + @unittest.skipUnless(os.name == "nt", "Windows Known Folder API only") + def test_windows_api_path_smoke_without_exposing_value(self): + import ntpath + self.assertTrue(ntpath.isabs(peer.codex_windows_program_data())) + + def cf_api(self, *, forced=(True, True, False), value_type=7, sync=True, + copied=30, extract=True): + cf = mock.Mock() + cf.CFStringCreateWithCString.side_effect = [10, 20, 21] + cf.CFPreferencesAppSynchronize.return_value = sync + cf.CFPreferencesAppValueIsForced.side_effect = list(forced) + cf.CFPreferencesCopyAppValue.return_value = copied + cf.CFGetTypeID.return_value = value_type + cf.CFStringGetTypeID.return_value = 7 + def get_string(value, buffer, size, encoding): + self.assertEqual(size, peer.MAX_CODEX_CONFIG_BYTES * 2 + 1) + buffer.value = base64.b64encode(b'model="fixture"\n') + return extract + cf.CFStringGetCString.side_effect = get_string + return cf + + def read_cf(self, cf, **kwargs): + with mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(ctypes, "CDLL", return_value=cf): + return peer.codex_managed_preference_values(**kwargs) + + def test_wake154_reads_ordinary_preferences_without_forced_gate(self): + cf = self.cf_api(forced=()) + self.assertEqual(len(self.read_cf(cf, forced_only=False)), 2) + cf.CFPreferencesAppValueIsForced.assert_not_called() + self.assertEqual(cf.CFPreferencesCopyAppValue.call_count, 2) + self.assertEqual(cf.CFRelease.call_args_list, + [mock.call(30), mock.call(30), mock.call(21), mock.call(20), mock.call(10)]) + + def test_cf_forced_string_read_and_owned_values_released(self): + cf = self.cf_api() + self.assertEqual(len(self.read_cf(cf)), 1) + self.assertEqual(cf.CFRelease.call_args_list, + [mock.call(30), mock.call(21), mock.call(20), mock.call(10)]) + + def test_cf_nonforced_or_released_force_does_not_read_value(self): + cf = self.cf_api(forced=(False, False)) + self.assertEqual(self.read_cf(cf), []) + cf.CFPreferencesCopyAppValue.assert_not_called() + cf = self.cf_api(forced=(True, False, False), value_type=99) + self.assertEqual(self.read_cf(cf), []) + cf.CFGetTypeID.assert_not_called() + cf.CFRelease.assert_any_call(30) + + def test_cf_missing_value_is_ignored(self): + cf = self.cf_api(forced=(True, False), copied=0) + self.assertEqual(self.read_cf(cf), []) + self.assertNotIn(mock.call(0), cf.CFRelease.call_args_list) + + def test_cf_invalid_type_unbounded_value_or_sync_failure_is_typed_refusal(self): + for options in ({"value_type": 99}, {"extract": False}, {"sync": False}): + cf = self.cf_api(**options) + with self.subTest(options=options), \ + mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(ctypes, "CDLL", return_value=cf): + with self.assertRaises(peer.CcPeerError) as caught: + peer._refuse_managed_preference_storage() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + cf.CFRelease.assert_any_call(10) + + def test_cf_not_loaded_on_other_platform(self): + with mock.patch.object(peer.sys, "platform", "linux"), mock.patch.object(ctypes, "CDLL") as load: + self.assertEqual(peer.codex_managed_preference_values(), []) + load.assert_not_called() + + def test_cf_loading_or_missing_symbol_failure_is_typed(self): + for failure in (OSError("fixture"), AttributeError("fixture")): + with self.subTest(kind=type(failure).__name__), \ + mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(ctypes, "CDLL", side_effect=failure): + with self.assertRaises(peer.CcPeerError) as caught: + peer._refuse_managed_preference_storage() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + + def test_cf_key_creation_failure_releases_existing_domain(self): + cf = self.cf_api() + cf.CFStringCreateWithCString.side_effect = [10, 0] + with mock.patch.object(peer.sys, "platform", "darwin"), \ + mock.patch.object(ctypes, "CDLL", return_value=cf): + with self.assertRaises(peer.CcPeerError) as caught: + peer._refuse_managed_preference_storage() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + self.assertEqual(cf.CFRelease.call_args_list, [mock.call(10)]) + cf.CFPreferencesCopyAppValue.assert_not_called() + + +class NativeProjectRoots(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.base = Path(temp.name).resolve() + self.root = self.base / "home" + self.root.mkdir() + self.repo = self.base / "repo" + self.cwd = self.repo / "a" / "b" + self.cwd.mkdir(parents=True) + for patcher in (mock.patch.object(peer, "codex_system_config_paths", return_value=[]), + mock.patch.object(peer, "codex_managed_preference_values", return_value=[])): + patcher.start() + self.addCleanup(patcher.stop) + + def paths(self): + return peer.codex_project_config_paths(self.cwd, root=self.root) + + def test_no_marker_or_headless_marker_uses_only_cwd(self): + self.assertEqual(self.paths(), [self.cwd / ".codex" / "config.toml"]) + (self.repo / ".git").mkdir() + self.assertEqual(len(self.paths()), 1) + + def test_git_directory_head_or_worktree_pointer_stops_at_closest_root(self): + (self.repo / ".git").mkdir() + (self.repo / ".git" / "HEAD").write_text("ref: refs/heads/main") + self.assertEqual(len(self.paths()), 3) + (self.cwd.parent / ".git").write_text("gitdir: fixture") + self.assertEqual(len(self.paths()), 2) + self.assertNotIn(self.repo / ".codex" / "config.toml", self.paths()) + + def test_above_root_and_cwd_config_are_not_native_layers(self): + (self.repo / ".git").write_text("gitdir: fixture") + (self.base / ".codex").mkdir() + (self.base / ".codex" / "config.toml").write_text('sqlite_home="ignored-above-root"') + (self.cwd / "config.toml").write_text('sqlite_home="ignored-cwd-config"') + peer.check_codex_storage(self.root, cwd=self.cwd) + (self.repo / ".codex").mkdir() + (self.repo / ".codex" / "config.toml").write_text('sqlite_home="fixture"') + with self.assertRaises(peer.CcPeerError) as caught: + peer.check_codex_storage(self.root, cwd=self.cwd) + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + + @unittest.skipUnless(hasattr(os, "mkfifo"), "FIFO marker fixture needs POSIX") + def test_non_directory_marker_is_recognized_without_opening_it(self): + os.mkfifo(self.repo / ".git") + self.assertEqual(len(self.paths()), 3) + (self.repo / ".codex").mkdir() + (self.repo / ".codex" / "config.toml").write_text('sqlite_home="fixture"') + with self.assertRaises(peer.CcPeerError) as caught: + peer.check_codex_storage(self.root, cwd=self.cwd) + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + + def test_custom_markers_in_known_configuration_retain_superset(self): + (self.root / "config.toml").write_text('"project_root_markers"=["fixture"]') + self.assertIn(self.base / ".codex" / "config.toml", self.paths()) + (self.root / "config.toml").write_text('[profiles.unselected]\nproject_root_markers=["fixture"]') + self.assertEqual(len(self.paths()), 1) + + def test_wake154_ordinary_preference_custom_marker_keeps_superset(self): + encoded = base64.b64encode(b'project_root_markers=["fixture"]').decode('ascii') + def values(*, forced_only=True): + return [] if forced_only else [encoded] + with mock.patch.object(peer, "codex_managed_preference_values", side_effect=values): + self.assertEqual(len(self.paths()), 1) + paths = peer.codex_project_config_paths(self.cwd, root=self.root, include_ordinary_preferences=True) + self.assertIn(self.base / ".codex" / "config.toml", paths) + # The new mode reaches the classifier as well, not only path output. + (self.base / ".codex").mkdir() + (self.base / ".codex" / "config.toml").write_text('sqlite_home="fixture"') + peer.check_codex_storage(self.root, cwd=self.cwd) + with self.assertRaises(peer.CcPeerError) as caught: + peer.check_codex_storage(self.root, cwd=self.cwd, include_ordinary_preferences=True) + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + + def test_symlink_marker_fails_closed(self): + try: + (self.repo / ".git").symlink_to(self.root, target_is_directory=True) + except (NotImplementedError, OSError): + self.skipTest("symlinks unavailable") + with self.assertRaises(peer.CcPeerError) as caught: + self.paths() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + + def test_project_directory_equal_to_selected_home_is_not_a_second_layer(self): + home = self.cwd / ".codex" + home.mkdir() + with mock.patch.object(peer, "_refuse_configured_storage") as inspect: + peer.check_codex_storage(home, cwd=self.cwd) + self.assertEqual(inspect.call_args_list, + [mock.call(home / "config.toml", "sqlite_home_configuration")]) + + def test_unreadable_root_marker_fails_closed(self): + original = Path.lstat + def inspect(path, *args, **kwargs): + if path.name == ".git": + raise PermissionError("fixture") + return original(path, *args, **kwargs) + with mock.patch.object(Path, "lstat", inspect): + with self.assertRaises(peer.CcPeerError) as caught: + self.paths() + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") diff --git a/tests/codex/test_storage.py b/tests/codex/test_storage.py index bedf0e24..aef43ed2 100644 --- a/tests/codex/test_storage.py +++ b/tests/codex/test_storage.py @@ -11,7 +11,7 @@ from unittest import mock import session_peer as peer -from tests.codex.support import THREAD +from tests.codex.support import THREAD, isolate_native_layers class CodexStorage(unittest.TestCase): @@ -27,6 +27,8 @@ def setUp(self): patcher = mock.patch.dict(os.environ, {}, clear=True) patcher.start() self.addCleanup(patcher.stop) + # Machine-wide layers are covered by test_storage_layers with fixtures. + isolate_native_layers(self, self.root) def refuses(self, reason=None): with self.assertRaises(peer.CcPeerError) as caught: @@ -203,7 +205,10 @@ def test_isolated_fake_child_observes_selected_home_and_no_override(self): self.assertEqual(result["queueId"], "fixture-storage") self.assertIs(result["consumptionConfirmed"], False) recorded = json.loads((self.root / "fixture-environment.json").read_text(encoding="utf-8")) - self.assertEqual(recorded, {"home": str(self.root), "sqliteOverridePresent": False}) + # PWD is only aligned on POSIX, matching codex_process_environment. + self.assertEqual(recorded, {"home": str(self.root), "sqliteOverridePresent": False, + "cwd": os.path.realpath(self.root), + "pwd": None if peer.IS_WINDOWS else str(self.root)}) if __name__ == "__main__": diff --git a/tests/codex/test_storage_layers.py b/tests/codex/test_storage_layers.py new file mode 100644 index 00000000..6465385e --- /dev/null +++ b/tests/codex/test_storage_layers.py @@ -0,0 +1,519 @@ +"""Offline fixtures for native Codex storage layers beyond the selected home. + +Native evidence (codex-cli 0.162.1, rust-v0.162.1): sqlite_home precedence is +requirements > config layers > CODEX_SQLITE_HOME > CODEX_HOME. Config layers +include system files, macOS managed profiles and the project layers of the +process working directory; `codex queue` routes through a running app-server +daemon that accepts a connection. By user policy session-peer neither probes +nor refuses on daemon presence. These tests prove only pre-effect refusal of +known storage relocation; passing is not an attestation of effective storage +and none of this is evidence of native delivery, consumption or misrouting. +""" +import argparse +import base64 +import contextlib +import json +import os +from pathlib import Path +import plistlib +import shutil +import socket +import sys +import sqlite3 +import tempfile +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex.support import THREAD + +RELOCATED_STORAGE = "relocated-storage-fixture" +# Captured before each test replaces them with fixture paths. +REAL_SYSTEM_PATHS = peer.codex_system_config_paths +REAL_PROJECT_PATHS = peer.codex_project_config_paths + + +class StorageLayers(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.base = Path(temp.name).resolve() + self.root = self.base / "home" + self.root.mkdir() + self.system = self.base / "system" + self.system.mkdir() + self.managed = self.base / "managed" + self.managed.mkdir() + self.system_paths = [self.system / "config.toml", self.system / "managed_config.toml", + self.system / "requirements.toml"] + self.managed_paths = [self.managed / "user.plist", self.managed / "device.plist"] + patches = ( + mock.patch.dict(os.environ, {}, clear=True), + mock.patch.object(peer, "codex_system_config_paths", return_value=self.system_paths), + mock.patch.object(peer, "codex_queue_preference_policy", return_value=False), + mock.patch.object(peer, "codex_managed_preference_values", side_effect=self.managed_values), + # Never read the developer's real ancestor directories. + mock.patch.object(peer, "codex_project_config_paths", side_effect=lambda cwd, **kwargs: [ + path for path in REAL_PROJECT_PATHS(cwd, **kwargs) + if os.path.abspath(path).startswith(str(self.base) + os.sep)]), + ) + for patcher in patches: + patcher.start() + self.addCleanup(patcher.stop) + self.args = argparse.Namespace(codex_home=str(self.root), codex_bin="fixture", + to="codex:" + THREAD, dry_run=False, all=False, + allow_inactive_codex_home=True, wake=False) + + def managed_values(self, **kwargs): + values = [] + for path in self.managed_paths: + if path.exists(): + try: + record = plistlib.loads(path.read_bytes()) + if not isinstance(record, dict): + raise ValueError("fixture preference shape") + values.extend(record[key] for key in peer.CODEX_MANAGED_PREFERENCES_KEYS if key in record) + except plistlib.InvalidFileException as exc: + raise ValueError("fixture preference shape") from exc + return values + + def refuses(self, reason, **kwargs): + with self.assertRaises(peer.CcPeerError) as caught: + peer.check_codex_storage(self.root, **kwargs) + details = caught.exception.details + self.assertEqual(details["reason"], reason) + self.assertEqual(details["status"], "refused") + self.assertIs(details["submitted"], False) + self.assertIs(details["retryAllowed"], False) + # Never reflect configuration contents or any inspected path. + self.assertNotIn(RELOCATED_STORAGE, str(caught.exception)) + self.assertNotIn(str(self.base), str(caught.exception)) + return caught.exception + + def database(self, cwd=None): + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: + conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") + rollout = self.root / "rollout.jsonl" + rollout.write_text("{}\n", encoding="utf-8") + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?)", + (THREAD, "fixture", str(cwd or self.root), 1, 0, str(rollout))) + conn.commit() + + # -- system config and requirements layers --------------------------- + + def test_each_system_layer_with_root_storage_is_refused(self): + for path in self.system_paths: + with self.subTest(layer=path.name): + path.write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.refuses("sqlite_home_system_configuration") + path.unlink() + peer.check_codex_storage(self.root) + + def test_system_layers_reuse_root_only_classification(self): + # Legacy [profiles.*] has no sqlite_home field in the native schema. + for text in ('[profiles.work]\nsqlite_home="%s"\n' % RELOCATED_STORAGE, + '# sqlite_home="%s"\nmodel="fixture"\n' % RELOCATED_STORAGE): + with self.subTest(text=text): + self.system_paths[0].write_text(text, encoding="utf-8") + peer.check_codex_storage(self.root) + + def test_unverifiable_system_layer_fails_closed(self): + target = self.system_paths[2] + target.mkdir() + self.refuses("storage_config_unverifiable") + target.rmdir() + target.write_text('sqlite_home = "unterminated\n', encoding="utf-8") + self.refuses("storage_config_unverifiable") + + def test_real_system_paths_are_platform_specific(self): + with mock.patch.object(peer, "IS_WINDOWS", False): + self.assertEqual(REAL_SYSTEM_PATHS(), [Path("/etc/codex/config.toml"), + Path("/etc/codex/managed_config.toml"), + Path("/etc/codex/requirements.toml")]) + with mock.patch.object(peer, "IS_WINDOWS", True), \ + mock.patch.dict(os.environ, {"ProgramData": "untrusted-env"}), \ + mock.patch.object(peer, "codex_windows_program_data", return_value="D:" + os.sep + "Data"): + base = Path("D:" + os.sep + "Data") / "OpenAI" / "Codex" + self.assertEqual(REAL_SYSTEM_PATHS(), [base / "config.toml", base / "requirements.toml"]) + + # -- macOS managed preferences -------------------------------------- + + def write_plist(self, path, values): + path.write_bytes(plistlib.dumps(values)) + + def encoded(self, text): + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + def test_managed_config_and_requirements_with_storage_are_refused(self): + for key in peer.CODEX_MANAGED_PREFERENCES_KEYS: + for path in self.managed_paths: + with self.subTest(key=key, path=path.name): + self.write_plist(path, {key: self.encoded('sqlite_home = "%s"\n' % RELOCATED_STORAGE)}) + self.refuses("sqlite_home_managed_configuration") + path.unlink() + + def test_managed_preferences_without_storage_are_allowed(self): + self.write_plist(self.managed_paths[0], { + "config_toml_base64": self.encoded('model = "fixture"\n[profiles.x]\nsqlite_home="%s"\n' % RELOCATED_STORAGE), + "unrelated": "value"}) + self.write_plist(self.managed_paths[1], {"unrelated": 1}) + peer.check_codex_storage(self.root) + + def test_unverifiable_managed_preferences_fail_closed(self): + cases = ( + {"config_toml_base64": "%%% not base64"}, + {"config_toml_base64": 7}, + {"requirements_toml_base64": base64.b64encode(b"\xff\xfe").decode()}, + {"requirements_toml_base64": self.encoded('sqlite_home = "unterminated\n')}, + ) + for values in cases: + with self.subTest(values=sorted(values)): + self.write_plist(self.managed_paths[0], values) + self.refuses("storage_config_unverifiable") + self.managed_paths[0].write_bytes(b"not a plist") + self.refuses("storage_config_unverifiable") + self.managed_paths[0].write_bytes(plistlib.dumps(["not", "a", "dictionary"])) + self.refuses("storage_config_unverifiable") + + # -- project layers of the native process working directory ----------- + + def test_project_layers_of_the_process_directory_are_refused(self): + project = self.base / "repo" / "nested" / "work" + project.mkdir(parents=True) + (self.base / "repo" / ".git").write_text("gitdir: fixture") + candidates = (project / ".codex" / "config.toml", + self.base / "repo" / ".codex" / "config.toml") + for path in candidates: + with self.subTest(layer=str(path.relative_to(self.base))): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.refuses("sqlite_home_project_configuration", cwd=project) + # Without a process directory, project layers are not consulted. + peer.check_codex_storage(self.root) + path.unlink() + peer.check_codex_storage(self.root, cwd=project) + + def test_project_layers_refuse_regardless_of_trust_and_ignore_profile_tables(self): + project = self.base / "untrusted" + (project / ".codex").mkdir(parents=True) + config = project / ".codex" / "config.toml" + config.write_text('[profiles.x]\nsqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + peer.check_codex_storage(self.root, cwd=project) + config.write_text('"sqlite_home" = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.refuses("sqlite_home_project_configuration", cwd=project) + + def test_home_as_working_directory_keeps_the_user_layer_reason(self): + (self.root / "config.toml").write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.refuses("sqlite_home_configuration", cwd=self.root) + + def test_project_paths_without_marker_cover_only_cwd(self): + project = self.base / "a" / "b" + project.mkdir(parents=True) + with mock.patch.object(Path, "home", side_effect=AssertionError("fixture must use its selected home")): + paths = REAL_PROJECT_PATHS(project, root=self.root) + self.assertEqual(paths, [project / ".codex" / "config.toml"]) + + # -- shared app-server daemon (policy: no daemon-based refusal) ------- + # The daemon's startup storage cannot be attested through the reviewed + # native APIs, and native TUIs start one by default. The user chose to + # leave daemon routing and embedded fallback to native Codex: an entry or + # a live socket must neither block nor be probed, and no authority is + # claimed. Known relocation in another layer still refuses. + + def daemon_entry(self): + path = self.root / "app-server-control" / "app-server-control.sock" + path.parent.mkdir(parents=True) + return path + + def queue(self, dry_run): + self.args.dry_run = dry_run + completed = mock.Mock(returncode=0, stderr="", + stdout="Queued message q-1 for thread %s.\n" % THREAD) + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run", return_value=completed) as run: + result = peer.queue_codex(self.args, "fixture") + return result, run + + def assert_no_authority_claim(self, result): + for key in ("storageAuthority", "storageVerified", "daemon"): + self.assertNotIn(key, result) + + @unittest.skipIf(os.name == "nt" or not hasattr(socket, "AF_UNIX"), "POSIX socket fixture") + def test_live_daemon_socket_neither_blocks_nor_is_probed(self): + # AF_UNIX paths are short; macOS per-user temp directories are not. + short = Path(tempfile.mkdtemp(prefix="sp", dir="/tmp" if os.path.isdir("/tmp") else None)) + self.addCleanup(shutil.rmtree, short, True) + self.root = short.resolve() + self.args.codex_home = str(self.root) + path = self.daemon_entry() + if len(os.fsencode(str(path))) > 100: + self.skipTest("temporary path too long for an AF_UNIX socket") + self.database() + with contextlib.closing(socket.socket(socket.AF_UNIX)) as server: + server.bind(str(path)) + server.listen(1) + server.setblocking(False) + validated, run = self.queue(dry_run=True) + run.assert_not_called() + queued, run = self.queue(dry_run=False) + run.assert_called_once() + # session-peer never connected: native Codex owns daemon selection. + with self.assertRaises(BlockingIOError): + server.accept() + self.assertEqual(validated["status"], "validated") + self.assertEqual(queued["status"], "queued") + for result in (validated, queued): + self.assert_no_authority_claim(result) + + def test_stale_daemon_entry_does_not_block_dry_run_or_queue(self): + self.daemon_entry().write_text("", encoding="utf-8") + self.database() + self.assertEqual(self.queue(dry_run=True)[0]["status"], "validated") + result, run = self.queue(dry_run=False) + self.assertEqual(result["status"], "queued") + run.assert_called_once() + self.assert_no_authority_claim(result) + + def test_daemon_entry_does_not_mask_another_unsupported_layer(self): + self.daemon_entry().write_text("", encoding="utf-8") + self.database() + self.system_paths[2].write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run): + with self.assertRaises(peer.CcPeerError) as caught: + self.queue(dry_run) + self.assertEqual(caught.exception.details["reason"], + "sqlite_home_system_configuration") + + # -- effect boundaries ---------------------------------------------- + + def test_queue_pins_the_process_directory_to_the_inspected_home(self): + self.database() + elsewhere = self.base / "caller" + (elsewhere / ".codex").mkdir(parents=True) + # The caller's own project layers are irrelevant once the queue is pinned. + (elsewhere / ".codex" / "config.toml").write_text( + 'sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + completed = mock.Mock(returncode=0, stdout="Queued message q-1 for thread %s.\n" % THREAD, + stderr="") + previous = os.getcwd() + os.chdir(elsewhere) + self.addCleanup(os.chdir, previous) + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run", return_value=completed) as run: + result = peer.queue_codex(self.args, "fixture") + self.assertEqual(result["queueId"], "q-1") + self.assertEqual(run.call_args.kwargs["cwd"], str(self.root)) + if not peer.IS_WINDOWS: + self.assertEqual(run.call_args.kwargs["env"]["PWD"], str(self.root)) + + def test_consumer_thread_directory_layers_refuse_dry_run_and_send(self): + # The live writer normally started in the saved thread directory; its + # project layers can make it poll storage other than the home's queue. + project = self.base / "thread-project" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "config.toml").write_text( + 'sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.database(cwd=project) + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run") as run: + for dry_run in (True, False): + self.args.dry_run = dry_run + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, "fixture") + self.assertEqual(caught.exception.details["reason"], + "sqlite_home_project_configuration") + run.assert_not_called() + + def test_missing_thread_directory_has_no_layers_to_inspect(self): + self.database(cwd=self.base / "deleted-project") + self.assertIsNone(peer.codex_thread_cwd(self.root, THREAD)) + self.assertIsNone(peer.codex_thread_cwd(self.root, "00000000-0000-4000-8000-000000000000")) + peer.check_codex_consumer_storage(self.root, THREAD) + + def test_translated_native_home_checks_the_inherited_directory(self): + self.database() + caller = self.base / "caller" + (caller / ".codex").mkdir(parents=True) + (caller / ".codex" / "config.toml").write_text( + 'sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + previous = os.getcwd() + os.chdir(caller) + self.addCleanup(os.chdir, previous) + self.args.codex_native_home = r"C:\fixture\home" + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run") as run: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, "fixture") + run.assert_not_called() + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + + def test_consumer_directory_stat_errors_are_not_silently_absent(self): + self.database() + for error in (PermissionError("private"), OSError("private")): + with self.subTest(error=type(error).__name__), mock.patch.object(Path, "stat", side_effect=error): + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_thread_cwd(self.root, THREAD) + self.assertEqual(caught.exception.details["reason"], "consumer_directory_unverifiable") + self.assertNotIn("private", str(caught.exception)) + for error in (FileNotFoundError(), NotADirectoryError()): + with mock.patch.object(Path, "stat", side_effect=error): + self.assertIsNone(peer.codex_thread_cwd(self.root, THREAD)) + + @unittest.skipIf(peer.IS_WINDOWS, "translated route runs in POSIX Python") + def test_translated_windows_cwd_preserves_unverified_dry_run_and_queue(self): + self.database() + self.args.codex_native_home = r"C:\fixture\home" + for cwd in (r"C:\project", r"\\server\share\project"): + with self.subTest(cwd=cwd): + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: + conn.execute("UPDATE threads SET cwd=? WHERE id=?", (cwd, THREAD)) + conn.commit() + # The Windows consumer's directory is outside POSIX inspection. + with mock.patch.object(peer, "codex_thread_cwd", side_effect=AssertionError("not inspectable")): + validated, dry = self.queue(dry_run=True) + queued, sent = self.queue(dry_run=False) + dry.assert_not_called() + sent.assert_called_once() + self.assertEqual(validated["status"], "validated") + self.assertEqual(queued["status"], "queued") + self.assertIsNone(sent.call_args.kwargs["cwd"]) + self.assertEqual(sent.call_args.kwargs["env"]["CODEX_HOME"], self.args.codex_native_home) + self.assertFalse(queued["consumptionConfirmed"]) + self.assert_no_authority_claim(validated) + self.assert_no_authority_claim(queued) + + @unittest.skipIf(peer.IS_WINDOWS, "translated route runs in POSIX Python") + def test_translated_windows_cwd_wake_still_refuses_before_budget_or_queue(self): + self.database(cwd=r"C:\project") + self.args.codex_native_home = r"C:\fixture\home" + self.args.wake = True + version = mock.Mock(stdout=sorted(peer.CODEX_WAKE_VERSIONS)[0] + "\n") + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run", return_value=version) as run, \ + mock.patch.object(peer, "wake_target_rate_reserve") as reserve, \ + mock.patch.object(peer.subprocess, "Popen") as start: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, "fixture") + self.assertEqual(caught.exception.details["wake"]["reason"], "cwd_unavailable") + run.assert_called_once() + self.assertEqual(run.call_args.args[0], ["fixture", "--version"]) + reserve.assert_not_called() + start.assert_not_called() + + def test_layer_change_at_effect_boundary_refuses_before_queue(self): + self.database() + def revalidate(*unused): + self.system_paths[1].write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer, "revalidate_codex_home", side_effect=revalidate), \ + mock.patch.object(peer.subprocess, "run") as run: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, "fixture") + run.assert_not_called() + self.assertEqual(caught.exception.details["reason"], "sqlite_home_system_configuration") + + @unittest.skipUnless(sys.platform in ("darwin", "linux"), "wake is macOS/Linux only") + def test_storage_refusal_does_not_consume_a_wake_reservation(self): + # Relocation that appears after the guarded wake preflight must still + # refuse before the per-target budget is reserved and before any child. + project = self.base / "thread-project" + project.mkdir() + cases = ( + (self.system_paths[0], "sqlite_home_system_configuration"), + (project / ".codex" / "config.toml", "sqlite_home_project_configuration"), + ) + self.database(cwd=project) + self.args.wake = True + self.args.wake_timeout = 1 + self.args.allow_inactive_codex_home = False + version = mock.Mock(stdout=sorted(peer.CODEX_WAKE_VERSIONS)[0] + "\n") + state = self.root / "session-peer" / "wake-locks" / (THREAD + ".lock") + real_preflight = peer.codex_wake_preflight + for target, reason in cases: + calls = {"n": 0} + + def preflight(*args, **kwargs): + result = real_preflight(*args, **kwargs) + calls["n"] += 1 + if calls["n"] == 2: # the re-check inside the wake guard + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + return result + + with self.subTest(reason=reason), \ + mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer, "codex_wake_preflight", side_effect=preflight), \ + mock.patch.object(peer.subprocess, "run", return_value=version) as run, \ + mock.patch.object(peer.subprocess, "Popen") as popen, \ + mock.patch.object(peer, "wake_target_rate_reserve", + wraps=peer.wake_target_rate_reserve) as reserve: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, "fixture") + self.assertEqual(caught.exception.details["reason"], reason) + self.assertEqual(calls["n"], 2) + reserve.assert_not_called() + popen.assert_not_called() + # Only version probes ran; no queue child was started. + self.assertTrue(all(call.args[0][1:] == ["--version"] for call in run.call_args_list)) + target.unlink() + # The per-target budget file holds no reservation after the refusal. + self.assertEqual(state.read_bytes() if state.exists() else b"", b"") + + def test_unreadable_or_malformed_consumer_metadata_refuses(self): + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: + conn.execute("CREATE TABLE threads (id TEXT)") + conn.execute("INSERT INTO threads VALUES (?)", (THREAD,)) + conn.commit() + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_thread_cwd(self.root, THREAD) + self.assertEqual(caught.exception.details["reason"], "consumer_directory_unverifiable") + (self.root / "state_5.sqlite").unlink() + for value in ("relative/project", 7): + with self.subTest(value=value): + path = self.root / "state_5.sqlite" + if path.exists(): + path.unlink() + with contextlib.closing(sqlite3.connect(path)) as conn: + conn.execute("CREATE TABLE threads (id TEXT, cwd)") + conn.execute("INSERT INTO threads VALUES (?, ?)", (THREAD, value)) + conn.commit() + with self.assertRaises(peer.CcPeerError) as caught: + peer.check_codex_consumer_storage(self.root, THREAD) + self.assertEqual(caught.exception.details["reason"], + "consumer_directory_unverifiable") + for value in (None, ""): + with self.subTest(value=value): + path = self.root / "state_5.sqlite" + path.unlink() + with contextlib.closing(sqlite3.connect(path)) as conn: + conn.execute("CREATE TABLE threads (id TEXT, cwd)") + conn.execute("INSERT INTO threads VALUES (?, ?)", (THREAD, value)) + conn.commit() + self.assertIsNone(peer.codex_thread_cwd(self.root, THREAD)) + + @unittest.skipIf(os.name == "nt", "wake is macOS/Linux only") + def test_wake_checks_the_thread_directory_before_starting_app_server(self): + project = self.base / "thread-project" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "config.toml").write_text( + 'sqlite_home = "%s"\n' % RELOCATED_STORAGE, encoding="utf-8") + self.database(cwd=project) + version = mock.Mock(stdout=sorted(peer.CODEX_WAKE_VERSIONS)[0] + "\n") + with mock.patch.object(peer.subprocess, "run", return_value=version), \ + mock.patch.object(peer.subprocess, "Popen") as popen: + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_wake_preflight(self.root, THREAD, "fixture") + popen.assert_not_called() + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/codex/test_storage_physical_cwd.py b/tests/codex/test_storage_physical_cwd.py new file mode 100644 index 00000000..34c11c1f --- /dev/null +++ b/tests/codex/test_storage_physical_cwd.py @@ -0,0 +1,263 @@ +"""Synthetic physical-cwd boundaries; no native Codex or user config is read.""" +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +import session_peer as peer +from tests.codex.support import THREAD, isolate_native_layers + + +class PhysicalStorageCwd(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.base = Path(temporary.name).resolve() + self.root = self.base / "home" + self.root.mkdir() + self.repo = self.base / "physical" / "repo" + self.cwd = self.repo / "nested" + self.cwd.mkdir(parents=True) + (self.repo / ".git").mkdir() + (self.repo / ".git" / "HEAD").write_text("fixture\n", encoding="utf-8") + self.rollout = self.root / "rollout.jsonl" + self.rollout.write_text("{}\n", encoding="utf-8") + isolate_native_layers(self, self.base) + environment = mock.patch.dict(os.environ, {}, clear=True) + environment.start() + self.addCleanup(environment.stop) + self.args = argparse.Namespace(codex_home=str(self.root), codex_bin="fixture", + to="codex:" + THREAD, dry_run=False, all=False, + allow_inactive_codex_home=True, wake=False) + + def link(self, path, destination): + try: + path.symlink_to(destination, target_is_directory=True) + except (OSError, NotImplementedError): + self.skipTest("directory symlinks are unavailable") + return path + + def database(self, cwd): + with contextlib.closing(sqlite3.connect(self.root / "state_5.sqlite")) as conn: + conn.execute("CREATE TABLE threads (id TEXT, title TEXT, cwd TEXT, updated_at INTEGER, archived INTEGER, rollout_path TEXT)") + conn.execute("INSERT INTO threads VALUES (?,?,?,?,?,?)", + (THREAD, "fixture", str(cwd), 1, 0, str(self.rollout))) + conn.commit() + + def relocation(self): + directory = self.repo / ".codex" + directory.mkdir(exist_ok=True) + (directory / "config.toml").write_text('sqlite_home="relocated-fixture"\n', encoding="utf-8") + + def assert_storage_refused(self, action): + with self.assertRaises(peer.CcPeerError) as caught: + action() + self.assertEqual(caught.exception.details["reason"], "sqlite_home_project_configuration") + self.assertIs(caught.exception.details["submitted"], False) + self.assertIs(caught.exception.details["retryAllowed"], False) + self.assertNotIn(str(self.base), str(caught.exception)) + self.assertNotIn("relocated-fixture", str(caught.exception)) + + def test_leaf_alias_checks_physical_project_ancestors(self): + alias = self.link(self.base / "alias", self.cwd) + self.relocation() + self.assert_storage_refused(lambda: peer.check_codex_storage(self.root, cwd=alias)) + + def test_ancestor_alias_checks_physical_project_ancestors(self): + (self.cwd / "deeper").mkdir() + ancestor = self.link(self.base / "alias-parent", self.cwd) + alias = ancestor / "deeper" + self.assertFalse(alias.is_symlink()) + self.relocation() + self.assert_storage_refused(lambda: peer.check_codex_storage(self.root, cwd=alias)) + + def test_consumer_cwd_is_physical_and_missing_directory_still_skips(self): + alias = self.link(self.base / "alias", self.cwd) + self.database(alias) + self.assertEqual(peer.codex_thread_cwd(self.root, THREAD), self.cwd) + self.relocation() + self.assert_storage_refused(lambda: peer.check_codex_consumer_storage(self.root, THREAD)) + alias.unlink() + self.assertIsNone(peer.codex_thread_cwd(self.root, THREAD)) + + def test_consumer_also_refuses_a_lexical_only_project_layer(self): + # An already-running writer may have received an explicit logical cwd. + # Its path spelling is not evidence that it used physical cwd semantics. + logical = self.base / "logical" + logical.mkdir() + (logical / ".git").mkdir() + (logical / ".git" / "HEAD").write_text("fixture\n", encoding="utf-8") + alias = self.link(logical / "alias", self.cwd) + (logical / ".codex").mkdir() + (logical / ".codex" / "config.toml").write_text( + 'sqlite_home="relocated-fixture"\n', encoding="utf-8") + self.database(alias) + # The physical-only project tree is clean, but is not the only possible + # startup tree for an existing writer with a logical saved cwd. + peer.check_codex_storage(self.root, cwd=self.cwd) + self.assert_storage_refused(lambda: peer.check_codex_consumer_storage(self.root, THREAD)) + + def test_consumer_lexical_scan_stops_at_its_nearest_git_marker(self): + logical = self.base / "logical" + logical.mkdir() + ancestor = self.link(logical / "alias-parent", self.repo) + alias = ancestor / "nested" + (logical / ".codex").mkdir() + (logical / ".codex" / "config.toml").write_text( + 'sqlite_home="relocated-fixture"\n', encoding="utf-8") + self.database(alias) + # alias-parent/.git resolves to the real repo marker; the logical + # parent's config is outside that root and must not be over-scanned. + peer.check_codex_consumer_storage(self.root, THREAD) + + def test_isolation_also_filters_consumer_lexical_configuration(self): + # The lexical alias is outside this test's configured fixture root, + # but remains in a separately owned temporary directory. Never use + # actual machine preferences or ancestor config values as a fixture. + outside = tempfile.TemporaryDirectory() + self.addCleanup(outside.cleanup) + lexical_root = Path(outside.name).resolve() + (lexical_root / ".git").mkdir() + (lexical_root / ".git" / "HEAD").write_text("fixture\n", encoding="utf-8") + (lexical_root / ".codex").mkdir() + (lexical_root / ".codex" / "config.toml").write_text( + 'sqlite_home="outside-fixture"\n', encoding="utf-8") + alias = self.link(lexical_root / "alias", self.cwd) + self.database(alias) + actual_read = peer._read_codex_config_bytes + def read_only_fixture(path): + if not os.path.abspath(path).startswith(str(self.base) + os.sep): + raise AssertionError("outside-fixture configuration read") + return actual_read(path) + with mock.patch.object(peer, "_read_codex_config_bytes", side_effect=read_only_fixture), \ + mock.patch("ctypes.CDLL", side_effect=AssertionError("native preferences read")): + peer.check_codex_consumer_storage(self.root, THREAD) + + def test_consumer_alias_refuses_dry_run_and_send_without_child(self): + alias = self.link(self.base / "alias", self.cwd) + self.database(alias) + self.relocation() + with mock.patch.object(peer, "known_codex_homes", return_value=[self.root]), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer.subprocess, "run") as run, \ + mock.patch.object(peer.subprocess, "Popen") as start: + for dry_run in (True, False): + self.args.dry_run = dry_run + self.assert_storage_refused(lambda: peer.queue_codex(self.args, "fixture")) + run.assert_not_called() + start.assert_not_called() + + def test_queue_plan_and_pwd_use_the_same_physical_home(self): + alias = self.link(self.base / "home-alias", self.root) + self.database(self.cwd) + planned = peer.codex_queue_cwd(alias, None) + self.assertEqual(planned, self.root) + env = peer.codex_process_environment(self.root, cwd=alias) + if not peer.IS_WINDOWS: + self.assertEqual(env["PWD"], str(self.root)) + self.assertEqual(peer.codex_queue_storage_preflight(self.args, self.root, THREAD), self.root) + + def test_resolution_errors_are_sanitized_and_do_not_start_a_child(self): + for error in (OSError("private-path"), RuntimeError("private-loop"), ValueError("private-value")): + with self.subTest(kind=type(error).__name__), \ + mock.patch.object(Path, "resolve", side_effect=error), \ + mock.patch.object(peer.subprocess, "run") as run, \ + mock.patch.object(peer.subprocess, "Popen") as start: + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_physical_cwd(self.cwd) + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + self.assertIs(caught.exception.details["submitted"], False) + self.assertNotIn("private-", str(caught.exception)) + run.assert_not_called() + start.assert_not_called() + + def test_dangling_and_loop_cwds_are_refused(self): + dangling = self.link(self.base / "dangling", self.base / "absent") + loop = self.link(self.base / "loop", self.base / "loop") + for path in (dangling, loop): + with self.subTest(kind=path.name): + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_physical_cwd(path) + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + self.assertIs(caught.exception.details["submitted"], False) + self.assertNotIn(str(self.base), str(caught.exception)) + + def test_cwd_must_be_a_directory(self): + with self.assertRaises(peer.CcPeerError) as caught: + peer.codex_physical_cwd(self.rollout) + self.assertEqual(caught.exception.details["reason"], "storage_config_unverifiable") + + def test_consumer_alias_retarget_at_effect_boundary_refuses_queue(self): + safe = self.base / "safe" + safe.mkdir() + alias = self.link(self.base / "alias", safe) + self.database(alias) + self.relocation() + + def revalidate(*args, **kwargs): + alias.unlink() + alias.symlink_to(self.cwd, target_is_directory=True) + + with mock.patch.object(peer, "resolve_codex_home", return_value=(self.root, {})), \ + mock.patch.object(peer, "codex_executable", return_value="fixture"), \ + mock.patch.object(peer, "revalidate_codex_home", side_effect=revalidate), \ + mock.patch.object(peer.subprocess, "run") as run: + self.assert_storage_refused(lambda: peer._queue_codex(self.args, "fixture")) + run.assert_not_called() + + def test_translated_consumer_keeps_the_explicit_unverified_contract(self): + self.database(self.base / "missing-consumer") + with mock.patch.object(peer, "IS_WINDOWS", False), \ + mock.patch.object(peer, "_codex_thread_directory") as inspect: + peer.check_codex_consumer_storage(self.root, THREAD, native_home=r"C:\fixture\home") + inspect.assert_not_called() + + @unittest.skipUnless(sys.platform in ("darwin", "linux"), "wake supports macOS/Linux only") + def test_wake_preflight_returns_physical_cwd_and_refuses_its_layers(self): + alias = self.link(self.base / "alias", self.cwd) + self.database(alias) + with mock.patch.object(peer.subprocess, "run", return_value=subprocess.CompletedProcess( + [], 0, "codex-cli 0.154.0\n", "")), \ + mock.patch.object(peer, "inspect_codex_writer", return_value={"activity": "inactive"}): + self.assertEqual(peer.codex_wake_preflight(self.root, THREAD, "fixture")["cwd"], str(self.cwd)) + self.relocation() + self.assert_storage_refused(lambda: peer.codex_wake_preflight(self.root, THREAD, "fixture")) + + @unittest.skipUnless(sys.platform in ("darwin", "linux"), "wake supports macOS/Linux only") + def test_wake_child_and_resume_share_physical_cwd(self): + alias = self.link(self.base / "alias", self.cwd) + + class Input(io.BytesIO): + def close(self): + pass + + incoming = Input() + process = mock.Mock() + process.stdin = incoming + process.stdout.fileno.return_value = 47 + replies = b"\n".join(json.dumps(value).encode() for value in ( + {"id": 1, "result": {}}, + {"id": 2, "result": {"thread": {"id": THREAD}}}, + {"method": "turn/completed", "params": {"threadId": THREAD, "turn": {"status": "completed"}}}, + )) + b"\n" + selector = mock.Mock() + selector.select.return_value = [(None, None)] + with mock.patch.object(peer.subprocess, "Popen", return_value=process) as start, \ + mock.patch.object(peer.selectors, "DefaultSelector", return_value=selector), \ + mock.patch.object(peer.os, "read", return_value=replies), \ + mock.patch.object(peer, "stop_codex_wake"): + result = peer.run_codex_wake("fixture", self.root, THREAD, str(alias), 1) + self.assertEqual(result["status"], "completed") + self.assertEqual(start.call_args.kwargs["cwd"], str(self.cwd)) + self.assertEqual(start.call_args.kwargs["env"]["PWD"], str(self.cwd)) + frames = [json.loads(line) for line in incoming.getvalue().splitlines()] + resume = next(frame for frame in frames if frame.get("method") == "thread/resume") + self.assertEqual(resume["params"]["cwd"], str(self.cwd)) diff --git a/tests/codex/test_wake.py b/tests/codex/test_wake.py index 9b76b52b..7c9c3e40 100644 --- a/tests/codex/test_wake.py +++ b/tests/codex/test_wake.py @@ -1,5 +1,6 @@ """Wake lifecycle, queue preservation and native process cleanup tests.""" import argparse +import base64 import contextlib import io import json @@ -13,7 +14,7 @@ from unittest import mock import session_peer as peer -from tests.codex.support import THREAD +from tests.codex.support import THREAD, isolate_native_layers @unittest.skipUnless(sys.platform in ('darwin', 'linux'), 'POSIX wake') @@ -22,6 +23,7 @@ def setUp(self): temp = tempfile.TemporaryDirectory() self.addCleanup(temp.cleanup) self.root = Path(temp.name).resolve() + isolate_native_layers(self, self.root) self.rollout = self.root / 'rollout.jsonl' self.rollout.write_text('initialized\n') with contextlib.closing(sqlite3.connect(self.root / 'state_5.sqlite')) as conn: @@ -73,6 +75,113 @@ def test_unsupported_version_never_submits(self): self.assertEqual(error.exception.details['wake']['reason'], 'unsupported_version') queue.assert_not_called() + def ordinary_preferences(self, *, forced_only=True): + # Synthetic ordinary (not forced) preference; never access CF/user state. + return [] if forced_only else [base64.b64encode(b'sqlite_home="fixture"').decode('ascii')] + + def test_wake154_ordinary_preference_refuses_before_queue_and_budget(self): + for dry_run in (False, True): + self.args.dry_run = dry_run + with self.subTest(dry_run=dry_run), \ + mock.patch.object(peer, 'codex_managed_preference_values', side_effect=self.ordinary_preferences), \ + mock.patch.object(peer.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'codex-cli 0.154.0', '')), \ + mock.patch.object(peer, '_queue_codex') as queue, \ + mock.patch.object(peer, 'wake_target_rate_reserve') as reserve, \ + mock.patch.object(peer.subprocess, 'Popen') as start: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, 'fixture') + self.assertEqual(caught.exception.details['reason'], 'sqlite_home_managed_configuration') + self.assertNotIn('fixture', str(caught.exception)) + queue.assert_not_called() + reserve.assert_not_called() + start.assert_not_called() + + def test_unsupported_wake_never_reads_ordinary_preferences(self): + def read(*, forced_only=True): + self.assertTrue(forced_only) + return [] + with mock.patch.object(peer, 'codex_managed_preference_values', side_effect=read), \ + mock.patch.object(peer.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'codex-cli 0.999.0', '')): + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, 'fixture') + self.assertEqual(caught.exception.details['wake']['reason'], 'unsupported_version') + + def test_ordinary_preference_mode_is_wake_only_and_rechecked_before_budget(self): + with mock.patch.object(peer, 'codex_managed_preference_values', side_effect=self.ordinary_preferences): + peer.check_codex_storage(self.root, cwd=self.root) + peer.codex_process_environment(self.root, cwd=self.root) + with self.assertRaises(peer.CcPeerError): + peer.codex_queue_storage_preflight(self.args, self.root, THREAD) + self.args.wake = False + peer.codex_queue_storage_preflight(self.args, self.root, THREAD) + + def test_wake_queue_effect_rechecks_new_ordinary_preference(self): + self.args.allow_inactive_codex_home = True + with mock.patch.object(peer, 'resolve_codex_home', return_value=(self.root, {})), \ + mock.patch.object(peer, 'codex_queue_storage_preflight', return_value=self.root), \ + mock.patch.object(peer, 'revalidate_codex_home'), \ + mock.patch.object(peer, 'codex_managed_preference_values', side_effect=self.ordinary_preferences), \ + mock.patch.object(peer.subprocess, 'run') as run: + with self.assertRaises(peer.CcPeerError) as caught: + peer._queue_codex(self.args, 'fixture') + self.assertEqual(caught.exception.details['reason'], 'sqlite_home_managed_configuration') + run.assert_not_called() + + def test_active_wake_also_checks_154_queue_preferences(self): + active = {'activity': 'live_writer', 'ownerStable': True} + with mock.patch.object(peer, 'codex_managed_preference_values', side_effect=self.ordinary_preferences), \ + mock.patch.object(peer, 'inspect_codex_writer', return_value=active), \ + mock.patch.object(peer.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'codex-cli 0.154.0', '')), \ + mock.patch.object(peer, '_queue_codex') as post, \ + mock.patch.object(peer, 'wake_target_rate_reserve') as reserve: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, 'fixture') + self.assertEqual(caught.exception.details['reason'], 'sqlite_home_managed_configuration') + post.assert_not_called() + reserve.assert_not_called() + + def test_ordinary_preference_appearing_after_guarded_preflight_does_not_reserve(self): + calls = {'n': 0} + def preflight(*unused): + calls['n'] += 1 + if calls['n'] == 2: + reader.side_effect = self.ordinary_preferences + return {'cwd': str(self.root), 'writer': {'activity': 'inactive'}} + with mock.patch.object(peer, 'codex_wake_preflight', side_effect=preflight), \ + mock.patch.object(peer, 'codex_managed_preference_values', return_value=[]) as reader, \ + mock.patch.object(peer, '_queue_codex') as post, \ + mock.patch.object(peer, 'wake_target_rate_reserve') as reserve: + with self.assertRaises(peer.CcPeerError) as caught: + peer.queue_codex(self.args, 'fixture') + self.assertEqual(caught.exception.details['reason'], 'sqlite_home_managed_configuration') + self.assertEqual(calls['n'], 2) + post.assert_not_called() + reserve.assert_not_called() + + def test_wake154_allows_ordinary_preference_without_storage(self): + encoded = base64.b64encode(b'model="fixture"').decode('ascii') + with mock.patch.object(peer, 'codex_managed_preference_values', return_value=[encoded]): + result = self.preflight() + environment = peer.codex_process_environment(self.root, cwd=self.root, include_ordinary_preferences=True) + self.assertEqual(result['version'], 'codex-cli 0.154.0') + self.assertEqual(environment['CODEX_HOME'], str(self.root)) + + def test_ordinary_preference_appearing_after_queue_prevents_wake_child_without_resend(self): + def queue(*unused): + reader.side_effect = self.ordinary_preferences + return dict(self.submitted) + with mock.patch.object(peer, 'codex_wake_preflight', return_value={'cwd': str(self.root)}), \ + mock.patch.object(peer, '_queue_codex', side_effect=queue) as post, \ + mock.patch.object(peer, 'codex_managed_preference_values', return_value=[]) as reader, \ + mock.patch.object(peer.subprocess, 'Popen') as start: + result = peer.queue_codex(self.args, 'fixture') + self.assertTrue(result['submitted']) + self.assertEqual(result['queueId'], 'queue-test') + self.assertFalse(result['ok']) + self.assertEqual(result['wake']['status'], 'unknown') + post.assert_called_once() + start.assert_not_called() + def test_guard_blocks_second_submission(self): with peer.codex_wake_guard(self.root, THREAD): with self.assertRaises(peer.CcPeerError) as error: diff --git a/tests/codex/test_wake_chain.py b/tests/codex/test_wake_chain.py index c6bd497b..f8f9bb3a 100644 --- a/tests/codex/test_wake_chain.py +++ b/tests/codex/test_wake_chain.py @@ -12,7 +12,7 @@ import session_peer as peer import session_peer_mcp as mcp_peer -from tests.codex.support import THREAD +from tests.codex.support import THREAD, isolate_native_layers ORIGIN = '01900000-0000-7000-8000-000000000002' CONTEXT = {'depth': 2, 'origin': ORIGIN, 'maxDepth': 3} @@ -25,6 +25,7 @@ def setUp(self): env = patch.dict(os.environ, clean, clear=True) env.start() self.addCleanup(env.stop) + isolate_native_layers(self) def args(self, **values): return argparse.Namespace(wake=True, wake_timeout=1, to='codex:'+THREAD, **values) @@ -135,8 +136,11 @@ def test_rate_rejection_precedes_queue_and_preserves_active_and_dryrun(self): root = Path(folder) inactive = {'activity':'inactive'} submitted = {'ok':True, 'submitted':True, 'queueId':'fixture-only', 'consumptionConfirmed':False} + # Storage refusal ordering is covered by test_storage_layers; this + # unit test isolates the rate budget from every other collaborator. with patch.object(peer, 'resolve_codex_home', return_value=(root, {})), \ patch.object(peer, 'revalidate_codex_home'), \ + patch.object(peer, 'codex_queue_storage_preflight', return_value=root), \ patch.object(peer, 'codex_executable', return_value='fixture-codex'), \ patch.object(peer, 'codex_wake_preflight', return_value={'cwd':folder, 'writer':inactive}) as preflight, \ patch.object(peer, 'inspect_codex_writer', return_value=inactive) as inspect, \ diff --git a/tests/core/test_handoff_codex.py b/tests/core/test_handoff_codex.py index 1ca64cb6..b70d2a3c 100644 --- a/tests/core/test_handoff_codex.py +++ b/tests/core/test_handoff_codex.py @@ -1,5 +1,6 @@ """Owned SQLite/fake queue child tests, never a native Codex/model session.""" import contextlib +import base64 import io import json import os @@ -16,6 +17,7 @@ from unittest import mock import session_peer as peer +from tests.codex.support import isolate_native_layers @unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "bounded owned POSIX queue child") @@ -24,6 +26,7 @@ def setUp(self): self.directory = tempfile.TemporaryDirectory(prefix="codex-ho-queue-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) self.addCleanup(self.directory.cleanup) self.root = Path(self.directory.name).resolve() + isolate_native_layers(self, self.root) self.home = self.root / "owned-home" self.home.mkdir() self.thread = "12345678-abcd-1234-abcd-123456789abc" @@ -41,7 +44,7 @@ def setUp(self): def make_queue(self, ending="", version="codex-cli 0.159.0", receipt=True, version_ending="raise SystemExit(0)"): code = "import json,os,sys,time\n" code += "if sys.argv[1:] == ['--version']:\n print(" + repr(version) + ", flush=True)\n " + version_ending + "\n" - code += "with open(" + repr(str(self.log)) + ", 'w') as f: json.dump({'argv':sys.argv[1:],'home':os.environ.get('CODEX_HOME'),'sqliteEnv':os.environ.get('CODEX_SQLITE_HOME')}, f)\n" + code += "with open(" + repr(str(self.log)) + ", 'w') as f: json.dump({'argv':sys.argv[1:],'home':os.environ.get('CODEX_HOME'),'sqliteEnv':os.environ.get('CODEX_SQLITE_HOME'),'cwd':os.getcwd(),'pwd':os.environ.get('PWD')}, f)\n" if receipt: code += "print('Queued message owned-queue-01 for thread ' + sys.argv[sys.argv.index('--thread')+1] + '.', flush=True)\n" code += ending + "\n" @@ -63,6 +66,141 @@ def invoke(self, extras=(), correlation=None, body=None): code = peer.main(command) return code, json.loads(output.getvalue()) + def prepared_correlation(self): + args = peer.build_parser().parse_args(["send", "--to", "codex:" + self.thread, + "--codex-home", str(self.home), "--allow-inactive-codex-home"]) + with mock.patch.object(peer, "known_codex_homes", return_value=[self.home]): + binding, generation, _ = peer.handoff_binding(args, self.body) + return self.ledger.prepare(binding, generation, native_context=peer.handoff_native_context(args))[1]["id"] + + def test_handoff_storage_plan_pins_home_not_caller_project(self): + caller = self.root / "caller" + (caller / ".codex").mkdir(parents=True) + (caller / ".codex" / "config.toml").write_text('sqlite_home="caller-relocation-fixture"\n') + # The temporary caller directory is deliberately NOT the consumer cwd. + with mock.patch.object(peer.os, "getcwd", return_value=str(caller)): + code, dry = self.invoke(["--dry-run"], correlation=self.prepared_correlation()) + self.assertEqual(code, 0, dry) + self.assertFalse(self.log.exists()) + code, sent = self.invoke(["--request-ack"]) + self.assertEqual(code, 0, sent) + native = json.loads(self.log.read_text()) + self.assertEqual(native["cwd"], str(self.home)) + self.assertEqual(native["pwd"], str(self.home)) + self.assertEqual(native["home"], str(self.home)) + + def test_handoff_existing_version_probe_selects_ordinary_preference_policy(self): + def values(*, forced_only=True): + return [] if forced_only else [base64.b64encode(b'sqlite_home="ordinary-fixture"').decode("ascii")] + for version, expected in (("codex-cli 0.154.0", 1), ("codex-cli 0.162.1", 0)): + self.make_queue(version=version) + if self.log.exists(): + self.log.unlink() + with self.subTest(version=version), \ + mock.patch.object(peer, "codex_managed_preference_values", side_effect=values): + code, result = self.invoke(["--request-ack"]) + self.assertEqual(code, expected, result) + self.assertEqual(self.log.exists(), expected == 0) + if expected: + self.assertEqual(result["handoff"]["submission"]["status"], "refused") + self.assertFalse(result["handoff"]["retry"]["allowed"]) + + def test_handoff_retains_154_policy_for_effect_boundary(self): + self.make_queue(version="codex-cli 0.154.0") + actual = peer.handoff_codex_context + def changed(*args): + context = actual(*args) + reader.side_effect = lambda *, forced_only=True: ([] if forced_only else + [base64.b64encode(b'sqlite_home="late-ordinary-fixture"').decode("ascii")]) + return context + with mock.patch.object(peer, "codex_managed_preference_values", return_value=[]) as reader, \ + mock.patch.object(peer, "handoff_codex_context", side_effect=changed): + code, result = self.invoke(["--request-ack"]) + self.assertEqual(code, 1, result) + self.assertEqual(result["reason"], "native_submission_refused") + self.assertFalse(result["handoff"]["retry"]["allowed"]) + self.assertFalse(self.log.exists()) + + def test_consumer_storage_refusal_never_starts_native_or_attempts_intent(self): + consumer = self.root / "consumer" + (consumer / ".codex").mkdir(parents=True) + (consumer / ".codex" / "config.toml").write_text('sqlite_home="consumer-relocation-fixture"\n') + with contextlib.closing(sqlite3.connect(self.home / "state_5.sqlite")) as db: + db.execute("UPDATE threads SET cwd=?", (str(consumer),)) + db.commit() + for extra in (["--dry-run"], ["--request-ack"]): + with self.subTest(extra=extra), mock.patch.object(peer, "handoff_child") as child: + code, result = self.invoke(extra, correlation=self.prepared_correlation()) + self.assertEqual(code, 1, result) + self.assertEqual(result["handoff"]["submission"]["status"], "refused") + self.assertFalse(result["handoff"]["retry"]["allowed"]) + record = self.ledger.record(result["handoff"]["correlationId"])[1] + self.assertNotEqual(record["phase"], "attempted") + child.assert_not_called() + self.assertFalse(self.log.exists()) + + def test_storage_change_after_durable_intent_refuses_without_resubmission(self): + actual = peer.handoff_codex_submit + correlation = self.prepared_correlation() + def changed(args, text, binding, context, cutoff, total): + record = self.ledger.record(correlation)[1] + self.assertEqual(record["phase"], "attempted") + (self.home / "config.toml").write_text('sqlite_home="late-relocation-fixture"\n') + return actual(args, text, binding, context, cutoff, total) + with mock.patch.object(peer, "handoff_codex_submit", side_effect=changed): + code, result = self.invoke(["--request-ack"], correlation=correlation) + self.assertEqual(code, 1, result) + self.assertEqual(result["reason"], "native_submission_refused") + self.assertEqual(result["handoff"]["submission"]["status"], "refused") + self.assertFalse(result["handoff"]["retry"]["allowed"]) + self.assertFalse(self.log.exists()) + (self.home / "config.toml").unlink() + with mock.patch.object(peer, "handoff_codex_submit") as submit: + code, repeated = self.invoke(["--request-ack"], correlation=result["handoff"]["correlationId"]) + self.assertEqual(code, 1, repeated) + submit.assert_not_called() + + def test_consumer_change_after_intent_is_rechecked_before_native_queue(self): + actual = peer.handoff_codex_submit + consumer = self.root / "consumer" + consumer.mkdir() + with contextlib.closing(sqlite3.connect(self.home / "state_5.sqlite")) as db: + db.execute("UPDATE threads SET cwd=?", (str(consumer),)) + db.commit() + def changed(*args): + (consumer / ".codex").mkdir() + (consumer / ".codex" / "config.toml").write_text('sqlite_home="late-consumer-fixture"\n') + return actual(*args) + with mock.patch.object(peer, "handoff_codex_submit", side_effect=changed): + code, result = self.invoke(["--request-ack"]) + self.assertEqual(code, 1, result) + self.assertEqual(result["reason"], "native_submission_refused") + self.assertEqual(result["handoff"]["submission"]["status"], "refused") + self.assertFalse(result["handoff"]["retry"]["allowed"]) + self.assertFalse(self.log.exists()) + + def test_translated_native_home_keeps_ordinary_unverified_execution_plan(self): + args = peer.build_parser().parse_args(["send", "--to", "codex:" + self.thread, + "--codex-home", str(self.home), "--codex-bin", str(self.fake), "--allow-inactive-codex-home"]) + with mock.patch.object(peer, "known_codex_homes", return_value=[self.home]): + binding, _, _ = peer.handoff_binding(args, self.body) + args.codex_native_home = r"C:\owned-native-home" + done = {"interrupted": False, "reason": None, "returncode": 0, + "stdout": b"codex-cli 0.159.0\n", "stderr": b"", "spawned": True, "cleanupFailed": False} + cutoff = peer.handoff_now() + 3 + with mock.patch.object(peer, "codex_queue_cwd", return_value=self.root) as cwd_plan, \ + mock.patch.object(peer, "handoff_child", return_value=done) as child: + context = peer.handoff_codex_context(args, binding, cutoff, cutoff + 1) + version_call = child.call_args + done["stdout"] = ("Queued message fixture-id for thread " + self.thread + ".\n").encode() + result = peer.handoff_codex_submit(args, self.body, binding, context, cutoff, cutoff + 1) + cwd_plan.assert_called_once_with(self.home, args.codex_native_home) + self.assertEqual(version_call.kwargs["cwd"], self.root) + self.assertEqual(version_call.args[3]["CODEX_HOME"], args.codex_native_home) + self.assertEqual(child.call_args.kwargs["cwd"], self.root) + self.assertEqual(child.call_args.args[3]["CODEX_HOME"], args.codex_native_home) + self.assertTrue(result["submitted"]) + def test_dry_run_retains_original_home_and_refuses_changed_resolution(self): def prepare(): args = peer.build_parser().parse_args(["send", "--to", "codex:" + self.thread, diff --git a/tests/core/test_handoff_process.py b/tests/core/test_handoff_process.py index e8e482b2..c1613e63 100644 --- a/tests/core/test_handoff_process.py +++ b/tests/core/test_handoff_process.py @@ -109,19 +109,50 @@ def test_early_stdin_close_preserves_complete_raw_response(self): self.assertFalse(result.cleanup_failed) def test_own_term_resistant_forked_descendant_is_not_left_executing(self): - code = '''import os, signal, time + # Python/runner startup is not the cleanup behavior under test. Give + # only this fixture a bounded (5s) readiness phase, then retain the + # original .2s execution deadline and .5s cleanup reserve. Production + # clocks and budgets are unchanged. An unready child still times out. + with tempfile.TemporaryDirectory(prefix="handoff-child-ready-") as directory: + ready = Path(directory) / "pid-ready" + real_monotonic = time.monotonic + startup = real_monotonic() + running = [None] + def fixture_clock(): + now = real_monotonic() + if running[0] is None: + try: + value = ready.read_text() + except FileNotFoundError: + value = "" + if value.strip().isdigit(): + running[0] = now + elif now - startup >= 5: + # Exhaust both budgets if the fixture never became ready. + return startup + 6 + return startup if running[0] is None else startup + now - running[0] + code = '''import os, signal, time +# Exercise readiness after a startup slower than the .2s cleanup-test budget. +time.sleep(.3) signal.signal(signal.SIGTERM, signal.SIG_IGN) child = os.fork() if child == 0: while True: time.sleep(10) print(child, flush=True) +with open(READY_PATH, 'w') as f: + f.write(str(child)) while True: time.sleep(10) -''' - result = self.run_child(code, seconds=.2) - self.assertEqual(result.reason, "process_deadline") - self.assertFalse(result.cleanup_failed) - self.assertEqual(result.returncode, -signal.SIGKILL) - self.assert_not_executing(int(result.stdout)) +'''.replace("READY_PATH", repr(str(ready))) + # Patch both clocks in this fixture: the primitive's independent + # monotonic fallback must use the same bounded readiness phase. + with patch.object(peer.time, "monotonic", side_effect=fixture_clock): + result = self.run_child(code, seconds=.2, clock=fixture_clock) + self.assertIsNotNone(running[0], "fixture failed to become ready within 5s") + self.assertEqual(result.reason, "process_deadline") + self.assertFalse(result.cleanup_failed) + self.assertEqual(result.returncode, -signal.SIGKILL) + self.assertEqual(int(result.stdout), int(ready.read_text())) + self.assert_not_executing(int(result.stdout)) def test_descendant_holding_pipes_after_leader_exit_is_bounded(self): code = '''import os, time diff --git a/tests/core/test_handoff_remote.py b/tests/core/test_handoff_remote.py index 57891182..7d6ddc80 100644 --- a/tests/core/test_handoff_remote.py +++ b/tests/core/test_handoff_remote.py @@ -316,6 +316,13 @@ def test_actual_owned_source_streamed_codex_submission_is_fenced_and_stripped(se + "scope={'__name__':'owned_source','__file__':" + repr(str(Path(peer.__file__).resolve())) + "}\n" + "exec(compile(source,'owned_stream','exec'),scope)\n" + "scope['known_codex_homes']=lambda *a,**k:[scope['Path'](" + repr(self.home) + ")]\n" + # Isolate this source-streamed stand-in from machine system/MDM + # settings. This is not a live native CFPreferences/API test. + + "assert all(n in scope for n in ('codex_system_config_paths','codex_managed_preference_values','codex_project_config_paths'))\n" + + "scope['codex_system_config_paths']=lambda:[]\n" + + "scope['codex_managed_preference_values']=lambda **kwargs:[]\n" + + "project_paths=scope['codex_project_config_paths']\n" + + "scope['codex_project_config_paths']=lambda *a,**k:[p for p in project_paths(*a,**k) if str(p).startswith(" + repr(str(self.fixture.root) + os.sep) + ")]\n" + "def forbidden(*a,**k): raise AssertionError('remote ledger/receipt authority forbidden')\n" + "scope['handoff_root']=forbidden\nscope['handoff_ensure_collector']=forbidden\nscope['handoff_producer_command']=forbidden\n" + "sys.argv=['owned_stream']+shlex.split(sys.argv[-1])[2:]\n" @@ -355,6 +362,11 @@ def streamed(argv, source, cutoff, total, clock, **kwargs): self.assertTrue(response["submitted"]) self.assertFalse(response["consumptionConfirmed"]) self.assertEqual(response["queueId"], "owned-queue-01") + # The streamed receiver derives cwd from its own selected home. The + # sender never serializes a local cwd into the private wire context. + native = json.loads(self.fixture.log.read_text()) + self.assertEqual(native["cwd"], self.home) + self.assertEqual(native["pwd"], self.home) self.assertIsNone(response["handoff"]["targetGeneration"]) self.assertEqual(response["handoff"]["ack"]["status"], "unsupported") for secret in ("handoffNative", "nativeContext", "homeSelection", "monotonicMs", "capability"): diff --git a/tests/fixtures/codex_storage_cli.py b/tests/fixtures/codex_storage_cli.py index 2863b9f1..d28f0104 100644 --- a/tests/fixtures/codex_storage_cli.py +++ b/tests/fixtures/codex_storage_cli.py @@ -10,6 +10,9 @@ (root / "fixture-environment.json").write_text(json.dumps({ "home": os.environ["CODEX_HOME"], "sqliteOverridePresent": "CODEX_SQLITE_HOME" in os.environ, + # Native queue loads project config layers from its working directory. + "cwd": os.getcwd(), + "pwd": os.environ.get("PWD"), }), encoding="utf-8") thread = sys.argv[sys.argv.index("--thread") + 1] print("Queued message fixture-storage for thread " + thread + ".")