diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ff611f0..15765d5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -10,6 +10,10 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest, macos-latest] + python: ['3.11'] + include: + - os: windows-latest + python: '3.13' runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -17,7 +21,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: ${{ matrix.python }} - run: python -m unittest discover -s tests -v - run: python scripts/check_package.py - name: Require a new version for distributed changes diff --git a/README.md b/README.md index 4ddf68c..9c7fe0c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Acecoreが日常利用で改善している分担原則を既定値として同梱します。親のモデル・effortを維持し、引継ぎ、同じ担当の継続、重要なレビューまで含めて、品質と総利用負担を考慮します。次回送信のモデル選択を勧める機能はありません。節約率は未実証です。 -0.8.0の基準運用は、**親Terra/xhighで通常作業を完結し、専門工程だけ固定effortの担当へ渡す**方式です。利用者が選んだ親設定は変更しません。 +現在の基準運用は、**親Terra/xhighで通常作業を完結し、専門工程だけ固定effortの担当へ渡す**方式です。利用者が選んだ親設定は変更しません。 | 担当 | 固定値 | 役割 | | --- | --- | --- | @@ -16,16 +16,14 @@ Acecoreが日常利用で改善している分担原則を既定値として同 Terra子は標準ルートから外します。min/max設定は互換性のため残し、自動選定は有効なdefault_effortに固定します。既存overrideと他モデル親は保持します。並列分担には親の独立作業が必要で、直列の専門委譲は具体的な追加価値とホストの許可を確認した場合だけ使います。 -## 必要な環境 +## 導入 + +必要な環境は次のとおりです。 - Codexのプラグイン、ライフサイクルフック、標準サブエージェントが使えるローカル環境。 - `python --version` がPython 3.11以上を返すこと。Python標準ライブラリだけを使います。 -- 自動更新にはCodex CLIとGitが必要です。定期実行の登録はWindowsに対応し、Windowsタスクスケジューラと同じPythonの`pythonw.exe`を使います。 - 選択したモデル・effortが、そのアカウントと実行環境で利用できること。 - -最初の検証対象はWindowsとCodex CLIです。設定の要求値を確認しても、実際の子がそのモデルで実行した証明にはなりません。起動時のツールと実行結果を別に確認します。 - -## 導入 +- 任意の自動更新にはCodex CLIとGitが必要です。定期実行の登録はWindowsに対応し、Windowsタスクスケジューラと同じPythonの`pythonw.exe`を使います。 次のコマンドで `main` の配布版を導入します。複数PCで同じ版を固定する場合は、`main` の代わりに同じcommit SHAまたは公開タグを指定してください。 @@ -43,53 +41,48 @@ codex plugin add codex-task-routing@codex-task-routing 導入後、CLIの `/hooks` でこのプラグインのフックを確認して信頼し、新しいタスクを開始してください。フックの定義を変更した版は、再確認が必要な場合があります。スキルの検出だけでは常時適用されません。[公式フック仕様](https://learn.chatgpt.com/docs/hooks) -開始・再開・コンパクション時に有効方針を読み込みます。子には短い引継ぎ用のコンテキストと参照先を渡します。フック自体はモデル呼出し・ネットワークアクセス・子の起動を行いません。 +## 適用確認 -### 普段の起動方法のまま自動更新する - -0.2.0以降では、Windowsで自動更新を一度登録すると、ログオン中に15分ごとに登録したGit refを確認します。`--ref main`で導入した場合はmainを追従し、固定したcommit SHAは維持します。**専用ショートカットは不要です。普段どおりCodexを開いてください。** +新しいタスクの開始時に有効方針の版、policy hash、参照先が渡されることを確認します。開始・再開・コンパクション時に有効方針を読み込み、子には短い引継ぎ用のコンテキストと参照先を渡します。フック自体はモデル呼出し・ネットワークアクセス・子の起動を行いません。 -プラグイン導入後、PowerShellで次を一度実行します。リポジトリのクローンは不要です。 +設定と競合はリポジトリ直下で診断できます。 -```powershell -$routingPlugin = (codex plugin list --marketplace codex-task-routing --json | ConvertFrom-Json).installed | - Where-Object pluginId -eq 'codex-task-routing@codex-task-routing' -python (Join-Path $routingPlugin.source.path 'scripts/install_updater.py') install +```text +python plugins/codex-task-routing/scripts/routing.py status --json +python plugins/codex-task-routing/scripts/routing.py render --output-dir outputs/effective ``` -リポジトリのcheckoutがある場合は`python scripts/install_updater.py install`でも登録できます。登録内容を先に確認するには`install --dry-run`を使います。管理者権限やパスワードの保存は不要です。インストーラーは自動更新用ファイルと本人用の定期タスクを作り、Codexの起動・終了や、その場でのプラグイン更新は行いません。 +`status` は設定・版・上書き・競合の診断です。ホストのフック信頼や実行モデルを推測して「有効」とは判定しません。実際の開始時に方針が渡されたことと、子の起動時のツール・実行結果を別に確認してください。最初の検証対象はWindowsとCodex CLIであり、設定の要求値だけでは実際の子がそのモデルで実行した証明になりません。 -自動更新の動作は次のとおりです。 +## 設定を変更する -- CodexとChatGPTのアプリ・CLIがすべて終了していれば、対象marketplaceだけを標準CLIで同期します。モデルを呼ばず、AIのトークンを消費しません。 -- 使用中、稼働状態が判別できない場合、別の更新処理が実行中の場合は、更新を見送って次の確認を待ちます。アプリを長時間開いたままの場合は、閉じた後の確認まで更新されません。 -- 通信・認証・CLIエラーでは、削除や再インストールを試みません。更新失敗は短い診断として残り、次の定期確認で再度確認します。利用者のアプリを停止・再起動しません。 -- 個別の`overrides.json`、親設定、指示、フックの信頼設定を編集しません。フック定義が変わった版では、Codex標準の`/hooks`による再確認が必要な場合があります。 +上書きは `$CODEX_HOME/codex-task-routing/overrides.json` へ保存します。`CODEX_HOME` が未設定なら `~/.codex` です。上書きがなければ作者の既定値を使います。プラグインの更新・解除でこの上書きファイルを編集・削除しません。 -登録状態と直近の結果の確認、定期実行の解除は次のコマンドで行えます。 +例として、Terraの標準effortだけ変更する場合は次のように指定します。 -```text -python scripts/install_updater.py status -python scripts/install_updater.py uninstall +```json +{ + "schema_version": 1, + "models": { + "terra": { "default_effort": "high" } + } +} ``` -checkoutがない場合は、上のPowerShell例と同じ`install_updater.py`のパスに`status`または`uninstall`を渡します。解除後も個別の上書きやCodex設定は残ります。自動更新用のファイルはプラグインの版別キャッシュの外へ保存し、プラグイン更新後は新しい更新処理を読み込みます。固定された起動インターフェースを変更する将来の版では再登録が必要になる場合があります。 - -診断先は`/codex-task-routing/updater/state/last-sync.json`です。認証情報やCLI出力全文は保存しません。PCの休止中・ログオフ中には実行せず、次に実行できるタイミングで確認します。通常起動と更新の開始を完全には排他できないため、**毎回の起動直前に必ず最新版になる保証はありません**。 - -Windows以外では定期実行の登録に未対応です。Codexが終了している時に`python plugins/codex-task-routing/scripts/updater.py`で同じ一回分の更新処理を実行できます。 +これは使い方の例で、既定値を下げる推奨ではありません。原則自体も `principles.summary` や対応する詳細節を置き換えて変更できます。[設定の詳細](plugins/codex-task-routing/skills/task-routing/references/configuration.md) -## 適用確認と変更 +## 任意機能 -### 通常Chatの一時Chat(GPT-6 Pro)へ渡す(任意) +### 通常Chatの一時Chat(GPT-6 Pro)へ渡す -0.7.0では、確認済み接続の操作・範囲・有効期限をローカル台帳に保持し、`chat_plan.py` が必要な項目だけ機械照合します。分類専用のAI呼出しや全接続の再調査を避け、`chat_transfer.py` の一回限りのローカルフォームで、準備済み本文と最終回答をBrowser内の変数のまま受け渡せます。親のモデルへ全文を出力して再転記する必要を減らします。[接続と分類](plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md)、[転送補助](plugins/codex-task-routing/skills/task-routing/references/chat-transfer.md)。これらは明示実行の補助で、フックから起動・送信しません。 +通常Chat経路は明示的に有効にした場合だけ使います。確認済み接続の操作・範囲・有効期限をローカル台帳に保持し、`chat_plan.py` が必要な項目だけ機械照合します。分類専用のAI呼出しや全接続の再調査を避け、`chat_transfer.py` の一回限りのローカルフォームで、準備済み本文と最終回答をBrowser内の変数のまま受け渡せます。親のモデルへ全文を出力して再転記する必要を減らします。[接続と分類](plugins/codex-task-routing/skills/task-routing/references/chat-capabilities.md)、[転送補助](plugins/codex-task-routing/skills/task-routing/references/chat-transfer.md)。これらは明示実行の補助で、フックから起動・送信しません。 通常ChatのGPT-6 ProはWork/Codexと別枠ですが、親の準備・操作・検収はCodex側に残ります。6 Proには契約別の上限があり、無制限ではありません。モデルや残量は実際のUIで確認し、第三者ツール固有の料金まで無料と扱いません。[公式の利用枠](https://help.openai.com/en/articles/20001354)、[Work/Codexの共有枠](https://learn.chatgpt.com/docs/pricing)。 -0.8.0では有効なroot経路において、6 Proを分析・設計・比較・文案・レビューの**最初の主担当候補**にします。実際の接続・書込み権限・検証環境がそろう実装・テスト・PR作成も対象です。短い仕事やCodex側の現物との頻繁な往復は親または専門子に残します。分類だけで外部書込みやマージを承認せず、同じ分析を別のAstraで全面再実行しません。 +有効なroot経路では、6 Proを分析・設計・比較・文案・レビューの**最初の主担当候補**にします。実際の接続・書込み権限・検証環境がそろう実装・テスト・PR作成も対象です。短い仕事やCodex側の現物との頻繁な往復は親または専門子に残します。分類だけで外部書込みやマージを承認せず、同じ分析を別のAstraで全面再実行しません。 Pro $200の週次枠を積極活用する運用ですが、契約価格だけで経路を有効化しません。[最新上限・送受信方法の許可・比較計画](plugins/codex-task-routing/skills/task-routing/references/chat-limits.md)を確認してください。送信直前は`chat_plan.py --live`で新しい観測を照合し、上限・モデル変更・許可不明で停止します。自動抽出に関する例外の許可は本プラグインでは確認していません。 + 新規依頼には、目的・材料・合格条件に加え、`handoff` で必要情報、出典要件、鮮度、許可ツール、返却形式、停止条件を渡します。MCPはChat側で必要な操作が使えるかを確認し、回答に実取得の出典・版や日時・取得範囲を求めます。設定済み・選択済みだけを取得成功と扱いません。本文だけの仕事はツールなしで完結させ、承認済み範囲の確認を繰り返しません。 `/codex-task-routing/chatgpt.json` に次を保存します。プラグインキャッシュ内には保存しません。 @@ -109,49 +102,57 @@ Pro $200の週次枠を積極活用する運用ですが、契約価格だけで 一時Chatでは `send_message_to_thread` を使いません。内部で `isTemporaryChat: false` となるためです。既に明示した `"transport": "codex-app-tools"` はlegacyとして互換を保ちますが、自動で書き換えず、一時Chatの作成・操作には使いません。既存の承認済みMCPは材料の受け渡しに使えます。独自MCPサーバーの公開・書き戻し・自動ファイル添付は含みません。通常Chatの生成はChatの枠、親の処理はCodexの枠を使い、接続先サービスやサーバーの費用は別途確認します。[運用手順・制約](plugins/codex-task-routing/skills/task-routing/references/chatgpt.md) と [browser不要化の課題](docs/backlog.md) を参照してください。 -### Codexサブエージェントの設定 +### 使用量の振返りを残す -リポジトリ直下で実行します。 +有効方針ごとに通常作業3件までを観測対象にします。開始時フックが残枠と記録状態を知らせ、AIが対象作業の開始と最終報告前に同じ記録を更新します。再開や子の仕事は同じ作業にまとめ、設定確認・監査は3件に含めません。プラグインの版だけが変わって分担方針が同じ場合も枠を取り直しません。 + +記録には親子の要求設定と確認できた実行設定、使用量の対象範囲、検証結果と手直しを残します。使用量を取得できない項目は理由付きの欠測になります。記録が閉じても、実測が揃ったとは限りません。会話ログ・DBを自動収集する仕組みや、毎回の計測用モデル実行はありません。 ```text -python plugins/codex-task-routing/scripts/routing.py status --json -python plugins/codex-task-routing/scripts/routing.py render --output-dir outputs/effective +python plugins/codex-task-routing/scripts/routing.py observation status --json ``` -`status` は設定・版・上書き・競合の診断です。ホストのフック信頼や実行モデルを推測して「有効」とは判定しません。実際の開始時に方針が渡されたかも確認してください。 +記録はCodexホームのプラグイン専用領域へ保存し、リポジトリや公開用ファイルへ会話内容を追加しません。入力例と状態の意味は[観測手順](plugins/codex-task-routing/skills/task-routing/references/observation.md)を参照してください。自動的な利用料の集計や節約率の算定は行いません。 -上書きは `$CODEX_HOME/codex-task-routing/overrides.json` へ保存します。`CODEX_HOME` が未設定なら `~/.codex` です。上書きがなければ作者の既定値を使います。プラグインの更新・解除でこの上書きファイルを編集・削除しません。 +### 普段の起動方法のまま自動更新する -例: Terraの標準effortだけ変更する場合。 +Windowsで自動更新を一度登録すると、ログオン中に15分ごとに登録したGit refを確認します。`--ref main`で導入した場合はmainを追従し、固定したcommit SHAは維持します。**専用ショートカットは不要です。普段どおりCodexを開いてください。** -```json -{ - "schema_version": 1, - "models": { - "terra": { "default_effort": "high" } - } -} +プラグイン導入後、PowerShellで次を一度実行します。リポジトリのクローンは不要です。 + +```powershell +$routingPlugin = (codex plugin list --marketplace codex-task-routing --json | ConvertFrom-Json).installed | + Where-Object pluginId -eq 'codex-task-routing@codex-task-routing' +python (Join-Path $routingPlugin.source.path 'scripts/install_updater.py') install ``` -これは使い方の例で、既定値を下げる推奨ではありません。原則自体も `principles.summary` や対応する詳細節を置き換えて変更できます。[設定の詳細](plugins/codex-task-routing/skills/task-routing/references/configuration.md) +リポジトリのcheckoutがある場合は`python scripts/install_updater.py install`でも登録できます。登録内容を先に確認するには`install --dry-run`を使います。管理者権限やパスワードの保存は不要です。インストーラーは自動更新用ファイルと本人用の定期タスクを作り、Codexの起動・終了や、その場でのプラグイン更新は行いません。 -## 使用量の振返りを残す +自動更新の動作は次のとおりです。 -0.3.1では、有効方針ごとに通常作業3件までを観測対象にします。開始時フックが残枠と記録状態を知らせ、AIが対象作業の開始と最終報告前に同じ記録を更新します。再開や子の仕事は同じ作業にまとめ、設定確認・監査は3件に含めません。プラグインの版だけが変わって分担方針が同じ場合も枠を取り直しません。 +- CodexとChatGPTのアプリ・CLIがすべて終了していれば、対象marketplaceだけを標準CLIで同期します。モデルを呼ばず、AIのトークンを消費しません。 +- 使用中、稼働状態が判別できない場合、別の更新処理が実行中の場合は、更新を見送って次の確認を待ちます。アプリを長時間開いたままの場合は、閉じた後の確認まで更新されません。 +- 通信・認証・CLIエラーでは、削除や再インストールを試みません。更新失敗は短い診断として残り、次の定期確認で再度確認します。利用者のアプリを停止・再起動しません。 +- 個別の`overrides.json`、親設定、指示、フックの信頼設定を編集しません。フック定義が変わった版では、Codex標準の`/hooks`による再確認が必要な場合があります。 -記録には親子の要求設定と確認できた実行設定、使用量の対象範囲、検証結果と手直しを残します。使用量を取得できない項目は理由付きの欠測になります。記録が閉じても、実測が揃ったとは限りません。会話ログ・DBを自動収集する仕組みや、毎回の計測用モデル実行はありません。 +登録状態と直近の結果の確認、定期実行の解除は次のコマンドで行えます。 ```text -python plugins/codex-task-routing/scripts/routing.py observation status --json +python scripts/install_updater.py status +python scripts/install_updater.py uninstall ``` -記録はCodexホームのプラグイン専用領域へ保存し、リポジトリや公開用ファイルへ会話内容を追加しません。入力例と状態の意味は[観測手順](plugins/codex-task-routing/skills/task-routing/references/observation.md)を参照してください。自動的な利用料の集計や節約率の算定は行いません。 +checkoutがない場合は、上のPowerShell例と同じ`install_updater.py`のパスに`status`または`uninstall`を渡します。解除後も個別の上書きやCodex設定は残ります。自動更新用のファイルはプラグインの版別キャッシュの外へ保存し、プラグイン更新後は新しい更新処理を読み込みます。固定された起動インターフェースを変更する将来の版では再登録が必要になる場合があります。 + +診断先は`/codex-task-routing/updater/state/last-sync.json`です。認証情報やCLI出力全文は保存しません。PCの休止中・ログオフ中には実行せず、次に実行できるタイミングで確認します。通常起動と更新の開始を完全には排他できないため、**毎回の起動直前に必ず最新版になる保証はありません**。 + +Windows以外では定期実行の登録に未対応です。Codexが終了している時に`python plugins/codex-task-routing/scripts/updater.py`で同じ一回分の更新処理を実行できます。 ## 更新・同じ版の再現・解除 同じ版を再現する場合、両PCの取得元のrefを同じcommit SHAまたは公開タグにそろえ、同じ上書きを用意します。診断のpolicy hashも比較します。モデルの生成結果まで同一になるという意味ではありません。 -自動更新を登録せず手動更新する場合や0.1.0から移行する場合は、Codexアプリ・CLIをすべて終了してから次を実行します。`upgrade`だけで導入済みプラグインも更新します。 +自動更新を登録せず手動更新する場合は、Codexアプリ・CLIをすべて終了してから次を実行します。`upgrade`だけで導入済みプラグインも更新します。 ```text codex plugin marketplace upgrade codex-task-routing @@ -166,7 +167,7 @@ codex plugin marketplace add acecore-systems/codex-task-routing --ref COMMIT_SHA codex plugin add codex-task-routing@codex-task-routing ``` -配布ファイルを変更するPRではmanifestの版も更新します。CIは同じ版のまま配布内容が変わることを拒否します。ローカルの開発ではmanifestのcachebusterまたは版を更新して再導入します。更新は旧導入コピーを削除するため、作業中のフックから自己更新しません。起動後の新しいタスクで適用を確認します。 +配布ファイルを変更するPRではmanifestの版も上げます。CIはSemVerとして不正な版、同じ版、後退、build metadataだけの変更を拒否します。ローカルの開発でもmanifestの版を上げて再導入します。更新は旧導入コピーを削除するため、作業中のフックから自己更新しません。起動後の新しいタスクで適用を確認します。 プラグインを解除する場合は、先に`install_updater.py uninstall`で定期実行を解除し、次を実行します。通常のCodex設定や上書きファイルは残ります。 @@ -174,9 +175,15 @@ codex plugin add codex-task-routing@codex-task-routing codex plugin remove codex-task-routing@codex-task-routing ``` -## 既存の分担指示がある場合 +## トラブル対応 -Codexホームや作業先の `AGENTS.md` / `AGENTS.override.md` に既知の分担指示があると、二重適用を防ぐためフックは新方針を追加せず競合を知らせます。自動で旧指示を削除しません。旧指示をバックアップして対象の分担節だけ移行し、新しいタスクで適用を確認してください。異なる表現の類似方針をすべて機械検出できるわけではありません。 +- **新しいタスクに方針が表示されない:** `/hooks` でフックの存在と信頼状態を確認します。スキルの検出だけでは常時適用されません。フック定義が変わった後は再確認が必要な場合があります。 +- **`status --json` がエラーを返す:** `error_code` で原因の種類を、`hint` で秘密値を含まない対処を確認します。既存の `error` フィールドも互換性のため維持します。設定ファイルを自動修正する診断ではありません。各コードの意味は[設定の詳細](plugins/codex-task-routing/skills/task-routing/references/configuration.md)を参照してください。 +- **既存の分担指示との競合が表示される:** Codexホームや作業先の `AGENTS.md` / `AGENTS.override.md` に既知の分担指示があると、二重適用を防ぐためフックは新方針を追加しません。旧指示を自動削除しないため、バックアップして対象の分担節だけ移行し、新しいタスクで適用を確認します。異なる表現の類似方針をすべて機械検出できるわけではありません。 +- **要求したモデルやeffortで動いたか不明:** `status` の要求値だけでは実行を証明できません。子の起動時のツールと実行結果を確認します。モデルとeffortの利用可否はホストごとに異なります。 +- **通常Chat経路が有効にならない:** `chatgpt.json`、新しいタスクのフック表示、ログイン済みBrowser、UIの `6 Pro` 表示を順に確認します。上限・モデル変更・許可不明の場合は送信しません。 +- **自動更新されない:** `install_updater.py status` と `last-sync.json` を確認します。CodexかChatGPTが稼働中、稼働状態が不明、別更新が実行中、PCが休止・ログオフ中の場合は更新を見送ります。 +- **別の版へ戻す、または固定する:** 実行中のCodexアプリ・CLIを終了し、上の再登録手順でcommit SHAまたは公開タグを指定します。上書きは自動削除されません。 ## 開発と検証 @@ -185,10 +192,10 @@ python -m unittest discover -s tests -v python scripts/check_package.py ``` -テストでは既定本文の再現、上書き・無効設定、開始イベント、既存指示との競合、ファイル保護を検証します。実際のフック信頼操作・通常会話の分担判断・他OSは別の確認対象です。検証実績は [docs/validation.md](docs/validation.md) を参照してください。 +テストでは既定本文の再現、上書き・無効設定、開始イベント、既存指示との競合、ファイル保護を検証します。実際のフック信頼操作・通常会話の分担判断・他OSは別の確認対象です。検証実績は [docs/validation.md](docs/validation.md)、版ごとの変更は [docs/changelog.md](docs/changelog.md) を参照してください。 詳細は必要な節だけ読みます。方針の正本は `defaults/config.json`、分類と引継ぎのテンプレートは `defaults/templates/` にあります。既定の元文書は個人パスと適用経路を調整し、意味を保った正本スナップショットと照合しています。 このプラグインは認証・会話履歴・メモリ・契約の利用率を読みません。方針フックはローカルの有効方針キャッシュを書き出し、観測台帳は読み取るだけです。観測CLIはAIから渡された短い構造化データを専用台帳へ保存します。任意の自動更新機能は専用ファイル・設定・同期診断とWindowsの定期タスクを作成し、標準CLIによるmarketplace更新を行います。内部ログ解析や常時計測は含みません。 -MIT License · Acecore +MIT License。詳細は [plugins/codex-task-routing/LICENSE](plugins/codex-task-routing/LICENSE) を参照してください。 diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..63b233b --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,39 @@ +# 変更履歴 + +現在の導入・適用確認・任意機能・トラブル対応は [README](../README.md) を参照してください。各版で確認した環境と未検証範囲は [検証記録](validation.md) に残しています。 + +## 0.8.2 + +- release検査をSemVerの正しいprecedence比較へ変更し、同一版、版の後退、build metadataだけの変更、不正な版を拒否するようにしました。 +- `status --json` の失敗結果に機械判定用の `error_code` と秘密値を含まない固定の `hint` を追加しました。 +- 既存のCLIと方針を保ったままrouting実装を責務別モジュールへ分割し、Windows / Python 3.13をCI対象に追加しました。 +- READMEを導入、適用確認、任意機能、トラブル対応の順に整理しました。 + +## 0.8.1 + +- PRレビューで、通常Chat向けbundleのシリアライズ後のサイズ境界を検査し、初回操作の権限確認と実行済み確認を区別する `permission_checked` を追加しました。 + +## 0.8.0 + +- 基準運用を親Terra/xhigh、子Luna/max・Sol/high・Astra/highの固定effortへ更新しました。Terra子は標準ルートから外し、利用者が選んだ親設定、既存override、他モデル親は維持します。 +- 有効なroot経路では、能力と許可が合う通常Chat 6 Proをまとまった工程の最初の主担当候補にしました。並列分担には親の独立作業、直列の専門委譲には具体的な追加価値とホストの許可が必要です。 + +## 0.7.0 + +- 確認済み接続の操作・範囲・有効期限をローカル台帳に保持し、`chat_plan.py` が必要な項目だけを照合する方式を追加しました。 +- `chat_transfer.py` の一回限りのローカルフォームで、準備済み本文と最終回答をBrowser内の変数のまま受け渡せるようにしました。フックからの起動・送信は行いません。 + +## 0.3.1 + +- 有効方針ごとに通常作業3件までを対象とする観測記録を追加しました。再開や子の仕事は同じ作業にまとめ、設定確認・監査は枠に含めません。 +- プラグインの版だけが変わり、分担方針が同じ場合は同じ観測群を継続します。 + +## 0.2.0 + +- Windowsで、通常の起動方法を変えずに登録したGit refを15分ごとに確認する任意の自動更新を追加しました。 +- プラグイン配布ファイルの変更時にmanifest versionの変更を要求するCI検査を追加しました。 + +## 0.1.0 + +- 標準サブエージェント向けの分担方針、部分上書き、競合診断、開始・再開時のフックを含む初版です。 +- 初版から移行する場合も、Codexアプリ・CLIをすべて終了して `codex plugin marketplace upgrade codex-task-routing` を実行します。 diff --git a/docs/validation.md b/docs/validation.md index d1e9c00..3045d20 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,5 +1,16 @@ # 検証記録 +## 0.8.2: 診断・テスト・リリース検査の改善 + +2026-09-23、Windows / Python 3.13の通常サンドボックス内で134件のテストと `python scripts/check_package.py` が成功しました。隔離Codexホームではnative CLIによる導入・両フック起動・再インストール・解除と個別設定の保持も確認しました。稼働中アプリへの再読み込み、通常Chat画面、子モデルの実行、利用量は今回の検証に含みません。CIには既存の3 OS / Python 3.11に加えてWindows / Python 3.13を追加し、リモート結果はPRで確認します。 + +- 変更前は一時フォルダへのアクセス拒否が発生しました。テスト用フォルダをcheckout配下でACLを継承して作成する方式に統一した対象3ファイルは、権限変更やサンドボックス外実行なしで成功しました。 +- Windowsのrename再試行テストは、疑似エラーで試行枠を消費した後に実ファイル操作を混ぜていたため、全体実行では失敗、単独では成功しました。再試行判断の単体テストと実ファイルへの公開・読戻しの結合テストを分離しています。実装側の再試行回数や待機時間は変更していません。 +- `status --json` は固定の `error_code` / `hint` を返し、未知キー、JSON、effort範囲、テンプレート、ファイル異常を区別します。入力した秘密値、未知キー名、OSエラー本文の非表示と設定ファイルの無変更を回帰テストで確認しています。追加レビューで、不正UTF-8やサイズ超過のテンプレートも `invalid_template` として案内するよう修正し、回帰テストを追加しました。 +- 観測記録を `scripts/routing_core/observation.py`、共有I/Oを `common.py` へ移動しました。観測26関数のASTが変更前と同一であること、既存CLI、観測台帳、ロック、配布フックのrunpy起動を確認しています。分担方針・既定設定・記録形式は変更していません。 +- リリース検査はSemVerの増加を要求し、後退・同一版・build metadataのみの変更を拒否します。数値比較、プレリリース、不正入力、非配布変更のテストを追加しました。 +- READMEを導入・確認・任意機能・トラブル対応中心に整理し、過去版の説明を変更履歴へ移しました。 + ## 0.8.1: PR #11の追加レビュー 2026-09-15、固定HEAD `6f57344a35813698b30ae4010cf350a1901dcc0e` と基準版の25ファイル差分、方針・接続判定・引継ぎ互換・テストをレビューしました。 diff --git a/plugins/codex-task-routing/.codex-plugin/plugin.json b/plugins/codex-task-routing/.codex-plugin/plugin.json index 30d7687..0ab8521 100644 --- a/plugins/codex-task-routing/.codex-plugin/plugin.json +++ b/plugins/codex-task-routing/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-task-routing", - "version": "0.8.1", + "version": "0.8.2", "description": "Task delegation policies for research, documents, images, operations, and development in Codex.", "author": { "name": "Acecore", diff --git a/plugins/codex-task-routing/scripts/routing.py b/plugins/codex-task-routing/scripts/routing.py index 2488584..8306ee8 100644 --- a/plugins/codex-task-routing/scripts/routing.py +++ b/plugins/codex-task-routing/scripts/routing.py @@ -8,8 +8,6 @@ from __future__ import annotations import argparse -from contextlib import contextmanager -from datetime import datetime, timezone import errno import hashlib import importlib.util @@ -17,12 +15,32 @@ import os import re import secrets -import stat import sys import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Iterator, Mapping +from typing import Any, Callable, Mapping + + +# The shipped hook uses runpy.run_path, which does not add the script's +# directory to sys.path. Resolve bundled modules for both that entry and CLI use. +_SCRIPT_DIRECTORY = str(Path(__file__).resolve().parent) +if _SCRIPT_DIRECTORY not in sys.path: + sys.path.insert(0, _SCRIPT_DIRECTORY) + +from routing_core.common import ( + EFFORT_ORDER, MAX_JSON_BYTES, RoutingError, DuplicateKeyError, diagnostic_fields, + _reject_duplicate_pairs, _is_link_or_reparse, _absolute, + _assert_safe_ancestors, _read_limited_utf8, _load_json, + _is_schema_version_one, _canonical_json, _safe_mkdir, _write_atomic, +) +from routing_core.observation import ( + OBSERVATIONS_RELATIVE, OBSERVATION_LOCK_NAME, + MAX_OBSERVATION_INPUT_BYTES, observation_sampling_key, observation_status, + observation_start, observation_complete, _observation_sampling_key, + _observation_task_id, _find_observation_task, _read_observation_state, + _observation_write_lock, +) PLUGIN_ROOT = Path(__file__).resolve().parents[1] @@ -30,8 +48,6 @@ TEMPLATE_RELATIVE = Path("defaults") / "templates" OVERRIDE_RELATIVE = Path("codex-task-routing") / "overrides.json" CACHE_RELATIVE = Path("codex-task-routing") / "cache" -OBSERVATIONS_RELATIVE = Path("codex-task-routing") / "observations" -OBSERVATION_LOCK_NAME = ".state.lock" MARKER_NAME = ".codex-task-routing-managed.json" RENDERED_TEMPLATE_NAMES = ( "effective.md", @@ -42,17 +58,10 @@ RENDERED_FILE_NAMES = RENDERED_TEMPLATE_NAMES DETAILED_TEMPLATE_NAMES = tuple(name for name in RENDERED_TEMPLATE_NAMES if name != "effective.md") -MAX_JSON_BYTES = 512 * 1024 MAX_TEMPLATE_BYTES = 1024 * 1024 MAX_HOOK_INPUT_BYTES = 128 * 1024 MAX_VALUE_CHARS = 100 * 1024 MAX_RENDERED_BYTES = 1024 * 1024 -MAX_OBSERVATION_STATE_BYTES = 512 * 1024 -MAX_OBSERVATION_INPUT_BYTES = 128 * 1024 -MAX_OBSERVATION_TASK_ID_CHARS = 256 -MAX_OBSERVATION_REFERENCE_CHARS = 4096 -MAX_OBSERVATION_SCOPE_CHARS = 500 -MAX_OBSERVATION_RUNS = 32 MAX_TOKEN_DEPTH = 20 # hooks.json uses a token-oriented platform limit. Keep this independent, # conservative character ceiling so the runtime never asks the host to truncate @@ -67,7 +76,6 @@ CACHE_RENAME_RETRY_SECONDS = 0.05 WINDOWS_TRANSIENT_RENAME_WINERRORS = frozenset({5, 32, 33}) -EFFORT_ORDER = {"medium": 0, "high": 1, "xhigh": 2, "max": 3} MODEL_FIELDS = ("id", "min_effort", "default_effort", "max_effort") REQUIRED_MODEL_ROLES = ("luna", "terra", "sol", "astra") SAFE_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") @@ -76,99 +84,9 @@ ) -class RoutingError(Exception): - """A safe, user-actionable policy error. - - Messages deliberately contain neither parsed configuration values nor file - bodies. Hook diagnostics can therefore use a fixed error category. - """ - - -class DuplicateKeyError(RoutingError): - pass - - -def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise DuplicateKeyError("JSON contains duplicate object keys") - result[key] = value - return result - - -def _is_link_or_reparse(path: Path) -> bool: - """Return true for a symlink, Windows junction, or other reparse point.""" - - try: - mode = os.lstat(path).st_mode - except FileNotFoundError: - return False - except OSError as exc: - raise RoutingError("cannot inspect a filesystem path") from exc - if stat.S_ISLNK(mode): - return True - try: - attributes = os.lstat(path).st_file_attributes # type: ignore[attr-defined] - except AttributeError: - return False - except OSError as exc: - raise RoutingError("cannot inspect a filesystem path") from exc - return bool(attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT - - -def _absolute(path: Path | str) -> Path: - # abspath normalizes ``.`` and ``..`` without resolving symlinks. - return Path(os.path.abspath(os.fspath(path))) - - -def _assert_safe_ancestors(path: Path | str) -> Path: - """Normalize a path and reject every existing link/reparse component.""" - - absolute = _absolute(path) - parts: list[Path] = [] - current = absolute - while True: - parts.append(current) - if current.parent == current: - break - current = current.parent - for candidate in reversed(parts): - if os.path.lexists(candidate) and _is_link_or_reparse(candidate): - raise RoutingError("refusing a symlink or reparse-point path") - return absolute - - -def _read_limited_utf8(path: Path, limit: int, purpose: str) -> str: - path = _assert_safe_ancestors(path) - if not os.path.lexists(path) or _is_link_or_reparse(path): - raise RoutingError(f"{purpose} is unavailable") - try: - metadata = os.lstat(path) - except OSError as exc: - raise RoutingError(f"{purpose} is unavailable") from exc - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > limit: - raise RoutingError(f"{purpose} is invalid") - try: - return path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as exc: - raise RoutingError(f"{purpose} is not valid UTF-8") from exc - - -def _load_json(path: Path, purpose: str, limit: int = MAX_JSON_BYTES) -> dict[str, Any]: - text = _read_limited_utf8(path, limit, purpose) - try: - value = json.loads(text, object_pairs_hook=_reject_duplicate_pairs) - except (json.JSONDecodeError, DuplicateKeyError, RecursionError) as exc: - raise RoutingError(f"{purpose} is not valid JSON") from exc - if not isinstance(value, dict): - raise RoutingError(f"{purpose} must be a JSON object") - return value - - def _require_exact_keys(value: Mapping[str, Any], allowed: set[str], purpose: str) -> None: if set(value) - allowed: - raise RoutingError(f"{purpose} contains an unsupported key") + raise RoutingError(f"{purpose} contains an unsupported key", code="unsupported_key") def _validate_string(value: Any, purpose: str) -> str: @@ -177,11 +95,6 @@ def _validate_string(value: Any, purpose: str) -> str: return value -def _is_schema_version_one(value: Any) -> bool: - # ``bool`` is an ``int`` subclass in Python, but JSON true is not schema 1. - return type(value) is int and value == 1 - - def _validate_model(model: Mapping[str, Any], purpose: str, *, partial: bool) -> None: _require_exact_keys(model, set(MODEL_FIELDS), purpose) if not model: @@ -196,7 +109,7 @@ def _validate_model(model: Mapping[str, Any], purpose: str, *, partial: bool) -> if effort_key in model: effort = model[effort_key] if not isinstance(effort, str) or effort not in EFFORT_ORDER: - raise RoutingError(f"{purpose} has an unsupported effort") + raise RoutingError(f"{purpose} has an unsupported effort", code="unsupported_effort") def _validate_effort_range(model: Mapping[str, Any], purpose: str) -> None: @@ -209,7 +122,7 @@ def _validate_effort_range(model: Mapping[str, Any], purpose: str) -> None: except (KeyError, TypeError) as exc: raise RoutingError(f"{purpose} is incomplete") from exc if not valid: - raise RoutingError(f"{purpose} has an invalid effort range") + raise RoutingError(f"{purpose} has an invalid effort range", code="invalid_effort_range") def _validate_defaults(config: dict[str, Any]) -> None: @@ -217,7 +130,7 @@ def _validate_defaults(config: dict[str, Any]) -> None: config, {"schema_version", "policy_revision", "models", "principles"}, "defaults" ) if not _is_schema_version_one(config.get("schema_version")): - raise RoutingError("defaults has an unsupported schema version") + raise RoutingError("defaults has an unsupported schema version", code="unsupported_schema") _validate_string(config.get("policy_revision"), "defaults policy revision") models = config.get("models") if not isinstance(models, dict) or set(models) != set(REQUIRED_MODEL_ROLES): @@ -239,7 +152,7 @@ def _validate_defaults(config: dict[str, Any]) -> None: def _merge_override(defaults: dict[str, Any], override: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: _require_exact_keys(override, {"schema_version", "models", "principles"}, "override") if not _is_schema_version_one(override.get("schema_version")): - raise RoutingError("override has an unsupported schema version") + raise RoutingError("override has an unsupported schema version", code="unsupported_schema") has_models = "models" in override has_principles = "principles" in override if not has_models and not has_principles: @@ -253,7 +166,7 @@ def _merge_override(defaults: dict[str, Any], override: dict[str, Any]) -> tuple raise RoutingError("override models must be a non-empty object") for role, patch in models.items(): if role not in merged["models"] or not isinstance(patch, dict): - raise RoutingError("override contains an unsupported model") + raise RoutingError("override contains an unsupported model", code="unsupported_key") _validate_model(patch, "override model", partial=True) for field, value in patch.items(): merged["models"][role][field] = value @@ -264,7 +177,7 @@ def _merge_override(defaults: dict[str, Any], override: dict[str, Any]) -> tuple raise RoutingError("override principles must be a non-empty object") for key, value in principles.items(): if key not in merged["principles"]: - raise RoutingError("override contains an unsupported principle") + raise RoutingError("override contains an unsupported principle", code="unsupported_key") merged["principles"][key] = _validate_string(value, "override principle") applied.append(f"principles.{key}") @@ -273,10 +186,6 @@ def _merge_override(defaults: dict[str, Any], override: dict[str, Any]) -> tuple return merged, applied -def _canonical_json(value: Any) -> bytes: - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") - - def _manifest_version(plugin_root: Path) -> str: manifest = _load_json(plugin_root / ".codex-plugin" / "plugin.json", "plugin manifest") version = manifest.get("version") @@ -286,19 +195,24 @@ def _manifest_version(plugin_root: Path) -> str: def _load_templates(plugin_root: Path) -> dict[str, str]: directory = _assert_safe_ancestors(plugin_root / TEMPLATE_RELATIVE) if not directory.is_dir() or _is_link_or_reparse(directory): - raise RoutingError("template directory is unavailable") + raise RoutingError("template directory is unavailable", code="invalid_template") templates: dict[str, str] = {} try: entries = list(directory.iterdir()) except OSError as exc: - raise RoutingError("template directory is unavailable") from exc + raise RoutingError("template directory is unavailable", code="invalid_template") from exc for path in entries: if path.suffix.lower() == ".md": if _is_link_or_reparse(path) or not path.is_file(): - raise RoutingError("template is unavailable") - templates[path.name] = _read_limited_utf8(path, MAX_TEMPLATE_BYTES, "template") + raise RoutingError("template is unavailable", code="invalid_template") + try: + templates[path.name] = _read_limited_utf8(path, MAX_TEMPLATE_BYTES, "template") + except RoutingError as exc: + if exc.code in {"invalid_json", "invalid_policy"}: + raise RoutingError("template is invalid", code="invalid_template") from exc + raise if set(templates) != set(RENDERED_TEMPLATE_NAMES): - raise RoutingError("templates do not match the packaged policy set") + raise RoutingError("templates do not match the packaged policy set", code="invalid_template") return templates @@ -327,7 +241,7 @@ def value_for(path: str) -> Any: current: Any = self.config for part in path.split("."): if not isinstance(current, dict) or part not in current: - raise RoutingError("policy contains an unresolved token") + raise RoutingError("policy contains an unresolved token", code="invalid_template") current = current[part] return current @@ -335,15 +249,15 @@ def resolve(path: str, stack: tuple[str, ...]) -> str: if path in cache: return cache[path] if path in stack: - raise RoutingError("policy contains a cyclic token") + raise RoutingError("policy contains a cyclic token", code="invalid_template") if len(stack) >= MAX_TOKEN_DEPTH: - raise RoutingError("policy token expansion is too deep") + raise RoutingError("policy token expansion is too deep", code="invalid_template") value = value_for(path) if not isinstance(value, str): - raise RoutingError("policy token does not resolve to text") + raise RoutingError("policy token does not resolve to text", code="invalid_template") expanded = substitute(value, lambda child: resolve(child, stack + (path,))) if len(expanded.encode("utf-8")) > MAX_RENDERED_BYTES: - raise RoutingError("policy token expansion is too large") + raise RoutingError("policy token expansion is too large", code="invalid_template") cache[path] = expanded return expanded @@ -352,7 +266,7 @@ def resolve(path: str, stack: tuple[str, ...]) -> str: def render_template(self, text: str) -> str: rendered = substitute(text, self.resolve_token) if len(rendered.encode("utf-8")) > MAX_RENDERED_BYTES: - raise RoutingError("rendered policy is too large") + raise RoutingError("rendered policy is too large", code="invalid_template") return rendered def rendered_documents(self) -> dict[str, str]: @@ -373,7 +287,7 @@ def replace(match: re.Match[str]) -> str: rendered = SAFE_TOKEN.sub(replace, text) if "{{" in rendered or "}}" in rendered: - raise RoutingError("policy contains an unresolved token") + raise RoutingError("policy contains an unresolved token", code="invalid_template") return rendered @@ -391,7 +305,7 @@ def load_policy( explicit_override = config_path is not None override_present = os.path.lexists(override_path) if explicit_override and not override_present: - raise RoutingError("override configuration is unavailable") + raise RoutingError("override configuration is unavailable", code="filesystem_unavailable") if override_present: override = _load_json(override_path, "override configuration") config, applied = _merge_override(defaults, override) @@ -418,18 +332,6 @@ def load_policy( ) -def _safe_mkdir(path: Path) -> Path: - path = _assert_safe_ancestors(path) - try: - path.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise RoutingError("cannot create the policy output directory") from exc - path = _assert_safe_ancestors(path) - if not path.is_dir() or _is_link_or_reparse(path): - raise RoutingError("policy output directory is unsafe") - return path - - def _safe_directory_entries(directory: Path) -> list[Path]: directory = _assert_safe_ancestors(directory) if not directory.is_dir() or _is_link_or_reparse(directory): @@ -473,33 +375,6 @@ def _prepare_render_directory(output_dir: Path) -> Path: return _safe_mkdir(output_dir) -def _write_atomic(directory: Path, name: str, content: str) -> None: - directory = _assert_safe_ancestors(directory) - target = directory / name - if os.path.lexists(target) and _is_link_or_reparse(target): - raise RoutingError("refusing to overwrite a link or reparse point") - temporary = directory / f".routing-write-{secrets.token_hex(12)}" - try: - with open(temporary, "x", encoding="utf-8", newline="\n") as handle: - handle.write(content) - handle.flush() - os.fsync(handle.fileno()) - if _is_link_or_reparse(temporary): - raise RoutingError("temporary policy output is unsafe") - os.replace(temporary, target) - except RoutingError: - raise - except OSError as exc: - raise RoutingError("cannot write policy output") from exc - finally: - # Only clean up a file created by this invocation, never directory data. - if os.path.lexists(temporary): - try: - os.unlink(temporary) - except OSError: - pass - - def render_policy(policy: Policy, output_dir: Path) -> dict[str, Path]: """Render into a new, empty, or explicitly plugin-managed directory.""" @@ -704,527 +579,6 @@ def _cache_status(codex_home: Path, policy: Policy) -> tuple[str, Path]: return "unsafe", destination -def _utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def _observation_text(value: Any, purpose: str, limit: int = MAX_OBSERVATION_REFERENCE_CHARS) -> str: - if not isinstance(value, str) or not value.strip() or len(value) > limit: - raise RoutingError(f"{purpose} must be a non-empty string") - if any(ord(character) < 32 for character in value): - raise RoutingError(f"{purpose} contains control characters") - return value - - -def _observation_optional_text(value: Any, purpose: str) -> str | None: - if value is None: - return None - return _observation_text(value, purpose) - - -def _observation_task_id(value: Any) -> str: - return _observation_text(value, "observation task id", MAX_OBSERVATION_TASK_ID_CHARS) - - -def _observation_sampling_key(value: Any) -> str: - if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): - raise RoutingError("observation sampling key is invalid") - return value - - -def observation_sampling_key(policy: Policy) -> str: - """Hash rendered policy content, deliberately excluding the manifest version. - - The policy's usual content hash includes the package version because it is - useful for cache invalidation. Sampling must instead continue across a - package-only release when the rendered policy is identical. - """ - - documents = policy.rendered_documents() - canonical_documents = {name: documents[name] for name in sorted(documents)} - return hashlib.sha256(_canonical_json(canonical_documents)).hexdigest() - - -def _observation_policy_metadata(policy: Policy, sampling_key: str) -> dict[str, str]: - return { - "policy_revision": policy.config["policy_revision"], - "manifest_version": policy.version, - "content_hash": policy.content_hash, - "sampling_key": sampling_key, - } - - -def _empty_observation_state(sampling_key: str) -> dict[str, Any]: - sampling_key = _observation_sampling_key(sampling_key) - return {"schema_version": 1, "samples": {sampling_key: {"tasks": {}}}} - - -def _validate_observation_measurement(value: Any, *, allow_partial: bool) -> None: - if not isinstance(value, dict): - raise RoutingError("observation measurement must be an object") - if set(value) != {"state", "reason"}: - raise RoutingError("observation measurement has unsupported fields") - state = value.get("state") - reason = value.get("reason") - allowed = {"measured", "missing"} - if allow_partial: - allowed.add("partial") - if state not in allowed: - raise RoutingError("observation measurement state is invalid") - if state == "measured": - if reason is not None: - raise RoutingError("measured observation must not have a missing reason") - elif _observation_optional_text(reason, "observation measurement reason") is None: - raise RoutingError("missing observation measurement needs a reason") - - -def _validate_observation_verification(value: Any) -> None: - if not isinstance(value, dict) or set(value) != {"reference", "reason"}: - raise RoutingError("observation verification has unsupported fields") - reference = _observation_optional_text(value.get("reference"), "verification reference") - reason = _observation_optional_text(value.get("reason"), "verification missing reason") - if reference is None and reason is None: - raise RoutingError("missing verification needs a reason") - if reference is not None and reason is not None: - raise RoutingError("verified observation must not have a missing reason") - - -def _validate_observation_requested(value: Any) -> None: - if not isinstance(value, dict) or set(value) != {"model", "effort", "reason"}: - raise RoutingError("requested run settings have unsupported fields") - model = _observation_optional_text(value.get("model"), "requested model") - effort = value.get("effort") - if effort is not None and effort not in EFFORT_ORDER: - raise RoutingError("requested effort is invalid") - reason = _observation_optional_text(value.get("reason"), "requested settings reason") - if (model is None or effort is None) and reason is None: - raise RoutingError("unknown requested settings need a reason") - if model is not None and effort is not None and reason is not None: - raise RoutingError("known requested settings must not have an unknown reason") - - -def _validate_observation_observed(value: Any) -> None: - if not isinstance(value, dict) or set(value) != {"model", "effort", "evidence_ref", "reason"}: - raise RoutingError("observed run settings have unsupported fields") - model = _observation_optional_text(value.get("model"), "observed model") - effort = value.get("effort") - if effort is not None and effort not in EFFORT_ORDER: - raise RoutingError("observed effort is invalid") - evidence = _observation_optional_text(value.get("evidence_ref"), "observed settings reference") - reason = _observation_optional_text(value.get("reason"), "observed settings reason") - if model is not None or effort is not None: - if evidence is None: - raise RoutingError("observed settings need a verification reference") - elif evidence is not None: - raise RoutingError("unknown observed settings must not have a verification reference") - if (model is None or effort is None) and reason is None: - raise RoutingError("unknown observed settings need a reason") - if model is not None and effort is not None and reason is not None: - raise RoutingError("known observed settings must not have an unknown reason") - - -def _validate_observation_usage(value: Any) -> None: - fields = { - "state", - "reason", - "evidence_ref", - "turn_scope", - "input_tokens", - "cache_input_tokens", - "output_tokens", - "reasoning_tokens", - "unknown_reasons", - } - if not isinstance(value, dict) or set(value) != fields: - raise RoutingError("run usage has unsupported fields") - state = value.get("state") - reason = _observation_optional_text(value.get("reason"), "run usage reason") - evidence = _observation_optional_text(value.get("evidence_ref"), "run usage reference") - if value.get("turn_scope") not in {"full_turn", "partial_turn"}: - raise RoutingError("run usage turn scope is invalid") - token_fields = ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens") - unknown_reasons = value.get("unknown_reasons") - if not isinstance(unknown_reasons, dict) or set(unknown_reasons) - set(token_fields): - raise RoutingError("run usage unknown reasons are invalid") - for name in token_fields: - amount = value.get(name) - if amount is not None and (type(amount) is not int or amount < 0): - raise RoutingError("run usage token count is invalid") - missing_reason = unknown_reasons.get(name) - if amount is None: - if state == "measured" and _observation_optional_text( - missing_reason, "run usage unknown reason" - ) is None: - raise RoutingError("unknown measured token count needs a reason") - elif missing_reason is not None: - raise RoutingError("known token count must not have an unknown reason") - if state == "missing": - if reason is None or evidence is not None or any(value.get(name) is not None for name in token_fields): - raise RoutingError("missing run usage is invalid") - if unknown_reasons: - raise RoutingError("missing run usage uses one shared reason") - elif state == "measured": - if reason is not None or evidence is None or not any(value.get(name) is not None for name in token_fields): - raise RoutingError("measured run usage is invalid") - else: - raise RoutingError("run usage state is invalid") - input_tokens = value.get("input_tokens") - cache_input_tokens = value.get("cache_input_tokens") - output_tokens = value.get("output_tokens") - reasoning_tokens = value.get("reasoning_tokens") - if input_tokens is not None and cache_input_tokens is not None and cache_input_tokens > input_tokens: - raise RoutingError("cache input exceeds input") - if output_tokens is not None and reasoning_tokens is not None and reasoning_tokens > output_tokens: - raise RoutingError("reasoning exceeds output") - - -def _validate_observation_run(value: Any) -> None: - fields = {"run_key", "relationship", "state", "run_id", "run_id_reason", "requested", "observed", "usage"} - if not isinstance(value, dict) or set(value) != fields: - raise RoutingError("observation run has unsupported fields") - _observation_text(value.get("run_key"), "run key", 128) - if value.get("relationship") not in {"parent", "child", "grandchild"}: - raise RoutingError("run relationship is invalid") - state = value.get("state") - if state not in {"ongoing", "completed", "interrupted"}: - raise RoutingError("run state is invalid") - run_id = _observation_optional_text(value.get("run_id"), "run id") - run_id_reason = _observation_optional_text(value.get("run_id_reason"), "run id missing reason") - if run_id is None and run_id_reason is None: - raise RoutingError("unknown run id needs a reason") - if run_id is not None and run_id_reason is not None: - raise RoutingError("known run id must not have an unknown reason") - _validate_observation_requested(value.get("requested")) - _validate_observation_observed(value.get("observed")) - _validate_observation_usage(value.get("usage")) - if state != "completed" and value["usage"]["state"] == "measured": - raise RoutingError("unfinished run usage cannot be recorded as completed usage") - - -def _validate_observation_completion(value: Any) -> None: - fields = {"state", "scope", "measurement", "verification", "rework", "runs"} - if not isinstance(value, dict) or set(value) != fields: - raise RoutingError("observation completion has unsupported fields") - state = value.get("state") - if state not in {"completed", "interrupted"}: - raise RoutingError("observation completion state is invalid") - _observation_text(value.get("scope"), "observation scope", MAX_OBSERVATION_SCOPE_CHARS) - _validate_observation_measurement(value.get("measurement"), allow_partial=True) - _validate_observation_verification(value.get("verification")) - _observation_text(value.get("rework"), "rework record") - runs = value.get("runs") - if not isinstance(runs, list) or len(runs) > MAX_OBSERVATION_RUNS: - raise RoutingError("observation runs are invalid") - run_keys: set[str] = set() - for run in runs: - _validate_observation_run(run) - if run["run_key"] in run_keys: - raise RoutingError("observation run keys must be unique") - run_keys.add(run["run_key"]) - measurement = value["measurement"]["state"] - measured_runs = [run for run in runs if run["usage"]["state"] == "measured"] - token_fields = ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens") - full_coverage = ( - state == "completed" - and any(run["relationship"] == "parent" for run in runs) - and all( - run["state"] == "completed" - and run["usage"]["state"] == "measured" - and run["usage"]["turn_scope"] == "full_turn" - and all(run["usage"][field] is not None for field in token_fields) - for run in runs - ) - ) - if measurement == "measured": - if not full_coverage: - raise RoutingError("measured observation has incomplete run usage") - elif measurement == "missing" and measured_runs: - raise RoutingError("missing observation cannot contain measured run usage") - elif measurement == "partial" and (not measured_runs or full_coverage): - raise RoutingError("partial observation measurement is inconsistent") - - -def _validate_observation_record(value: Any, sampling_key: str, task_id: str) -> None: - fields = { - "task_id", "sampling_key", "policy", "state", "measurement", "started_at", - "updated_at", "completed_at", "scope", "rework", "verification", "runs", - } - if not isinstance(value, dict) or set(value) != fields: - raise RoutingError("observation record is invalid") - if value.get("task_id") != task_id or value.get("sampling_key") != sampling_key: - raise RoutingError("observation record identity is invalid") - _observation_task_id(value["task_id"]) - policy = value.get("policy") - if not isinstance(policy, dict) or set(policy) != {"policy_revision", "manifest_version", "content_hash", "sampling_key"}: - raise RoutingError("observation record policy is invalid") - _observation_text(policy.get("policy_revision"), "observation policy revision") - _observation_text(policy.get("manifest_version"), "observation manifest version") - if not isinstance(policy.get("content_hash"), str) or not re.fullmatch(r"[0-9a-f]{64}", policy["content_hash"]): - raise RoutingError("observation policy hash is invalid") - if policy.get("sampling_key") != sampling_key: - raise RoutingError("observation policy sampling key is invalid") - if value.get("state") not in {"ongoing", "completed", "interrupted"}: - raise RoutingError("observation record state is invalid") - _validate_observation_measurement(value.get("measurement"), allow_partial=True) - for name in ("started_at", "updated_at"): - _observation_text(value.get(name), "observation timestamp", 64) - completed_at = value.get("completed_at") - if value["state"] == "ongoing": - if completed_at is not None or value.get("scope") is not None or value.get("rework") is not None or value.get("verification") is not None or value.get("runs") != []: - raise RoutingError("ongoing observation record is invalid") - else: - _observation_text(completed_at, "observation completion timestamp", 64) - _validate_observation_completion( - { - "state": value["state"], - "scope": value["scope"], - "measurement": value["measurement"], - "verification": value["verification"], - "rework": value["rework"], - "runs": value["runs"], - } - ) - - -def _validate_observation_state(value: Any, sampling_key: str) -> dict[str, Any]: - sampling_key = _observation_sampling_key(sampling_key) - if not isinstance(value, dict) or set(value) != {"schema_version", "samples"}: - raise RoutingError("observation state is invalid") - if not _is_schema_version_one(value.get("schema_version")) or not isinstance(value.get("samples"), dict): - raise RoutingError("observation state is invalid") - if set(value["samples"]) != {sampling_key}: - raise RoutingError("observation state has an unexpected sampling key") - sample = value["samples"][sampling_key] - if not isinstance(sample, dict) or set(sample) != {"tasks"} or not isinstance(sample.get("tasks"), dict): - raise RoutingError("observation sample is invalid") - if len(sample["tasks"]) > 3: - raise RoutingError("observation sample exceeds its limit") - for task_id, record in sample["tasks"].items(): - _observation_task_id(task_id) - _validate_observation_record(record, sampling_key, task_id) - return value - - -def _observation_directory(codex_home: Path, *, create: bool) -> Path: - directory = _absolute(codex_home) / OBSERVATIONS_RELATIVE - if create: - return _safe_mkdir(directory) - directory = _assert_safe_ancestors(directory) - if os.path.lexists(directory) and (not directory.is_dir() or _is_link_or_reparse(directory)): - raise RoutingError("observation directory is unsafe") - return directory - - -def _observation_state_path(codex_home: Path, sampling_key: str, *, create: bool) -> Path: - sampling_key = _observation_sampling_key(sampling_key) - return _observation_directory(codex_home, create=create) / f"{sampling_key}.json" - - -def _read_observation_state(codex_home: Path, sampling_key: str) -> dict[str, Any]: - sampling_key = _observation_sampling_key(sampling_key) - directory = _observation_directory(codex_home, create=False) - path = directory / f"{sampling_key}.json" - if not os.path.lexists(path): - return _empty_observation_state(sampling_key) - return _validate_observation_state( - _load_json(path, "observation state", MAX_OBSERVATION_STATE_BYTES), sampling_key - ) - - -@contextmanager -def _observation_write_lock(codex_home: Path, sampling_key: str | None = None) -> Iterator[None]: - """Serialize writes with an OS lock that is released if the process exits.""" - - directory = _observation_directory(codex_home, create=True) - lock_name = ( - f".{_observation_sampling_key(sampling_key)}.lock" - if sampling_key is not None - else OBSERVATION_LOCK_NAME - ) - lock_path = directory / lock_name - if os.path.lexists(lock_path) and _is_link_or_reparse(lock_path): - raise RoutingError("observation lock is unsafe") - flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) - try: - descriptor = os.open(lock_path, flags, 0o600) - except OSError as exc: - raise RoutingError("cannot lock observation state") from exc - try: - try: - if not stat.S_ISREG(os.fstat(descriptor).st_mode): - raise RoutingError("observation lock is not a regular file") - if os.name == "nt": - import msvcrt - - if os.fstat(descriptor).st_size == 0: - os.write(descriptor, b"0") - os.lseek(descriptor, 0, os.SEEK_SET) - msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError as exc: - raise RoutingError("observation state is busy or unavailable") from exc - yield - finally: - # Keep the inode fixed: unlinking allows two writers to lock different - # files. Closing (including process termination) releases the OS lock. - os.close(descriptor) - - -def _write_observation_state(codex_home: Path, sampling_key: str, state: dict[str, Any]) -> None: - sampling_key = _observation_sampling_key(sampling_key) - _validate_observation_state(state, sampling_key) - encoded = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" - if len(encoded.encode("utf-8")) > MAX_OBSERVATION_STATE_BYTES: - raise RoutingError("observation state is too large") - path = _observation_state_path(codex_home, sampling_key, create=True) - _write_atomic(path.parent, path.name, encoded) - - -def _find_observation_task(state: dict[str, Any], sampling_key: str, task_id: str) -> dict[str, Any] | None: - sampling_key = _observation_sampling_key(sampling_key) - return state["samples"][sampling_key]["tasks"].get(task_id) - - -def observation_status( - *, codex_home: Path, policy: Policy | None = None, sampling_key: str | None = None -) -> dict[str, Any]: - """Read one sampling group without reading or writing any other group.""" - - if sampling_key is None: - if policy is None: - raise RoutingError("observation status needs a policy or sampling key") - sampling_key = observation_sampling_key(policy) - else: - sampling_key = _observation_sampling_key(sampling_key) - try: - state = _read_observation_state(codex_home, sampling_key) - sample = state["samples"][sampling_key] - tasks = list(sample["tasks"].values()) - except (RoutingError, OSError): - return { - "state": "unknown", - "remaining": None, - "recorded": None, - "pending": None, - "completed": None, - "missing": None, - } - pending = sum(record["state"] == "ongoing" for record in tasks) - completed = sum(record["state"] == "completed" for record in tasks) - missing = sum( - record["state"] != "ongoing" and record["measurement"]["state"] != "measured" - for record in tasks - ) - return { - "state": "valid", - "sampling_key": sampling_key, - "remaining": 3 - len(tasks), - "recorded": len(tasks), - "pending": pending, - "completed": completed, - "missing": missing, - "task_ids": [record["task_id"] for record in tasks], - } - - -def observation_start(*, codex_home: Path, policy: Policy, task_id: str, kind: str = "normal") -> dict[str, Any]: - """Reserve one normal-work observation, idempotently by the parent task key.""" - - task_id = _observation_task_id(task_id) - if kind != "normal": - if kind not in {"audit", "config"}: - raise RoutingError("observation kind is invalid") - return {"state": "skipped", "reason": "non_normal_work", "recorded": False} - sampling_key = observation_sampling_key(policy) - with _observation_write_lock(codex_home, sampling_key): - state = _read_observation_state(codex_home, sampling_key) - existing = _find_observation_task(state, sampling_key, task_id) - now = _utc_now() - if existing is not None: - existing["updated_at"] = now - _write_observation_state(codex_home, sampling_key, state) - return {"state": "existing", "recorded": True, "sampling_key": sampling_key} - sample = state["samples"][sampling_key] - if len(sample["tasks"]) >= 3: - return {"state": "full", "recorded": False, "sampling_key": sampling_key} - sample["tasks"][task_id] = { - "task_id": task_id, - "sampling_key": sampling_key, - "policy": _observation_policy_metadata(policy, sampling_key), - "state": "ongoing", - "measurement": {"state": "missing", "reason": "completion is not recorded"}, - "started_at": now, - "updated_at": now, - "completed_at": None, - "scope": None, - "rework": None, - "verification": None, - "runs": [], - } - _write_observation_state(codex_home, sampling_key, state) - return {"state": "started", "recorded": True, "sampling_key": sampling_key} - - -def observation_complete( - *, codex_home: Path, sampling_key: str, task_id: str, completion: dict[str, Any] -) -> dict[str, Any]: - """Close one reserved group without loading current policy or other groups.""" - - task_id = _observation_task_id(task_id) - sampling_key = _observation_sampling_key(sampling_key) - _validate_observation_completion(completion) - with _observation_write_lock(codex_home, sampling_key): - state = _read_observation_state(codex_home, sampling_key) - record = _find_observation_task(state, sampling_key, task_id) - if record is None: - raise RoutingError("observation task is not reserved") - if record["verification"] is not None and record["verification"]["reference"] is not None and completion["verification"]["reference"] is None: - raise RoutingError("completion must retain existing verification evidence") - # Each completion is a full snapshot. A resumed task must not erase - # earlier runs or replace acquired evidence with unknown values. - incoming_runs = {run["run_key"]: run for run in completion["runs"]} - for previous in record["runs"]: - current = incoming_runs.get(previous["run_key"]) - if current is None: - raise RoutingError("completion must retain existing runs") - if current["relationship"] != previous["relationship"] or ( - previous["run_id"] is not None and current["run_id"] != previous["run_id"] - ): - raise RoutingError("a different run needs a new run key") - if previous["state"] == "completed" and current["state"] != "completed": - raise RoutingError("completion must retain completed run evidence") - for section, names in ( - ("usage", ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens")), - ("observed", ("model", "effort")), - ("requested", ("model", "effort")), - ): - if any(previous[section][name] is not None and current[section][name] is None for name in names): - raise RoutingError("completion must retain acquired run values") - now = _utc_now() - record.update( - { - "state": completion["state"], - "scope": completion["scope"], - "measurement": completion["measurement"], - "verification": completion["verification"], - "rework": completion["rework"], - "runs": completion["runs"], - "completed_at": now, - "updated_at": now, - } - ) - _write_observation_state(codex_home, sampling_key, state) - return { - "state": "closed", - "observation_state": completion["state"], - "measurement_state": completion["measurement"]["state"], - "sampling_key": sampling_key, - } - - def status_payload( *, plugin_root: Path = PLUGIN_ROOT, @@ -1265,9 +619,11 @@ def status_payload( } if not result["ok"]: result["error"] = "policy cache is unsafe or does not match the effective policy" + result.update(diagnostic_fields("invalid_cache")) return result - except (RoutingError, OSError): + except (RoutingError, OSError) as exc: return { + **diagnostic_fields(exc.code if isinstance(exc, RoutingError) else "filesystem_unavailable"), "ok": False, "error": "policy configuration or templates are invalid", "host": { diff --git a/plugins/codex-task-routing/scripts/routing_core/__init__.py b/plugins/codex-task-routing/scripts/routing_core/__init__.py new file mode 100644 index 0000000..12e6d6a --- /dev/null +++ b/plugins/codex-task-routing/scripts/routing_core/__init__.py @@ -0,0 +1 @@ +"""Internal policy runtime modules; the supported CLI remains routing.py.""" diff --git a/plugins/codex-task-routing/scripts/routing_core/common.py b/plugins/codex-task-routing/scripts/routing_core/common.py new file mode 100644 index 0000000..95cdb2b --- /dev/null +++ b/plugins/codex-task-routing/scripts/routing_core/common.py @@ -0,0 +1,175 @@ +"""Shared safe filesystem and JSON primitives for policy and observation code.""" +from __future__ import annotations + +import json +import os +import secrets +import stat +from pathlib import Path +from typing import Any + +MAX_JSON_BYTES = 512 * 1024 +EFFORT_ORDER = {"medium": 0, "high": 1, "xhigh": 2, "max": 3} + +# Fixed diagnostic text only: never interpolate keys, values, paths, or OS errors. +ERROR_HINTS = { + "invalid_policy": "Check the configuration structure and required fields against references/configuration.md.", + "invalid_json": "Use a UTF-8 JSON object with valid syntax and no duplicate keys.", + "unsupported_key": "Remove unknown keys; compare model and principle keys with the bundled defaults.", + "unsupported_schema": "Use the supported schema_version: 1 (an integer).", + "unsupported_effort": "Use a supported effort: medium, high, xhigh, or max.", + "invalid_effort_range": "Set min_effort <= default_effort <= max_effort.", + "invalid_template": "Check template references for unknown names, cycles, and excessive expansion; restore damaged bundled templates from the same release.", + "filesystem_unavailable": "Check that the configured files exist and are readable; do not change unrelated permissions.", + "unsafe_path": "Use a regular file or directory without symlink or reparse-point ancestors.", + "invalid_cache": "The existing cache is unsafe or differs from the policy; inspect it before replacing or removing it.", +} + + +class RoutingError(Exception): + """A safe, user-actionable policy error. + + Messages deliberately contain neither parsed configuration values nor file + bodies. Hook diagnostics can therefore use a fixed error category. + """ + + def __init__(self, message: str, *, code: str = "invalid_policy") -> None: + super().__init__(message) + self.code = code if code in ERROR_HINTS else "invalid_policy" + + +def diagnostic_fields(code: str) -> dict[str, str]: + code = code if code in ERROR_HINTS else "invalid_policy" + return {"error_code": code, "hint": ERROR_HINTS[code]} + + +class DuplicateKeyError(RoutingError): + pass + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError("JSON contains duplicate object keys") + result[key] = value + return result + + +def _is_link_or_reparse(path: Path) -> bool: + """Return true for a symlink, Windows junction, or other reparse point.""" + + try: + mode = os.lstat(path).st_mode + except FileNotFoundError: + return False + except OSError as exc: + raise RoutingError("cannot inspect a filesystem path", code="filesystem_unavailable") from exc + if stat.S_ISLNK(mode): + return True + try: + attributes = os.lstat(path).st_file_attributes # type: ignore[attr-defined] + except AttributeError: + return False + except OSError as exc: + raise RoutingError("cannot inspect a filesystem path", code="filesystem_unavailable") from exc + return bool(attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT + + +def _absolute(path: Path | str) -> Path: + # abspath normalizes ``.`` and ``..`` without resolving symlinks. + return Path(os.path.abspath(os.fspath(path))) + + +def _assert_safe_ancestors(path: Path | str) -> Path: + """Normalize a path and reject every existing link/reparse component.""" + + absolute = _absolute(path) + parts: list[Path] = [] + current = absolute + while True: + parts.append(current) + if current.parent == current: + break + current = current.parent + for candidate in reversed(parts): + if os.path.lexists(candidate) and _is_link_or_reparse(candidate): + raise RoutingError("refusing a symlink or reparse-point path", code="unsafe_path") + return absolute + + +def _read_limited_utf8(path: Path, limit: int, purpose: str) -> str: + path = _assert_safe_ancestors(path) + if not os.path.lexists(path) or _is_link_or_reparse(path): + raise RoutingError(f"{purpose} is unavailable", code="filesystem_unavailable") + try: + metadata = os.lstat(path) + except OSError as exc: + raise RoutingError(f"{purpose} is unavailable", code="filesystem_unavailable") from exc + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > limit: + raise RoutingError(f"{purpose} is invalid") + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise RoutingError(f"{purpose} is unavailable", code="filesystem_unavailable") from exc + except UnicodeDecodeError as exc: + raise RoutingError(f"{purpose} is not valid UTF-8", code="invalid_json") from exc + + +def _load_json(path: Path, purpose: str, limit: int = MAX_JSON_BYTES) -> dict[str, Any]: + text = _read_limited_utf8(path, limit, purpose) + try: + value = json.loads(text, object_pairs_hook=_reject_duplicate_pairs) + except (json.JSONDecodeError, DuplicateKeyError, RecursionError) as exc: + raise RoutingError(f"{purpose} is not valid JSON", code="invalid_json") from exc + if not isinstance(value, dict): + raise RoutingError(f"{purpose} must be a JSON object", code="invalid_json") + return value + + +def _is_schema_version_one(value: Any) -> bool: + # ``bool`` is an ``int`` subclass in Python, but JSON true is not schema 1. + return type(value) is int and value == 1 + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _safe_mkdir(path: Path) -> Path: + path = _assert_safe_ancestors(path) + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise RoutingError("cannot create the policy output directory") from exc + path = _assert_safe_ancestors(path) + if not path.is_dir() or _is_link_or_reparse(path): + raise RoutingError("policy output directory is unsafe") + return path + + +def _write_atomic(directory: Path, name: str, content: str) -> None: + directory = _assert_safe_ancestors(directory) + target = directory / name + if os.path.lexists(target) and _is_link_or_reparse(target): + raise RoutingError("refusing to overwrite a link or reparse point") + temporary = directory / f".routing-write-{secrets.token_hex(12)}" + try: + with open(temporary, "x", encoding="utf-8", newline="\n") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + if _is_link_or_reparse(temporary): + raise RoutingError("temporary policy output is unsafe") + os.replace(temporary, target) + except RoutingError: + raise + except OSError as exc: + raise RoutingError("cannot write policy output") from exc + finally: + # Only clean up a file created by this invocation, never directory data. + if os.path.lexists(temporary): + try: + os.unlink(temporary) + except OSError: + pass diff --git a/plugins/codex-task-routing/scripts/routing_core/observation.py b/plugins/codex-task-routing/scripts/routing_core/observation.py new file mode 100644 index 0000000..268f8eb --- /dev/null +++ b/plugins/codex-task-routing/scripts/routing_core/observation.py @@ -0,0 +1,558 @@ +"""Bounded observation validation and storage, independent of hook delivery.""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +import re +import stat +from pathlib import Path +from typing import Any, Iterator, Protocol + +from .common import ( + EFFORT_ORDER, RoutingError, _absolute, _assert_safe_ancestors, + _canonical_json, _is_link_or_reparse, _is_schema_version_one, + _load_json, _safe_mkdir, _write_atomic, +) + +OBSERVATIONS_RELATIVE = Path("codex-task-routing") / "observations" +OBSERVATION_LOCK_NAME = ".state.lock" +MAX_OBSERVATION_STATE_BYTES = 512 * 1024 +MAX_OBSERVATION_INPUT_BYTES = 128 * 1024 +MAX_OBSERVATION_TASK_ID_CHARS = 256 +MAX_OBSERVATION_REFERENCE_CHARS = 4096 +MAX_OBSERVATION_SCOPE_CHARS = 500 +MAX_OBSERVATION_RUNS = 32 + + +class Policy(Protocol): + """Only the policy data needed by observations; no runtime import cycle.""" + + config: dict[str, Any] + version: str + content_hash: str + + def rendered_documents(self) -> dict[str, str]: ... + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _observation_text(value: Any, purpose: str, limit: int = MAX_OBSERVATION_REFERENCE_CHARS) -> str: + if not isinstance(value, str) or not value.strip() or len(value) > limit: + raise RoutingError(f"{purpose} must be a non-empty string") + if any(ord(character) < 32 for character in value): + raise RoutingError(f"{purpose} contains control characters") + return value + + +def _observation_optional_text(value: Any, purpose: str) -> str | None: + if value is None: + return None + return _observation_text(value, purpose) + + +def _observation_task_id(value: Any) -> str: + return _observation_text(value, "observation task id", MAX_OBSERVATION_TASK_ID_CHARS) + + +def _observation_sampling_key(value: Any) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): + raise RoutingError("observation sampling key is invalid") + return value + + +def observation_sampling_key(policy: Policy) -> str: + """Hash rendered policy content, deliberately excluding the manifest version. + + The policy's usual content hash includes the package version because it is + useful for cache invalidation. Sampling must instead continue across a + package-only release when the rendered policy is identical. + """ + + documents = policy.rendered_documents() + canonical_documents = {name: documents[name] for name in sorted(documents)} + return hashlib.sha256(_canonical_json(canonical_documents)).hexdigest() + + +def _observation_policy_metadata(policy: Policy, sampling_key: str) -> dict[str, str]: + return { + "policy_revision": policy.config["policy_revision"], + "manifest_version": policy.version, + "content_hash": policy.content_hash, + "sampling_key": sampling_key, + } + + +def _empty_observation_state(sampling_key: str) -> dict[str, Any]: + sampling_key = _observation_sampling_key(sampling_key) + return {"schema_version": 1, "samples": {sampling_key: {"tasks": {}}}} + + +def _validate_observation_measurement(value: Any, *, allow_partial: bool) -> None: + if not isinstance(value, dict): + raise RoutingError("observation measurement must be an object") + if set(value) != {"state", "reason"}: + raise RoutingError("observation measurement has unsupported fields") + state = value.get("state") + reason = value.get("reason") + allowed = {"measured", "missing"} + if allow_partial: + allowed.add("partial") + if state not in allowed: + raise RoutingError("observation measurement state is invalid") + if state == "measured": + if reason is not None: + raise RoutingError("measured observation must not have a missing reason") + elif _observation_optional_text(reason, "observation measurement reason") is None: + raise RoutingError("missing observation measurement needs a reason") + + +def _validate_observation_verification(value: Any) -> None: + if not isinstance(value, dict) or set(value) != {"reference", "reason"}: + raise RoutingError("observation verification has unsupported fields") + reference = _observation_optional_text(value.get("reference"), "verification reference") + reason = _observation_optional_text(value.get("reason"), "verification missing reason") + if reference is None and reason is None: + raise RoutingError("missing verification needs a reason") + if reference is not None and reason is not None: + raise RoutingError("verified observation must not have a missing reason") + + +def _validate_observation_requested(value: Any) -> None: + if not isinstance(value, dict) or set(value) != {"model", "effort", "reason"}: + raise RoutingError("requested run settings have unsupported fields") + model = _observation_optional_text(value.get("model"), "requested model") + effort = value.get("effort") + if effort is not None and effort not in EFFORT_ORDER: + raise RoutingError("requested effort is invalid") + reason = _observation_optional_text(value.get("reason"), "requested settings reason") + if (model is None or effort is None) and reason is None: + raise RoutingError("unknown requested settings need a reason") + if model is not None and effort is not None and reason is not None: + raise RoutingError("known requested settings must not have an unknown reason") + + +def _validate_observation_observed(value: Any) -> None: + if not isinstance(value, dict) or set(value) != {"model", "effort", "evidence_ref", "reason"}: + raise RoutingError("observed run settings have unsupported fields") + model = _observation_optional_text(value.get("model"), "observed model") + effort = value.get("effort") + if effort is not None and effort not in EFFORT_ORDER: + raise RoutingError("observed effort is invalid") + evidence = _observation_optional_text(value.get("evidence_ref"), "observed settings reference") + reason = _observation_optional_text(value.get("reason"), "observed settings reason") + if model is not None or effort is not None: + if evidence is None: + raise RoutingError("observed settings need a verification reference") + elif evidence is not None: + raise RoutingError("unknown observed settings must not have a verification reference") + if (model is None or effort is None) and reason is None: + raise RoutingError("unknown observed settings need a reason") + if model is not None and effort is not None and reason is not None: + raise RoutingError("known observed settings must not have an unknown reason") + + +def _validate_observation_usage(value: Any) -> None: + fields = { + "state", + "reason", + "evidence_ref", + "turn_scope", + "input_tokens", + "cache_input_tokens", + "output_tokens", + "reasoning_tokens", + "unknown_reasons", + } + if not isinstance(value, dict) or set(value) != fields: + raise RoutingError("run usage has unsupported fields") + state = value.get("state") + reason = _observation_optional_text(value.get("reason"), "run usage reason") + evidence = _observation_optional_text(value.get("evidence_ref"), "run usage reference") + if value.get("turn_scope") not in {"full_turn", "partial_turn"}: + raise RoutingError("run usage turn scope is invalid") + token_fields = ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens") + unknown_reasons = value.get("unknown_reasons") + if not isinstance(unknown_reasons, dict) or set(unknown_reasons) - set(token_fields): + raise RoutingError("run usage unknown reasons are invalid") + for name in token_fields: + amount = value.get(name) + if amount is not None and (type(amount) is not int or amount < 0): + raise RoutingError("run usage token count is invalid") + missing_reason = unknown_reasons.get(name) + if amount is None: + if state == "measured" and _observation_optional_text( + missing_reason, "run usage unknown reason" + ) is None: + raise RoutingError("unknown measured token count needs a reason") + elif missing_reason is not None: + raise RoutingError("known token count must not have an unknown reason") + if state == "missing": + if reason is None or evidence is not None or any(value.get(name) is not None for name in token_fields): + raise RoutingError("missing run usage is invalid") + if unknown_reasons: + raise RoutingError("missing run usage uses one shared reason") + elif state == "measured": + if reason is not None or evidence is None or not any(value.get(name) is not None for name in token_fields): + raise RoutingError("measured run usage is invalid") + else: + raise RoutingError("run usage state is invalid") + input_tokens = value.get("input_tokens") + cache_input_tokens = value.get("cache_input_tokens") + output_tokens = value.get("output_tokens") + reasoning_tokens = value.get("reasoning_tokens") + if input_tokens is not None and cache_input_tokens is not None and cache_input_tokens > input_tokens: + raise RoutingError("cache input exceeds input") + if output_tokens is not None and reasoning_tokens is not None and reasoning_tokens > output_tokens: + raise RoutingError("reasoning exceeds output") + + +def _validate_observation_run(value: Any) -> None: + fields = {"run_key", "relationship", "state", "run_id", "run_id_reason", "requested", "observed", "usage"} + if not isinstance(value, dict) or set(value) != fields: + raise RoutingError("observation run has unsupported fields") + _observation_text(value.get("run_key"), "run key", 128) + if value.get("relationship") not in {"parent", "child", "grandchild"}: + raise RoutingError("run relationship is invalid") + state = value.get("state") + if state not in {"ongoing", "completed", "interrupted"}: + raise RoutingError("run state is invalid") + run_id = _observation_optional_text(value.get("run_id"), "run id") + run_id_reason = _observation_optional_text(value.get("run_id_reason"), "run id missing reason") + if run_id is None and run_id_reason is None: + raise RoutingError("unknown run id needs a reason") + if run_id is not None and run_id_reason is not None: + raise RoutingError("known run id must not have an unknown reason") + _validate_observation_requested(value.get("requested")) + _validate_observation_observed(value.get("observed")) + _validate_observation_usage(value.get("usage")) + if state != "completed" and value["usage"]["state"] == "measured": + raise RoutingError("unfinished run usage cannot be recorded as completed usage") + + +def _validate_observation_completion(value: Any) -> None: + fields = {"state", "scope", "measurement", "verification", "rework", "runs"} + if not isinstance(value, dict) or set(value) != fields: + raise RoutingError("observation completion has unsupported fields") + state = value.get("state") + if state not in {"completed", "interrupted"}: + raise RoutingError("observation completion state is invalid") + _observation_text(value.get("scope"), "observation scope", MAX_OBSERVATION_SCOPE_CHARS) + _validate_observation_measurement(value.get("measurement"), allow_partial=True) + _validate_observation_verification(value.get("verification")) + _observation_text(value.get("rework"), "rework record") + runs = value.get("runs") + if not isinstance(runs, list) or len(runs) > MAX_OBSERVATION_RUNS: + raise RoutingError("observation runs are invalid") + run_keys: set[str] = set() + for run in runs: + _validate_observation_run(run) + if run["run_key"] in run_keys: + raise RoutingError("observation run keys must be unique") + run_keys.add(run["run_key"]) + measurement = value["measurement"]["state"] + measured_runs = [run for run in runs if run["usage"]["state"] == "measured"] + token_fields = ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens") + full_coverage = ( + state == "completed" + and any(run["relationship"] == "parent" for run in runs) + and all( + run["state"] == "completed" + and run["usage"]["state"] == "measured" + and run["usage"]["turn_scope"] == "full_turn" + and all(run["usage"][field] is not None for field in token_fields) + for run in runs + ) + ) + if measurement == "measured": + if not full_coverage: + raise RoutingError("measured observation has incomplete run usage") + elif measurement == "missing" and measured_runs: + raise RoutingError("missing observation cannot contain measured run usage") + elif measurement == "partial" and (not measured_runs or full_coverage): + raise RoutingError("partial observation measurement is inconsistent") + + +def _validate_observation_record(value: Any, sampling_key: str, task_id: str) -> None: + fields = { + "task_id", "sampling_key", "policy", "state", "measurement", "started_at", + "updated_at", "completed_at", "scope", "rework", "verification", "runs", + } + if not isinstance(value, dict) or set(value) != fields: + raise RoutingError("observation record is invalid") + if value.get("task_id") != task_id or value.get("sampling_key") != sampling_key: + raise RoutingError("observation record identity is invalid") + _observation_task_id(value["task_id"]) + policy = value.get("policy") + if not isinstance(policy, dict) or set(policy) != {"policy_revision", "manifest_version", "content_hash", "sampling_key"}: + raise RoutingError("observation record policy is invalid") + _observation_text(policy.get("policy_revision"), "observation policy revision") + _observation_text(policy.get("manifest_version"), "observation manifest version") + if not isinstance(policy.get("content_hash"), str) or not re.fullmatch(r"[0-9a-f]{64}", policy["content_hash"]): + raise RoutingError("observation policy hash is invalid") + if policy.get("sampling_key") != sampling_key: + raise RoutingError("observation policy sampling key is invalid") + if value.get("state") not in {"ongoing", "completed", "interrupted"}: + raise RoutingError("observation record state is invalid") + _validate_observation_measurement(value.get("measurement"), allow_partial=True) + for name in ("started_at", "updated_at"): + _observation_text(value.get(name), "observation timestamp", 64) + completed_at = value.get("completed_at") + if value["state"] == "ongoing": + if completed_at is not None or value.get("scope") is not None or value.get("rework") is not None or value.get("verification") is not None or value.get("runs") != []: + raise RoutingError("ongoing observation record is invalid") + else: + _observation_text(completed_at, "observation completion timestamp", 64) + _validate_observation_completion( + { + "state": value["state"], + "scope": value["scope"], + "measurement": value["measurement"], + "verification": value["verification"], + "rework": value["rework"], + "runs": value["runs"], + } + ) + + +def _validate_observation_state(value: Any, sampling_key: str) -> dict[str, Any]: + sampling_key = _observation_sampling_key(sampling_key) + if not isinstance(value, dict) or set(value) != {"schema_version", "samples"}: + raise RoutingError("observation state is invalid") + if not _is_schema_version_one(value.get("schema_version")) or not isinstance(value.get("samples"), dict): + raise RoutingError("observation state is invalid") + if set(value["samples"]) != {sampling_key}: + raise RoutingError("observation state has an unexpected sampling key") + sample = value["samples"][sampling_key] + if not isinstance(sample, dict) or set(sample) != {"tasks"} or not isinstance(sample.get("tasks"), dict): + raise RoutingError("observation sample is invalid") + if len(sample["tasks"]) > 3: + raise RoutingError("observation sample exceeds its limit") + for task_id, record in sample["tasks"].items(): + _observation_task_id(task_id) + _validate_observation_record(record, sampling_key, task_id) + return value + + +def _observation_directory(codex_home: Path, *, create: bool) -> Path: + directory = _absolute(codex_home) / OBSERVATIONS_RELATIVE + if create: + return _safe_mkdir(directory) + directory = _assert_safe_ancestors(directory) + if os.path.lexists(directory) and (not directory.is_dir() or _is_link_or_reparse(directory)): + raise RoutingError("observation directory is unsafe") + return directory + + +def _observation_state_path(codex_home: Path, sampling_key: str, *, create: bool) -> Path: + sampling_key = _observation_sampling_key(sampling_key) + return _observation_directory(codex_home, create=create) / f"{sampling_key}.json" + + +def _read_observation_state(codex_home: Path, sampling_key: str) -> dict[str, Any]: + sampling_key = _observation_sampling_key(sampling_key) + directory = _observation_directory(codex_home, create=False) + path = directory / f"{sampling_key}.json" + if not os.path.lexists(path): + return _empty_observation_state(sampling_key) + return _validate_observation_state( + _load_json(path, "observation state", MAX_OBSERVATION_STATE_BYTES), sampling_key + ) + + +@contextmanager +def _observation_write_lock(codex_home: Path, sampling_key: str | None = None) -> Iterator[None]: + """Serialize writes with an OS lock that is released if the process exits.""" + + directory = _observation_directory(codex_home, create=True) + lock_name = ( + f".{_observation_sampling_key(sampling_key)}.lock" + if sampling_key is not None + else OBSERVATION_LOCK_NAME + ) + lock_path = directory / lock_name + if os.path.lexists(lock_path) and _is_link_or_reparse(lock_path): + raise RoutingError("observation lock is unsafe") + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(lock_path, flags, 0o600) + except OSError as exc: + raise RoutingError("cannot lock observation state") from exc + try: + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise RoutingError("observation lock is not a regular file") + if os.name == "nt": + import msvcrt + + if os.fstat(descriptor).st_size == 0: + os.write(descriptor, b"0") + os.lseek(descriptor, 0, os.SEEK_SET) + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise RoutingError("observation state is busy or unavailable") from exc + yield + finally: + # Keep the inode fixed: unlinking allows two writers to lock different + # files. Closing (including process termination) releases the OS lock. + os.close(descriptor) + + +def _write_observation_state(codex_home: Path, sampling_key: str, state: dict[str, Any]) -> None: + sampling_key = _observation_sampling_key(sampling_key) + _validate_observation_state(state, sampling_key) + encoded = json.dumps(state, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n" + if len(encoded.encode("utf-8")) > MAX_OBSERVATION_STATE_BYTES: + raise RoutingError("observation state is too large") + path = _observation_state_path(codex_home, sampling_key, create=True) + _write_atomic(path.parent, path.name, encoded) + + +def _find_observation_task(state: dict[str, Any], sampling_key: str, task_id: str) -> dict[str, Any] | None: + sampling_key = _observation_sampling_key(sampling_key) + return state["samples"][sampling_key]["tasks"].get(task_id) + + +def observation_status( + *, codex_home: Path, policy: Policy | None = None, sampling_key: str | None = None +) -> dict[str, Any]: + """Read one sampling group without reading or writing any other group.""" + + if sampling_key is None: + if policy is None: + raise RoutingError("observation status needs a policy or sampling key") + sampling_key = observation_sampling_key(policy) + else: + sampling_key = _observation_sampling_key(sampling_key) + try: + state = _read_observation_state(codex_home, sampling_key) + sample = state["samples"][sampling_key] + tasks = list(sample["tasks"].values()) + except (RoutingError, OSError): + return { + "state": "unknown", + "remaining": None, + "recorded": None, + "pending": None, + "completed": None, + "missing": None, + } + pending = sum(record["state"] == "ongoing" for record in tasks) + completed = sum(record["state"] == "completed" for record in tasks) + missing = sum( + record["state"] != "ongoing" and record["measurement"]["state"] != "measured" + for record in tasks + ) + return { + "state": "valid", + "sampling_key": sampling_key, + "remaining": 3 - len(tasks), + "recorded": len(tasks), + "pending": pending, + "completed": completed, + "missing": missing, + "task_ids": [record["task_id"] for record in tasks], + } + + +def observation_start(*, codex_home: Path, policy: Policy, task_id: str, kind: str = "normal") -> dict[str, Any]: + """Reserve one normal-work observation, idempotently by the parent task key.""" + + task_id = _observation_task_id(task_id) + if kind != "normal": + if kind not in {"audit", "config"}: + raise RoutingError("observation kind is invalid") + return {"state": "skipped", "reason": "non_normal_work", "recorded": False} + sampling_key = observation_sampling_key(policy) + with _observation_write_lock(codex_home, sampling_key): + state = _read_observation_state(codex_home, sampling_key) + existing = _find_observation_task(state, sampling_key, task_id) + now = _utc_now() + if existing is not None: + existing["updated_at"] = now + _write_observation_state(codex_home, sampling_key, state) + return {"state": "existing", "recorded": True, "sampling_key": sampling_key} + sample = state["samples"][sampling_key] + if len(sample["tasks"]) >= 3: + return {"state": "full", "recorded": False, "sampling_key": sampling_key} + sample["tasks"][task_id] = { + "task_id": task_id, + "sampling_key": sampling_key, + "policy": _observation_policy_metadata(policy, sampling_key), + "state": "ongoing", + "measurement": {"state": "missing", "reason": "completion is not recorded"}, + "started_at": now, + "updated_at": now, + "completed_at": None, + "scope": None, + "rework": None, + "verification": None, + "runs": [], + } + _write_observation_state(codex_home, sampling_key, state) + return {"state": "started", "recorded": True, "sampling_key": sampling_key} + + +def observation_complete( + *, codex_home: Path, sampling_key: str, task_id: str, completion: dict[str, Any] +) -> dict[str, Any]: + """Close one reserved group without loading current policy or other groups.""" + + task_id = _observation_task_id(task_id) + sampling_key = _observation_sampling_key(sampling_key) + _validate_observation_completion(completion) + with _observation_write_lock(codex_home, sampling_key): + state = _read_observation_state(codex_home, sampling_key) + record = _find_observation_task(state, sampling_key, task_id) + if record is None: + raise RoutingError("observation task is not reserved") + if record["verification"] is not None and record["verification"]["reference"] is not None and completion["verification"]["reference"] is None: + raise RoutingError("completion must retain existing verification evidence") + # Each completion is a full snapshot. A resumed task must not erase + # earlier runs or replace acquired evidence with unknown values. + incoming_runs = {run["run_key"]: run for run in completion["runs"]} + for previous in record["runs"]: + current = incoming_runs.get(previous["run_key"]) + if current is None: + raise RoutingError("completion must retain existing runs") + if current["relationship"] != previous["relationship"] or ( + previous["run_id"] is not None and current["run_id"] != previous["run_id"] + ): + raise RoutingError("a different run needs a new run key") + if previous["state"] == "completed" and current["state"] != "completed": + raise RoutingError("completion must retain completed run evidence") + for section, names in ( + ("usage", ("input_tokens", "cache_input_tokens", "output_tokens", "reasoning_tokens")), + ("observed", ("model", "effort")), + ("requested", ("model", "effort")), + ): + if any(previous[section][name] is not None and current[section][name] is None for name in names): + raise RoutingError("completion must retain acquired run values") + now = _utc_now() + record.update( + { + "state": completion["state"], + "scope": completion["scope"], + "measurement": completion["measurement"], + "verification": completion["verification"], + "rework": completion["rework"], + "runs": completion["runs"], + "completed_at": now, + "updated_at": now, + } + ) + _write_observation_state(codex_home, sampling_key, state) + return { + "state": "closed", + "observation_state": completion["state"], + "measurement_state": completion["measurement"]["state"], + "sampling_key": sampling_key, + } diff --git a/plugins/codex-task-routing/skills/task-routing/references/configuration.md b/plugins/codex-task-routing/skills/task-routing/references/configuration.md index c9864e3..d9372d5 100644 --- a/plugins/codex-task-routing/skills/task-routing/references/configuration.md +++ b/plugins/codex-task-routing/skills/task-routing/references/configuration.md @@ -44,6 +44,23 @@ effortの設定値は `medium`、`high`、`xhigh`、`max` で、下限≤標準 ## 状態の区別 +`status --json` が `ok: false` を返す場合は、`error_code` と `hint` で原因と対処を確認できます。設定値・未知キーの名前・OSエラー本文を表示せず、固定の診断文だけを返します。終了コードは従来どおり2です。 + +| error_code | 確認する内容 | +| --- | --- | +| `invalid_json` | UTF-8、JSON構文、重複キー、トップレベルがobjectか | +| `unsupported_key` | モデル・原則・設定のキーが既定定義に存在するか | +| `unsupported_schema` | `schema_version` が整数の1か | +| `unsupported_effort` | effortが対応値か | +| `invalid_effort_range` | 下限≤標準≤上限を満たすか | +| `invalid_template` | 未知参照、循環、展開上限、配布テンプレートの欠損 | +| `filesystem_unavailable` | 指定ファイルの存在と読み取り権限 | +| `unsafe_path` | パスや親ディレクトリにリンク・reparse pointがないか | +| `invalid_cache` | キャッシュの不一致・安全に扱えないパス | +| `invalid_policy` | その他の型・必須フィールド・構造 | + +診断は自動修復しません。フックはこれまでどおり短い失敗通知にとどめ、詳細な確認を `status --json` に分けます。 + - `status` の解決成功: ファイルと設定が正しい。 - Codexのプラグイン有効状態: ホストのプラグイン管理で確認する。 - フックの信頼: `/hooks` で利用者が確認する。 diff --git a/scripts/check_package.py b/scripts/check_package.py index 93d7c77..1342800 100644 --- a/scripts/check_package.py +++ b/scripts/check_package.py @@ -20,7 +20,7 @@ def check(): for name in ['LICENSE', 'scripts/routing.py', 'scripts/chatgpt_route.py', 'scripts/updater.py', 'scripts/updater_entry.py', 'scripts/install_updater.py', 'hooks/hooks.json', 'defaults/config.json', 'skills/task-routing/SKILL.md', 'skills/task-routing/references/configuration.md', 'skills/task-routing/references/chatgpt.md', 'skills/task-routing/references/observation.md', 'skills/task-routing/references/chat-limits.md', 'skills/task-routing/references/observation-example.json']: assert (PLUGIN / name).is_file(), name hooks = json.loads((PLUGIN / 'hooks/hooks.json').read_text(encoding='utf-8'))['hooks'] - for name in ['scripts/chat_plan.py', 'scripts/chat_transfer.py', 'skills/task-routing/references/chat-capabilities.md', 'skills/task-routing/references/chat-transfer.md']: + for name in ['scripts/routing_core/__init__.py', 'scripts/routing_core/common.py', 'scripts/routing_core/observation.py', 'scripts/chat_plan.py', 'scripts/chat_transfer.py', 'skills/task-routing/references/chat-capabilities.md', 'skills/task-routing/references/chat-transfer.md']: assert (PLUGIN / name).is_file(), name assert set(hooks) == {'SessionStart', 'SubagentStart'} for groups in hooks.values(): diff --git a/scripts/check_release.py b/scripts/check_release.py index de800f9..e75f339 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -4,15 +4,71 @@ import json import os from pathlib import Path +import re import subprocess ROOT = Path(__file__).resolve().parents[1] PREFIX = 'plugins/codex-task-routing/' MANIFEST = PREFIX + '.codex-plugin/plugin.json' +SEMVER = re.compile( + r'^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + r'(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)' + r'(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?' + r'(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' +) + + +def parse_semver(version): + """Return SemVer precedence fields, rejecting non-SemVer input. + + Numeric identifiers remain strings so even unusually large valid values can + be compared without depending on Python's integer-conversion digit limit. + Build metadata is validated but deliberately omitted from the result because + SemVer excludes it from precedence. + """ + if not isinstance(version, str): + raise ValueError(f'Invalid semantic version: {version!r}') + match = SEMVER.fullmatch(version) + if match is None: + raise ValueError(f'Invalid semantic version: {version!r}') + major, minor, patch, prerelease, _build = match.groups() + return (major, minor, patch), (() if prerelease is None else tuple(prerelease.split('.'))) + + +def _compare_numeric(left, right): + if len(left) != len(right): + return -1 if len(left) < len(right) else 1 + return (left > right) - (left < right) + + +def compare_semver(left, right): + """Compare two valid semantic versions by SemVer 2.0.0 precedence.""" + left_core, left_pre = parse_semver(left) + right_core, right_pre = parse_semver(right) + for left_value, right_value in zip(left_core, right_core): + comparison = _compare_numeric(left_value, right_value) + if comparison: + return comparison + + if not left_pre or not right_pre: + return (not left_pre) - (not right_pre) + for left_value, right_value in zip(left_pre, right_pre): + if left_value == right_value: + continue + left_numeric = left_value.isascii() and left_value.isdigit() + right_numeric = right_value.isascii() and right_value.isdigit() + if left_numeric and right_numeric: + return _compare_numeric(left_value, right_value) + if left_numeric != right_numeric: + return -1 if left_numeric else 1 + return (left_value > right_value) - (left_value < right_value) + return (len(left_pre) > len(right_pre)) - (len(left_pre) < len(right_pre)) def needs_version_change(paths, before, after): - return any(path.startswith(PREFIX) for path in paths) and before == after + if not any(path.startswith(PREFIX) for path in paths): + return False + return compare_semver(after, before) <= 0 def main(): @@ -29,8 +85,12 @@ def main(): before = json.loads(subprocess.check_output(['git', 'show', base + ':' + MANIFEST], cwd=ROOT, text=True, encoding='utf-8'))['version'] after = json.loads((ROOT / MANIFEST).read_text(encoding='utf-8'))['version'] - if needs_version_change(paths, before, after): - raise SystemExit('Distributed plugin files changed without a new manifest version.') + try: + invalid_change = needs_version_change(paths, before, after) + except ValueError as error: + raise SystemExit(str(error)) from error + if invalid_change: + raise SystemExit('Distributed plugin files changed without a higher SemVer manifest version.') print('Release version check passed.') diff --git a/tests/runtime_fixtures.py b/tests/runtime_fixtures.py new file mode 100644 index 0000000..65c7f58 --- /dev/null +++ b/tests/runtime_fixtures.py @@ -0,0 +1,19 @@ +"""Synthetic fixtures with inherited ACLs under the checkout, never the user home.""" +from contextlib import contextmanager +from pathlib import Path +import shutil +import uuid + + +@contextmanager +def runtime_directory(): + # Python 3.13 TemporaryDirectory's 0700 ACL excludes the restricted Windows + # runner. Match the existing fixtures' mkdir behavior without changing ACLs. + root = Path(__file__).resolve().parent / '.tmp-runtime-tests' + root.mkdir(exist_ok=True) + directory = root / f'fixture-{uuid.uuid4().hex}' + directory.mkdir() + try: + yield directory + finally: + shutil.rmtree(directory) diff --git a/tests/test_chat_boundary_regressions.py b/tests/test_chat_boundary_regressions.py index b868cf0..08d6fc1 100644 --- a/tests/test_chat_boundary_regressions.py +++ b/tests/test_chat_boundary_regressions.py @@ -4,7 +4,7 @@ import json from pathlib import Path import sys -import tempfile +from runtime_fixtures import runtime_directory import unittest SCRIPTS = Path(__file__).resolve().parents[1] / 'plugins/codex-task-routing/scripts' @@ -35,9 +35,7 @@ def test_lone_surrogates_rejected_before_preparing_or_saving(self): invalid = dict(self.response) invalid[field] = [surrogate] if field == 'evidence' else surrogate with self.subTest(surrogate=repr(surrogate), field=field): - root = Path(__file__).resolve().parent / '.tmp-runtime-tests' - root.mkdir(exist_ok=True) - with tempfile.TemporaryDirectory(dir=root) as directory: + with runtime_directory() as directory: reply = Path(directory) / 'reply.json' state = chat_transfer.TransferState(bundle=self.bundle, reply_path=reply) with self.assertRaisesRegex(chat_transfer.ChatTransferError, 'valid Unicode'): diff --git a/tests/test_fixed_routing.py b/tests/test_fixed_routing.py index 29977ad..7483967 100644 --- a/tests/test_fixed_routing.py +++ b/tests/test_fixed_routing.py @@ -6,7 +6,7 @@ import json from pathlib import Path import sys -import tempfile +from runtime_fixtures import runtime_directory import unittest from contextlib import redirect_stdout, redirect_stderr @@ -20,11 +20,7 @@ class FixedRoutingTests(unittest.TestCase): def setUp(self): - root = ROOT / 'tests/.tmp-runtime-tests' - root.mkdir(exist_ok=True) - self.temp = tempfile.TemporaryDirectory(dir=root) - self.addCleanup(self.temp.cleanup) - self.home = Path(self.temp.name) + self.home = self.enterContext(runtime_directory()) self.now = datetime(2026, 9, 15, 12, tzinfo=timezone.utc) self.facts = dict(substantial=True, parent_has_independent_work=False, materials_approved=True, handoff_proportionate=True, diff --git a/tests/test_pr11_review.py b/tests/test_pr11_review.py index 0a7dad4..f80d685 100644 --- a/tests/test_pr11_review.py +++ b/tests/test_pr11_review.py @@ -6,7 +6,7 @@ import json from pathlib import Path import sys -import tempfile +from runtime_fixtures import runtime_directory import unittest SCRIPTS = Path(__file__).resolve().parents[1] / 'plugins/codex-task-routing/scripts' @@ -18,13 +18,7 @@ class PreparedBundleSizeTests(unittest.TestCase): def setUp(self): - # macOS's system temporary directory has a symlink ancestor. Keep test - # files under the checkout to exercise the same safe-path rules on all OSes. - root = Path(__file__).resolve().parent / '.tmp-runtime-tests' - root.mkdir(exist_ok=True) - self.temp = tempfile.TemporaryDirectory(dir=root) - self.addCleanup(self.temp.cleanup) - self.home = Path(self.temp.name) + self.home = self.enterContext(runtime_directory()) self.request = dict(request_id='00000000-0000-4000-8000-000000000011', task='Review supplied code.', materials='Fixture.', acceptance_criteria=['Report findings.']) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..f9d9f74 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_release.py" +SPEC = importlib.util.spec_from_file_location("check_release_under_test", SCRIPT) +assert SPEC and SPEC.loader +release = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release +SPEC.loader.exec_module(release) + + +class ReleaseVersionTest(unittest.TestCase): + distributed = ["plugins/codex-task-routing/scripts/routing.py"] + + def test_requires_strictly_higher_version_for_distributed_changes(self) -> None: + cases = [ + ("1.2.3", "1.2.3", True), + ("2.0.0", "1.99.99", True), + ("1.2.3", "1.2.4", False), + ("1.2.3", "2.0.0", False), + ("1.2.3+old", "1.2.3+new", True), + ("1.2.3", "1.2.3+build.1", True), + ] + for before, after, expected in cases: + with self.subTest(before=before, after=after): + self.assertIs(release.needs_version_change(self.distributed, before, after), expected) + + def test_non_distributed_changes_do_not_require_a_version_change(self) -> None: + self.assertFalse(release.needs_version_change(["README.md", "docs/changelog.md"], "1.2.3", "1.2.3")) + self.assertFalse(release.needs_version_change([], "not-semver", "still-not-semver")) + + def test_prerelease_precedence_matches_semver(self) -> None: + ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ] + for before, after in zip(ordered, ordered[1:]): + with self.subTest(before=before, after=after): + self.assertLess(release.compare_semver(before, after), 0) + self.assertGreater(release.compare_semver(after, before), 0) + + def test_numeric_identifiers_are_compared_numerically_without_integer_conversion(self) -> None: + huge = "9" * 5000 + self.assertLess(release.compare_semver("1.0.0-2", "1.0.0-11"), 0) + self.assertLess(release.compare_semver(f"1.0.0-{huge}", f"1.0.0-1{huge}"), 0) + self.assertGreater(release.compare_semver(f"{huge}.0.0", "999.0.0"), 0) + + def test_build_metadata_does_not_affect_precedence(self) -> None: + self.assertEqual(release.compare_semver("1.2.3-alpha+one", "1.2.3-alpha+two"), 0) + + def test_invalid_semver_is_rejected(self) -> None: + invalid = [ + "1.0", + "v1.0.0", + "01.0.0", + "1.01.0", + "1.0.00", + "1.0.0-", + "1.0.0-alpha..1", + "1.0.0-01", + "1.0.0+", + "1.0.0+build..1", + "1.0.0-β", + 100, + None, + ] + for version in invalid: + with self.subTest(version=version): + with self.assertRaisesRegex(ValueError, "Invalid semantic version"): + release.parse_semver(version) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_routing.py b/tests/test_routing.py index 23b5e8b..4b5326b 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -148,6 +148,7 @@ def test_unknown_cyclic_and_deep_tokens_reject(self) -> None: (self.plugin / "defaults" / "config.json").write_text(json.dumps(config), encoding="utf-8") with self.assertRaises(routing.RoutingError): self.policy().render_template("{{principles.policy_timing}}") + config = json.loads(json.dumps(DEFAULTS)) for index in range(routing.MAX_TOKEN_DEPTH + 1): key = f"deep_{index}" @@ -164,6 +165,48 @@ def test_unknown_cyclic_and_deep_tokens_reject(self) -> None: with self.assertRaises(routing.RoutingError): self.policy().render_template("{{principles.policy_timing}}") + def test_status_explains_invalid_configuration_without_echoing_secrets(self) -> None: + secret = "private-input-sentinel" + cases = [ + ({"schema_version": 1, secret: secret}, "unsupported_key"), + ({"schema_version": True, "models": {"terra": {"default_effort": "max"}}}, "unsupported_schema"), + ({"schema_version": 1, "models": {"terra": {"default_effort": secret}}}, "unsupported_effort"), + ({"schema_version": 1, "models": {"terra": {"min_effort": "max"}}}, "invalid_effort_range"), + ({"schema_version": 1, "principles": {"summary": "{{" + secret + "}}"}}, "invalid_template"), + ('{"schema_version":1,"schema_version":1,"' + secret + '":0}', "invalid_json"), + ('{"' + secret + '":', "invalid_json"), + ] + for value, code in cases: + with self.subTest(code=code, value=value): + path = self.write_override(value) + before = path.read_bytes() + result = routing.status_payload(plugin_root=self.plugin, codex_home=self.home) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_code"], code) + self.assertTrue(result["hint"]) + self.assertEqual(path.read_bytes(), before) + self.assertNotIn(secret, json.dumps(result)) + + def test_status_does_not_echo_os_errors_or_unrecognized_error_text(self) -> None: + for error, code in [ + (OSError("private-filesystem-sentinel"), "filesystem_unavailable"), + (routing.RoutingError("private-filesystem-sentinel", code="private-code"), "invalid_policy"), + ]: + with self.subTest(code=code), patch.object(routing, "load_policy", side_effect=error): + result = routing.status_payload(plugin_root=self.plugin, codex_home=self.home) + self.assertEqual(result["error_code"], code) + self.assertNotIn("private-", json.dumps(result)) + + def test_status_identifies_invalid_template_encoding_and_size(self) -> None: + template = self.plugin / "defaults/templates/effective.md" + for content, limit in [(b"\xff", routing.MAX_TEMPLATE_BYTES), (b"x" * 33, 32)]: + with self.subTest(content=content), patch.object(routing, "MAX_TEMPLATE_BYTES", limit): + template.write_bytes(content) + result = routing.status_payload(plugin_root=self.plugin, codex_home=self.home) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_code"], "invalid_template") + self.assertIn("template", result["hint"]) + def test_hash_is_stable_and_changes_for_config_or_template(self) -> None: first = self.policy() second = self.policy() @@ -219,7 +262,6 @@ def denied(source, destination): @unittest.skipUnless(os.name == "nt", "Windows rename error mapping") def test_cache_rename_retries_only_bounded_windows_access_candidates(self) -> None: policy = self.policy() - original_rename = routing.os.rename transient_home = self.base / "home-transient-access" calls = 0 @@ -228,7 +270,9 @@ def fail_twice_then_publish(source, destination): calls += 1 if calls < routing.CACHE_RENAME_MAX_ATTEMPTS: raise OSError(errno.EACCES, "temporary access", None, 5) - original_rename(source, destination) + # Unit-test retry decisions without spending the last attempt on a + # real Windows rename. Actual publication is covered independently. + return None with ( patch.object(routing.os, "rename", side_effect=fail_twice_then_publish), @@ -285,6 +329,14 @@ def publish_mismatch_then_signal_access(source, destination): "do not overwrite", ) + def test_cache_publication_roundtrips_on_real_filesystem(self) -> None: + policy = self.policy() + cache = routing._cache_directory(self.home, policy) + self.assertTrue(routing._is_managed_directory(cache)) + for name, expected in policy.rendered_documents().items(): + self.assertEqual((cache / name).read_text(encoding="utf-8"), expected) + self.assertEqual(routing._cache_directory(self.home, policy), cache) + def test_mutated_cache_is_not_reused_as_effective_policy(self) -> None: policy = self.policy() cache = routing._cache_directory(self.home, policy)