diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000000000000000000000000000000..61addafe3b8fad1b2786cfb271c9c52b07784ba5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Core crate ownership. +/codex-rs/arg0/ @openai/codex-core-agent-team +/codex-rs/codex-mcp/ @openai/codex-core-agent-team +/codex-rs/core/ @openai/codex-core-agent-team +/codex-rs/exec-server/ @openai/codex-core-agent-team +/codex-rs/exec-server-protocol/ @openai/codex-core-agent-team +/codex-rs/ext/extension-api/ @openai/codex-core-agent-team +/codex-rs/prompts/ @openai/codex-core-agent-team +/codex-rs/utils/path-uri/ @openai/codex-core-agent-team + +# Keep macOS AKV signing changes reviewed by Codex maintainers. +/.github/actions/setup-akv-pkcs11-codesigning/ @openai/codex-core-agent-team +/.github/scripts/macos-signing/ @openai/codex-core-agent-team +/.github/workflows/rust-release.yml @openai/codex-core-agent-team + +# Keep ownership changes reviewed by the same team. +/.github/CODEOWNERS @openai/codex-core-agent-team diff --git a/.github/blob-size-allowlist.txt b/.github/blob-size-allowlist.txt new file mode 100644 index 0000000000000000000000000000000000000000..27d1c59e87bedc248d78672cd5b9ae0a7d70a577 --- /dev/null +++ b/.github/blob-size-allowlist.txt @@ -0,0 +1,11 @@ +# Paths are matched exactly, relative to the repository root. +# Keep this list short and limited to intentional large checked-in assets. + +.github/codex-cli-splash.png +MODULE.bazel.lock +codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +codex-rs/models-manager/models.json +codex-rs/tui/tests/fixtures/oss-story.jsonl +codex-rs/tui_app_server/tests/fixtures/oss-story.jsonl +codex-rs/tui/src/app.rs diff --git a/.github/codex/home/config.toml b/.github/codex/home/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..7b065212a81e47722c6c2036e61f6aec5f6df083 --- /dev/null +++ b/.github/codex/home/config.toml @@ -0,0 +1,3 @@ +model = "gpt-5.1" + +# Consider setting [mcp_servers] here! diff --git a/.github/codex/labels/codex-attempt.md b/.github/codex/labels/codex-attempt.md new file mode 100644 index 0000000000000000000000000000000000000000..b2a3e93af2af9316b93d6c32af1d8c92873f024f --- /dev/null +++ b/.github/codex/labels/codex-attempt.md @@ -0,0 +1,9 @@ +Attempt to solve the reported issue. + +If a code change is required, create a new branch, commit the fix, and open a pull request that resolves the problem. + +Here is the original GitHub issue that triggered this run: + +### {CODEX_ACTION_ISSUE_TITLE} + +{CODEX_ACTION_ISSUE_BODY} diff --git a/.github/codex/labels/codex-review.md b/.github/codex/labels/codex-review.md new file mode 100644 index 0000000000000000000000000000000000000000..7c6c14ad572f18c95e7ecce449c3afba84dd014b --- /dev/null +++ b/.github/codex/labels/codex-review.md @@ -0,0 +1,7 @@ +Review this PR and respond with a very concise final message, formatted in Markdown. + +There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary. + +Then provide the **review** (1-2 sentences plus bullet points, friendly tone). + +{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the `base` and `head` refs that define this PR. Both refs are available locally. diff --git a/.github/codex/labels/codex-rust-review.md b/.github/codex/labels/codex-rust-review.md new file mode 100644 index 0000000000000000000000000000000000000000..ae74953a5c11c6d9b6c21da77329d1f39ef803b6 --- /dev/null +++ b/.github/codex/labels/codex-rust-review.md @@ -0,0 +1,139 @@ +Review this PR and respond with a very concise final message, formatted in Markdown. + +There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary. + +Then provide the **review** (1-2 sentences plus bullet points, friendly tone). + +Things to look out for when doing the review: + +## General Principles + +- **Make sure the pull request body explains the motivation behind the change.** If the author has failed to do this, call it out, and if you think you can deduce the motivation behind the change, propose copy. +- Ideally, the PR body also contains a small summary of the change. For small changes, the PR title may be sufficient. +- Each PR should ideally do one conceptual thing. For example, if a PR does a refactoring as well as introducing a new feature, push back and suggest the refactoring be done in a separate PR. This makes things easier for the reviewer, as refactoring changes can often be far-reaching, yet quick to review. +- When introducing new code, be on the lookout for code that duplicates existing code. When found, propose a way to refactor the existing code such that it should be reused. + +## Code Organization + +- Each crate in the Cargo workspace in `codex-rs` has a specific purpose: make a note if you believe new code is not introduced in the correct crate. +- When possible, try to keep the `core` crate as small as possible. Non-core but shared logic is often a good candidate for `codex-rs/common`. +- Be wary of large files and offer suggestions for how to break things into more reasonably-sized files. +- Rust files should generally be organized such that the public parts of the API appear near the top of the file and helper functions go below. This is analogous to the "inverted pyramid" structure that is favored in journalism. + +## Assertions in Tests + +Assert the equality of the entire objects instead of doing "piecemeal comparisons," performing `assert_eq!()` on individual fields. + +Note that unit tests also function as "executable documentation." As shown in the following example, "piecemeal comparisons" are often more verbose, provide less coverage, and are not as useful as executable documentation. + +For example, suppose you have the following enum: + +```rust +#[derive(Debug, PartialEq)] +enum Message { + Request { + id: String, + method: String, + params: Option, + }, + Notification { + method: String, + params: Option, + }, +} +``` + +This is an example of a _piecemeal_ comparison: + +```rust +// BAD: Piecemeal Comparison + +#[test] +fn test_get_latest_messages() { + let messages = get_latest_messages(); + assert_eq!(messages.len(), 2); + + let m0 = &messages[0]; + match m0 { + Message::Request { id, method, params } => { + assert_eq!(id, "123"); + assert_eq!(method, "subscribe"); + assert_eq!( + *params, + Some(json!({ + "conversation_id": "x42z86" + })) + ) + } + Message::Notification { .. } => { + panic!("expected Request"); + } + } + + let m1 = &messages[1]; + match m1 { + Message::Request { .. } => { + panic!("expected Notification"); + } + Message::Notification { method, params } => { + assert_eq!(method, "log"); + assert_eq!( + *params, + Some(json!({ + "level": "info", + "message": "subscribed" + })) + ) + } + } +} +``` + +This is a _deep_ comparison: + +```rust +// GOOD: Verify the entire structure with a single assert_eq!(). + +use pretty_assertions::assert_eq; + +#[test] +fn test_get_latest_messages() { + let messages = get_latest_messages(); + + assert_eq!( + vec![ + Message::Request { + id: "123".to_string(), + method: "subscribe".to_string(), + params: Some(json!({ + "conversation_id": "x42z86" + })), + }, + Message::Notification { + method: "log".to_string(), + params: Some(json!({ + "level": "info", + "message": "subscribed" + })), + }, + ], + messages, + ); +} +``` + +## More Tactical Rust Things To Look Out For + +- Do not use `unsafe` (unless you have a really, really good reason like using an operating system API directly and no safe wrapper exists). For example, there are cases where it is tempting to use `unsafe` in order to use `std::env::set_var()`, but this indeed `unsafe` and has led to race conditions on multiple occasions. (When this happens, find a mechanism other than environment variables to use for configuration.) +- Encourage the use of small enums or the newtype pattern in Rust if it helps readability without adding significant cognitive load or lines of code. +- If you see opportunities for the changes in a diff to use more idiomatic Rust, please make specific recommendations. For example, favor the use of expressions over `return`. +- When modifying a `Cargo.toml` file, make sure that dependency lists stay alphabetically sorted. Also consider whether a new dependency is added to the appropriate place (e.g., `[dependencies]` versus `[dev-dependencies]`) + +## Pull Request Body + +- If the nature of the change seems to have a visual component (which is often the case for changes to `codex-rs/tui`), recommend including a screenshot or video to demonstrate the change, if appropriate. +- References to existing GitHub issues and PRs are encouraged, where appropriate, though you likely do not have network access, so may not be able to help here. + +# PR Information + +{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the `base` and `head` refs that define this PR. Both refs are available locally. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000000000000000000000000000000000000..728beaa584c441f44791086d23276aceb3be240b --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,36 @@ +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#package-ecosystem- + +version: 2 +updates: + - package-ecosystem: bun + directory: .github/actions/codex + schedule: + interval: weekly + cooldown: + default-days: 7 + - package-ecosystem: cargo + directories: + - codex-rs + - codex-rs/* + schedule: + interval: weekly + cooldown: + default-days: 7 + - package-ecosystem: docker + directory: codex-cli + schedule: + interval: weekly + cooldown: + default-days: 7 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + - package-ecosystem: rust-toolchain + directory: codex-rs + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/dotslash-argument-comment-lint-config.json b/.github/dotslash-argument-comment-lint-config.json new file mode 100644 index 0000000000000000000000000000000000000000..19a2a4803c013d8d524a3bcf21e4be746c331957 --- /dev/null +++ b/.github/dotslash-argument-comment-lint-config.json @@ -0,0 +1,24 @@ +{ + "outputs": { + "argument-comment-lint": { + "platforms": { + "macos-aarch64": { + "regex": "^argument-comment-lint-aarch64-apple-darwin\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "linux-x86_64": { + "regex": "^argument-comment-lint-x86_64-unknown-linux-gnu\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "linux-aarch64": { + "regex": "^argument-comment-lint-aarch64-unknown-linux-gnu\\.tar\\.gz$", + "path": "argument-comment-lint/bin/argument-comment-lint" + }, + "windows-x86_64": { + "regex": "^argument-comment-lint-x86_64-pc-windows-msvc\\.zip$", + "path": "argument-comment-lint/bin/argument-comment-lint.exe" + } + } + } + } +} diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json new file mode 100644 index 0000000000000000000000000000000000000000..8e948bc3738d262643a7e21887684db15875d880 --- /dev/null +++ b/.github/dotslash-config.json @@ -0,0 +1,136 @@ +{ + "outputs": { + "codex": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" + }, + "macos-x86_64": { + "regex": "^codex-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" + }, + "linux-x86_64": { + "regex": "^codex-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" + }, + "linux-aarch64": { + "regex": "^codex-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" + }, + "windows-x86_64": { + "regex": "^codex-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" + }, + "windows-aarch64": { + "regex": "^codex-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" + } + } + }, + "codex-app-server": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-app-server-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" + }, + "macos-x86_64": { + "regex": "^codex-app-server-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" + }, + "linux-x86_64": { + "regex": "^codex-app-server-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" + }, + "linux-aarch64": { + "regex": "^codex-app-server-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" + }, + "windows-x86_64": { + "regex": "^codex-app-server-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" + }, + "windows-aarch64": { + "regex": "^codex-app-server-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" + } + } + }, + "codex-responses-api-proxy": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "macos-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-apple-darwin\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "windows-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + }, + "windows-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + } + } + }, + "bwrap": { + "platforms": { + "linux-x86_64": { + "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", + "path": "bwrap" + }, + "linux-aarch64": { + "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", + "path": "bwrap" + } + } + }, + "codex-command-runner": { + "platforms": { + "windows-x86_64": { + "regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-command-runner.exe" + }, + "windows-aarch64": { + "regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-command-runner.exe" + } + } + }, + "codex-windows-sandbox-setup": { + "platforms": { + "windows-x86_64": { + "regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-setup.exe" + }, + "windows-aarch64": { + "regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-setup.exe" + } + } + }, + "codex-windows-sandbox-service": { + "platforms": { + "windows-x86_64": { + "regex": "^codex-windows-sandbox-service-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-service.exe" + }, + "windows-aarch64": { + "regex": "^codex-windows-sandbox-service-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-service.exe" + } + } + } + } +} diff --git a/.github/dotslash-provisioned-config.json b/.github/dotslash-provisioned-config.json new file mode 100644 index 0000000000000000000000000000000000000000..2f7363ed06abbdf9040cd8d96adec349d2c250ca --- /dev/null +++ b/.github/dotslash-provisioned-config.json @@ -0,0 +1,16 @@ +{ + "outputs": { + "codex-provisioned": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-provisioned-package-aarch64-apple-darwin\\.tar\\.gz$", + "path": "bin/codex" + }, + "macos-x86_64": { + "regex": "^codex-provisioned-package-x86_64-apple-darwin\\.tar\\.gz$", + "path": "bin/codex" + } + } + } + } +} diff --git a/.github/dotslash-zsh-config.json b/.github/dotslash-zsh-config.json new file mode 100644 index 0000000000000000000000000000000000000000..2c2b11eec597298410cd0e265aa4bdf222f12ce9 --- /dev/null +++ b/.github/dotslash-zsh-config.json @@ -0,0 +1,32 @@ +{ + "outputs": { + "codex-zsh": { + "platforms": { + "macos-aarch64": { + "name": "codex-zsh-aarch64-apple-darwin.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-zsh/bin/zsh" + }, + "macos-x86_64": { + "name": "codex-zsh-x86_64-apple-darwin.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-zsh/bin/zsh" + }, + "linux-x86_64": { + "name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-zsh/bin/zsh" + }, + "linux-aarch64": { + "name": "codex-zsh-aarch64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "hash": "sha256", + "path": "codex-zsh/bin/zsh" + } + } + } + } +} diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f8ebae902bd4252fe2ce5cfab57d4ac526bd1e3 --- /dev/null +++ b/.github/workflows/cargo-deny.yml @@ -0,0 +1,31 @@ +name: cargo-deny + +on: + workflow_call: + +jobs: + cargo-deny: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./codex-rs + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-ci + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + + - name: Run cargo-deny + uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 + with: + rust-version: 1.95.0 + manifest-path: ./codex-rs/Cargo.toml + + - name: Check for a clean worktree + if: always() && !cancelled() + uses: ./.github/actions/check-clean-worktree diff --git a/.github/workflows/python-runtime-release.yml b/.github/workflows/python-runtime-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7f0b4af235ed0a7709b2d291f8533d27dbf7782 --- /dev/null +++ b/.github/workflows/python-runtime-release.yml @@ -0,0 +1,63 @@ +name: python-runtime-release + +on: + workflow_dispatch: + inputs: + runtime_version: + description: "Runtime version to publish before updating the SDK pin, for example 0.136.0, 0.136.0a2, or 0.136.0a2.post1." + required: true + type: string + +concurrency: + group: python-runtime-release-${{ inputs.runtime_version }} + cancel-in-progress: false + +jobs: + prepare-python-runtime: + name: prepare-python-runtime + permissions: + contents: read + uses: ./.github/workflows/python-runtime-build.yml + with: + runtime_version: ${{ inputs.runtime_version }} + + # PyPI must trust this top-level workflow for manual runtime publication. + publish-python-runtime: + if: github.repository == 'openai/codex' + name: publish-python-runtime + needs: prepare-python-runtime + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download Python runtime wheels + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-runtime-wheels + path: dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/python-runtime + skip-existing: true + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + + - name: Verify Python runtime wheels are available on PyPI + env: + PYTHON_RUNTIME_VERSION: ${{ needs.prepare-python-runtime.outputs.python_version }} + run: | + uv run --no-project --with packaging==26.2 python .github/scripts/verify_pypi_release.py \ + openai-codex-cli-bin "$PYTHON_RUNTIME_VERSION" diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000000000000000000000000000000..5003c6de083970cf658f84b11bc1da99cb113bca --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + "recommendations": [ + "BazelBuild.vscode-bazel", + "rust-lang.rust-analyzer", + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "vadimcn.vscode-lldb", + + // Useful if touching files in .github/workflows, though most + // contributors will not be doing that? + // "github.vscode-github-actions", + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..d87ce482e463a7f8876655d6748838ca4a9cfd49 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Cargo launch", + "cargo": { + "cwd": "${workspaceFolder}/codex-rs", + "args": ["build", "--bin=codex-tui"] + }, + "args": [] + }, + { + "type": "lldb", + "request": "attach", + "name": "Attach to running codex CLI", + "pid": "${command:pickProcess}", + "sourceLanguages": ["rust"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..d19746aec400fc50a41090f87673ac196da87050 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "rust-analyzer.checkOnSave": true, + "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.extraArgs": ["--tests"], + "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"], + "rust-analyzer.cargo.targetDir": "${workspaceFolder}/codex-rs/target/rust-analyzer", + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true, + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml", + "editor.formatOnSave": true, + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit", + }, + }, + // Array order for options in ~/.codex/config.toml such as `notify` and the + // `args` for an MCP server is significant, so we disable reordering. + "evenBetterToml.formatter.reorderArrays": false, + "evenBetterToml.formatter.reorderKeys": true, +} diff --git a/bazel/build-info/BUILD.bazel b/bazel/build-info/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..ab199d362b769a8cc0ba2204f2908db14f8d1844 --- /dev/null +++ b/bazel/build-info/BUILD.bazel @@ -0,0 +1,12 @@ +load("@bazel_lib//lib:expand_template.bzl", "expand_template") + +# Isolate workspace status in a cheap action whose output changes only when +# the embedded commit changes. Rust compiler actions consume only this file. +expand_template( + name = "build-commit-env", + out = "build-commit.env", + stamp = 1, + stamp_substitutions = {"{BUILD_COMMIT}": "{{STABLE_GIT_COMMIT}}"}, + template = ["STABLE_GIT_COMMIT={BUILD_COMMIT}"], + visibility = ["//codex-rs:__subpackages__"], +) diff --git a/bazel/extensions/BUILD.bazel b/bazel/extensions/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..1cb795b92c884a187e43d01c64f33ee25a85344f --- /dev/null +++ b/bazel/extensions/BUILD.bazel @@ -0,0 +1,3 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["codex_release_archive.bzl"]) diff --git a/bazel/extensions/codex_release_archive.bzl b/bazel/extensions/codex_release_archive.bzl new file mode 100644 index 0000000000000000000000000000000000000000..e363bace5a1be9a5593308131532c3adb8e2e61d --- /dev/null +++ b/bazel/extensions/codex_release_archive.bzl @@ -0,0 +1,76 @@ +"""Bazel module extension for pinned Codex release archives.""" + +_CODEX_RELEASE_BUILD_FILE = """\ +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "codex", + srcs = [{entrypoint}], +) + +filegroup( + name = "package", + srcs = glob([ + "codex-package.json", + {binaries}, + {resources}, + {path}, + ]), +) +""" + +def _codex_release_repository_impl(repository_ctx): + asset = "codex-package-x86_64-unknown-linux-musl.tar.gz" + version = repository_ctx.attr.version + repository_ctx.download_and_extract( + url = [ + "https://releases.openai.com/codex/releases/{}/{}".format(version, asset), + "https://github.com/openai/codex/releases/download/rust-v{}/{}".format(version, asset), + ], + sha256 = repository_ctx.attr.sha256, + ) + manifest = json.decode(repository_ctx.read("codex-package.json")) + entrypoint = manifest["entrypoint"] + binaries = entrypoint.rpartition("/")[0] + "/**" + repository_ctx.file( + "BUILD.bazel", + _CODEX_RELEASE_BUILD_FILE.format( + binaries = json.encode(binaries), + entrypoint = json.encode(entrypoint), + path = json.encode(manifest["pathDir"] + "/**"), + resources = json.encode(manifest["resourcesDir"] + "/**"), + ), + executable = False, + ) + return repository_ctx.repo_metadata(reproducible = True) + +_codex_release_repository = repository_rule( + implementation = _codex_release_repository_impl, + attrs = { + "sha256": attr.string(mandatory = True), + "version": attr.string(mandatory = True), + }, +) + +_RELEASE = tag_class( + attrs = { + "sha256": attr.string(mandatory = True), + "version": attr.string(mandatory = True), + }, +) + +def _codex_release_archive_impl(module_ctx): + for module in module_ctx.modules: + for release in module.tags.release: + _codex_release_repository( + name = "codex_release_{}_linux_x86_64".format(release.version), + sha256 = release.sha256, + version = release.version, + ) + + return module_ctx.extension_metadata(reproducible = True) + +codex_release_archive = module_extension( + implementation = _codex_release_archive_impl, + tag_classes = {"release": _RELEASE}, +) diff --git a/bazel/modules/BUILD.bazel b/bazel/modules/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..bcc3dcf5240364deb3d94aa58b0604b647fdd5ba --- /dev/null +++ b/bazel/modules/BUILD.bazel @@ -0,0 +1 @@ +exports_files(["wine.MODULE.bazel"]) diff --git a/bazel/modules/wine.MODULE.bazel b/bazel/modules/wine.MODULE.bazel new file mode 100644 index 0000000000000000000000000000000000000000..9f60b2ac405249cdfc50c3fe66956efb69342eb7 --- /dev/null +++ b/bazel/modules/wine.MODULE.bazel @@ -0,0 +1,26 @@ +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +# Pin a new-WoW64 build so tests need neither system Wine nor 32-bit host +# libraries. +http_archive( + name = "wine_linux_x86_64", + build_file = "//third_party/wine:BUILD.bazel", + sha256 = "39574efa1132c3ca0d5c77dd2eddbe4a49cca0d6cc2c290ff4924493a1c40314", + strip_prefix = "wine-11.0-amd64-wow64", + urls = [ + "https://github.com/Kron4ek/Wine-Builds/releases/download/11.0/wine-11.0-amd64-wow64.tar.xz", + ], +) + +# Pin the self-contained Windows distribution so Wine tests need neither a +# system PowerShell installation nor a separate .NET runtime. This intentionally +# stays on 7.2.24 for the test fixture: 7.4.16 and 7.6.2 currently fail during +# CLR startup under the pinned Wine 11 runtime, while 7.2.24 runs successfully. +http_archive( + name = "powershell_windows_x86_64", + build_file = "//third_party/powershell:BUILD.bazel", + sha256 = "a1ccb6d8ad52f917470a136c3752af4465f261bcbe570cf44f52aa69ae6e867e", + urls = [ + "https://github.com/PowerShell/PowerShell/releases/download/v7.2.24/PowerShell-7.2.24-win-x64.zip", + ], +) diff --git a/bazel/platforms/BUILD.bazel b/bazel/platforms/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..398d5d8083775c2aa1398329b807e4e2d407bcad --- /dev/null +++ b/bazel/platforms/BUILD.bazel @@ -0,0 +1 @@ +# Release platform rules live in this package. diff --git a/bazel/platforms/release_binaries.bzl b/bazel/platforms/release_binaries.bzl new file mode 100644 index 0000000000000000000000000000000000000000..887bedd8542433bd215f3d344809a57660d68d57 --- /dev/null +++ b/bazel/platforms/release_binaries.bzl @@ -0,0 +1,30 @@ +"""Rules for building release binaries across supported target platforms.""" + +load("@rules_platform//platform_data:defs.bzl", "platform_data") + +_PLATFORM_TRIPLES = { + "linux_arm64_musl": "aarch64-unknown-linux-musl", + "linux_amd64_musl": "x86_64-unknown-linux-musl", + "macos_amd64": "x86_64-apple-darwin", + "macos_arm64": "aarch64-apple-darwin", + "windows_amd64": "x86_64-pc-windows-msvc", + "windows_arm64": "aarch64-pc-windows-msvc", +} + +PLATFORMS = _PLATFORM_TRIPLES.keys() + +def multiplatform_binaries(name, platforms = PLATFORMS): + """Build a binary for a subset of the declared release platforms.""" + for platform in platforms: + platform_data( + name = name + "_" + platform, + platform = "@rules_rs//rs/platforms:" + _PLATFORM_TRIPLES[platform], + target = name, + tags = ["manual"], + ) + + native.filegroup( + name = "release_binaries", + srcs = [name + "_" + platform for platform in platforms], + tags = ["manual"], + ) diff --git a/bazel/rules/BUILD.bazel b/bazel/rules/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..ab6d1f9763cdd9067c714eccd0c18fdbcb109101 --- /dev/null +++ b/bazel/rules/BUILD.bazel @@ -0,0 +1,6 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "e2e_benchmark.bzl", + "schema_bundle.bzl", +]) diff --git a/bazel/rules/e2e_benchmark.bzl b/bazel/rules/e2e_benchmark.bzl new file mode 100644 index 0000000000000000000000000000000000000000..035419adad1ef455def97e3f6c0c235877b1c8df --- /dev/null +++ b/bazel/rules/e2e_benchmark.bzl @@ -0,0 +1,56 @@ +load("@crates//:defs.bzl", "all_crate_deps") +load("@rules_rust//rust:defs.bzl", "rust_binary") +load("//:defs.bzl", "workspace_root_test") + +_WORKSPACE_ROOT_MARKER = "//codex-rs/utils/cargo-bin:repo_root.marker" + +def codex_e2e_benchmark(name, binaries = [], data = [], deps = []): + """Defines a Bazel-only Divan end-to-end benchmark. + + The benchmark source lives at `e2e_benches/.rs`, with hyphens in + `name` replaced by underscores. `binaries` are runtime executables made + available through the same `CARGO_BIN_EXE_*` bridge used by Rust tests. + + Args: + name: Stem for the generated `-bench` target and benchmark. + binaries: Runtime executable labels that the benchmark spawns. + data: Additional runtime files needed by the benchmark. + deps: Additional Rust dependencies beyond the crate's Cargo deps. + """ + benchmark_name = name.replace("-", "_") + source = "e2e_benches/{}.rs".format(benchmark_name) + binary_name = name + "-bench-bin" + runfile_env = { + binary: "CARGO_BIN_EXE_" + native.package_relative_label(binary).name + for binary in binaries + } + + rust_binary( + name = binary_name, + testonly = True, + srcs = [source], + crate_name = benchmark_name + "_bench", + crate_root = source, + deps = all_crate_deps( + normal = True, + normal_dev = True, + ) + [ + "@crates//:divan", + ] + deps, + ) + + workspace_root_test( + name = name + "-bench", + args = [ + "--bench", + benchmark_name, + ], + data = data, + # Keep path resolution inside the wrapper so manifest-only runfiles + # work on every supported host platform. + runfile_env = runfile_env, + tags = ["manual"], + test_bin = ":" + binary_name, + visibility = ["//codex-rs:__pkg__"], + workspace_root_marker = _WORKSPACE_ROOT_MARKER, + ) diff --git a/bazel/rules/schema_bundle.bzl b/bazel/rules/schema_bundle.bzl new file mode 100644 index 0000000000000000000000000000000000000000..5f8e2eae0161b562dc1eebd68b0823d1d50fe064 --- /dev/null +++ b/bazel/rules/schema_bundle.bzl @@ -0,0 +1,39 @@ +"""Cache schema generation from the filtered public Rust test executable.""" + +def _schema_bundle_impl(ctx): + outputs = [] + for mode in ["stable", "experimental"]: + schema = ctx.actions.declare_directory(ctx.label.name + "." + mode) + ctx.actions.run( + executable = ctx.executable.generator, + tools = [ctx.attr.generator[DefaultInfo].files_to_run], + arguments = [ + "--exact", + "--ignored", + "schema_fixtures_tests::write_schema_fixtures_from_env", + ], + env = { + "CODEX_APP_SERVER_SCHEMA_ROOT": schema.path, + "CODEX_APP_SERVER_SCHEMA_EXPERIMENTAL": "1" if mode == "experimental" else "0", + "RUST_MIN_STACK": "8388608", + }, + outputs = [schema], + mnemonic = "PublicSchema", + progress_message = "Generating %s public app-server schemas" % mode, + ) + outputs.append(schema) + + # Expose the pinned compressor alongside the cached directories so callers + # can normalize the JSON without a separate `bazel run` invocation. + zstd = ctx.actions.declare_file(ctx.label.name + ".zstd") + ctx.actions.symlink(output = zstd, target_file = ctx.executable.zstd, is_executable = True) + return [DefaultInfo(files = depset(outputs + [zstd]))] + +schema_bundle = rule( + implementation = _schema_bundle_impl, + attrs = { + "generator": attr.label(mandatory = True, executable = True, cfg = "exec"), + "zstd": attr.label(default = "@zstd//:zstd_cli", executable = True, cfg = "exec"), + }, + doc = "Generates both schema modes as cacheable actions with explicit tool dependencies.", +) diff --git a/bazel/rules/testing/BUILD.bazel b/bazel/rules/testing/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..921cfacc8aa5a234819698def445a3507f9daec1 --- /dev/null +++ b/bazel/rules/testing/BUILD.bazel @@ -0,0 +1,5 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "foreign_platform_binary.bzl", +]) diff --git a/bazel/rules/testing/compat/BUILD.bazel b/bazel/rules/testing/compat/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..9f4bf9b0ba9be566a854e1a601675927fa2dfb20 --- /dev/null +++ b/bazel/rules/testing/compat/BUILD.bazel @@ -0,0 +1,31 @@ +load("@rules_rust//rust:defs.bzl", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +exports_files(["exec_server_compat_test.bzl"]) + +# exec_server_compat_test wraps this shared binary with current or released builds. +# Keep it manual so only wrapped compatibility tests enter the normal test graph. +rust_test( + name = "exec-server-compat-test-bin", + srcs = ["exec_server_compat_test.rs"], + crate_name = "exec_server_compat_test", + crate_root = "exec_server_compat_test.rs", + edition = "2024", + tags = ["manual"], + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + visibility = ["//codex-rs/exec-server:__pkg__"], + deps = [ + "//codex-rs/exec-server", + "//codex-rs/exec-server/tests/support", + "@crates//:anyhow", + "@crates//:pretty_assertions", + "@crates//:serde_json", + "@crates//:tempfile", + "@crates//:tokio", + "@crates//:wiremock", + ], +) diff --git a/bazel/rules/testing/compat/exec_server_compat_test.bzl b/bazel/rules/testing/compat/exec_server_compat_test.bzl new file mode 100644 index 0000000000000000000000000000000000000000..8e9f6c7533889d6a6944dad466a2c34a9ec17202 --- /dev/null +++ b/bazel/rules/testing/compat/exec_server_compat_test.bzl @@ -0,0 +1,47 @@ +"""Bazel coverage for app-server and exec-server release compatibility.""" + +load("//:defs.bzl", "workspace_root_test") + +def exec_server_compat_test( + name, + comparison_binary = None, + current_binary = "//codex-rs/cli:codex", + release = None): + """Tests both executor directions against another Codex build or release. + + Args: + name: Name of the generated compatibility test target. + comparison_binary: Built Codex executable to compare with the current build. + current_binary: Built Codex executable representing the current version. + release: External release repository exposing `:codex` and `:package`. + """ + if (comparison_binary == None) == (release == None): + fail("exactly one of comparison_binary and release must be set") + + comparison = comparison_binary if release == None else release + "//:codex" + data = [] if release == None else [release + "//:package"] + comparison_alias = name + "-comparison-binary" + native.alias( + name = comparison_alias, + actual = comparison, + tags = ["manual"], + visibility = ["//visibility:private"], + ) + + workspace_root_test( + name = name, + args = ["--test-threads=1"], + data = data, + runfile_env = { + "//codex-rs/bwrap:bwrap": "CARGO_BIN_EXE_bwrap", + current_binary: "CODEX_TEST_CURRENT_CODEX", + ":" + comparison_alias: "CODEX_TEST_RELEASED_CODEX", + }, + tags = ["no-sandbox"], + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + test_bin = "//bazel/rules/testing/compat:exec-server-compat-test-bin", + workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker", + ) diff --git a/bazel/rules/testing/compat/exec_server_compat_test.rs b/bazel/rules/testing/compat/exec_server_compat_test.rs new file mode 100644 index 0000000000000000000000000000000000000000..863d5a85e111882d5be8cac7f508d5e025b0fb62 --- /dev/null +++ b/bazel/rules/testing/compat/exec_server_compat_test.rs @@ -0,0 +1,379 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; + +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; +use codex_exec_server::CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR; +use codex_exec_server::CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_exec_server_test_support::relay::TEST_TIMEOUT as VERSION_SKEW_TIMEOUT; +use codex_exec_server_test_support::relay::accept_websocket; +use codex_exec_server_test_support::relay::assert_relay_data_is_encrypted; +use codex_exec_server_test_support::relay::proxy_relay_frames; +use codex_exec_server_test_support::relay::registered_executor_public_key; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::io::Lines; +use tokio::net::TcpListener; +use tokio::process::ChildStdin; +use tokio::process::ChildStdout; +use tokio::process::Command; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const ENVIRONMENT_ID: &str = "env-noise-relay-test"; +const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; +const HARNESS_KEY_AUTHORIZATION: &str = "harness-key-authorization"; +const REGISTRY_TOKEN: &str = "registry-token"; +const RELEASED_CODEX_ENV_VAR: &str = "CODEX_TEST_RELEASED_CODEX"; +const CURRENT_CODEX_ENV_VAR: &str = "CODEX_TEST_CURRENT_CODEX"; +const EXECUTOR_MARKER_ENV_VAR: &str = "CODEX_EXECUTOR_VERSION_SKEW_MARKER"; +const EXPECTED_OUTPUT: &str = "executor-version-skew-ok"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn current_app_server_runs_commands_on_released_exec_server_over_noise() -> Result<()> { + let Some((current, released)) = version_skew_binaries()? else { + return Ok(()); + }; + assert_noise_version_skew(¤t, &released).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn released_app_server_runs_commands_on_current_exec_server_over_noise() -> Result<()> { + let Some((current, released)) = version_skew_binaries()? else { + return Ok(()); + }; + assert_noise_version_skew(&released, ¤t).await +} + +fn version_skew_binaries() -> Result> { + let Some(released) = std::env::var_os(RELEASED_CODEX_ENV_VAR) else { + return Ok(None); + }; + let current = std::env::var_os(CURRENT_CODEX_ENV_VAR) + .with_context(|| format!("{CURRENT_CODEX_ENV_VAR} must name the current Codex binary"))?; + let current = PathBuf::from(current); + let released = PathBuf::from(released); + anyhow::ensure!( + current.is_file(), + "current Codex does not exist: {}", + current.display() + ); + anyhow::ensure!( + released.is_file(), + "released Codex does not exist: {}", + released.display() + ); + Ok(Some((current, released))) +} + +async fn assert_noise_version_skew(app_binary: &Path, executor_binary: &Path) -> Result<()> { + let codex_home = TempDir::new()?; + let model = mock_model(codex_home.path()).await?; + let model_url = model.uri(); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider" +base_url = "{model_url}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let rendezvous_url = format!("ws://{}", listener.local_addr()?); + let registry = MockServer::start().await; + let registry_url = registry.uri(); + Mock::given(method("POST")) + .and(path(format!( + "/cloud/environment/{ENVIRONMENT_ID}/register" + ))) + .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "environment_id": ENVIRONMENT_ID, + "url": format!("{rendezvous_url}/relay?role=environment"), + "security_profile": "noise_hybrid_ik_v1", + "executor_registration_id": EXECUTOR_REGISTRATION_ID, + }))) + .expect(1) + .mount(®istry) + .await; + Mock::given(method("POST")) + .and(path(format!( + "/cloud/environment/{ENVIRONMENT_ID}/validate" + ))) + .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"valid": true}))) + .expect(1) + .mount(®istry) + .await; + + let mut executor = Command::new(executor_binary) + .args([ + "exec-server", + "--remote", + registry_url.as_str(), + "--environment-id", + ENVIRONMENT_ID, + ]) + .current_dir(codex_home.path()) + .env("CODEX_HOME", codex_home.path()) + .env("CODEX_API_KEY", REGISTRY_TOKEN) + .env(EXECUTOR_MARKER_ENV_VAR, EXPECTED_OUTPUT) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("start remote executor from {}", executor_binary.display()))?; + + let environment_websocket = accept_websocket(&listener, "environment").await?; + let executor_public_key = registered_executor_public_key(®istry).await?; + Mock::given(method("POST")) + .and(path(format!("/cloud/environment/{ENVIRONMENT_ID}/connect"))) + .and(header("authorization", format!("Bearer {REGISTRY_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "environment_id": ENVIRONMENT_ID, + "url": format!("{rendezvous_url}/relay?role=harness"), + "security_profile": "noise_hybrid_ik_v1", + "executor_registration_id": EXECUTOR_REGISTRATION_ID, + "executor_public_key": executor_public_key, + "harness_key_authorization": HARNESS_KEY_AUTHORIZATION, + }))) + .expect(1) + .mount(®istry) + .await; + + let captured_frames = Arc::new(Mutex::new(Vec::new())); + let captured_relay_frames = Arc::clone(&captured_frames); + let relay = tokio::spawn(async move { + let harness_websocket = accept_websocket(&listener, "harness").await?; + proxy_relay_frames( + environment_websocket, + harness_websocket, + captured_relay_frames, + ) + .await + }); + + let mut app_server = Command::new(app_binary) + .arg("app-server") + .current_dir(codex_home.path()) + .env("CODEX_HOME", codex_home.path()) + .env("CODEX_API_KEY", REGISTRY_TOKEN) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .env(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR, ®istry_url) + .env( + CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR, + ENVIRONMENT_ID, + ) + .env(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, REGISTRY_TOKEN) + .env_remove(CODEX_EXEC_SERVER_URL_ENV_VAR) + .env_remove(EXECUTOR_MARKER_ENV_VAR) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("start app-server from {}", app_binary.display()))?; + let mut stdin = app_server.stdin.take().context("app-server stdin")?; + let stdout = app_server.stdout.take().context("app-server stdout")?; + let mut stdout = BufReader::new(stdout).lines(); + let mut notifications = Vec::new(); + + app_server_request( + &mut stdin, + &mut stdout, + &mut notifications, + /*id*/ 1, + "initialize", + json!({ + "clientInfo": {"name": "noise-version-skew", "version": "0.1.0"}, + "capabilities": {"experimentalApi": true}, + }), + ) + .await?; + stdin.write_all(b"{\"method\":\"initialized\"}\n").await?; + + let thread = app_server_request( + &mut stdin, + &mut stdout, + &mut notifications, + /*id*/ 2, + "thread/start", + json!({"cwd": codex_home.path()}), + ) + .await?; + let thread_id = thread["thread"]["id"] + .as_str() + .context("thread/start should return a thread id")?; + app_server_request( + &mut stdin, + &mut stdout, + &mut notifications, + /*id*/ 3, + "turn/start", + json!({ + "threadId": thread_id, + "input": [{ + "type": "text", + "text": "run the Noise compatibility command", + "textElements": [], + }], + }), + ) + .await?; + + while !notifications + .iter() + .any(|notification: &Value| notification["method"] == "turn/completed") + { + let line = timeout(VERSION_SKEW_TIMEOUT, stdout.next_line()) + .await + .context("waiting for turn/completed")?? + .context("app-server exited before turn/completed")?; + notifications.push(serde_json::from_str(&line)?); + } + + assert_eq!( + std::fs::read_to_string(codex_home.path().join("version-skew-output.txt"))?, + EXPECTED_OUTPUT + ); + assert_relay_data_is_encrypted(&captured_frames)?; + registry.verify().await; + model.verify().await; + + let _ = app_server.start_kill(); + let _ = executor.start_kill(); + relay.abort(); + Ok(()) +} + +async fn app_server_request( + stdin: &mut ChildStdin, + stdout: &mut Lines>, + notifications: &mut Vec, + id: u64, + method: &str, + params: Value, +) -> Result { + let request = json!({"id": id, "method": method, "params": params}); + stdin.write_all(request.to_string().as_bytes()).await?; + stdin.write_all(b"\n").await?; + + loop { + let line = timeout(VERSION_SKEW_TIMEOUT, stdout.next_line()) + .await + .with_context(|| format!("waiting for {method} response"))?? + .with_context(|| format!("app-server exited before {method} response"))?; + let response: Value = serde_json::from_str(&line)?; + if response["id"] == id { + anyhow::ensure!( + response.get("error").is_none(), + "{method} failed: {}", + response["error"] + ); + return response.get("result").cloned().context("missing result"); + } + notifications.push(response); + } +} + +async fn mock_model(codex_home: &Path) -> Result { + let server = MockServer::start().await; + let arguments = serde_json::to_string(&json!({ + "cmd": format!("printf '%s' \"${EXECUTOR_MARKER_ENV_VAR}\" > version-skew-output.txt"), + "workdir": codex_home, + "yield_time_ms": 5_000, + }))?; + let completed = |id| { + json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0, + }, + }, + }) + }; + let responses = vec![ + event_stream(vec![ + json!({"type": "response.created", "response": {"id": "response-1"}}), + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": "noise-version-skew-command", + "name": "exec_command", + "arguments": arguments, + }, + }), + completed("response-1"), + ])?, + event_stream(vec![ + json!({"type": "response.created", "response": {"id": "response-2"}}), + json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": "message-1", + "content": [{"type": "output_text", "text": "done"}], + }, + }), + completed("response-2"), + ])?, + ]; + for response in responses { + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200).set_body_raw(response, "text/event-stream")) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + } + Ok(server) +} + +fn event_stream(events: Vec) -> Result { + events + .into_iter() + .map(|event| { + let event_type = event["type"].as_str().context("SSE event type")?; + Ok(format!("event: {event_type}\ndata: {event}\n\n")) + }) + .collect() +} diff --git a/bazel/rules/testing/foreign_platform_binary.bzl b/bazel/rules/testing/foreign_platform_binary.bzl new file mode 100644 index 0000000000000000000000000000000000000000..cb709d81c8cf291c786b396d2cfe232bb3cb3290 --- /dev/null +++ b/bazel/rules/testing/foreign_platform_binary.bzl @@ -0,0 +1,53 @@ +"""Makes a binary built for a foreign platform available as test data.""" + +_EXTRA_RUSTC_FLAGS = "@rules_rust//rust/settings:extra_rustc_flags" + +def _foreign_platform_transition_impl(settings, attr): + # A transition cannot rewrite a dependency's rule attributes. Use the + # rules_rust build setting when every Rust target in the foreign + # configuration needs additional compiler or linker flags. + return { + "//command_line_option:platforms": [attr.platform], + _EXTRA_RUSTC_FLAGS: settings[_EXTRA_RUSTC_FLAGS] + attr.extra_rustc_flags, + } + +_foreign_platform_transition = transition( + implementation = _foreign_platform_transition_impl, + inputs = [_EXTRA_RUSTC_FLAGS], + outputs = [ + "//command_line_option:platforms", + _EXTRA_RUSTC_FLAGS, + ], +) + +def _foreign_platform_binary_impl(ctx): + if len(ctx.attr.binary) != 1: + fail("expected exactly one transitioned binary") + binary = ctx.attr.binary[0][DefaultInfo] + runfiles = ctx.runfiles(transitive_files = binary.files) + runfiles = runfiles.merge(binary.default_runfiles) + return [ + DefaultInfo( + files = binary.files, + runfiles = runfiles, + ), + ] + +foreign_platform_binary = rule( + implementation = _foreign_platform_binary_impl, + attrs = { + "binary": attr.label( + cfg = _foreign_platform_transition, + executable = True, + mandatory = True, + ), + "extra_rustc_flags": attr.string_list( + doc = "Additional flags applied to every Rust target in the foreign configuration.", + ), + "platform": attr.string(mandatory = True), + "_allowlist_function_transition": attr.label( + default = "@bazel_tools//tools/allowlists/function_transition_allowlist", + ), + }, + doc = "Builds `binary` for `platform` and exposes its files and runfiles.", +) diff --git a/bazel/rules/testing/wine/BUILD.bazel b/bazel/rules/testing/wine/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..4bd9acf6d7a61910a1276642bc5b81ae7a345519 --- /dev/null +++ b/bazel/rules/testing/wine/BUILD.bazel @@ -0,0 +1,54 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") +load(":wine.bzl", "wine_rust_test") + +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "wine.bzl", + "wine_runtime.bzl", +]) + +rust_library( + name = "wine_test_support", + testonly = True, + srcs = [ + "src/lib.rs", + "src/lib_tests.rs", + ], + crate_name = "wine_test_support", + crate_root = "src/lib.rs", + edition = "2024", + target_compatible_with = ["@platforms//os:linux"], + deps = [ + "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/pty", + "@crates//:anyhow", + "@crates//:tempfile", + "@crates//:tokio", + ], +) + +rust_binary( + name = "windows-smoke", + testonly = True, + srcs = ["fixtures/windows_smoke.rs"], + crate_name = "wine_smoke", + crate_root = "fixtures/windows_smoke.rs", + edition = "2024", + tags = ["manual"], + target_compatible_with = ["@platforms//os:windows"], + visibility = ["//visibility:private"], +) + +wine_rust_test( + name = "wine-test-support-unit-tests", + timeout = "short", + crate = ":wine_test_support", + windows_binaries = { + "wine-smoke": ":windows-smoke", + }, + deps = [ + "@crates//:futures", + "@crates//:pretty_assertions", + ], +) diff --git a/bazel/rules/testing/wine/fixtures/windows_smoke.rs b/bazel/rules/testing/wine/fixtures/windows_smoke.rs new file mode 100644 index 0000000000000000000000000000000000000000..ffd98a2443599925879d4f5bfaf42880dae1b2e2 --- /dev/null +++ b/bazel/rules/testing/wine/fixtures/windows_smoke.rs @@ -0,0 +1,15 @@ +use std::io::Write; + +fn main() { + println!("WINE_TEST_READY"); + std::io::stdout().flush().expect("flush readiness marker"); + + if std::env::args().any(|arg| arg == "--fail") { + std::process::exit(9); + } + if std::env::args().any(|arg| arg == "--wait") { + loop { + std::thread::park(); + } + } +} diff --git a/bazel/rules/testing/wine/src/lib.rs b/bazel/rules/testing/wine/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..76cb029e17dca561a93c0fd5294e413b8c439873 --- /dev/null +++ b/bazel/rules/testing/wine/src/lib.rs @@ -0,0 +1,402 @@ +#[cfg(not(target_os = "linux"))] +compile_error!("wine_test_support can only run on Linux"); + +use std::ffi::OsString; +use std::fs; +use std::future::Future; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command as StdCommand; +use std::process::Stdio; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use tempfile::TempDir; +use tokio::process::Child; +use tokio::process::ChildStdout; +use tokio::process::Command as TokioCommand; + +/// Builds a command that runs a Windows executable in an isolated Wine prefix. +pub struct WineTestCommand { + executable: PathBuf, + args: Vec, + env: Vec<(OsString, OsString)>, +} + +/// Owns a Wine process and its isolated wineserver. +/// +/// Call [`Self::scope`] or [`Self::shutdown`] on every successful path. A +/// normal unguarded drop panics, while a drop during unwinding performs +/// blocking cleanup without introducing a second panic. +pub struct WineTestProcess { + processes: Option, +} + +struct WineProcesses { + child: Child, + cleanup_complete: bool, + prefix: TempDir, + runtime: WineRuntimePaths, +} + +struct WineRuntimePaths { + dll_path: PathBuf, + powershell_runtime: PathBuf, + wine: PathBuf, + wineserver: PathBuf, +} + +impl WineTestCommand { + /// Creates a Wine command for `executable`. + pub fn new(executable: impl Into) -> Self { + Self { + executable: executable.into(), + args: Vec::new(), + env: Vec::new(), + } + } + + /// Adds an argument passed to the Windows executable. + #[must_use] + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Adds or overrides an environment variable for the Wine process. + #[must_use] + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + /// Starts the Windows executable with a fresh `WINEPREFIX`. + pub fn spawn(self) -> Result { + let runtime = WineRuntimePaths::from_runfiles()?; + let prefix = TempDir::new().context("create isolated Wine prefix")?; + install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?; + let mut command = StdCommand::new(&runtime.wine); + configure_wine_environment(&mut command, &runtime, prefix.path()); + command + .arg(self.executable) + .args(self.args) + .envs(self.env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut command = TokioCommand::from(command); + command.kill_on_drop(true); + let child = command + .spawn() + .context("start Windows process under Wine")?; + + Ok(WineTestProcess { + processes: Some(WineProcesses { + child, + cleanup_complete: false, + prefix, + runtime, + }), + }) + } +} + +impl WineTestProcess { + /// Returns the host path to this process's isolated Wine prefix. + pub fn prefix_path(&self) -> &Path { + let Some(processes) = self.processes.as_ref() else { + panic!("Wine process guard is missing"); + }; + processes.prefix.path() + } + + /// Takes the piped standard output of the Wine process. + /// + /// This may only be called once for a process created by + /// [`WineTestCommand::spawn`]. + pub fn take_stdout(&mut self) -> ChildStdout { + let Some(processes) = self.processes.as_mut() else { + panic!("Wine process guard is missing"); + }; + let Some(stdout) = processes.child.stdout.take() else { + panic!("Wine process stdout has already been taken"); + }; + stdout + } + + /// Runs `future`, then asynchronously tears down Wine before returning. + /// + /// If both the scoped operation and teardown fail, the operation error is + /// returned with the teardown error attached as context. A panic in the + /// scoped operation triggers the blocking unwind-time fallback instead. + pub async fn scope(self, future: impl Future>) -> Result { + let scope_result = future.await; + let shutdown_result = self.shutdown().await; + match (scope_result, shutdown_result) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(error), Err(shutdown_error)) => { + Err(error.context(format!("Wine teardown also failed: {shutdown_error:#}"))) + } + } + } + + /// Kills the Windows process, waits for it, and stops its wineserver. + pub async fn shutdown(mut self) -> Result<()> { + let Some(processes) = self.processes.as_mut() else { + anyhow::bail!("Wine process guard is missing"); + }; + let result = processes.shutdown().await; + self.processes.take(); + result + } +} + +impl Drop for WineTestProcess { + fn drop(&mut self) { + // Panicking here starts unwinding, after which WineProcesses performs + // the blocking fallback while its field is dropped. + if self.processes.is_some() && !std::thread::panicking() { + panic!("WineTestProcess dropped without async teardown"); + } + } +} + +impl WineRuntimePaths { + fn from_runfiles() -> Result { + let wine = codex_utils_cargo_bin::cargo_bin("wine")?; + let runtime_marker = codex_utils_cargo_bin::cargo_bin("wine-runtime-marker")?; + let dll_path = runtime_marker + .parent() + .context("locate Wine runtime directory")? + .to_path_buf(); + let wineserver = codex_utils_cargo_bin::cargo_bin("wineserver")?; + let powershell_runtime = codex_utils_cargo_bin::cargo_bin("pwsh-runtime-marker")? + .parent() + .context("locate PowerShell runtime directory")? + .to_path_buf(); + Ok(Self { + dll_path, + powershell_runtime, + wine, + wineserver, + }) + } +} + +impl WineProcesses { + async fn shutdown(&mut self) -> Result<()> { + let (kill_result, check_exit_status) = match self.child.try_wait() { + Ok(Some(_)) => (Ok(()), true), + Ok(None) => ( + self.child + .start_kill() + .context("kill Windows process running under Wine"), + false, + ), + Err(error) => (Err(error).context("check Windows process status"), false), + }; + let wait_result = self + .child + .wait() + .await + .context("wait for Windows process running under Wine") + .and_then(|status| { + anyhow::ensure!( + !check_exit_status || status.success(), + "Windows process exited with {status}" + ); + Ok(()) + }); + let wineserver_result = async { + let mut command = TokioCommand::from(self.stop_wineserver_command()); + let status = command.status().await.context("stop isolated wineserver")?; + anyhow::ensure!(status.success(), "wineserver exited with {status}"); + Ok(()) + } + .await; + + // Every cleanup action has been attempted, so an individual error + // should not cause the blocking fallback to repeat them. + self.cleanup_complete = true; + kill_result?; + wait_result?; + wineserver_result + } + + fn stop_wineserver_command(&self) -> StdCommand { + let mut command = StdCommand::new(&self.runtime.wineserver); + configure_wine_environment(&mut command, &self.runtime, self.prefix.path()); + command + .args(["-k", "-w"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command + } + + fn shutdown_blocking(&mut self) { + log_panic_cleanup(format_args!( + "Wine panic cleanup starting for prefix {}", + self.prefix.path().display() + )); + if let Err(error) = self.child.start_kill() { + log_panic_cleanup(format_args!( + "Wine panic cleanup could not kill its child: {error}" + )); + } + + log_panic_cleanup(format_args!("Wine panic cleanup waiting for its child")); + loop { + match self.child.try_wait() { + Ok(Some(status)) => { + log_panic_cleanup(format_args!( + "Wine panic cleanup child exited with {status}" + )); + break; + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(error) => { + log_panic_cleanup(format_args!( + "Wine panic cleanup could not wait for its child: {error}" + )); + break; + } + } + } + + log_panic_cleanup(format_args!("Wine panic cleanup stopping its wineserver")); + match self.stop_wineserver_command().status() { + Ok(status) => log_panic_cleanup(format_args!( + "Wine panic cleanup wineserver exited with {status}" + )), + Err(error) => log_panic_cleanup(format_args!( + "Wine panic cleanup could not stop its wineserver: {error}" + )), + } + self.cleanup_complete = true; + log_panic_cleanup(format_args!("Wine panic cleanup complete")); + } +} + +impl Drop for WineProcesses { + fn drop(&mut self) { + // Never introduce a second panic while unwinding. Blocking here is + // intentional because test failures must not leak Wine children. + if !self.cleanup_complete && std::thread::panicking() { + self.shutdown_blocking(); + } + } +} + +fn log_panic_cleanup(args: std::fmt::Arguments<'_>) { + let _ = writeln!(std::io::stderr().lock(), "{args}"); +} + +fn configure_wine_environment(command: &mut StdCommand, runtime: &WineRuntimePaths, prefix: &Path) { + command + .env_remove("DISPLAY") + .env("HOME", prefix) + .env("XDG_RUNTIME_DIR", prefix) + .env("WINEARCH", "win64") + .env("WINEPREFIX", prefix) + .env("WINEDLLPATH", &runtime.dll_path) + .env("WINESERVER", &runtime.wineserver) + .env("WINEDEBUG", "-all") + .env("WINEDLLOVERRIDES", "mscoree,mshtml,winegstreamer=") + .env("LANG", "C.UTF-8") + .env("LC_ALL", "C.UTF-8") + .env("LC_CTYPE", "C.UTF-8") + .env("TEMP", r"C:\windows\temp") + .env("TMP", r"C:\windows\temp"); +} + +/// Installs the complete pinned PowerShell distribution where Windows tooling +/// expects to discover PowerShell 7. +/// +/// `pwsh.exe` is not a standalone executable: it loads its adjacent .NET host, +/// managed assemblies, native libraries, modules, and configuration files at +/// startup. The Bazel archive is exposed through runfiles rather than a normal +/// Windows installation, while shell detection deliberately probes the +/// conventional `C:\Program Files\PowerShell\7` fallback. We therefore have to +/// reproduce the archive's directory tree inside each isolated Wine prefix; +/// copying only the executable would fail before a command could run. +fn install_powershell_runtime(prefix: &Path, runtime: &Path) -> Result<()> { + let powershell_parent = prefix + .join("drive_c") + .join("Program Files") + .join("PowerShell"); + fs::create_dir_all(&powershell_parent).context("create PowerShell installation parent")?; + let destination = powershell_parent.join("7"); + materialize_runtime_directory(runtime, &destination) +} + +/// Recursively reproduces a runfiles directory in a writable Wine prefix. +/// +/// Bazel runfiles may be immutable, represented by a symlink forest, and may +/// contain the PowerShell distribution on a different filesystem from the +/// temporary prefix. Hard links avoid repeatedly copying the roughly +/// hundred-megabyte runtime when both locations share a filesystem; the copy +/// fallback preserves correctness for sandbox or remote-execution layouts +/// where cross-device hard links are unavailable. +fn materialize_runtime_directory(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| { + format!( + "create PowerShell runtime directory {}", + destination.display() + ) + })?; + for entry in fs::read_dir(source) + .with_context(|| format!("read PowerShell runtime directory {}", source.display()))? + { + let entry = entry.context("read PowerShell runtime entry")?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + // Local Bazel runfiles trees expose external-repository files as + // symlinks. Resolve those trusted runfiles entries before inspecting + // or linking them so the writable prefix contains ordinary files. + let resolved_source_path = fs::canonicalize(&source_path).with_context(|| { + format!("resolve PowerShell runtime entry {}", source_path.display()) + })?; + let file_type = fs::metadata(&resolved_source_path) + .with_context(|| { + format!( + "inspect PowerShell runtime entry {}", + resolved_source_path.display() + ) + })? + .file_type(); + if file_type.is_dir() { + // PowerShell resolves assemblies and modules by their relative + // locations, so flattening the archive is not an option. + materialize_runtime_directory(&resolved_source_path, &destination_path)?; + } else if file_type.is_file() { + // A hard link gives each prefix the expected installation layout + // without duplicating the large runtime in the common local case. + if fs::hard_link(&resolved_source_path, &destination_path).is_err() { + // Cross-device links are common under Bazel sandboxing and + // remote execution, where an ordinary copy is still valid. + fs::copy(&resolved_source_path, &destination_path).with_context(|| { + format!( + "copy PowerShell runtime file {} to {}", + resolved_source_path.display(), + destination_path.display() + ) + })?; + } + } else { + anyhow::bail!( + "unsupported PowerShell runtime entry type at {}", + source_path.display() + ); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/bazel/rules/testing/wine/src/lib_tests.rs b/bazel/rules/testing/wine/src/lib_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..db10a4be03a8ff98ffbbfaad0b5216b2ef023b74 --- /dev/null +++ b/bazel/rules/testing/wine/src/lib_tests.rs @@ -0,0 +1,451 @@ +use std::any::Any; +use std::collections::HashMap; +use std::fs; +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use futures::FutureExt; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::process::Command as TokioCommand; +use tokio::time::timeout; + +use super::WineRuntimePaths; +use super::WineTestCommand; +use super::WineTestProcess; +use super::install_powershell_runtime; + +async fn waiting_smoke_process() -> Result { + let executable = codex_utils_cargo_bin::cargo_bin("wine-smoke")?; + let mut process = WineTestCommand::new(executable).arg("--wait").spawn()?; + let mut lines = BufReader::new(process.take_stdout()).lines(); + let ready_line = lines + .next_line() + .await? + .context("Windows smoke process exited before becoming ready")?; + assert_eq!(ready_line, "WINE_TEST_READY"); + Ok(process) +} + +fn prefix_path(process: &WineTestProcess) -> PathBuf { + process + .processes + .as_ref() + .expect("Wine process guard") + .prefix + .path() + .to_path_buf() +} + +fn assert_prefix_removed(prefix: &Path) { + assert!( + !prefix.exists(), + "Wine prefix remains: {}", + prefix.display() + ); +} + +fn assert_panic_message(panic: Box, expected: &str) { + assert_eq!(panic.downcast_ref::<&str>(), Some(&expected)); +} + +async fn assert_future_panics(future: impl Future, expected: &str) { + let panic = match AssertUnwindSafe(future).catch_unwind().await { + Ok(_) => panic!("future should panic"), + Err(panic) => panic, + }; + assert_panic_message(panic, expected); +} + +async fn process_with_failing_wineserver_stop() -> Result { + let mut process = waiting_smoke_process().await?; + let processes = process.processes.as_mut().expect("Wine process guard"); + + let mut command = TokioCommand::from(processes.stop_wineserver_command()); + let status = command + .status() + .await + .context("pre-stop isolated wineserver")?; + assert!(status.success(), "wineserver exited with {status}"); + + processes.runtime.wineserver = processes.prefix.path().join("missing-wineserver"); + Ok(process) +} + +#[tokio::test] +async fn dropping_without_teardown_panics() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + assert_future_panics( + async move { drop(process) }, + "WineTestProcess dropped without async teardown", + ) + .await; + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn dropping_while_panicking_does_not_panic_again() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + assert_future_panics( + async move { + let _process = process; + panic!("sentinel panic"); + }, + "sentinel panic", + ) + .await; + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn async_teardown_disarms_drop_bomb() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + process.shutdown().await?; + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn take_stdout_panics_when_called_twice() -> Result<()> { + let mut process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + assert_future_panics( + async { + process.take_stdout(); + }, + "Wine process stdout has already been taken", + ) + .await; + process.shutdown().await?; + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn scope_returns_value_and_tears_down() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + let value = process + .scope(async { Ok::<_, anyhow::Error>("scope value") }) + .await?; + assert_eq!(value, "scope value"); + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn scope_returns_body_error_and_tears_down() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + + let error = process + .scope(async { Err::<(), _>(anyhow!("scope body failed")) }) + .await + .expect_err("scope body should fail"); + + assert_eq!(error.to_string(), "scope body failed"); + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn scope_panic_preserves_panic_and_tears_down() -> Result<()> { + let process = waiting_smoke_process().await?; + let prefix = prefix_path(&process); + + assert_future_panics( + process.scope::<()>(async { panic!("scope panic") }), + "scope panic", + ) + .await; + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn shutdown_reports_nonzero_process_exit() -> Result<()> { + let executable = codex_utils_cargo_bin::cargo_bin("wine-smoke")?; + let mut process = WineTestCommand::new(executable).arg("--fail").spawn()?; + let prefix = prefix_path(&process); + let status = process + .processes + .as_mut() + .expect("Wine process guard") + .child + .wait() + .await?; + assert!( + !status.success(), + "Windows smoke process unexpectedly passed" + ); + + let error = process.shutdown().await.expect_err("shutdown should fail"); + + assert!(error.to_string().starts_with("Windows process exited with")); + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn scope_preserves_body_error_when_teardown_also_fails() -> Result<()> { + let process = process_with_failing_wineserver_stop().await?; + let prefix = prefix_path(&process); + + let error = process + .scope(async { Err::<(), _>(anyhow!("scope body failed")) }) + .await + .expect_err("scope body and teardown should fail"); + + assert!( + error + .to_string() + .starts_with("Wine teardown also failed: stop isolated wineserver"), + "unexpected error: {error:#}" + ); + assert_eq!( + error.chain().last().map(ToString::to_string), + Some("scope body failed".to_string()) + ); + assert_prefix_removed(&prefix); + Ok(()) +} + +#[tokio::test] +async fn shutdown_returns_teardown_error() -> Result<()> { + let process = process_with_failing_wineserver_stop().await?; + let prefix = prefix_path(&process); + + let error = process + .shutdown() + .await + .expect_err("shutdown should report a wineserver failure"); + + assert_eq!(error.to_string(), "stop isolated wineserver"); + assert_prefix_removed(&prefix); + Ok(()) +} + +#[test] +fn powershell_runtime_is_materialized_at_the_windows_fallback_path() -> Result<()> { + let prefix = TempDir::new()?; + let runtime = TempDir::new()?; + fs::create_dir(runtime.path().join("Modules"))?; + fs::write(runtime.path().join("pwsh.exe"), b"pwsh")?; + fs::write(runtime.path().join("Modules").join("marker.txt"), b"module")?; + + install_powershell_runtime(prefix.path(), runtime.path())?; + + let installed = prefix + .path() + .join("drive_c") + .join("Program Files") + .join("PowerShell") + .join("7"); + assert_eq!(fs::read(installed.join("pwsh.exe"))?, b"pwsh"); + assert_eq!( + fs::read(installed.join("Modules").join("marker.txt"))?, + b"module" + ); + Ok(()) +} + +#[test] +fn powershell_runtime_follows_runfiles_symlinks() -> Result<()> { + let prefix = TempDir::new()?; + let runtime = TempDir::new()?; + let backing = TempDir::new()?; + let backing_file = backing.path().join("pwsh.exe"); + fs::write(&backing_file, b"pwsh")?; + std::os::unix::fs::symlink(&backing_file, runtime.path().join("pwsh.exe"))?; + + install_powershell_runtime(prefix.path(), runtime.path())?; + + let installed = prefix + .path() + .join("drive_c") + .join("Program Files") + .join("PowerShell") + .join("7") + .join("pwsh.exe"); + assert_eq!(fs::read(installed)?, b"pwsh"); + Ok(()) +} + +#[tokio::test] +async fn pinned_powershell_runs_under_wine_with_a_pty() -> Result<()> { + // Keep this integration smoke test local to the Wine support crate. The + // production-shaped PowerShell launch path belongs to exec-server tests. + // The marker makes the assertion resilient to Wine or PTY startup chatter. + const POWERSHELL_SMOKE_MARKER: &str = "WINE_PWSH_SMOKE"; + // Besides proving that the pinned runtime starts, report the properties + // that shell detection and command construction rely on: PowerShell 7 Core + // running with Windows semantics and a backslash path separator. + const POWERSHELL_SMOKE_SCRIPT: &str = concat!( + "$ErrorActionPreference = 'Stop'; ", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ", + "if ($PSVersionTable.PSVersion.Major -ne 7) { throw 'expected PowerShell 7' }; ", + "if ($PSVersionTable.PSEdition -ne 'Core') { throw 'expected PowerShell Core' }; ", + "if (-not $IsWindows) { throw 'expected Windows semantics' }; ", + "if ($separatorCode -ne 92) { throw 'expected backslash path separator' }; ", + "Write-Output ('WINE_PWSH_SMOKE|' + ", + "$PSVersionTable.PSVersion.ToString() + '|' + ", + "$PSVersionTable.PSEdition + '|' + ", + "$IsWindows.ToString().ToLowerInvariant() + '|' + $separatorCode)", + ); + let runtime = WineRuntimePaths::from_runfiles()?; + let prefix = TempDir::new()?; + install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?; + let mut env = std::env::vars().collect::>(); + env.remove("DISPLAY"); + env.extend([ + ( + "HOME".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ( + "XDG_RUNTIME_DIR".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ("WINEARCH".to_string(), "win64".to_string()), + ( + "WINEPREFIX".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ( + "WINEDLLPATH".to_string(), + runtime.dll_path.to_string_lossy().into_owned(), + ), + ( + "WINESERVER".to_string(), + runtime.wineserver.to_string_lossy().into_owned(), + ), + ("WINEDEBUG".to_string(), "-all".to_string()), + ( + "WINEDLLOVERRIDES".to_string(), + "mscoree,mshtml,winegstreamer=".to_string(), + ), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), + ("TEMP".to_string(), r"C:\windows\temp".to_string()), + ("TMP".to_string(), r"C:\windows\temp".to_string()), + ]); + let args = [ + r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(), + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + POWERSHELL_SMOKE_SCRIPT.to_string(), + ]; + let wine = runtime.wine.to_string_lossy().into_owned(); + let SpawnedProcess { + session, + mut stdout_rx, + mut stderr_rx, + exit_rx, + } = codex_utils_pty::spawn_pty_process( + &wine, + &args, + prefix.path(), + &env, + /*arg0*/ &None, + TerminalSize::default(), + /*inherited_fds*/ &[], + ) + .await?; + let command_result = timeout(Duration::from_secs(30), async { + let stdout = async { + let mut output = Vec::new(); + while let Some(chunk) = stdout_rx.recv().await { + output.extend(chunk); + } + output + }; + let stderr = async { + let mut output = Vec::new(); + while let Some(chunk) = stderr_rx.recv().await { + output.extend(chunk); + } + output + }; + let (stdout, stderr, exit_code) = tokio::join!(stdout, stderr, exit_rx); + Ok::<_, anyhow::Error>((stdout, stderr, exit_code.context("wait for PowerShell")?)) + }) + .await + .context("PowerShell smoke test timed out") + .and_then(std::convert::identity); + drop(session); + let shutdown_result = timeout(Duration::from_secs(10), async { + let mut command = TokioCommand::new(&runtime.wineserver); + command + .args(["-k", "-w"]) + .env("HOME", prefix.path()) + .env("WINEPREFIX", prefix.path()) + .env("XDG_RUNTIME_DIR", prefix.path()) + .kill_on_drop(true); + let status = command.status().await.context("stop isolated wineserver")?; + anyhow::ensure!( + status.success() || status.code() == Some(1), + "wineserver exited with {status}" + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .context("stop isolated wineserver timed out") + .and_then(std::convert::identity); + let (stdout, stderr, exit_code) = match (command_result, shutdown_result) { + (Ok(output), Ok(())) => output, + (Err(error), Ok(())) => return Err(error), + (Ok(_), Err(error)) => return Err(error), + (Err(error), Err(shutdown_error)) => { + return Err(error.context(format!("Wine teardown also failed: {shutdown_error:#}"))); + } + }; + anyhow::ensure!( + exit_code == 0, + "PowerShell exited with {}; stderr: {}", + exit_code, + String::from_utf8_lossy(&stderr), + ); + let output = String::from_utf8(stdout)?; + let marker_start = output + .find(POWERSHELL_SMOKE_MARKER) + .with_context(|| format!("PowerShell smoke marker was missing from {output:?}"))?; + let smoke = output[marker_start..] + .lines() + .next() + .context("PowerShell smoke marker line was incomplete")? + .trim_end_matches('\r'); + let fields = smoke.split('|').collect::>(); + assert_eq!( + fields.len(), + 5, + "unexpected PowerShell smoke output: {smoke}" + ); + assert_eq!(fields[0], POWERSHELL_SMOKE_MARKER); + assert_eq!( + fields[1].split('.').next(), + Some("7"), + "expected PowerShell 7.x, got {}", + fields[1], + ); + assert_eq!(&fields[2..], &["Core", "true", "92"]); + Ok(()) +} diff --git a/bazel/rules/testing/wine/wine.bzl b/bazel/rules/testing/wine/wine.bzl new file mode 100644 index 0000000000000000000000000000000000000000..94b0d7a96e13ad67aeaded6c5e601fbbdb6055ca --- /dev/null +++ b/bazel/rules/testing/wine/wine.bzl @@ -0,0 +1,69 @@ +"""Macros for cross-building Windows Rust binaries and testing them with Wine.""" + +load("@rules_rust//rust:defs.bzl", "rust_test") +load("//:defs.bzl", "WINDOWS_GNULLVM_RUSTC_LINK_FLAGS") +load("//bazel/rules/testing:foreign_platform_binary.bzl", "foreign_platform_binary") +load(":wine_runtime.bzl", "WINE_TEST_TARGET_COMPATIBLE_WITH", "wine_test_runtime") + +def wine_rust_test( + name, + windows_binaries, + host_binaries = {}, + data = [], + target_compatible_with = [], + **kwargs): + """Defines an x86-64 Linux Rust test with a pinned Wine runtime. + + Each `windows_binaries` executable is transitioned to GNU/LLVM Windows; + every Rust dependency receives the repository's Windows linker flags while + the test stays on x86-64 Linux. Its environment-variable contract is: + + * Each `host_binaries` and `windows_binaries` entry contributes + `CARGO_BIN_EXE_` for its executable. + * `CARGO_BIN_EXE_wine` and `CARGO_BIN_EXE_wineserver` identify Wine tools. + * `CARGO_BIN_EXE_wine-runtime-marker` identifies a file whose parent is the + Wine DLL directory to use as `WINEDLLPATH`. + * `CARGO_BIN_EXE_pwsh` identifies the pinned PowerShell executable and + `CARGO_BIN_EXE_pwsh-runtime-marker` identifies a file whose parent is the + complete PowerShell runtime. + + These are Bazel runfile locations. Resolve binaries with + `codex_utils_cargo_bin::cargo_bin`; `:wine_test_support` resolves the fixed + runtime names and starts each process in an isolated prefix. + + Args: + name: Name of the generated Linux `rust_test`. + windows_binaries: Map from `CARGO_BIN_EXE_*` suffixes to Windows targets. + host_binaries: Map from `CARGO_BIN_EXE_*` suffixes to Linux host targets. + data: Additional runtime data for the Linux test. + target_compatible_with: Additional compatibility constraints. + **kwargs: Remaining attributes forwarded to `rust_test`. + """ + binaries = dict(host_binaries) + for index, binary_name in enumerate(sorted(windows_binaries.keys())): + if binary_name in binaries: + fail("Windows test binary name collides with host binary: {}".format(binary_name)) + transitioned_binary = name + "-windows-binary-" + str(index) + foreign_platform_binary( + name = transitioned_binary, + binary = windows_binaries[binary_name], + extra_rustc_flags = WINDOWS_GNULLVM_RUSTC_LINK_FLAGS, + platform = "//:windows_x86_64_gnullvm", + tags = ["manual"], + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + testonly = True, + visibility = ["//visibility:private"], + ) + binaries[binary_name] = ":" + transitioned_binary + + runtime = wine_test_runtime(binaries) + rust_test( + name = name, + data = data + runtime.data, + env = runtime.env, + target_compatible_with = target_compatible_with + WINE_TEST_TARGET_COMPATIBLE_WITH, + **kwargs + ) diff --git a/bazel/rules/testing/wine/wine_runtime.bzl b/bazel/rules/testing/wine/wine_runtime.bzl new file mode 100644 index 0000000000000000000000000000000000000000..9f47bdcff00b5368137737f177ee1a8be3e074ef --- /dev/null +++ b/bazel/rules/testing/wine/wine_runtime.bzl @@ -0,0 +1,40 @@ +"""Runfiles shared by tests that execute Windows binaries through Wine.""" + +_WINE_RUNTIME_BINARIES = { + "pwsh": "@powershell_windows_x86_64//:pwsh", + "pwsh-runtime-marker": "@powershell_windows_x86_64//:runtime_marker", + "wine": "@wine_linux_x86_64//:wine", + "wine-runtime-marker": "@wine_linux_x86_64//:runtime_marker", + "wineserver": "@wine_linux_x86_64//:wineserver", +} + +_WINE_RUNTIME_DATA = [ + "@powershell_windows_x86_64//:runtime", + "@wine_linux_x86_64//:runtime", +] + +WINE_TEST_TARGET_COMPATIBLE_WITH = [ + "@llvm//constraints/libc:gnu.2.28", + "@platforms//cpu:x86_64", + "@platforms//os:linux", +] + +def wine_test_runtime(test_binaries = {}): + """Returns data and environment mappings for a Wine-backed test.""" + binaries = dict(_WINE_RUNTIME_BINARIES) + for binary_name in sorted(test_binaries.keys()): + if binary_name in binaries: + fail("test binary name collides with Wine runtime: {}".format(binary_name)) + binaries[binary_name] = test_binaries[binary_name] + + return struct( + data = _WINE_RUNTIME_DATA + [binary for binary in binaries.values()], + env = { + "CARGO_BIN_EXE_{}".format(binary_name): "$(rlocationpath {})".format(binary) + for binary_name, binary in binaries.items() + }, + runfile_env = { + binary_label: "CARGO_BIN_EXE_" + binary_name + for binary_name, binary_label in binaries.items() + }, + ) diff --git a/bazel/schema/BUILD.bazel b/bazel/schema/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..401d30b00f61bb7b49e7314f3d1b128e22f251d6 --- /dev/null +++ b/bazel/schema/BUILD.bazel @@ -0,0 +1,8 @@ +load("//bazel/rules:schema_bundle.bzl", "schema_bundle") + +schema_bundle( + name = "public-schema-bundle", + testonly = True, + generator = "//codex-rs/app-server-protocol:schema-generator", + tags = ["manual"], +) diff --git a/bazel/toolchains/prost/BUILD.bazel b/bazel/toolchains/prost/BUILD.bazel new file mode 100644 index 0000000000000000000000000000000000000000..2b87000bb21996a98aa179bce63496a8a6e5ab34 --- /dev/null +++ b/bazel/toolchains/prost/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_rust//extensions/prost:defs.bzl", "rust_prost_toolchain") +load("@rules_rust//rust:defs.bzl", "rust_library_group") + +package(default_visibility = ["//visibility:public"]) + +rust_library_group( + name = "prost_runtime", + deps = ["@crates//:prost"], +) + +rust_library_group( + name = "tonic_runtime", + deps = [ + ":prost_runtime", + "@crates//:tonic", + "@crates//:tonic-prost", + ], +) + +rust_prost_toolchain( + name = "toolchain_impl", + compile_well_known_types = False, + prost_plugin = "@rules_rs//rs/private/prost:protoc-gen-prost__protoc-gen-prost", + prost_runtime = ":prost_runtime", + prost_types = "@crates//:prost-types-0.14.3", + tonic_plugin = "@rules_rs//rs/private/prost:protoc-gen-tonic__protoc-gen-tonic", + tonic_plugin_flag = "--plugin=protoc-gen-tonic=%s", + tonic_runtime = ":tonic_runtime", +) + +toolchain( + name = "toolchain", + toolchain = ":toolchain_impl", + toolchain_type = "@rules_rust//extensions/prost:toolchain_type", +) diff --git a/scripts/build_codex_package.py b/scripts/build_codex_package.py new file mode 100644 index 0000000000000000000000000000000000000000..8e1097c488ac18bacf3994abd4db7694042b261c --- /dev/null +++ b/scripts/build_codex_package.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Build a canonical Codex package directory and optional archive.""" + +from pathlib import Path +import sys + + +# Some developer environments set PYTHONSAFEPATH=1, which prevents Python from +# adding the script directory to sys.path. Add it explicitly so the local helper +# package remains importable when this executable is launched from any cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from codex_package.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_winget_package.py b/scripts/build_winget_package.py new file mode 100644 index 0000000000000000000000000000000000000000..a5d547048e2679e86a7153149d2a4d4de3adc914 --- /dev/null +++ b/scripts/build_winget_package.py @@ -0,0 +1,37 @@ +"""Adapt an extracted signed Windows package to WinGet's existing root filenames.""" + +import argparse +import json +import shutil +from pathlib import Path + + +def prepare_winget_package(package: Path) -> None: + metadata_path = package / "codex-package.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + target = metadata["target"] + if target not in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"): + raise ValueError("WinGet requires a Windows package") + if metadata["entrypoint"] != "bin/codex.exe": + raise ValueError("WinGet requires the canonical Codex entrypoint") + entrypoint = f"codex-{target}.exe" + manifest_path = package / "codex-resources/voice/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["sha256"][entrypoint] = manifest["sha256"].pop("bin/codex.exe") + (package / "bin/codex.exe").rename(package / entrypoint) + (package / "bin/codex-code-mode-host.exe").rename( + package / "codex-code-mode-host.exe" + ) + # Keep resources in place for package-aware discovery and provide the root + # filenames declared by the existing WinGet portable installer manifest. + for helper in ("codex-command-runner.exe", "codex-windows-sandbox-setup.exe"): + shutil.copy2(package / "codex-resources" / helper, package / helper) + metadata["entrypoint"] = entrypoint + for path, value in ((metadata_path, metadata), (manifest_path, manifest)): + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + prepare_winget_package(parser.parse_args().package) diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh new file mode 100644 index 0000000000000000000000000000000000000000..49ddaaef2bfc9a3c568bcd2ab3818cd968ccf6dd --- /dev/null +++ b/scripts/check-module-bazel-lock.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "${repo_root}/.github/scripts/run_bazel_with_buildbuddy.py" mod deps --lockfile_mode=error; then + echo "MODULE.bazel.lock is out of date." + echo "Run 'just bazel-lock-update' and commit the updated lockfile." + exit 1 +fi diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md new file mode 100644 index 0000000000000000000000000000000000000000..972659d4e653907d28e8b470fd8c56b16cd73bca --- /dev/null +++ b/scripts/codex_package/README.md @@ -0,0 +1,92 @@ +# Codex package builder + +This package contains the implementation behind `scripts/build_codex_package.py`. +The top-level script is the stable executable entry point; these modules keep the +package-building logic split by responsibility. + +Run the builder through `just`: + +```bash +just assemble-codex-package --help +just assemble-codex-package --variant codex-app-server +just assemble-codex-package --target x86_64-unknown-linux-gnu +``` + +The builder creates a canonical Codex package directory: + +```text +. +├── codex-package.json +├── bin +│ ├── [.exe] +│ └── codex-code-mode-host[.exe] +├── codex-resources +│ ├── bwrap # Linux only +│ ├── zsh/bin/zsh # supported Unix targets only +│ ├── codex-command-runner.exe # Windows only +│ └── codex-windows-sandbox-setup.exe # Windows only +└── codex-path + └── rg[.exe] +``` + +The package directory is the primary artifact. Archive formats such as +`.tar.gz`, `.tar.zst`, and `.zip` are serializations of that directory. + +If `--target` is omitted, the builder uses the release target for the current +host platform. On Linux, that default is a musl target to match Codex release +artifacts; pass a GNU Linux target explicitly for native glibc local builds. If +`--package-dir` is omitted, the builder creates a new temporary directory and +prints its path after the package is built. + +The `--variant` flag selects the package entrypoint. Supported variants are +`codex` and `codex-app-server`. The `--package-version` flag sets the version in +`codex-package.json`; it defaults to `[workspace.package].version` in +`codex-rs/Cargo.toml`. + +## Source-built artifacts + +Artifacts built from this repository are built by the package builder in one +grouped `cargo build` command per package when they are needed and no prebuilt +override was provided: + +- all targets: the selected entrypoint, unless `--entrypoint-bin` is provided +- all targets: `codex-code-mode-host`, unless `--code-mode-host-bin` is provided +- Linux targets: `bwrap`, unless `--bwrap-bin` is provided +- Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`, + unless the corresponding prebuilt helper flags are provided + +The default cargo profile is `dev-small` because local iteration should favor +fast, small builds. Release jobs should pass `--cargo-profile release` and an +explicit target. Release jobs that already built and signed/notarized the +entrypoint should pass `--entrypoint-bin` so the package contains that exact +binary instead of rebuilding it. + +Release jobs should likewise pass `--code-mode-host-bin` so the package contains +the signed host executable beside the signed entrypoint. + +Release jobs that already built package resource binaries should also pass the +corresponding resource flags: `--bwrap-bin` for Linux packages, and +`--codex-command-runner-bin` plus `--codex-windows-sandbox-setup-bin` for +Windows packages. This keeps package archive creation as a pure staging step +after signing instead of rebuilding resources. + +When the builder source-builds an entrypoint for a Darwin or Linux target, it +downloads and verifies the matching Codex-built V8 release pair before invoking +Cargo and sets `RUSTY_V8_ARCHIVE` plus `RUSTY_V8_SRC_BINDING_PATH` for that +build. Windows targets keep Cargo's release-build MSVC artifact path. Explicit +overrides remain authoritative when both variables are already set. Set +`V8_FROM_SOURCE=1` to leave the build with the `v8` crate source-build path. + +`rg` is not built from this repository, so the builder fetches it from the +DotSlash manifest at `scripts/codex_package/rg`. Downloaded archives are cached +under `$TMPDIR/codex-package/-rg` and are reused only after the recorded +size and SHA-256 digest have been verified. Pass `--rg-bin` to use a local +ripgrep executable instead. + +The patched zsh fork used by `shell_zsh_fork` is fetched from the DotSlash +manifest at `scripts/codex_package/codex-zsh` when the selected target has a +matching prebuilt artifact. Downloaded archives are cached under +`$TMPDIR/codex-package/-zsh` and installed at +`codex-resources/zsh/bin/zsh`. Pass `--zsh-bin` to package a prebuilt, signed +executable, or `--zsh-manifest` to use a different DotSlash manifest, such as +the manifest published with a standalone zsh artifact release. diff --git a/scripts/codex_package/__init__.py b/scripts/codex_package/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bf8a68ab7816022154af77f91000b647898e5729 --- /dev/null +++ b/scripts/codex_package/__init__.py @@ -0,0 +1 @@ +"""Helpers for building canonical Codex package archives.""" diff --git a/scripts/codex_package/archive.py b/scripts/codex_package/archive.py new file mode 100644 index 0000000000000000000000000000000000000000..85123cc01e869627a723208b2b3493c4f299c1c8 --- /dev/null +++ b/scripts/codex_package/archive.py @@ -0,0 +1,118 @@ +"""Archive writers for canonical Codex package directories.""" + +import shutil +import subprocess +import tarfile +import tempfile +import zipfile +from collections.abc import Callable +from pathlib import Path + +from .targets import REPO_ROOT + + +ZSTD_DOTSLASH = REPO_ROOT / ".github" / "workflows" / "zstd" + + +def write_archive(package_dir: Path, archive_path: Path, *, force: bool) -> None: + if is_relative_to(archive_path, package_dir): + raise RuntimeError( + f"Archive output must be outside the package directory: {archive_path}" + ) + + archive_path.parent.mkdir(parents=True, exist_ok=True) + if archive_path.exists(): + if not force: + raise RuntimeError(f"Archive output already exists: {archive_path}") + archive_path.unlink() + + archive_format = archive_format_for_path(archive_path) + if archive_format == "tar.gz": + write_tar_archive(package_dir, archive_path, mode="w:gz") + elif archive_format == "tar.zst": + write_tar_zst_archive(package_dir, archive_path) + elif archive_format == "zip": + write_zip_archive(package_dir, archive_path) + else: + raise AssertionError(f"unexpected archive format: {archive_format}") + + +def is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + +def archive_format_for_path(path: Path) -> str: + suffixes = path.suffixes + if suffixes[-2:] == [".tar", ".gz"] or path.suffix == ".tgz": + return "tar.gz" + if suffixes[-2:] == [".tar", ".zst"]: + return "tar.zst" + if path.suffix == ".zip": + return "zip" + raise RuntimeError( + f"Unsupported archive suffix for {path}. Use .tar.gz, .tgz, .tar.zst, or .zip." + ) + + +def write_tar_archive(package_dir: Path, archive_path: Path, *, mode: str) -> None: + options = {"compresslevel": 6} if mode == "w:gz" else {} + with tarfile.open(archive_path, mode, **options) as archive: + for path in package_entries(package_dir): + archive.add( + path, + arcname=path.relative_to(package_dir), + recursive=False, + ) + + +def write_tar_zst_archive(package_dir: Path, archive_path: Path) -> None: + zstd_command = resolve_zstd_command() + + with tempfile.TemporaryDirectory(prefix="codex-package-archive-") as temp_dir_str: + tar_path = Path(temp_dir_str) / "package.tar" + write_tar_archive(package_dir, tar_path, mode="w") + subprocess.check_call( + [*zstd_command, "-T0", "-19", "-f", str(tar_path), "-o", str(archive_path)] + ) + + +def resolve_zstd_command( + *, + dotslash_manifest: Path = ZSTD_DOTSLASH, + which: Callable[[str], str | None] = shutil.which, +) -> list[str]: + zstd = which("zstd") + if zstd is not None: + return [zstd] + + dotslash = which("dotslash") + if dotslash is not None and dotslash_manifest.is_file(): + return [dotslash, str(dotslash_manifest)] + + raise RuntimeError( + "zstd is required to write .tar.zst archives. Install zstd, or install " + f"DotSlash so the repository wrapper can run: {dotslash_manifest}" + ) + + +def write_zip_archive(package_dir: Path, archive_path: Path) -> None: + with zipfile.ZipFile( + archive_path, "w", compression=zipfile.ZIP_DEFLATED + ) as archive: + for path in package_entries(package_dir): + relative_path = path.relative_to(package_dir) + if path.is_dir(): + archive.write(path, f"{relative_path}/") + else: + archive.write(path, relative_path) + + +def package_entries(package_dir: Path) -> list[Path]: + return sorted( + package_dir.rglob("*"), + key=lambda path: path.relative_to(package_dir).as_posix(), + ) diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py new file mode 100644 index 0000000000000000000000000000000000000000..f759e447620c62d50ab0441880e3383e335e0648 --- /dev/null +++ b/scripts/codex_package/cargo.py @@ -0,0 +1,194 @@ +"""Cargo builds for source-built Codex package artifacts.""" + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .targets import REPO_ROOT +from .targets import PackageVariant +from .targets import TargetSpec +from .v8 import resolve_codex_v8_cargo_env + + +CODEX_RS_ROOT = REPO_ROOT / "codex-rs" + + +@dataclass(frozen=True) +class SourceBuildOutputs: + entrypoint_bin: Path + code_mode_host_bin: Path + bwrap_bin: Path | None + codex_command_runner_bin: Path | None + codex_windows_sandbox_setup_bin: Path | None + + +def build_source_binaries( + spec: TargetSpec, + variant: PackageVariant, + *, + cargo: str, + profile: str, + entrypoint_bin: Path | None, + code_mode_host_bin: Path | None, + bwrap_bin: Path | None, + codex_command_runner_bin: Path | None, + codex_windows_sandbox_setup_bin: Path | None, +) -> SourceBuildOutputs: + validate_prebuilt_resource_inputs( + spec, + bwrap_bin=bwrap_bin, + codex_command_runner_bin=codex_command_runner_bin, + codex_windows_sandbox_setup_bin=codex_windows_sandbox_setup_bin, + ) + binaries = source_binaries_for_target( + spec, + variant, + build_entrypoint=entrypoint_bin is None, + build_code_mode_host=code_mode_host_bin is None, + build_bwrap=spec.is_linux and bwrap_bin is None, + build_codex_command_runner=spec.is_windows and codex_command_runner_bin is None, + build_codex_windows_sandbox_setup=spec.is_windows + and codex_windows_sandbox_setup_bin is None, + ) + if binaries: + cmd = [ + cargo, + "build", + "--target", + spec.target, + "--profile", + profile, + ] + for binary in binaries: + cmd.extend(["--bin", binary]) + + cargo_env = None + if entrypoint_bin is None or code_mode_host_bin is None: + codex_v8_env = resolve_codex_v8_cargo_env(spec) + if codex_v8_env: + cargo_env = {**os.environ, **codex_v8_env} + + print("+", " ".join(cmd)) + subprocess.run( + cmd, + cwd=CODEX_RS_ROOT, + check=True, + env=cargo_env, + ) + + output_dir = cargo_profile_output_dir(spec, profile) + outputs = SourceBuildOutputs( + entrypoint_bin=resolve_output_path( + entrypoint_bin, + output_dir / variant.entrypoint_name(spec), + ), + code_mode_host_bin=( + code_mode_host_bin.resolve() + if code_mode_host_bin is not None + else output_dir / f"codex-code-mode-host{spec.exe_suffix}" + ), + bwrap_bin=resolve_output_path( + bwrap_bin, + output_dir / "bwrap" if spec.is_linux else None, + ), + codex_command_runner_bin=resolve_output_path( + codex_command_runner_bin, + output_dir / "codex-command-runner.exe" if spec.is_windows else None, + ), + codex_windows_sandbox_setup_bin=resolve_output_path( + codex_windows_sandbox_setup_bin, + output_dir / "codex-windows-sandbox-setup.exe" if spec.is_windows else None, + ), + ) + validate_source_outputs(outputs) + return outputs + + +def source_binaries_for_target( + spec: TargetSpec, + variant: PackageVariant, + *, + build_entrypoint: bool, + build_code_mode_host: bool, + build_bwrap: bool, + build_codex_command_runner: bool, + build_codex_windows_sandbox_setup: bool, +) -> list[str]: + binaries = [] + if build_entrypoint: + binaries.append(variant.cargo_bin) + if build_code_mode_host: + binaries.append("codex-code-mode-host") + if build_bwrap: + binaries.append("bwrap") + if build_codex_command_runner: + binaries.append("codex-command-runner") + if build_codex_windows_sandbox_setup: + binaries.append("codex-windows-sandbox-setup") + return binaries + + +def validate_prebuilt_resource_inputs( + spec: TargetSpec, + *, + bwrap_bin: Path | None, + codex_command_runner_bin: Path | None, + codex_windows_sandbox_setup_bin: Path | None, +) -> None: + if bwrap_bin is not None and not spec.is_linux: + raise RuntimeError("--bwrap-bin is only supported for Linux targets.") + if codex_command_runner_bin is not None and not spec.is_windows: + raise RuntimeError( + "--codex-command-runner-bin is only supported for Windows targets." + ) + if codex_windows_sandbox_setup_bin is not None and not spec.is_windows: + raise RuntimeError( + "--codex-windows-sandbox-setup-bin is only supported for Windows targets." + ) + + +def resolve_output_path( + explicit_path: Path | None, default_path: Path | None +) -> Path | None: + if explicit_path is not None: + return explicit_path.resolve() + + return default_path + + +def cargo_profile_output_dir(spec: TargetSpec, profile: str) -> Path: + target_dir = cargo_target_dir() + return target_dir / spec.target / cargo_profile_dirname(profile) + + +def cargo_target_dir() -> Path: + target_dir = os.environ.get("CARGO_TARGET_DIR") + if target_dir is None: + return CODEX_RS_ROOT / "target" + + path = Path(target_dir) + if path.is_absolute(): + return path + + return CODEX_RS_ROOT / path + + +def cargo_profile_dirname(profile: str) -> str: + if profile == "dev": + return "debug" + if profile == "release": + return "release" + return profile + + +def validate_source_outputs(outputs: SourceBuildOutputs) -> None: + for path in [ + outputs.entrypoint_bin, + outputs.code_mode_host_bin, + outputs.bwrap_bin, + outputs.codex_command_runner_bin, + outputs.codex_windows_sandbox_setup_bin, + ]: + if path is not None and not path.is_file(): + raise RuntimeError(f"cargo build did not produce expected binary: {path}") diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..fa569e9ae78fb7738404418a3802fc35106028d7 --- /dev/null +++ b/scripts/codex_package/cli.py @@ -0,0 +1,249 @@ +"""Command-line interface for building Codex package directories.""" + +import argparse +import re +import tempfile +from pathlib import Path + +from .archive import write_archive +from .cargo import build_source_binaries +from .layout import build_package_dir +from .layout import prepare_package_dir +from .layout import validate_package_dir +from .ripgrep import resolve_rg_bin +from .targets import PACKAGE_VARIANTS +from .targets import TARGET_SPECS +from .targets import PackageInputs +from .targets import default_target +from .targets import resolve_input_path +from .zsh import resolve_zsh_bin +from .version import read_workspace_version + + +# Release pipelines run this builder with system Python, so avoid new dependencies. +SEMVER_PATTERN = re.compile( + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:-(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" +) + + +def parse_package_version(value: str) -> str: + match = SEMVER_PATTERN.fullmatch(value) + if match is not None: + components = (match.group(name) for name in ("major", "minor", "patch")) + prerelease = match.group("prerelease") or "" + if all(int(component) <= 2**64 - 1 for component in components) and all( + not (part.isdigit() and len(part) > 1 and part.startswith("0")) + for part in prerelease.split(".") + ): + return value + + raise argparse.ArgumentTypeError(f"invalid semantic version: {value!r}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build a canonical Codex package directory and optional archive.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--target", + default=argparse.SUPPRESS, + choices=sorted(TARGET_SPECS), + help=( + "Rust target triple for the package. Defaults to the release target " + "for this host platform." + ), + ) + parser.add_argument( + "--variant", + choices=sorted(PACKAGE_VARIANTS), + default="codex", + help="Package variant to build.", + ) + parser.add_argument( + "--package-version", + type=parse_package_version, + default=read_workspace_version(), + help="Semantic version to record in codex-package.json.", + ) + parser.add_argument( + "--package-dir", + type=Path, + default=argparse.SUPPRESS, + help=( + "Output directory to create as the package root. Defaults to a new temporary directory." + ), + ) + parser.add_argument( + "--archive-output", + type=Path, + action="append", + default=[], + help=( + "Optional archive output path. May be repeated. Supported suffixes: " + ".tar.gz, .tgz, .tar.zst, .zip." + ), + ) + parser.add_argument( + "--force", + action="store_true", + help="Replace an existing package directory or archive output.", + ) + parser.add_argument( + "--cargo", + default="cargo", + help="Cargo executable to use for source-built package artifacts.", + ) + parser.add_argument( + "--cargo-profile", + default="dev-small", + help=( + "Cargo profile for source-built package artifacts. Use release for release packages." + ), + ) + parser.add_argument( + "--entrypoint-bin", + type=Path, + help=( + "Optional prebuilt entrypoint executable for the selected package " + "variant. If omitted, the entrypoint is built with Cargo." + ), + ) + parser.add_argument( + "--code-mode-host-bin", + type=Path, + help=( + "Optional prebuilt codex-code-mode-host executable. If omitted, " + "the host is built with Cargo." + ), + ) + parser.add_argument( + "--bwrap-bin", + type=Path, + help=( + "Optional prebuilt Linux bwrap executable. If omitted for Linux " + "targets, bwrap is built with Cargo." + ), + ) + zsh_source = parser.add_mutually_exclusive_group() + zsh_source.add_argument( + "--zsh-manifest", + type=Path, + help=( + "Optional DotSlash manifest for the patched zsh fork instead of " + "scripts/codex_package/codex-zsh." + ), + ) + zsh_source.add_argument( + "--zsh-bin", + type=Path, + help="Optional prebuilt zsh executable instead of fetching from a manifest.", + ) + parser.add_argument( + "--codex-command-runner-bin", + type=Path, + help=( + "Optional prebuilt Windows codex-command-runner.exe executable. " + "If omitted for Windows targets, codex-command-runner is built " + "with Cargo." + ), + ) + parser.add_argument( + "--codex-windows-sandbox-setup-bin", + type=Path, + help=( + "Optional prebuilt Windows codex-windows-sandbox-setup.exe " + "executable. If omitted for Windows targets, " + "codex-windows-sandbox-setup is built with Cargo." + ), + ) + parser.add_argument( + "--rg-bin", + type=Path, + help=( + "Optional local ripgrep executable override instead of fetching from " + "scripts/codex_package/rg." + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + spec = TARGET_SPECS[getattr(args, "target", None) or default_target()] + variant = PACKAGE_VARIANTS[args.variant] + package_dir_arg = getattr(args, "package_dir", None) + package_dir = ( + package_dir_arg.resolve() + if package_dir_arg is not None + else Path(tempfile.mkdtemp(prefix="codex-package-")).resolve() + ) + + source_outputs = build_source_binaries( + spec, + variant, + cargo=args.cargo, + profile=args.cargo_profile, + entrypoint_bin=resolve_optional_input_path( + args.entrypoint_bin, + "prebuilt entrypoint executable", + "--entrypoint-bin", + ), + code_mode_host_bin=resolve_optional_input_path( + args.code_mode_host_bin, + "prebuilt code-mode host executable", + "--code-mode-host-bin", + ), + bwrap_bin=resolve_optional_input_path( + args.bwrap_bin, + "prebuilt Linux bwrap executable", + "--bwrap-bin", + ), + codex_command_runner_bin=resolve_optional_input_path( + args.codex_command_runner_bin, + "prebuilt Windows codex-command-runner.exe executable", + "--codex-command-runner-bin", + ), + codex_windows_sandbox_setup_bin=resolve_optional_input_path( + args.codex_windows_sandbox_setup_bin, + "prebuilt Windows codex-windows-sandbox-setup.exe executable", + "--codex-windows-sandbox-setup-bin", + ), + ) + inputs = PackageInputs( + entrypoint_bin=source_outputs.entrypoint_bin, + code_mode_host_bin=source_outputs.code_mode_host_bin, + rg_bin=resolve_rg_bin(spec, args.rg_bin), + zsh_bin=resolve_zsh_bin(spec, args.zsh_manifest, zsh_bin=args.zsh_bin), + bwrap_bin=source_outputs.bwrap_bin, + codex_command_runner_bin=source_outputs.codex_command_runner_bin, + codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin, + ) + prepare_package_dir(package_dir, force=args.force) + build_package_dir(package_dir, args.package_version, variant, spec, inputs) + validate_package_dir( + package_dir, variant, spec, include_zsh=inputs.zsh_bin is not None + ) + + for archive_output in args.archive_output: + archive_path = archive_output.resolve() + write_archive(package_dir, archive_path, force=args.force) + print(f"Built Codex package archive at {archive_path}") + + print(f"Built Codex package directory at {package_dir}") + return 0 + + +def resolve_optional_input_path( + explicit_path: Path | None, + description: str, + flag_name: str, +) -> Path | None: + if explicit_path is None: + return None + + return resolve_input_path(explicit_path, description, flag_name) diff --git a/scripts/codex_package/codex-zsh b/scripts/codex_package/codex-zsh new file mode 100644 index 0000000000000000000000000000000000000000..68b8f03fd2cff9c35d28e553ed6f0ab85582b05d --- /dev/null +++ b/scripts/codex_package/codex-zsh @@ -0,0 +1,55 @@ +#!/usr/bin/env dotslash + +{ + "name": "codex-zsh", + "platforms": { + "macos-aarch64": { + "size": 358776, + "hash": "sha256", + "digest": "49dec9832379688c9090666694a3449502ac5ebd4d76b9ffde1d0999cd088205", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.134.0-alpha.3/codex-zsh-aarch64-apple-darwin.tar.gz" + } + ] + }, + "macos-x86_64": { + "size": 391161, + "hash": "sha256", + "digest": "0246fd4703bb540ae74f13ec739ca365ebd607df44a1f21e335eb7a421352923", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.134.0-alpha.3/codex-zsh-x86_64-apple-darwin.tar.gz" + } + ] + }, + "linux-x86_64": { + "size": 433412, + "hash": "sha256", + "digest": "e7f760998c9644448d2cb8a821084a0134d6820c14b284bd7f39d7f32262ad40", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.134.0-alpha.3/codex-zsh-x86_64-unknown-linux-musl.tar.gz" + } + ] + }, + "linux-aarch64": { + "size": 411655, + "hash": "sha256", + "digest": "8d50cff5dfabc97e37e1138513da022d9247a802c2ff996ab91654bf1892c7d5", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.134.0-alpha.3/codex-zsh-aarch64-unknown-linux-musl.tar.gz" + } + ] + } + } +} diff --git a/scripts/codex_package/dotslash.py b/scripts/codex_package/dotslash.py new file mode 100644 index 0000000000000000000000000000000000000000..9252ae29f1abadf79af4dfe56e5f23c09c63dd28 --- /dev/null +++ b/scripts/codex_package/dotslash.py @@ -0,0 +1,222 @@ +"""Fetch executable artifacts from checked-in DotSlash manifests.""" + +import hashlib +import json +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from .targets import TargetSpec + + +DOWNLOAD_TIMEOUT_SECS = 60 + + +@dataclass(frozen=True) +class DotSlashArtifact: + size: int + digest: str + archive_format: str + archive_member: str + url: str + + +def fetch_dotslash_executable( + spec: TargetSpec, + *, + manifest_path: Path, + artifact_label: str, + cache_key: str, + dest_name: str, + missing_ok: bool = False, +) -> Path | None: + artifact = artifact_for_target( + spec, + manifest_path, + artifact_label=artifact_label, + missing_ok=missing_ok, + ) + if artifact is None: + return None + + cache_dir = default_cache_root() / cache_key + archive_path = cache_dir / archive_filename(artifact.url) + + if not archive_is_valid(archive_path, artifact, artifact_label): + download_archive(artifact.url, archive_path) + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + raise + + dest = cache_dir / dest_name + extract_archive_member(archive_path, artifact, dest, artifact_label) + if not spec.is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return dest + + +def artifact_for_target( + spec: TargetSpec, + manifest_path: Path, + *, + artifact_label: str, + missing_ok: bool = False, +) -> DotSlashArtifact | None: + manifest = load_manifest(manifest_path) + platform_info = manifest.get("platforms", {}).get(spec.dotslash_platform) + if platform_info is None: + if missing_ok: + return None + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} is missing platform " + f"{spec.dotslash_platform!r}" + ) + + providers = platform_info.get("providers") + if not providers: + raise RuntimeError( + f"{artifact_label} manifest {manifest_path} has no providers for " + f"{spec.dotslash_platform!r}" + ) + + hash_name = platform_info.get("hash") + if hash_name != "sha256": + raise RuntimeError( + f"Unsupported {artifact_label} hash {hash_name!r} for " + f"{spec.dotslash_platform!r}; expected sha256" + ) + + return DotSlashArtifact( + size=int(platform_info["size"]), + digest=str(platform_info["digest"]), + archive_format=str(platform_info["format"]), + archive_member=str(platform_info["path"]), + url=str(providers[0]["url"]), + ) + + +def load_manifest(manifest_path: Path) -> dict: + text = manifest_path.read_text(encoding="utf-8") + if text.startswith("#!"): + text = "\n".join(text.splitlines()[1:]) + return json.loads(text) + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def archive_filename(url: str) -> str: + filename = Path(urlparse(url).path).name + if not filename: + raise RuntimeError(f"Unable to determine archive filename from {url}") + return filename + + +def archive_is_valid( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> bool: + if not archive_path.is_file(): + return False + try: + verify_archive(archive_path, artifact, artifact_label) + except RuntimeError: + archive_path.unlink(missing_ok=True) + return False + return True + + +def verify_archive( + archive_path: Path, + artifact: DotSlashArtifact, + artifact_label: str, +) -> None: + actual_size = archive_path.stat().st_size + if actual_size != artifact.size: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has size {actual_size}, " + f"expected {artifact.size}" + ) + + digest = hashlib.sha256() + with open(archive_path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + + actual_digest = digest.hexdigest() + if actual_digest != artifact.digest: + raise RuntimeError( + f"{artifact_label} archive {archive_path} has sha256 {actual_digest}, " + f"expected {artifact.digest}" + ) + + +def download_archive(url: str, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with open(temp_path, "wb") as out: + shutil.copyfileobj(response, out) + temp_path.replace(archive_path) + finally: + temp_path.unlink(missing_ok=True) + + +def extract_archive_member( + archive_path: Path, + artifact: DotSlashArtifact, + dest: Path, + artifact_label: str, +) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.unlink(missing_ok=True) + + if artifact.archive_format == "tar.gz": + with tarfile.open(archive_path, "r:gz") as archive: + try: + member = archive.getmember(artifact.archive_member) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + + extracted = archive.extractfile(member) + if extracted is None: + raise RuntimeError( + f"{artifact_label} archive member {artifact.archive_member!r} is not a file" + ) + with extracted, open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + return + + if artifact.archive_format == "zip": + with zipfile.ZipFile(archive_path) as archive: + try: + with archive.open(artifact.archive_member) as extracted: + with open(dest, "wb") as out: + shutil.copyfileobj(extracted, out) + except KeyError as exc: + raise RuntimeError( + f"{artifact_label} archive {archive_path} is missing " + f"{artifact.archive_member!r}" + ) from exc + return + + raise RuntimeError( + f"Unsupported {artifact_label} archive format {artifact.archive_format!r}; " + "expected tar.gz or zip" + ) diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py new file mode 100644 index 0000000000000000000000000000000000000000..f4fef6912c3ad6d555b1bef872998b665ee5852c --- /dev/null +++ b/scripts/codex_package/layout.py @@ -0,0 +1,187 @@ +"""Canonical Codex package directory layout.""" + +import json +import shutil +import stat +from pathlib import Path + +from .targets import PackageInputs +from .targets import PackageVariant +from .targets import TargetSpec +from .zsh import ZSH_RESOURCE_PATH + + +LAYOUT_VERSION = 1 + + +def prepare_package_dir(package_dir: Path, *, force: bool) -> None: + if package_dir.exists(): + if not package_dir.is_dir(): + raise RuntimeError( + f"Package output exists and is not a directory: {package_dir}" + ) + if any(package_dir.iterdir()): + if not force: + raise RuntimeError( + f"Package output directory is not empty: {package_dir}. " + "Pass --force to replace it." + ) + shutil.rmtree(package_dir) + + package_dir.mkdir(parents=True, exist_ok=True) + + +def build_package_dir( + package_dir: Path, + version: str, + variant: PackageVariant, + spec: TargetSpec, + inputs: PackageInputs, +) -> None: + bin_dir = package_dir / "bin" + resources_dir = package_dir / "codex-resources" + path_dir = package_dir / "codex-path" + bin_dir.mkdir() + resources_dir.mkdir() + path_dir.mkdir() + + entrypoint_name = variant.entrypoint_name(spec) + copy_executable( + inputs.entrypoint_bin, + bin_dir / entrypoint_name, + is_windows=spec.is_windows, + ) + copy_executable( + inputs.code_mode_host_bin, + bin_dir / f"codex-code-mode-host{spec.exe_suffix}", + is_windows=spec.is_windows, + ) + copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) + + if inputs.zsh_bin is not None: + copy_executable( + inputs.zsh_bin, + resources_dir / ZSH_RESOURCE_PATH, + is_windows=False, + ) + + if inputs.bwrap_bin is not None: + copy_executable(inputs.bwrap_bin, resources_dir / "bwrap", is_windows=False) + + if inputs.codex_command_runner_bin is not None: + copy_executable( + inputs.codex_command_runner_bin, + resources_dir / "codex-command-runner.exe", + is_windows=True, + ) + + if inputs.codex_windows_sandbox_setup_bin is not None: + copy_executable( + inputs.codex_windows_sandbox_setup_bin, + resources_dir / "codex-windows-sandbox-setup.exe", + is_windows=True, + ) + + metadata = { + "layoutVersion": LAYOUT_VERSION, + "version": version, + "target": spec.target, + "variant": variant.name, + "entrypoint": f"bin/{entrypoint_name}", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + } + write_json(package_dir / "codex-package.json", metadata) + + +def validate_package_dir( + package_dir: Path, + variant: PackageVariant, + spec: TargetSpec, + *, + include_zsh: bool, +) -> None: + required_dirs = [ + Path("bin"), + Path("codex-resources"), + Path("codex-path"), + ] + for relative_dir in required_dirs: + path = package_dir / relative_dir + if not path.is_dir(): + raise RuntimeError(f"Missing package directory: {relative_dir}") + + metadata_path = package_dir / "codex-package.json" + if not metadata_path.is_file(): + raise RuntimeError("Missing package metadata: codex-package.json") + + with open(metadata_path, encoding="utf-8") as fh: + metadata = json.load(fh) + + expected_metadata = { + "layoutVersion": LAYOUT_VERSION, + "target": spec.target, + "variant": variant.name, + "entrypoint": f"bin/{variant.entrypoint_name(spec)}", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + } + for key, expected in expected_metadata.items(): + actual = metadata.get(key) + if actual != expected: + raise RuntimeError( + f"Invalid package metadata field {key!r}: expected {expected!r}, got {actual!r}" + ) + + required_files = [ + Path("bin") / variant.entrypoint_name(spec), + Path("bin") / f"codex-code-mode-host{spec.exe_suffix}", + Path("codex-path") / spec.rg_name, + ] + executable_files = list(required_files) + + if include_zsh: + zsh_path = Path("codex-resources") / ZSH_RESOURCE_PATH + required_files.append(zsh_path) + executable_files.append(zsh_path) + + if spec.is_linux: + required_files.append(Path("codex-resources") / "bwrap") + executable_files.append(Path("codex-resources") / "bwrap") + + if spec.is_windows: + required_files.extend( + [ + Path("codex-resources") / "codex-command-runner.exe", + Path("codex-resources") / "codex-windows-sandbox-setup.exe", + ] + ) + + for relative_file in required_files: + path = package_dir / relative_file + if not path.is_file(): + raise RuntimeError(f"Missing package file: {relative_file}") + + if not spec.is_windows: + for relative_file in executable_files: + path = package_dir / relative_file + if not is_executable(path): + raise RuntimeError(f"Package file is not executable: {relative_file}") + + +def copy_executable(src: Path, dest: Path, *, is_windows: bool) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dest) + if not is_windows: + mode = dest.stat().st_mode + dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def write_json(path: Path, value: object) -> None: + with open(path, "w", encoding="utf-8") as out: + json.dump(value, out, indent=2) + out.write("\n") + + +def is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & stat.S_IXUSR) diff --git a/scripts/codex_package/rg b/scripts/codex_package/rg new file mode 100644 index 0000000000000000000000000000000000000000..3b7c34843aaafe4cf26a87b3daf5806195d4bb0e --- /dev/null +++ b/scripts/codex_package/rg @@ -0,0 +1,79 @@ +#!/usr/bin/env dotslash + +{ + "name": "rg", + "platforms": { + "macos-aarch64": { + "size": 1764284, + "hash": "sha256", + "digest": "3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4", + "format": "tar.gz", + "path": "ripgrep-15.2.0-aarch64-apple-darwin/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-apple-darwin.tar.gz" + } + ] + }, + "linux-aarch64": { + "size": 1854661, + "hash": "sha256", + "digest": "a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d", + "format": "tar.gz", + "path": "ripgrep-15.2.0-aarch64-unknown-linux-gnu/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-gnu.tar.gz" + } + ] + }, + "macos-x86_64": { + "size": 1878284, + "hash": "sha256", + "digest": "af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1", + "format": "tar.gz", + "path": "ripgrep-15.2.0-x86_64-apple-darwin/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-apple-darwin.tar.gz" + } + ] + }, + "linux-x86_64": { + "size": 2265718, + "hash": "sha256", + "digest": "33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c", + "format": "tar.gz", + "path": "ripgrep-15.2.0-x86_64-unknown-linux-musl/rg", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz" + } + ] + }, + "windows-x86_64": { + "size": 1789611, + "hash": "sha256", + "digest": "71b2fef860abe467217a538ff31de02f5258807c0129f771846f87bd029aafc5", + "format": "zip", + "path": "ripgrep-15.2.0-x86_64-pc-windows-msvc/rg.exe", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-pc-windows-msvc.zip" + } + ] + }, + "windows-aarch64": { + "size": 1640124, + "hash": "sha256", + "digest": "e4abca10c3a64ebea742667dd7009449d49403db5460dd6873e389fa2945360f", + "format": "zip", + "path": "ripgrep-15.2.0-aarch64-pc-windows-msvc/rg.exe", + "providers": [ + { + "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-pc-windows-msvc.zip" + } + ] + } + } +} diff --git a/scripts/codex_package/ripgrep.py b/scripts/codex_package/ripgrep.py new file mode 100644 index 0000000000000000000000000000000000000000..155c5d323ec60ba9fc2dfafd807145f8b26fb38b --- /dev/null +++ b/scripts/codex_package/ripgrep.py @@ -0,0 +1,32 @@ +"""Fetch ripgrep from the DotSlash manifest used by the package builder.""" + +from pathlib import Path + +from .dotslash import fetch_dotslash_executable +from .targets import TargetSpec, resolve_input_path + +RG_MANIFEST = Path(__file__).with_name("rg") + + +def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path: + if rg_bin is not None: + return resolve_input_path(rg_bin, "ripgrep executable", "--rg-bin") + + return fetch_rg(spec) + + +def fetch_rg( + spec: TargetSpec, + *, + manifest_path: Path = RG_MANIFEST, +) -> Path: + rg_bin = fetch_dotslash_executable( + spec, + manifest_path=manifest_path, + artifact_label="ripgrep", + cache_key=f"{spec.target}-rg", + dest_name=spec.rg_name, + ) + if rg_bin is None: + raise AssertionError("ripgrep is required for all package targets") + return rg_bin diff --git a/scripts/codex_package/smoke_tests/conftest.py b/scripts/codex_package/smoke_tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..d0d76f75f7e1acb1b85ed2a624512e998d1ca67f --- /dev/null +++ b/scripts/codex_package/smoke_tests/conftest.py @@ -0,0 +1,39 @@ +from pathlib import Path + +import pytest +from fixtures import code_mode_host_debug_symbols, package, responses_server + +__all__ = ["code_mode_host_debug_symbols", "package", "responses_server"] + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("Codex package smoke") + group.addoption( + "--compression", + choices=("gzip", "zstd"), + required=True, + help="Compression format of the supplied package archives.", + ) + group.addoption( + "--package-target", + required=True, + help="Rust target triple for the assembled release packages.", + ) + group.addoption( + "--cli-archive", + type=Path, + required=True, + help="Assembled Codex CLI package archive in the selected format.", + ) + group.addoption( + "--app-server-archive", + type=Path, + required=True, + help="Assembled app-server package archive in the selected format.", + ) + group.addoption( + "--symbols-archive", + type=Path, + required=True, + help="Gzip archive containing symbols for all packaged binaries.", + ) diff --git a/scripts/codex_package/smoke_tests/fixtures.py b/scripts/codex_package/smoke_tests/fixtures.py new file mode 100644 index 0000000000000000000000000000000000000000..7427b2b272ee72632828b58b85572ddebf92f41a --- /dev/null +++ b/scripts/codex_package/smoke_tests/fixtures.py @@ -0,0 +1,230 @@ +import gzip +import json +import os +import subprocess +import tarfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Self + +import pytest +import zstandard +from app_server_harness import MockResponsesServer + + +@dataclass(frozen=True) +class SmokePackage: + target: str + cli: Path + cli_root: Path + cli_path_dir: Path + app_server: Path + app_server_root: Path + app_server_path_dir: Path + directory: Path + environment: dict[str, str] + + @classmethod + def from_archives( + cls, + directory: Path, + target: str, + cli_archive: Path, + app_server_archive: Path, + compression: str, + ) -> Self: + config_dir = directory / "codex-config" + config_dir.mkdir() + environment = dict(os.environ) + + # Preserve host proxies while routing the fake localhost model directly. + inherited_bypass = ( + environment.get("NO_PROXY") or environment.get("no_proxy") or "" + ).split(",") + environment["NO_PROXY"] = ",".join( + dict.fromkeys( + host.strip() + for host in [*inherited_bypass, "127.0.0.1", "localhost"] + if host.strip() + ) + ) + + # Isolate package configuration and state from the user's Codex setup. + environment["CODEX_HOME"] = str(config_dir) + # Shell startup files can replace PATH and hide the packaged ripgrep. + environment.pop("BASH_ENV", None) + environment["ZDOTDIR"] = str(directory) + + extracted_packages = [] + for index, archive_path in enumerate((cli_archive, app_server_archive)): + extracted = directory / f"package-{index}" + extracted.mkdir() + decompress = zstandard.open if compression == "zstd" else gzip.open + with ( + decompress(archive_path, "rb") as source, + tarfile.open(fileobj=source, mode="r|") as archive, + ): + archive.extractall(extracted, filter="data") + manifest = json.loads((extracted / "codex-package.json").read_text()) + extracted_packages.append( + ( + extracted, + extracted / manifest["entrypoint"], + extracted / manifest["pathDir"], + ) + ) + + cli_root, cli, cli_path_dir = extracted_packages[0] + app_server_root, app_server, app_server_path_dir = extracted_packages[1] + + (config_dir / "config.toml").write_text( + """approval_policy = "never" +sandbox_mode = "workspace-write" +suppress_unstable_features_warning = true + +[sandbox_workspace_write] +network_access = true + +[features] +code_mode_only = true +code_mode_host = true +memories = false +apps = false +plugins = false + +[analytics] +enabled = false + +[otel] +exporter = "none" +trace_exporter = "none" +metrics_exporter = "none" +""" + ) + return cls( + target=target, + cli=cli, + cli_root=cli_root, + cli_path_dir=cli_path_dir, + app_server=app_server, + app_server_root=app_server_root, + app_server_path_dir=app_server_path_dir, + directory=directory, + environment=environment, + ) + + def run( + self, + *arguments: str, + stdin: str | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(self.cli), *arguments], + cwd=self.directory, + env=self.environment, + input=stdin, + check=True, + text=True, + capture_output=True, + timeout=45, + ) + + +@pytest.fixture(scope="session") +def package( + request: pytest.FixtureRequest, + tmp_path_factory: pytest.TempPathFactory, +) -> SmokePackage: + compression = request.config.getoption("compression") + target = request.config.getoption("package_target") + directory = tmp_path_factory.mktemp(f"codex-package-smoke-{compression}") + if "windows" in target: + # pytest's 0700 directories have protected ACLs, so restricted-token + # sandboxes need read/execute access to reach the packaged binaries. + for path in (directory.parent, directory): + subprocess.run( + ["icacls", str(path), "/grant", "*S-1-1-0:(OI)(CI)(RX)"], + check=True, + capture_output=True, + text=True, + ) + return SmokePackage.from_archives( + directory, + target, + request.config.getoption("cli_archive"), + request.config.getoption("app_server_archive"), + compression, + ) + + +@pytest.fixture +def responses_server(package: SmokePackage) -> Iterator[MockResponsesServer]: + config_path = Path(package.environment["CODEX_HOME"]) / "config.toml" + original_config = config_path.read_text() + with MockResponsesServer() as server: + # Direct CLI commands and SDK-launched servers must share a provider; + # CodexConfig only controls how the SDK launches its server process. + config_path.write_text( + f"""model = "package-smoke" +model_provider = "package_smoke" + +{original_config} + +[model_providers.package_smoke] +name = "package smoke" +base_url = "{server.url}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +""" + ) + try: + yield server + finally: + config_path.write_text(original_config) + + +@pytest.fixture(scope="session") +def code_mode_host_debug_symbols( + pytestconfig: pytest.Config, + tmp_path_factory: pytest.TempPathFactory, +) -> Path: + target = pytestconfig.getoption("package_target") + binaries = {"codex", "codex-app-server", "codex-code-mode-host"} + if "windows" in target: + binaries.update({"codex-command-runner", "codex-windows-sandbox-setup"}) + + if "apple-darwin" in target: + markers = { + binary: f"/{binary}.dSYM/Contents/Resources/DWARF/" for binary in binaries + } + else: + extension = "pdb" if "windows" in target else "debug" + markers = {binary: f"/{binary}.{extension}" for binary in binaries} + + destination = tmp_path_factory.mktemp("codex-debug-symbols") + found: set[str] = set() + symbol_path = None + with tarfile.open(pytestconfig.getoption("symbols_archive"), "r|gz") as archive: + for member in archive: + if not member.isfile(): + continue + for binary, marker in markers.items(): + if "apple-darwin" in target: + _, found_marker, dwarf_name = member.name.partition(marker) + matches = bool( + found_marker and dwarf_name and "/" not in dwarf_name + ) + else: + matches = member.name.endswith(marker) + if not matches: + continue + found.add(binary) + if binary == "codex-code-mode-host": + archive.extract(member, destination, filter="data") + symbol_path = destination / member.name + break + assert found == binaries, found + assert symbol_path is not None + return symbol_path diff --git a/scripts/codex_package/smoke_tests/pyproject.toml b/scripts/codex_package/smoke_tests/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..d1c5a0481381c16ad4d7c49fa591b3648aa02fb2 --- /dev/null +++ b/scripts/codex_package/smoke_tests/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "codex-package-smoke-tests" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.12,<3", + "pytest>=8.0", + "zstandard>=0.23", +] + +[tool.uv] +package = false diff --git a/scripts/codex_package/smoke_tests/test_codex_package.py b/scripts/codex_package/smoke_tests/test_codex_package.py new file mode 100644 index 0000000000000000000000000000000000000000..8ce57ea4c0476d329cadd950b406c8072275b4ca --- /dev/null +++ b/scripts/codex_package/smoke_tests/test_codex_package.py @@ -0,0 +1,240 @@ +"""Exercise each assembled package in the order its shipped files appear. + +ROOT_OF_EXTRACTED_PACKAGE +├── bin +│ ├── codex[.exe] # CLI package only +│ ├── codex-app-server[.exe] # app-server package only +│ └── codex-code-mode-host[.exe] +├── codex-package.json +├── codex-path +│ └── rg[.exe] +└── codex-resources + ├── bwrap # Linux only + ├── codex-command-runner.exe # Windows only + ├── codex-windows-sandbox-setup.exe # Windows only + └── zsh/bin/zsh # supported Unix targets only + +Debug symbols for all shipped binaries arrive in a separate companion archive. +Each package contains one entrypoint, not both codex and codex-app-server. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from app_server_harness import ( + MockResponsesServer, + ev_completed, + ev_response_created, + sse, +) +from openai_codex import ApprovalMode, Codex, CodexConfig, Sandbox + +from fixtures import SmokePackage + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + pytest.param(("--help",), "Usage:", id="help"), + pytest.param(("--version",), None, id="version"), + pytest.param(("features", "list"), "code_mode", id="features"), + pytest.param(("completion", "bash"), "codex", id="completion"), + ], +) +def test_cli_public_commands( + package: SmokePackage, + arguments: tuple[str, ...], + expected: str | None, +) -> None: + """Packaged CLI remains usable for discovery, version, features, and completions.""" + output = package.run(*arguments).stdout + assert expected in output if expected is not None else output.strip() + + +@pytest.mark.parametrize("entrypoint", ["codex", "codex-app-server"]) +def test_app_server_runs_code_mode_through_python_sdk( + package: SmokePackage, + responses_server: MockResponsesServer, + entrypoint: str, +) -> None: + """Both packages run their own ripgrep through sandboxed SDK code mode.""" + windows = "windows" in package.target + sandbox_config = ("--config", 'windows.sandbox="unelevated"') if windows else () + if entrypoint == "codex": + executable = package.cli + package_root = package.cli_root + package_path_dir = package.cli_path_dir + config = CodexConfig( + codex_bin=str(executable), + config_overrides=sandbox_config[1:], + cwd=str(package.directory), + env=package.environment, + ) + else: + executable = package.app_server + package_root = package.app_server_root + package_path_dir = package.app_server_path_dir + config = CodexConfig( + launch_args_override=(str(executable), *sandbox_config), + cwd=str(package.directory), + env=package.environment, + ) + if windows: + # TODO(anp): Assert resolved rg paths once Windows sandbox PATH finds them. + ripgrep = str(package_path_dir / "rg.exe").replace("'", "''") + command = ( + f"$rg = '{ripgrep}'; Write-Output $rg; & $rg --version; " + "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }" + ) + else: + command = "command -v rg && rg --version" + exec_arguments = {"cmd": command, "login": False, "yield_time_ms": 10_000} + responses_server.enqueue_sse( + sse( + [ + ev_response_created("code-mode"), + { + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "call_id": "package-smoke", + "name": "exec", + "input": ( + "text(JSON.stringify(await tools.exec_command(" + f"{json.dumps(exec_arguments)})))" + ), + }, + }, + ev_completed("code-mode"), + ] + ) + ) + responses_server.enqueue_assistant_message("Done", response_id="code-mode-done") + with Codex(config=config) as client: + turn = client.thread_start( + ephemeral=True, + approval_mode=ApprovalMode.deny_all, + sandbox=Sandbox.workspace_write, + ).run("run package smoke") + assert turn.final_response == "Done", turn + # The mock also records analytics POSTs, which have no Responses input list. + output = next( + item + for request in responses_server.requests() + if request.path == "/v1/responses" + for item in request.input() + if item.get("type") == "custom_tool_call_output" + and item.get("call_id") == "package-smoke" + )["output"] + if not isinstance(output, str): + output = next( + item["text"] for item in output if item.get("text", "").startswith("{") + ) + execution = json.loads(output) + execution_output = execution["output"] + assert execution["exit_code"] == 0, execution_output + assert "ripgrep" in execution_output, execution_output + + # Each entrypoint must execute the ripgrep bundled in its own package. + reported_ripgrep_path = Path(execution_output.splitlines()[0].strip()) + assert reported_ripgrep_path.is_relative_to(package_root), execution_output + + +def _code_mode_host_symbol_address( + package: SmokePackage, + symbols: Path, + *arguments: str, +) -> str: + symbol_output = subprocess.run( + ["nm", *arguments, str(symbols)], + cwd=package.directory, + env=package.environment, + capture_output=True, + text=True, + check=True, + timeout=45, + ).stdout + return next( + parts[0] + for line in symbol_output.splitlines() + if len(parts := line.split()) == 3 + and parts[1].lower() == "t" + and "codex_code_mode_host" in parts[2] + ) + + +@pytest.mark.skipif(sys.platform != "linux", reason="requires Linux debug symbols") +def test_linux_debug_symbols_resolve_packaged_code( + package: SmokePackage, + code_mode_host_debug_symbols: Path, +) -> None: + """Linux host symbols resolve a packaged function and its source location.""" + address = _code_mode_host_symbol_address( + package, code_mode_host_debug_symbols, "--defined-only", "--extern-only" + ) + resolved = subprocess.run( + [ + "addr2line", + "-e", + str(code_mode_host_debug_symbols), + "-f", + "-C", + f"0x{address}", + ], + cwd=package.directory, + env=package.environment, + capture_output=True, + text=True, + check=True, + timeout=45, + ).stdout + assert "codex_code_mode_host" in resolved, resolved + source = resolved.splitlines()[-1] + assert source != "??:?" and source.rsplit(":", 1)[-1].isdigit(), source + + +@pytest.mark.skipif(sys.platform != "darwin", reason="requires macOS debug symbols") +def test_macos_debug_symbols_resolve_packaged_code( + package: SmokePackage, + code_mode_host_debug_symbols: Path, +) -> None: + """macOS host symbols resolve a packaged function.""" + address = _code_mode_host_symbol_address(package, code_mode_host_debug_symbols) + resolved = subprocess.run( + ["atos", "-o", str(code_mode_host_debug_symbols), f"0x{address}"], + cwd=package.directory, + env=package.environment, + capture_output=True, + text=True, + check=True, + timeout=45, + ).stdout + assert "codex_code_mode_host" in resolved, resolved + + +@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows debug symbols") +def test_windows_debug_symbols_resolve_packaged_code( + package: SmokePackage, + code_mode_host_debug_symbols: Path, +) -> None: + """Windows host symbols match the packaged executable's debug signature.""" + # Rust embeds an underscored PDB name, but release archives normalize it. + symbols = code_mode_host_debug_symbols.rename( + code_mode_host_debug_symbols.with_name("codex_code_mode_host.pdb") + ) + host = symbols.with_suffix(".exe") + shutil.copy2(package.cli.with_name("codex-code-mode-host.exe"), host) + result = subprocess.run( + ["dumpbin", "/PDBPATH", str(host)], + cwd=package.directory, + env=package.environment, + capture_output=True, + text=True, + check=True, + timeout=45, + ) + assert str(symbols).casefold() in result.stdout.casefold(), result.stdout diff --git a/scripts/codex_package/smoke_tests/uv.lock b/scripts/codex_package/smoke_tests/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..525ea2a77bea8df2f2f23613b908a991d528274a --- /dev/null +++ b/scripts/codex_package/smoke_tests/uv.lock @@ -0,0 +1,258 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "codex-package-smoke-tests" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pytest" }, + { name = "zstandard" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.12,<3" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "zstandard", specifier = ">=0.23" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py new file mode 100644 index 0000000000000000000000000000000000000000..e217e8d0fd485cfddfc8f50ec9c0b3a90c8be985 --- /dev/null +++ b/scripts/codex_package/targets.py @@ -0,0 +1,170 @@ +"""Supported package targets and default binary discovery.""" + +import os +import platform +import stat +from dataclasses import dataclass +from pathlib import Path + +_repo_root = os.environ.get("CODEX_REPO_ROOT") +if _repo_root is None: + raise RuntimeError( + "CODEX_REPO_ROOT must point to the repository root; " + "run `just assemble-codex-package` to set it automatically" + ) +REPO_ROOT = Path(_repo_root) + + +@dataclass(frozen=True) +class TargetSpec: + target: str + is_windows: bool + is_linux: bool + dotslash_platform: str + + @property + def exe_suffix(self) -> str: + return ".exe" if self.is_windows else "" + + @property + def rg_name(self) -> str: + return f"rg{self.exe_suffix}" + + +@dataclass(frozen=True) +class PackageVariant: + name: str + cargo_bin: str + executable_stem: str + + def entrypoint_name(self, spec: TargetSpec) -> str: + return f"{self.executable_stem}{spec.exe_suffix}" + + +@dataclass(frozen=True) +class PackageInputs: + entrypoint_bin: Path + code_mode_host_bin: Path + rg_bin: Path + zsh_bin: Path | None + bwrap_bin: Path | None + codex_command_runner_bin: Path | None + codex_windows_sandbox_setup_bin: Path | None + + +PACKAGE_VARIANTS: dict[str, PackageVariant] = { + "codex": PackageVariant( + name="codex", + cargo_bin="codex", + executable_stem="codex", + ), + "codex-app-server": PackageVariant( + name="codex-app-server", + cargo_bin="codex-app-server", + executable_stem="codex-app-server", + ), +} + + +TARGET_SPECS: dict[str, TargetSpec] = { + "x86_64-unknown-linux-gnu": TargetSpec( + target="x86_64-unknown-linux-gnu", + is_windows=False, + is_linux=True, + dotslash_platform="linux-x86_64", + ), + "x86_64-unknown-linux-musl": TargetSpec( + target="x86_64-unknown-linux-musl", + is_windows=False, + is_linux=True, + dotslash_platform="linux-x86_64", + ), + "aarch64-unknown-linux-gnu": TargetSpec( + target="aarch64-unknown-linux-gnu", + is_windows=False, + is_linux=True, + dotslash_platform="linux-aarch64", + ), + "aarch64-unknown-linux-musl": TargetSpec( + target="aarch64-unknown-linux-musl", + is_windows=False, + is_linux=True, + dotslash_platform="linux-aarch64", + ), + "x86_64-apple-darwin": TargetSpec( + target="x86_64-apple-darwin", + is_windows=False, + is_linux=False, + dotslash_platform="macos-x86_64", + ), + "aarch64-apple-darwin": TargetSpec( + target="aarch64-apple-darwin", + is_windows=False, + is_linux=False, + dotslash_platform="macos-aarch64", + ), + "x86_64-pc-windows-msvc": TargetSpec( + target="x86_64-pc-windows-msvc", + is_windows=True, + is_linux=False, + dotslash_platform="windows-x86_64", + ), + "aarch64-pc-windows-msvc": TargetSpec( + target="aarch64-pc-windows-msvc", + is_windows=True, + is_linux=False, + dotslash_platform="windows-aarch64", + ), +} + + +HOST_RELEASE_TARGETS: dict[tuple[str, str], str] = { + ("darwin", "aarch64"): "aarch64-apple-darwin", + ("darwin", "x86_64"): "x86_64-apple-darwin", + ("linux", "aarch64"): "aarch64-unknown-linux-musl", + ("linux", "x86_64"): "x86_64-unknown-linux-musl", + ("windows", "aarch64"): "aarch64-pc-windows-msvc", + ("windows", "x86_64"): "x86_64-pc-windows-msvc", +} + + +def default_target() -> str: + system = platform.system().lower() + machine = normalize_machine(platform.machine()) + target = HOST_RELEASE_TARGETS.get((system, machine)) + if target is None: + supported = ", ".join(sorted(TARGET_SPECS)) + raise RuntimeError( + f"Unsupported host platform {platform.system()}/{platform.machine()}. " + f"Pass --target explicitly. Supported targets: {supported}" + ) + return target + + +def resolve_input_path( + explicit_path: Path | None, + description: str, + flag_name: str, +) -> Path: + if explicit_path is not None: + path = explicit_path.resolve() + if not path.is_file(): + raise RuntimeError(f"{description} does not exist: {path}") + if not is_executable(path): + raise RuntimeError(f"{description} is not executable: {path}") + return path + + raise RuntimeError(f"Must specify {flag_name} for {description}.") + + +def is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) + + +def normalize_machine(machine: str) -> str: + machine = machine.lower() + if machine in ("amd64", "x86_64"): + return "x86_64" + if machine in ("aarch64", "arm64"): + return "aarch64" + return machine diff --git a/scripts/codex_package/test_archive.py b/scripts/codex_package/test_archive.py new file mode 100644 index 0000000000000000000000000000000000000000..ae3902e215da73e1f1b0b8f89953eba464f641d4 --- /dev/null +++ b/scripts/codex_package/test_archive.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.archive import resolve_zstd_command + + +class ResolveZstdCommandTest(unittest.TestCase): + def test_prefers_zstd_from_path(self) -> None: + def which(name: str) -> str | None: + return {"zstd": "/usr/bin/zstd", "dotslash": "/usr/bin/dotslash"}.get(name) + + self.assertEqual(resolve_zstd_command(which=which), ["/usr/bin/zstd"]) + + def test_falls_back_to_dotslash_manifest(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + manifest = Path(temp_dir) / "zstd" + manifest.write_text("#!/usr/bin/env dotslash\n{}\n", encoding="utf-8") + + def which(name: str) -> str | None: + return {"dotslash": "/usr/bin/dotslash"}.get(name) + + self.assertEqual( + resolve_zstd_command(dotslash_manifest=manifest, which=which), + ["/usr/bin/dotslash", str(manifest)], + ) + + def test_errors_when_no_zstd_or_dotslash_manifest_is_available(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + missing_manifest = Path(temp_dir) / "zstd" + + with self.assertRaisesRegex(RuntimeError, "zstd is required"): + resolve_zstd_command( + dotslash_manifest=missing_manifest, + which=lambda _name: None, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/test_cargo.py b/scripts/codex_package/test_cargo.py new file mode 100644 index 0000000000000000000000000000000000000000..1a4a572b9b899cef472fb08b43e04b4a9c86e326 --- /dev/null +++ b/scripts/codex_package/test_cargo.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.cargo import build_source_binaries +from codex_package.cargo import source_binaries_for_target +from codex_package.targets import PACKAGE_VARIANTS +from codex_package.targets import TARGET_SPECS + + +class SourceBinariesForTargetTest(unittest.TestCase): + def test_macos_package_with_prebuilt_entrypoint_builds_nothing(self) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["aarch64-apple-darwin"], + PACKAGE_VARIANTS["codex"], + build_entrypoint=False, + build_code_mode_host=False, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + [], + ) + + def test_linux_package_with_prebuilt_entrypoint_and_bwrap_builds_nothing( + self, + ) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["x86_64-unknown-linux-musl"], + PACKAGE_VARIANTS["codex"], + build_entrypoint=False, + build_code_mode_host=False, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + [], + ) + + def test_windows_package_with_prebuilt_entrypoint_and_helpers_builds_nothing( + self, + ) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["x86_64-pc-windows-msvc"], + PACKAGE_VARIANTS["codex"], + build_entrypoint=False, + build_code_mode_host=False, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + [], + ) + + def test_missing_windows_helpers_are_built(self) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["x86_64-pc-windows-msvc"], + PACKAGE_VARIANTS["codex"], + build_entrypoint=False, + build_code_mode_host=False, + build_bwrap=False, + build_codex_command_runner=True, + build_codex_windows_sandbox_setup=True, + ), + ["codex-command-runner", "codex-windows-sandbox-setup"], + ) + + def test_missing_code_mode_host_is_built_for_app_server(self) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["aarch64-apple-darwin"], + PACKAGE_VARIANTS["codex-app-server"], + build_entrypoint=False, + build_code_mode_host=True, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + ["codex-code-mode-host"], + ) + + def test_build_uses_prebuilt_windows_helpers_without_running_cargo(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + entrypoint = touch_file(root / "codex.exe") + code_mode_host = touch_file(root / "codex-code-mode-host.exe") + command_runner = touch_file(root / "codex-command-runner.exe") + sandbox_setup = touch_file(root / "codex-windows-sandbox-setup.exe") + + outputs = build_source_binaries( + TARGET_SPECS["x86_64-pc-windows-msvc"], + PACKAGE_VARIANTS["codex"], + cargo=str(root / "cargo-that-should-not-run"), + profile="release", + entrypoint_bin=entrypoint, + code_mode_host_bin=code_mode_host, + bwrap_bin=None, + codex_command_runner_bin=command_runner, + codex_windows_sandbox_setup_bin=sandbox_setup, + ) + + self.assertEqual(outputs.entrypoint_bin, entrypoint) + self.assertEqual(outputs.code_mode_host_bin, code_mode_host) + self.assertEqual(outputs.codex_command_runner_bin, command_runner) + self.assertEqual(outputs.codex_windows_sandbox_setup_bin, sandbox_setup) + + +def touch_file(path: Path) -> Path: + path.write_text("", encoding="utf-8") + return path.resolve() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/test_cli.py b/scripts/codex_package/test_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..3b484673176a53154d73aa521bbea4a13d61e91b --- /dev/null +++ b/scripts/codex_package/test_cli.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import argparse +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.cli import parse_package_version + + +class PackageVersionTest(unittest.TestCase): + def test_accepts_release_prerelease_and_build_versions(self) -> None: + for version in ( + "0.0.0", + "1.2.3", + "0.0.0-internal.deadbeef", + "1.2.3-alpha.1+build.01", + "18446744073709551615.0.0", + ): + with self.subTest(version=version): + self.assertEqual(parse_package_version(version), version) + + def test_rejects_versions_the_runtime_cannot_parse(self) -> None: + for version in ( + "", + "1", + "1.2", + "1.2.3.4", + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.3-", + "1.2.3-alpha..1", + "1.2.3-01", + "1.2.3+", + "1.2.3+build..1", + "18446744073709551616.0.0", + ): + with self.subTest(version=version): + with self.assertRaises(argparse.ArgumentTypeError): + parse_package_version(version) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/test_layout.py b/scripts/codex_package/test_layout.py new file mode 100644 index 0000000000000000000000000000000000000000..b2df9601c1c2e2a7c151390511aac978325bb995 --- /dev/null +++ b/scripts/codex_package/test_layout.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import hashlib +import json +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from build_winget_package import prepare_winget_package +from codex_package.layout import build_package_dir +from codex_package.layout import validate_package_dir +from codex_package.targets import PACKAGE_VARIANTS +from codex_package.targets import PackageInputs +from codex_package.targets import TARGET_SPECS + + +class PackageLayoutTest(unittest.TestCase): + def test_winget_preserves_signed_files_and_voice_hashes(self) -> None: + for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"): + with self.subTest(target=target), tempfile.TemporaryDirectory() as temp: + package = Path(temp) + files = { + "bin/codex.exe": b"signed CLI", + "bin/codex-code-mode-host.exe": b"signed code mode host", + "codex-resources/codex-command-runner.exe": b"signed runner", + "codex-resources/codex-windows-sandbox-setup.exe": b"signed setup", + "codex-resources/voice/bin/codex-voice-host.exe": b"signed voice host", + "codex-resources/voice/bin/gstreamer-1.0-0.dll": b"signed audio DLL", + "codex-resources/voice/NOTICE.md": b"license notices", + "codex-path/rg.exe": b"ripgrep", + } + for name, contents in files.items(): + path = package / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + metadata = { + "layoutVersion": 1, + "target": target, + "entrypoint": "bin/codex.exe", + } + (package / "codex-package.json").write_text(json.dumps(metadata)) + manifest = { + "schemaVersion": 1, + "sha256": { + name: hashlib.sha256(contents).hexdigest() + for name, contents in files.items() + if name == "bin/codex.exe" + or name.startswith("codex-resources/voice/") + }, + } + manifest_path = package / "codex-resources/voice/manifest.json" + manifest_path.write_text(json.dumps(manifest)) + prepare_winget_package(package) + entrypoint = f"codex-{target}.exe" + files[entrypoint] = files.pop("bin/codex.exe") + files["codex-code-mode-host.exe"] = files.pop( + "bin/codex-code-mode-host.exe" + ) + for helper in ( + "codex-command-runner.exe", + "codex-windows-sandbox-setup.exe", + ): + files[helper] = files[f"codex-resources/{helper}"] + actual = { + str(path.relative_to(package)).replace("\\", "/"): path.read_bytes() + for path in package.rglob("*") + if path.is_file() + } + actual_metadata = json.loads(actual.pop("codex-package.json")) + actual_manifest = json.loads( + actual.pop("codex-resources/voice/manifest.json") + ) + self.assertEqual(actual, files) + metadata["entrypoint"] = entrypoint + self.assertEqual(actual_metadata, metadata) + manifest["sha256"][entrypoint] = manifest["sha256"].pop("bin/codex.exe") + self.assertEqual(actual_manifest, manifest) + for name, digest in actual_manifest["sha256"].items(): + self.assertEqual(hashlib.sha256(actual[name]).hexdigest(), digest) + + def test_macos_package_preserves_prebuilt_resource_binaries(self) -> None: + for variant_name in ("codex", "codex-app-server"): + for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"): + with self.subTest(variant=variant_name, target=target): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + package_dir = root / "package" + package_dir.mkdir() + rg_bin = touch_executable(root / "signed-rg") + zsh_bin = touch_executable(root / "signed-zsh") + rg_bin.write_bytes(b"signed ripgrep binary") + zsh_bin.write_bytes(b"signed zsh binary") + variant = PACKAGE_VARIANTS[variant_name] + spec = TARGET_SPECS[target] + inputs = PackageInputs( + entrypoint_bin=touch_executable( + root / variant.executable_stem + ), + code_mode_host_bin=touch_executable( + root / "codex-code-mode-host" + ), + rg_bin=rg_bin, + zsh_bin=zsh_bin, + bwrap_bin=None, + codex_command_runner_bin=None, + codex_windows_sandbox_setup_bin=None, + ) + + build_package_dir(package_dir, "1.2.3", variant, spec, inputs) + validate_package_dir( + package_dir, variant, spec, include_zsh=True + ) + + self.assertEqual( + { + "rg": (package_dir / "codex-path" / "rg").read_bytes(), + "zsh": ( + package_dir + / "codex-resources" + / "zsh" + / "bin" + / "zsh" + ).read_bytes(), + }, + { + "rg": b"signed ripgrep binary", + "zsh": b"signed zsh binary", + }, + ) + + def test_app_server_package_places_code_mode_host_beside_entrypoint(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + package_dir = root / "package" + package_dir.mkdir() + inputs = PackageInputs( + entrypoint_bin=touch_executable(root / "codex-app-server"), + code_mode_host_bin=touch_executable(root / "codex-code-mode-host"), + rg_bin=touch_executable(root / "rg"), + zsh_bin=None, + bwrap_bin=touch_executable(root / "bwrap"), + codex_command_runner_bin=None, + codex_windows_sandbox_setup_bin=None, + ) + + build_package_dir( + package_dir, + "1.2.3", + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + inputs, + ) + validate_package_dir( + package_dir, + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + include_zsh=False, + ) + + self.assertTrue((package_dir / "bin" / "codex-code-mode-host").is_file()) + + +def touch_executable(path: Path) -> Path: + path.touch(mode=0o755) + return path + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/test_v8.py b/scripts/codex_package/test_v8.py new file mode 100644 index 0000000000000000000000000000000000000000..a47f15a348b32d38b2c93724be48fb1ec2a25ea9 --- /dev/null +++ b/scripts/codex_package/test_v8.py @@ -0,0 +1,152 @@ +import hashlib +import sys +import tempfile +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package import v8 +from codex_package.targets import TARGET_SPECS, TargetSpec + + +class FetchCodexV8ArtifactsTest(unittest.TestCase): + version = "150.4.0" + + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + @contextmanager + def release( + self, + target: str, + *, + line_ending: bytes = b"\n", + trusted_digest: str | None = None, + trusted_name: str | None = None, + create_pins: bool = True, + ) -> Iterator[tuple[TargetSpec, MagicMock, str]]: + spec = TARGET_SPECS[target] + profile = v8.V8_ARTIFACT_PROFILE + archive_name = ( + f"rusty_v8_{profile}_{target}.lib.gz" + if spec.is_windows + else f"librusty_v8_{profile}_{target}.a.gz" + ) + binding_name = f"src_binding_{profile}_{target}.rs" + manifest_name = f"rusty_v8_{profile}_{target}.sha256" + archive = b"trusted V8 archive" + binding = b"trusted V8 binding" + manifest = ( + line_ending.join( + ( + f"{hashlib.sha256(archive).hexdigest()} {archive_name}".encode(), + f"{hashlib.sha256(binding).hexdigest()} {binding_name}".encode(), + ) + ) + + line_ending + ) + payloads = { + manifest_name: manifest, + archive_name: archive, + binding_name: binding, + } + + if create_pins: + pins = ( + self.root / "third_party/v8/rusty_v8_150_4_0_release_manifests.sha256" + ) + pins.parent.mkdir(parents=True) + digest = trusted_digest or hashlib.sha256(manifest).hexdigest() + name = trusted_name or manifest_name + pins.write_bytes(f"{digest} {name}".encode() + line_ending) + + def download(_url: str, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payloads[destination.name]) + + with ( + patch.object(v8, "REPO_ROOT", self.root), + patch.object(v8, "download_file", side_effect=download) as download_file, + ): + yield spec, download_file, manifest_name + + def test_fetches_artifacts_after_authenticating_manifest(self) -> None: + with self.release("x86_64-unknown-linux-gnu") as ( + spec, + download, + manifest_name, + ): + artifacts = v8.fetch_codex_v8_artifacts( + spec, version=self.version, cache_root=self.root / "cache" + ) + + self.assertEqual(artifacts.archive.read_bytes(), b"trusted V8 archive") + self.assertEqual(artifacts.binding.read_bytes(), b"trusted V8 binding") + self.assertEqual(download.call_args_list[0].args[1].name, manifest_name) + self.assertEqual(download.call_count, 3) + + def test_authenticates_windows_manifest_with_crlf(self) -> None: + with self.release("x86_64-pc-windows-msvc", line_ending=b"\r\n") as ( + spec, + download, + _manifest_name, + ): + artifacts = v8.fetch_codex_v8_artifacts( + spec, version=self.version, cache_root=self.root / "cache" + ) + + self.assertEqual(artifacts.archive.read_bytes(), b"trusted V8 archive") + self.assertEqual(artifacts.binding.read_bytes(), b"trusted V8 binding") + self.assertEqual(download.call_count, 3) + + def test_rejects_tampered_manifest_before_downloading_artifacts(self) -> None: + with self.release("x86_64-unknown-linux-gnu", trusted_digest="0" * 64) as ( + spec, + download, + manifest_name, + ): + with self.assertRaisesRegex( + RuntimeError, "does not match its trusted SHA-256" + ): + v8.fetch_codex_v8_artifacts( + spec, version=self.version, cache_root=self.root / "cache" + ) + + download.assert_called_once() + self.assertEqual(download.call_args.args[1].name, manifest_name) + + def test_rejects_missing_manifest_pin_before_downloading_artifacts(self) -> None: + with self.release( + "x86_64-unknown-linux-gnu", trusted_name="another-target.sha256" + ) as (spec, download, manifest_name): + with self.assertRaisesRegex(RuntimeError, "has no trusted SHA-256"): + v8.fetch_codex_v8_artifacts( + spec, version=self.version, cache_root=self.root / "cache" + ) + + download.assert_called_once() + self.assertEqual(download.call_args.args[1].name, manifest_name) + + def test_rejects_missing_pin_file_before_downloading_artifacts(self) -> None: + with self.release("x86_64-unknown-linux-gnu", create_pins=False) as ( + spec, + download, + manifest_name, + ): + with self.assertRaises(FileNotFoundError): + v8.fetch_codex_v8_artifacts( + spec, version=self.version, cache_root=self.root / "cache" + ) + + download.assert_called_once() + self.assertEqual(download.call_args.args[1].name, manifest_name) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/test_zsh.py b/scripts/codex_package/test_zsh.py new file mode 100644 index 0000000000000000000000000000000000000000..86d3c9fea80abaac3778a27d3322da8a2dbbed2a --- /dev/null +++ b/scripts/codex_package/test_zsh.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import hashlib +import json +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.targets import TARGET_SPECS +from codex_package.zsh import resolve_zsh_bin + + +class ResolveZshBinTest(unittest.TestCase): + def test_uses_prebuilt_executable_override(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + signed_zsh = Path(temp_dir) / "signed-zsh" + signed_zsh.write_bytes(b"signed zsh binary") + signed_zsh.chmod(0o755) + + with patch("codex_package.zsh.fetch_dotslash_executable") as fetch: + zsh_bin = resolve_zsh_bin( + TARGET_SPECS["aarch64-apple-darwin"], zsh_bin=signed_zsh + ) + + self.assertEqual(zsh_bin, signed_zsh.resolve()) + fetch.assert_not_called() + + def test_uses_manifest_override(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive = root / "codex-zsh.tar.gz" + source = root / "zsh" + source.write_bytes(b"standalone zsh") + with tarfile.open(archive, "w:gz") as tar: + tar.add(source, arcname="codex-zsh/bin/zsh") + + manifest = root / "codex-zsh" + manifest.write_text( + json.dumps( + { + "platforms": { + "linux-x86_64": { + "size": archive.stat().st_size, + "hash": "sha256", + "digest": hashlib.sha256( + archive.read_bytes() + ).hexdigest(), + "format": "tar.gz", + "path": "codex-zsh/bin/zsh", + "providers": [{"url": archive.as_uri()}], + } + } + } + ), + encoding="utf-8", + ) + + with patch( + "codex_package.dotslash.default_cache_root", + return_value=root / "cache", + ): + zsh_bin = resolve_zsh_bin( + TARGET_SPECS["x86_64-unknown-linux-musl"], manifest + ) + + self.assertIsNotNone(zsh_bin) + assert zsh_bin is not None + self.assertEqual(zsh_bin.read_bytes(), b"standalone zsh") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/codex_package/v8.py b/scripts/codex_package/v8.py new file mode 100644 index 0000000000000000000000000000000000000000..f2ac15dd09e857c281b44b2e5f922cdd9bade02d --- /dev/null +++ b/scripts/codex_package/v8.py @@ -0,0 +1,206 @@ +"""Codex-built V8 artifact overrides for package Cargo builds.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from urllib.request import urlopen + +from .targets import REPO_ROOT, TargetSpec + +DOWNLOAD_TIMEOUT_SECS = 120 +V8_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" + + +@dataclass(frozen=True) +class RustyV8ArtifactPair: + archive: Path + binding: Path + + +def resolve_codex_v8_cargo_env( + spec: TargetSpec, + *, + environ: Mapping[str, str] | None = None, + cache_root: Path | None = None, +) -> dict[str, str]: + environ = os.environ if environ is None else environ + if environ.get("V8_FROM_SOURCE") in {"true", "1", "yes"}: + return {} + + archive_override = environ.get("RUSTY_V8_ARCHIVE") + binding_override = environ.get("RUSTY_V8_SRC_BINDING_PATH") + if archive_override and binding_override: + return {} + if archive_override or binding_override: + raise RuntimeError( + "Cargo package builds need RUSTY_V8_ARCHIVE and RUSTY_V8_SRC_BINDING_PATH set together." + ) + + artifacts = fetch_codex_v8_artifacts(spec, cache_root=cache_root) + return { + "RUSTY_V8_ARCHIVE": str(artifacts.archive), + "RUSTY_V8_SRC_BINDING_PATH": str(artifacts.binding), + } + + +def fetch_codex_v8_artifacts( + spec: TargetSpec, + *, + version: str | None = None, + cache_root: Path | None = None, +) -> RustyV8ArtifactPair: + version = version or resolved_v8_crate_version() + release_url = ( + f"https://github.com/openai/codex/releases/download/rusty-v8-v{version}" + ) + target = spec.target + cache_dir = (cache_root or default_cache_root()) / f"rusty-v8-{version}-{target}" + + if spec.is_windows: + archive_name = f"rusty_v8_{V8_ARTIFACT_PROFILE}_{target}.lib.gz" + else: + archive_name = f"librusty_v8_{V8_ARTIFACT_PROFILE}_{target}.a.gz" + binding_name = f"src_binding_{V8_ARTIFACT_PROFILE}_{target}.rs" + checksums_name = f"rusty_v8_{V8_ARTIFACT_PROFILE}_{target}.sha256" + + archive = cache_dir / archive_name + binding = cache_dir / binding_name + checksums = cache_dir / checksums_name + + download_file(f"{release_url}/{checksums.name}", checksums) + verify_release_checksum_manifest(checksums, version=version) + expected_checksums = load_checksums(checksums, {archive.name, binding.name}) + for artifact in [archive, binding]: + ensure_valid_artifact( + artifact, + expected_checksums[artifact.name], + f"{release_url}/{artifact.name}", + ) + + return RustyV8ArtifactPair(archive=archive, binding=binding) + + +def resolved_v8_crate_version() -> str: + import tomllib + + cargo_lock = tomllib.loads((REPO_ROOT / "codex-rs" / "Cargo.lock").read_text()) + versions = sorted( + { + package["version"] + for package in cargo_lock["package"] + if package["name"] == "v8" + } + ) + if len(versions) != 1: + raise RuntimeError( + f"Expected exactly one resolved v8 version, found: {versions}" + ) + return versions[0] + + +def default_cache_root() -> Path: + return Path(tempfile.gettempdir()) / "codex-package" + + +def verify_release_checksum_manifest(checksums_path: Path, *, version: str) -> None: + version_suffix = version.replace(".", "_") + trusted_checksums = ( + REPO_ROOT + / "third_party" + / "v8" + / f"rusty_v8_{version_suffix}_release_manifests.sha256" + ) + + for line in trusted_checksums.read_text(encoding="utf-8").splitlines(): + digest, artifact_name = line.split(maxsplit=1) + if artifact_name != checksums_path.name: + continue + if has_checksum(checksums_path, digest): + return + + checksums_path.unlink(missing_ok=True) + raise RuntimeError( + f"V8 checksum manifest {checksums_path} does not match its trusted SHA-256." + ) + + raise RuntimeError( + f"V8 checksum manifest {checksums_path.name} has no trusted SHA-256 for {version}." + ) + + +def load_checksums(checksums_path: Path, artifact_names: set[str]) -> dict[str, str]: + checksums: dict[str, str] = {} + lines = checksums_path.read_text(encoding="utf-8").splitlines() + if len(lines) != len(artifact_names): + raise RuntimeError( + f"Expected {len(artifact_names)} V8 checksums in {checksums_path}, found {len(lines)}." + ) + + for line in lines: + parts = line.split(maxsplit=1) + if len(parts) != 2: + raise RuntimeError( + f"Invalid V8 checksum line in {checksums_path}: {line!r}" + ) + + digest, artifact_name = parts[0], parts[1].strip() + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + raise RuntimeError( + f"Invalid V8 checksum digest in {checksums_path}: {digest}" + ) + if artifact_name not in artifact_names: + raise RuntimeError( + f"Unexpected V8 checksum artifact in {checksums_path}: {artifact_name}" + ) + checksums[artifact_name] = digest + + if checksums.keys() != artifact_names: + raise RuntimeError( + f"V8 checksum manifest {checksums_path} does not cover {artifact_names}." + ) + return checksums + + +def ensure_valid_artifact(artifact: Path, checksum: str, url: str) -> None: + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + download_file(url, artifact) + if has_checksum(artifact, checksum): + return + + artifact.unlink(missing_ok=True) + raise RuntimeError( + f"Codex-built V8 artifact {artifact} failed checksum validation." + ) + + +def has_checksum(path: Path, expected: str) -> bool: + if not path.is_file(): + return False + + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() == expected + + +def download_file(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + temp_path = dest.with_suffix(f"{dest.suffix}.tmp") + temp_path.unlink(missing_ok=True) + try: + with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response: + with temp_path.open("wb") as output: + shutil.copyfileobj(response, output) + temp_path.replace(dest) + finally: + temp_path.unlink(missing_ok=True) diff --git a/scripts/codex_package/version.py b/scripts/codex_package/version.py new file mode 100644 index 0000000000000000000000000000000000000000..7aec8b9ccbfae3860934a46679cfb0b593a27140 --- /dev/null +++ b/scripts/codex_package/version.py @@ -0,0 +1,29 @@ +"""Version discovery for Codex packages.""" + +import re + +from .targets import REPO_ROOT + + +WORKSPACE_VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"') + + +def read_workspace_version() -> str: + cargo_toml = REPO_ROOT / "codex-rs" / "Cargo.toml" + in_workspace_package = False + with open(cargo_toml, encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if stripped == "[workspace.package]": + in_workspace_package = True + continue + + if in_workspace_package and stripped.startswith("["): + break + + if in_workspace_package: + match = WORKSPACE_VERSION_PATTERN.match(stripped) + if match is not None: + return match.group(1) + + raise RuntimeError(f"Could not find [workspace.package].version in {cargo_toml}") diff --git a/scripts/codex_package/zsh.py b/scripts/codex_package/zsh.py new file mode 100644 index 0000000000000000000000000000000000000000..e4ecc2de688d185e567cf228e11412f0945166b0 --- /dev/null +++ b/scripts/codex_package/zsh.py @@ -0,0 +1,28 @@ +"""Fetch the patched zsh fork used by shell_zsh_fork.""" + +from pathlib import Path + +from .dotslash import fetch_dotslash_executable +from .targets import TargetSpec, resolve_input_path + +ZSH_MANIFEST = Path(__file__).with_name("codex-zsh") +ZSH_RESOURCE_PATH = Path("zsh") / "bin" / "zsh" + + +def resolve_zsh_bin( + spec: TargetSpec, + manifest_path: Path | None = None, + *, + zsh_bin: Path | None = None, +) -> Path | None: + if zsh_bin is not None: + return resolve_input_path(zsh_bin, "zsh executable", "--zsh-bin") + + return fetch_dotslash_executable( + spec, + manifest_path=manifest_path or ZSH_MANIFEST, + artifact_label="codex-zsh", + cache_key=f"{spec.target}-zsh", + dest_name="zsh", + missing_ok=True, + ) diff --git a/scripts/debug-codex.sh b/scripts/debug-codex.sh new file mode 100644 index 0000000000000000000000000000000000000000..9583b8d1f4fa46337809a55ecef6396ced22ccd5 --- /dev/null +++ b/scripts/debug-codex.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Set "chatgpt.cliExecutable": "/Users//code/codex/scripts/debug-codex.sh" in VSCode settings to always get the +# latest codex-rs binary when debugging Codex Extension. + + +set -euo pipefail + +CODEX_RS_DIR=$(realpath "$(dirname "$0")/../codex-rs") +(cd "$CODEX_RS_DIR" && cargo run --quiet --bin codex -- "$@") \ No newline at end of file diff --git a/scripts/format.py b/scripts/format.py new file mode 100644 index 0000000000000000000000000000000000000000..9b408e1c7c8ac0b9f5d25697775dc9a846adeaa4 --- /dev/null +++ b/scripts/format.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Format repository sources or check that they are already formatted.""" + +import argparse +import os +import shlex +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class Command: + args: tuple[str, ...] + cwd: Path = REPO_ROOT + + +@dataclass(frozen=True) +class FormatterGroup: + name: str + commands: tuple[Command, ...] + + +@dataclass(frozen=True) +class FormatterResult: + name: str + output: str + returncode: int + + +def just_formatter_group(*, check: bool) -> FormatterGroup: + args = ["just", "--unstable", "--fmt"] + if check: + args.append("--check") + return FormatterGroup("Just", (Command(tuple(args)),)) + + +def rust_formatter_group(*, check: bool) -> FormatterGroup: + args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"] + if check: + args.append("--check") + command = Command(tuple(args), REPO_ROOT / "codex-rs") + return FormatterGroup("Rust", (command,)) + + +def buildifier_formatter_group(*, check: bool) -> FormatterGroup: + repository_files = subprocess.check_output( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], + cwd=REPO_ROOT, + ).split(b"\0") + buildifier_files: list[str] = [] + for encoded_path in repository_files: + if not encoded_path: + continue + path = Path(os.fsdecode(encoded_path)) + name = path.name + if ( + name in {"BUILD", "WORKSPACE", "MODULE.bazel"} + or name.startswith(("BUILD.", "WORKSPACE.")) + or name.endswith((".BUILD.bazel", ".MODULE.bazel", ".bzl", ".sky")) + or ".bzl." in name + or ".sky." in name + ): + buildifier_files.append(path.as_posix()) + buildifier_files.sort() + + # Invoke DotSlash explicitly because Windows does not honor shebangs. + buildifier_args = [ + "dotslash", + str(REPO_ROOT / "tools" / "buildifier"), + "-mode=check" if check else "-mode=fix", + "-lint=off", + *buildifier_files, + ] + return FormatterGroup("Bazel/Starlark", (Command(tuple(buildifier_args)),)) + + +def python_sdk_formatter_group(*, check: bool) -> FormatterGroup: + # Each `--project` retains its local dependency and Ruff configuration context. + uv_run_args = [ + "uv", + "run", + "--frozen", + "--project", + "sdk/python", + "--only-group", + "format", + ] + format_args = [ + *uv_run_args, + "ruff", + "format", + ] + if check: + format_args.append("--check") + # `ruff check --diff` reports lint-driven rewrites without changing files. + # It is the check-mode counterpart of `--fix --fix-only`, not a full lint gate. + lint_args = ["ruff", "check", "--diff"] + else: + # Ruff's lint fixer and formatter are separate passes: the first applies + # fixable lint rewrites, while the second formats source layout. + lint_args = ["ruff", "check", "--fix", "--fix-only"] + + return FormatterGroup( + "Python SDK", + ( + Command((*uv_run_args, *lint_args, "sdk/python")), + Command((*format_args, "sdk/python")), + ), + ) + + +def python_scripts_formatter_group(*, check: bool) -> FormatterGroup: + args = ["uv", "run", "--frozen", "--project", "scripts", "ruff", "format"] + if check: + args.append("--check") + args.append(".") + return FormatterGroup("Python scripts", (Command(tuple(args)),)) + + +def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]: + return ( + just_formatter_group(check=check), + rust_formatter_group(check=check), + buildifier_formatter_group(check=check), + python_sdk_formatter_group(check=check), + python_scripts_formatter_group(check=check), + ) + + +def run_formatter_group(group: FormatterGroup) -> FormatterResult: + """Run one formatter group sequentially and return its buffered output.""" + for command in group.commands: + try: + process = subprocess.run( + command.args, + cwd=command.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + except OSError as error: + output = f"$ {shlex.join(command.args)}\n{error}\n" + return FormatterResult(group.name, output, 1) + + if process.returncode != 0: + output = f"$ {shlex.join(command.args)}\n{process.stdout}" + if process.stdout and not process.stdout.endswith("\n"): + output += "\n" + return FormatterResult(group.name, output, process.returncode) + + return FormatterResult(group.name, "", 0) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--check", + action="store_true", + help="check formatting without modifying files", + ) + args = parser.parse_args() + groups = formatter_groups(check=args.check) + + failures: list[str] = [] + with ThreadPoolExecutor(max_workers=len(groups)) as executor: + futures = [executor.submit(run_formatter_group, group) for group in groups] + for future in as_completed(futures): + result = future.result() + if result.returncode != 0: + failures.append(result.name) + print(f"==> {result.name} formatter failed", file=sys.stderr) + print(result.output, end="", file=sys.stderr) + + if failures: + print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..cd9c0c778916362fc0e41b522bd804f6ce6e24ad --- /dev/null +++ b/scripts/install/install.ps1 @@ -0,0 +1,1194 @@ +[CmdletBinding()] +param( + [string]$Release = $env:CODEX_RELEASE +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +if ([string]::IsNullOrWhiteSpace($Release)) { + $Release = "latest" +} + +$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$" +$DefaultPreferReleasesOpenAICom = $true +$PreferReleasesOpenAICom = if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM)) { + $DefaultPreferReleasesOpenAICom +} else { + $env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM -match "^(?i:1|true|yes)$" +} +$ReleasesBaseUri = "https://releases.openai.com/codex" +$ReleasesMetadataTimeoutSec = 30 +$ReleasesAssetTimeoutSec = 300 + +function Write-Step { + param( + [string]$Message + ) + + Write-Host "==> $Message" +} + +function Write-WarningStep { + param( + [string]$Message + ) + + Write-Warning $Message +} + +function Prompt-YesNo { + param( + [string]$Prompt + ) + + if ($NonInteractive) { + return $false + } + + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { + return $false + } + + $choice = Read-Host "$Prompt [y/N]" + return $choice -match "^(?i:y(?:es)?)$" +} + +function Normalize-Version { + param( + [string]$RawVersion + ) + + if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") { + return "latest" + } + + if ($RawVersion.StartsWith("rust-v")) { + return $RawVersion.Substring(6) + } + + if ($RawVersion.StartsWith("v")) { + return $RawVersion.Substring(1) + } + + return $RawVersion +} + +function Assert-ValidReleaseVersion { + param( + [string]$Version + ) + + if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-alpha(?:\.[0-9]+){0,2}|-beta(?:\.[0-9]+)?)?$") { + throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]." + } +} + +function Find-ReleaseAssetMetadata { + param( + [string]$AssetName, + [object]$ReleaseMetadata, + [string]$Url = $null, + [string]$FallbackUrl = $null + ) + + $asset = $ReleaseMetadata.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 + if ($null -eq $asset) { + return $null + } + + $digestMatch = [regex]::Match([string]$asset.digest, "^sha256:([0-9a-fA-F]{64})$") + if (-not $digestMatch.Success) { + throw "Could not find SHA-256 digest for release asset $AssetName." + } + + return [PSCustomObject]@{ + Url = if ([string]::IsNullOrWhiteSpace($Url)) { $asset.browser_download_url } else { $Url } + FallbackUrl = $FallbackUrl + Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant() + } +} + +function Invoke-WebRequestWithFallback { + param( + [object]$Metadata, + [string]$OutFile, + [string]$ExpectedDigest, + [string]$AssetName, + [string]$ReleaseVersion, + [string]$RequiredManifestAsset + ) + + try { + if ($Metadata.Url.StartsWith("$ReleasesBaseUri/", [System.StringComparison]::OrdinalIgnoreCase)) { + Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile -TimeoutSec $ReleasesAssetTimeoutSec + } else { + Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile + } + Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest + if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { + $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset + } + } catch { + if ([string]::IsNullOrWhiteSpace($Metadata.FallbackUrl)) { + throw + } + Write-WarningStep "Could not download or verify $($Metadata.Url); retrying from GitHub Releases." + Invoke-WebRequest -UseBasicParsing -Uri $Metadata.FallbackUrl -OutFile $OutFile + try { + Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest + if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { + $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset + } + } catch { + $githubRelease = Resolve-ReleaseFromGitHub -NormalizedVersion $ReleaseVersion + $githubAssetMetadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ReleaseMetadata $githubRelease.Metadata + if ($null -eq $githubAssetMetadata) { + throw "Could not find GitHub release metadata for asset $AssetName." + } + Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $githubAssetMetadata.Sha256 + if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) { + $null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset + } + } + } +} + +function Resolve-ReleaseAssetSelection { + param( + [object]$ResolvedRelease, + [string]$Target, + [string]$NpmTag + ) + + $version = $ResolvedRelease.Version + $releaseMetadata = $ResolvedRelease.Metadata + $packageAsset = "codex-package-$Target.tar.gz" + $checksumAsset = "codex-package_SHA256SUMS" + $packageUrl = $null + $packageFallbackUrl = $null + $checksumUrl = $null + $checksumFallbackUrl = $null + if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") { + $packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset" + $packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset" + $checksumUrl = "$ReleasesBaseUri/releases/$version/$checksumAsset" + $checksumFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$checksumAsset" + } + + $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl + $checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ReleaseMetadata $releaseMetadata -Url $checksumUrl -FallbackUrl $checksumFallbackUrl + if ($null -ne $packageMetadata -and $null -ne $checksumMetadata) { + return [PSCustomObject]@{ + PackageAsset = $packageAsset + PackageMetadata = $packageMetadata + ChecksumMetadata = $checksumMetadata + InstallLayout = "Package" + } + } + + $packageAsset = "codex-npm-$NpmTag-$version.tgz" + $packageUrl = $null + $packageFallbackUrl = $null + if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") { + $packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset" + $packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset" + } + $packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl + if ($null -eq $packageMetadata) { + throw "Could not find Codex package or platform npm release assets for Codex $version." + } + + return [PSCustomObject]@{ + PackageAsset = $packageAsset + PackageMetadata = $packageMetadata + ChecksumMetadata = $null + InstallLayout = "LegacyPlatformNpm" + } +} + +function Test-ArchiveDigest { + param( + [string]$ArchivePath, + [string]$ExpectedDigest + ) + + $actualDigest = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualDigest -ne $ExpectedDigest) { + throw "Downloaded Codex archive checksum did not match expected digest. Expected $ExpectedDigest but got $actualDigest." + } +} + +function Get-PackageArchiveDigest { + param( + [string]$ManifestPath, + [string]$AssetName + ) + + $escapedAssetName = [regex]::Escape($AssetName) + foreach ($line in Get-Content -LiteralPath $ManifestPath) { + $match = [regex]::Match($line, "^\s*([0-9a-fA-F]{64})\s+$escapedAssetName\s*$") + if ($match.Success) { + return $match.Groups[1].Value.ToLowerInvariant() + } + } + + throw "Could not find SHA-256 digest for $AssetName in codex-package_SHA256SUMS." +} + +function Path-Contains { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $needle = $Entry.TrimEnd("\") + foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($segment.TrimEnd("\") -ieq $needle) { + return $true + } + } + + return $false +} + +function Prepend-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + $needle = $Entry.TrimEnd("\") + $segments = @($Entry) + if (-not [string]::IsNullOrWhiteSpace($PathValue)) { + $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) | + Where-Object { $_.TrimEnd("\") -ine $needle } + } + + return ($segments -join ";") +} + +function Invoke-WithInstallLock { + param( + [string]$LockPath, + [scriptblock]$Script + ) + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LockPath) | Out-Null + $lock = $null + while ($null -eq $lock) { + try { + $lock = [System.IO.File]::Open( + $LockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch [System.IO.IOException] { + Start-Sleep -Milliseconds 250 + } + } + try { + & $Script + } finally { + $lock.Dispose() + } +} + +function Remove-StaleInstallArtifacts { + param( + [string]$ReleasesDir + ) + + if (Test-Path -LiteralPath $ReleasesDir -PathType Container) { + Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Resolve-VersionFromReleaseMetadata { + param( + [object]$ReleaseMetadata + ) + + if (-not $ReleaseMetadata.tag_name) { + throw "Failed to resolve the latest Codex release version." + } + + $resolvedVersion = Normalize-Version -RawVersion $ReleaseMetadata.tag_name + Assert-ValidReleaseVersion -Version $resolvedVersion + return $resolvedVersion +} + +function Resolve-ReleaseFromGitHub { + param( + [string]$NormalizedVersion + ) + + if ($NormalizedVersion -eq "latest") { + $requestedRelease = "latest" + $metadataUri = "https://api.github.com/repos/openai/codex/releases/latest" + } else { + $resolvedVersion = $NormalizedVersion + $requestedRelease = $resolvedVersion + $metadataUri = "https://api.github.com/repos/openai/codex/releases/tags/rust-v$resolvedVersion" + } + + try { + $releaseMetadata = Invoke-RestMethod -Uri $metadataUri + } catch { + throw "Could not fetch GitHub release metadata for Codex $requestedRelease. GitHub API may be unavailable or rate limited. $($_.Exception.Message)" + } + + if ($NormalizedVersion -eq "latest") { + $resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata + } + + return [PSCustomObject]@{ + Version = $resolvedVersion + Metadata = $releaseMetadata + Source = "GitHub" + } +} + +function Resolve-ReleaseFromReleases { + param( + [string]$NormalizedVersion + ) + + $metadataUri = if ($NormalizedVersion -eq "latest") { + "$ReleasesBaseUri/channels/latest" + } else { + "$ReleasesBaseUri/releases/$NormalizedVersion/release.json" + } + try { + $metadataResponse = Invoke-WebRequest -UseBasicParsing -Uri $metadataUri -TimeoutSec $ReleasesMetadataTimeoutSec + $releaseMetadata = [string]$metadataResponse.Content | ConvertFrom-Json -ErrorAction Stop + $resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata + if ($NormalizedVersion -ne "latest" -and $resolvedVersion -cne $NormalizedVersion) { + throw "Release metadata version did not match requested Codex version $NormalizedVersion." + } + $resolvedRelease = [PSCustomObject]@{ + Version = $resolvedVersion + Metadata = $releaseMetadata + Source = "ReleasesOpenAICom" + } + $null = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag + } catch { + return $null + } + return $resolvedRelease +} + +function Resolve-Release { + $normalizedVersion = Normalize-Version -RawVersion $Release + Assert-ValidReleaseVersion -Version $normalizedVersion + + if ($PreferReleasesOpenAICom) { + $release = Resolve-ReleaseFromReleases -NormalizedVersion $normalizedVersion + if ($null -ne $release) { + return $release + } + Write-WarningStep "releases.openai.com is unavailable; falling back to GitHub Releases." + } + + return Resolve-ReleaseFromGitHub -NormalizedVersion $normalizedVersion +} + +function Get-VersionFromBinary { + param( + [string]$CodexPath + ) + + if (-not (Test-Path -LiteralPath $CodexPath -PathType Leaf)) { + return $null + } + + try { + $versionOutput = & $CodexPath --version 2>$null + } catch { + return $null + } + + if ($versionOutput -match '([0-9][0-9A-Za-z.+-]*)$') { + return $matches[1] + } + + return $null +} + +function Get-CurrentInstalledVersion { + param( + [string]$StandaloneCurrentDir + ) + + $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "bin\codex.exe") + if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { + return $standaloneVersion + } + + $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "codex.exe") + if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { + return $standaloneVersion + } + + return $null +} + +function Test-OldStandaloneBinLayout { + param( + [string]$VisibleBinDir, + [string]$DefaultVisibleBinDir + ) + + if (-not $VisibleBinDir.Equals($DefaultVisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { + return $false + } + if (-not (Test-Path -LiteralPath $VisibleBinDir -PathType Container)) { + return $false + } + + $item = Get-Item -LiteralPath $VisibleBinDir -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $false + } + + $requiredFiles = @("codex.exe", "rg.exe") + foreach ($fileName in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $VisibleBinDir $fileName) -PathType Leaf)) { + return $false + } + } + + $knownFiles = @( + "codex.exe", + "rg.exe", + "codex-command-runner.exe", + "codex-windows-sandbox.exe", + "codex-windows-sandbox-setup.exe" + ) + foreach ($child in Get-ChildItem -LiteralPath $VisibleBinDir -Force) { + if ($child.PSIsContainer) { + return $false + } + if ($knownFiles -notcontains $child.Name) { + return $false + } + } + + return $true +} + +function Move-OldStandaloneBinIfApproved { + param( + [string]$VisibleBinDir, + [string]$DefaultVisibleBinDir + ) + + if (-not (Test-OldStandaloneBinLayout -VisibleBinDir $VisibleBinDir -DefaultVisibleBinDir $DefaultVisibleBinDir)) { + return $null + } + + Write-Step "We found an older Codex install at $VisibleBinDir" + Write-WarningStep "To continue, Codex needs to update the install at this path." + if (-not (Prompt-YesNo "Replace it with the current Codex setup now?")) { + throw "Cannot replace older standalone install without confirmation: $VisibleBinDir" + } + + $backupDir = "$VisibleBinDir.backup.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()).$PID" + Write-Step "Moving older standalone install to $backupDir" + Move-Item -LiteralPath $VisibleBinDir -Destination $backupDir + return $backupDir +} + +function Add-JunctionSupportType { + # Older installer types remain loaded when users rerun irm | iex in one session. + if (([System.Management.Automation.PSTypeName]'CodexInstaller.JunctionV2').Type) { + return + } + + Add-Type -TypeDefinition @" +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace CodexInstaller +{ + public static class JunctionV2 + { + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4; + private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; + private const int HeaderLength = 20; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + byte[] lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle file, StringBuilder path, uint length, uint flags); + + public static string ResolveDirectory(string path) + { + using (SafeFileHandle handle = CreateFileW( + path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero)) + { + if (handle.IsInvalid) { throw new Win32Exception(Marshal.GetLastWin32Error()); } + StringBuilder resolved = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, resolved, (uint)resolved.Capacity, 0); + if (length == 0) { throw new Win32Exception(Marshal.GetLastWin32Error()); } + if (length >= resolved.Capacity) { throw new IOException("Resolved path is too long."); } + return resolved.ToString().TrimEnd('\\'); + } + } + + public static void SetTarget(string linkPath, string targetPath) + { + string substituteName = "\\??\\" + Path.GetFullPath(targetPath); + byte[] substituteNameBytes = Encoding.Unicode.GetBytes(substituteName); + if (substituteNameBytes.Length > ushort.MaxValue - HeaderLength) { + throw new ArgumentException("Junction target path is too long.", "targetPath"); + } + + byte[] reparseBuffer = new byte[substituteNameBytes.Length + HeaderLength]; + WriteUInt32(reparseBuffer, 0, IO_REPARSE_TAG_MOUNT_POINT); + WriteUInt16(reparseBuffer, 4, checked((ushort)(substituteNameBytes.Length + 12))); + WriteUInt16(reparseBuffer, 8, 0); + WriteUInt16(reparseBuffer, 10, checked((ushort)substituteNameBytes.Length)); + WriteUInt16(reparseBuffer, 12, checked((ushort)(substituteNameBytes.Length + 2))); + WriteUInt16(reparseBuffer, 14, 0); + Buffer.BlockCopy(substituteNameBytes, 0, reparseBuffer, 16, substituteNameBytes.Length); + + using (SafeFileHandle handle = CreateFileW( + linkPath, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + IntPtr.Zero)) + { + if (handle.IsInvalid) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + int bytesReturned; + if (!DeviceIoControl( + handle, + FSCTL_SET_REPARSE_POINT, + reparseBuffer, + reparseBuffer.Length, + IntPtr.Zero, + 0, + out bytesReturned, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + } + + private static void WriteUInt16(byte[] buffer, int offset, ushort value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + } + + private static void WriteUInt32(byte[] buffer, int offset, uint value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + buffer[offset + 2] = (byte)(value >> 16); + buffer[offset + 3] = (byte)(value >> 24); + } + } +} +"@ +} + +function Set-JunctionTarget { + param( + [string]$LinkPath, + [string]$TargetPath + ) + + Add-JunctionSupportType + [CodexInstaller.JunctionV2]::SetTarget($LinkPath, $TargetPath) +} + +function Test-IsJunction { + param( + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + + $item = Get-Item -LiteralPath $Path -Force + return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction" +} + +function Ensure-Junction { + param( + [string]$LinkPath, + [string]$TargetPath, + [string]$InstallerOwnedTargetPrefix + ) + + if (-not (Test-Path -LiteralPath $LinkPath)) { + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null + return + } + + $item = Get-Item -LiteralPath $LinkPath -Force + if (Test-IsJunction -Path $LinkPath) { + Add-JunctionSupportType + $existingTarget = [CodexInstaller.JunctionV2]::ResolveDirectory($LinkPath) + if (-not [string]::IsNullOrWhiteSpace($InstallerOwnedTargetPrefix)) { + $ownedTargetPrefix = [CodexInstaller.JunctionV2]::ResolveDirectory($InstallerOwnedTargetPrefix) + "\" + if (-not $existingTarget.StartsWith($ownedTargetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer." + } + } + if ($existingTarget.Equals([CodexInstaller.JunctionV2]::ResolveDirectory($TargetPath), [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + + # Keep the path itself in place and only retarget the junction. That + # avoids a gap where current or the visible bin path disappears during + # an update. + Set-JunctionTarget -LinkPath $LinkPath -TargetPath $TargetPath + return + } + + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to replace non-junction reparse point at $LinkPath." + } + + if ($item.PSIsContainer) { + if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { + throw "Refusing to replace non-empty directory at $LinkPath with a junction." + } + + Remove-Item -LiteralPath $LinkPath -Force + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null + return + } + + throw "Refusing to replace file at $LinkPath with a junction." +} + +function Test-PackageContentsAreComplete { + param( + [string]$PackageDir + ) + + if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { + return $false + } + + $expectedFiles = @( + "codex-package.json", + "bin\codex.exe", + "bin\codex-code-mode-host.exe", + "codex-path\rg.exe", + "codex-resources\codex-command-runner.exe", + "codex-resources\codex-windows-sandbox-setup.exe" + ) + foreach ($name in $expectedFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { + return $false + } + } + + return $true +} + +function Test-LegacyPlatformNpmContentsAreComplete { + param( + [string]$PackageDir + ) + + if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) { + return $false + } + + $expectedFiles = @( + "codex.exe", + "codex-resources\codex-command-runner.exe", + "codex-resources\codex-windows-sandbox-setup.exe", + "codex-resources\rg.exe" + ) + foreach ($name in $expectedFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) { + return $false + } + } + + return $true +} + +function Test-ReleaseIsComplete { + param( + [string]$ReleaseDir, + [string]$ExpectedVersion, + [string]$ExpectedTarget, + [string]$Layout + ) + + switch ($Layout) { + "Package" { + if (-not (Test-PackageContentsAreComplete -PackageDir $ReleaseDir)) { + return $false + } + $codexPath = Join-Path $ReleaseDir "bin\codex.exe" + } + "LegacyPlatformNpm" { + if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $ReleaseDir)) { + return $false + } + $codexPath = Join-Path $ReleaseDir "codex.exe" + } + default { + throw "Unknown Codex installer layout: $Layout" + } + } + + return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget" -and + (Get-VersionFromBinary -CodexPath $codexPath) -ceq $ExpectedVersion +} + +function Get-ExistingCodexCommand { + $existing = Get-Command codex -ErrorAction SilentlyContinue + if ($null -eq $existing) { + return $null + } + + return $existing.Source +} + +function Get-ExistingCodexManager { + param( + [string]$ExistingPath, + [string]$VisibleBinDir + ) + + if ([string]::IsNullOrWhiteSpace($ExistingPath)) { + return $null + } + + if ($ExistingPath.StartsWith($VisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { + return $null + } + + if ($ExistingPath -match "\\.bun\\") { + return "bun" + } + + if ($ExistingPath -match "node_modules" -or $ExistingPath -match "\\npm\\") { + return "npm" + } + + return $null +} + +function Get-ConflictingInstall { + param( + [string]$VisibleBinDir + ) + + $existingPath = Get-ExistingCodexCommand + $manager = Get-ExistingCodexManager -ExistingPath $existingPath -VisibleBinDir $VisibleBinDir + if ($null -eq $manager) { + return $null + } + + Write-Step "Detected existing $manager-managed Codex at $existingPath" + Write-WarningStep "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." + + return [PSCustomObject]@{ + Manager = $manager + Path = $existingPath + } +} + +function Maybe-HandleConflictingInstall { + param( + [object]$Conflict + ) + + if ($null -eq $Conflict) { + return + } + + $manager = $Conflict.Manager + + $uninstallArgs = if ($manager -eq "bun") { + @("remove", "-g", "@openai/codex") + } else { + @("uninstall", "-g", "@openai/codex") + } + $uninstallCommand = if ($manager -eq "bun") { "bun" } else { "npm" } + + if (Prompt-YesNo "Uninstall the existing $manager-managed Codex now?") { + Write-Step "Running: $uninstallCommand $($uninstallArgs -join ' ')" + try { + & $uninstallCommand @uninstallArgs + } catch { + Write-WarningStep "Failed to uninstall the existing $manager-managed Codex. Continuing with the standalone install." + } + } else { + Write-WarningStep "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." + } +} + +function Test-VisibleCodexCommand { + param( + [string]$VisibleBinDir + ) + + $codexCommand = Join-Path $VisibleBinDir "codex.exe" + & $codexCommand --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Installed Codex command failed verification: $codexCommand --version" + } +} + +if ($env:OS -ne "Windows_NT") { + Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." + exit 1 +} + +if (-not [Environment]::Is64BitOperatingSystem) { + Write-Error "Codex requires a 64-bit version of Windows." + exit 1 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +$target = $null +$platformLabel = $null +$npmTag = $null +switch ($architecture) { + "Arm64" { + $target = "aarch64-pc-windows-msvc" + $platformLabel = "Windows (ARM64)" + $npmTag = "win32-arm64" + } + "X64" { + $target = "x86_64-pc-windows-msvc" + $platformLabel = "Windows (x64)" + $npmTag = "win32-x64" + } + default { + Write-Error "Unsupported architecture: $architecture" + exit 1 + } +} + +$codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + Join-Path $env:USERPROFILE ".codex" +} else { + $env:CODEX_HOME +} +$daemonOnly = $env:CODEX_INSTALL_DAEMON_ONLY -eq "1" +$standaloneRoot = Join-Path $codexHome $(if ($daemonOnly) { "packages\app-server-daemon" } else { "packages\standalone" }) +$releasesDir = Join-Path $standaloneRoot "releases" +$currentDir = Join-Path $standaloneRoot "current" +$deferSelection = $env:CODEX_INSTALL_DEFER_SELECTION -eq "1" +if ($deferSelection) { + if (-not $daemonOnly) { throw "Deferred selection requires a daemon-only installation." } + $currentDir = Join-Path $standaloneRoot ".migration-current" +} +$autoUpdateVersion = Join-Path $standaloneRoot "auto-update-version" +$lockPath = Join-Path $standaloneRoot "install.lock" + +$defaultVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { + $visibleBinDir = $defaultVisibleBinDir +} else { + $visibleBinDir = $env:CODEX_INSTALL_DIR +} + +$currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir +$resolvedRelease = Resolve-Release +$resolvedVersion = $resolvedRelease.Version +$releaseMetadata = $resolvedRelease.Metadata +$releaseName = "$resolvedVersion-$target" +$releaseDir = Join-Path $releasesDir $releaseName + +if (-not [string]::IsNullOrWhiteSpace($currentVersion) -and $currentVersion -ne $resolvedVersion) { + Write-Step "Updating Codex CLI from $currentVersion to $resolvedVersion" +} elseif (-not [string]::IsNullOrWhiteSpace($currentVersion)) { + Write-Step "Updating Codex CLI" +} else { + Write-Step "Installing Codex CLI" +} +Write-Step "Detected platform: $platformLabel" +Write-Step "Resolved version: $resolvedVersion" + +$conflictingInstall = if ($daemonOnly) { $null } else { Get-ConflictingInstall -VisibleBinDir $visibleBinDir } +$oldStandaloneBackup = $null + +$checksumAsset = "codex-package_SHA256SUMS" +$assetSelection = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag +$packageAsset = $assetSelection.PackageAsset +$packageMetadata = $assetSelection.PackageMetadata +$checksumMetadata = $assetSelection.ChecksumMetadata +$installLayout = $assetSelection.InstallLayout +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null +$guardRejected = $false + +try { + Invoke-WithInstallLock -LockPath $lockPath -Script { + $updaterRecord = Join-Path $codexHome "app-server-daemon\app-server-updater.pid" + if ($daemonOnly) { $updaterRecord = Join-Path $codexHome "app-server-daemon\daemon-updater.pid" } + $oldUpdaterParent = $false + if ($Release -eq "latest" -and $env:CODEX_INSTALL_IF_LATEST -ne "1" -and $env:CODEX_INSTALL_IF_CURRENT -ne "1" -and (Test-Path -LiteralPath $updaterRecord)) { + $updaterPid = $null + $updaterStartTime = $null + try { + $record = Get-Content -LiteralPath $updaterRecord -Raw | ConvertFrom-Json + $updaterPid = [long]$record.pid + $updaterStartTime = [string]$record.processStartTime + } catch { + # Empty or stale PID reservations must not block a manual install. + } + if ($null -ne $updaterPid -and $updaterPid -gt 0 -and -not [string]::IsNullOrEmpty($updaterStartTime)) { + $updaterProcess = Get-Process -Id $updaterPid -ErrorAction SilentlyContinue + if ($null -ne $updaterProcess -and $updaterStartTime -eq [string]$updaterProcess.StartTime.ToFileTimeUtc()) { + try { + $parentPid = (Get-CimInstance Win32_Process -Filter "ProcessId = $PID" -ErrorAction Stop).ParentProcessId + if ([long]$parentPid -le 0) { throw "Missing updater parent process." } + } catch { + try { + $parentPid = (Get-WmiObject Win32_Process -Filter "ProcessId = $PID" -ErrorAction Stop).ParentProcessId + if ([long]$parentPid -le 0) { throw "Missing updater parent process." } + } catch { + throw "Cannot verify whether the standalone installer was launched by an older updater." + } + } + $oldUpdaterParent = $updaterPid -eq $parentPid + } + } + } + if ($env:CODEX_INSTALL_IF_LATEST -eq "1" -or $env:CODEX_INSTALL_IF_CURRENT -eq "1" -or $oldUpdaterParent) { + $previousRelease = if ($oldUpdaterParent -and (Test-Path -LiteralPath $autoUpdateVersion)) { + [System.IO.File]::ReadAllText($autoUpdateVersion) + } else { + $env:CODEX_UPDATE_FROM_RELEASE + } + Add-JunctionSupportType + $currentTarget = if (Test-Path -LiteralPath $currentDir) { (Get-Item -LiteralPath $currentDir).Target } else { $null } + if ($Release -ne "latest" -or [string]::IsNullOrEmpty($previousRelease) -or [string]::IsNullOrEmpty($currentTarget) -or + -not (Test-Path -LiteralPath (Join-Path $releasesDir $previousRelease)) -or + [CodexInstaller.JunctionV2]::ResolveDirectory($currentDir) -ne [CodexInstaller.JunctionV2]::ResolveDirectory((Join-Path $releasesDir $previousRelease))) { + if ($env:CODEX_INSTALL_IF_CURRENT -eq "1") { throw "Daemon selection changed; retry the update." } + $script:guardRejected = $true + return + } + # Explicit daemon updates may leave a local or pinned release. + if ($env:CODEX_INSTALL_IF_CURRENT -ne "1" -and + (-not (Test-Path -LiteralPath $autoUpdateVersion) -or + [System.IO.File]::ReadAllText($autoUpdateVersion) -cne $previousRelease)) { + $script:guardRejected = $true + return + } + } + Remove-StaleInstallArtifacts -ReleasesDir $releasesDir + + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) { + if (Test-Path -LiteralPath $releaseDir) { + if ($daemonOnly) { throw "Refusing to overwrite existing daemon release $releaseDir." } + Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." + } + + $archivePath = Join-Path $tempDir $packageAsset + $checksumPath = Join-Path $tempDir $checksumAsset + $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" + + Write-Step "Downloading Codex CLI" + if ($installLayout -eq "Package") { + Invoke-WebRequestWithFallback -Metadata $checksumMetadata -OutFile $checksumPath -ExpectedDigest $checksumMetadata.Sha256 -AssetName $checksumAsset -ReleaseVersion $resolvedVersion -RequiredManifestAsset $packageAsset + $expectedPackageDigest = Get-PackageArchiveDigest -ManifestPath $checksumPath -AssetName $packageAsset + } else { + $expectedPackageDigest = $packageMetadata.Sha256 + } + Invoke-WebRequestWithFallback -Metadata $packageMetadata -OutFile $archivePath -ExpectedDigest $expectedPackageDigest -AssetName $packageAsset -ReleaseVersion $resolvedVersion + + New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null + if (Test-Path -LiteralPath $stagingDir) { + Remove-Item -LiteralPath $stagingDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + if ($installLayout -eq "Package") { + tar -xzf $archivePath -C $stagingDir + if (-not (Test-PackageContentsAreComplete -PackageDir $stagingDir)) { + throw "Downloaded Codex package archive did not contain the expected package layout." + } + } else { + $extractDir = Join-Path $tempDir "extract" + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + tar -xzf $archivePath -C $extractDir + + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + $resourcesDir = Join-Path $stagingDir "codex-resources" + New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" + "path/rg.exe" = "codex-resources\rg.exe" + } + + foreach ($relativeSource in $copyMap.Keys) { + Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) + } + + if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $stagingDir)) { + throw "Downloaded Codex npm archive did not contain the expected legacy platform package layout." + } + } + + if (Test-Path -LiteralPath $releaseDir) { + Remove-Item -LiteralPath $releaseDir -Recurse -Force + } + Move-Item -LiteralPath $stagingDir -Destination $releaseDir + } + + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) { + throw "Installed Codex command did not report expected version $resolvedVersion." + } + + New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null + if ($deferSelection -and (Get-Item -LiteralPath (Join-Path $standaloneRoot "current") -Force -ErrorAction SilentlyContinue)) { + throw "A dedicated daemon is already selected; retry the update." + } + if ($daemonOnly -and -not $deferSelection) { + $installedCodex = Join-Path $releaseDir $(if ($installLayout -eq "Package") { "bin\codex.exe" } else { "codex.exe" }) + & $installedCodex app-server daemon pid-update-loop --check-package-ownership | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "The production release does not support daemon-owned packages; the current selection was left unchanged." + } + } + Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir + if ($Release -eq "latest") { + $tempMarker = "$autoUpdateVersion.tmp.$PID" + [System.IO.File]::WriteAllText($tempMarker, $releaseName) + Move-Item -LiteralPath $tempMarker -Destination $autoUpdateVersion -Force + } else { + if (Test-Path -LiteralPath $autoUpdateVersion) { + Remove-Item -LiteralPath $autoUpdateVersion -Force -ErrorAction Stop + } + } + + if ($daemonOnly) { return } + $visibleParent = Split-Path -Parent $visibleBinDir + $currentBinDir = if ($installLayout -eq "Package") { + Join-Path $currentDir "bin" + } else { + $currentDir + } + New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null + $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir + try { + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentBinDir -InstallerOwnedTargetPrefix $standaloneRoot + Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir + } catch { + if ($null -ne $oldStandaloneBackup -and (Test-Path -LiteralPath $oldStandaloneBackup)) { + if (Test-Path -LiteralPath $visibleBinDir) { + Remove-Item -LiteralPath $visibleBinDir -Recurse -Force + } + Move-Item -LiteralPath $oldStandaloneBackup -Destination $visibleBinDir + } + throw + } + if ($null -ne $oldStandaloneBackup) { + Remove-Item -LiteralPath $oldStandaloneBackup -Recurse -Force + } + } +} finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue +} +if ($guardRejected -or $daemonOnly) { return } + +Maybe-HandleConflictingInstall -Conflict $conflictingInstall + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +$prioritizeVisibleBin = $null -ne $conflictingInstall +if ($prioritizeVisibleBin) { + $newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir + if ($newUserPath -cne $userPath) { + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Step "PATH updated for future PowerShell sessions." + } else { + Write-Step "$visibleBinDir is already first on PATH." + } +} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { + if ([string]::IsNullOrWhiteSpace($userPath)) { + $newUserPath = $visibleBinDir + } else { + $newUserPath = "$visibleBinDir;$userPath" + } + + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Step "PATH updated for future PowerShell sessions." +} elseif (Path-Contains -PathValue $env:Path -Entry $visibleBinDir) { + Write-Step "$visibleBinDir is already on PATH." +} else { + Write-Step "PATH is already configured for future PowerShell sessions." +} + +if ($prioritizeVisibleBin) { + $env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir +} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { + if ([string]::IsNullOrWhiteSpace($env:Path)) { + $env:Path = $visibleBinDir + } else { + $env:Path = "$visibleBinDir;$env:Path" + } +} + +Write-Step "Current PowerShell session: codex" +Write-Step "Future PowerShell windows: open a new PowerShell window and run: codex" +Write-Host "Codex CLI $resolvedVersion installed successfully." + +$codexCommand = Join-Path $visibleBinDir "codex.exe" +if (Prompt-YesNo "Start Codex now?") { + Write-Step "Launching Codex" + & $codexCommand +} diff --git a/scripts/install/install.sh b/scripts/install/install.sh new file mode 100644 index 0000000000000000000000000000000000000000..58815c797ecb02eb738f3bc77e0279e3328572a4 --- /dev/null +++ b/scripts/install/install.sh @@ -0,0 +1,1305 @@ +#!/bin/sh + +set -eu + +RELEASE="${CODEX_RELEASE:-latest}" +NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}" +DAEMON_ONLY="${CODEX_INSTALL_DAEMON_ONLY:-0}" +DEFAULT_PREFER_RELEASES_OPENAI_COM="true" +PREFER_RELEASES_OPENAI_COM="${CODEX_INSTALLER_USE_RELEASES_OPENAI_COM:-$DEFAULT_PREFER_RELEASES_OPENAI_COM}" +RELEASES_BASE_URL="https://releases.openai.com/codex" +RELEASES_CONNECT_TIMEOUT=10 +RELEASES_METADATA_TIMEOUT=30 +RELEASES_ASSET_TIMEOUT=300 +release_source="github" + +BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" +BIN_PATH="$BIN_DIR/codex" +CODE_MODE_HOST_BIN_PATH="$BIN_DIR/codex-code-mode-host" +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" +if [ "$DAEMON_ONLY" = "1" ]; then + STANDALONE_ROOT="$CODEX_HOME_DIR/packages/app-server-daemon" +fi +RELEASES_DIR="$STANDALONE_ROOT/releases" +CURRENT_LINK="$STANDALONE_ROOT/current" +if [ "${CODEX_INSTALL_DEFER_SELECTION:-0}" = "1" ]; then + if [ "$DAEMON_ONLY" != "1" ]; then + echo "Deferred selection requires a daemon-only installation." >&2 + exit 1 + fi + CURRENT_LINK="$STANDALONE_ROOT/.migration-current" +fi +AUTO_UPDATE_VERSION="$STANDALONE_ROOT/auto-update-version" +LOCK_FILE="$STANDALONE_ROOT/install.lock" +LOCK_DIR="$STANDALONE_ROOT/install.lock.d" +LOCK_STALE_AFTER_SECS=600 + +path_action="already" +path_profile="" +conflict_manager="" +conflict_path="" +lock_kind="" +tmp_dir="" + +step() { + printf '==> %s\n' "$1" +} + +warn() { + printf 'WARNING: %s\n' "$1" >&2 +} + +normalize_version() { + case "$1" in + "" | latest) + printf 'latest\n' + ;; + rust-v*) + printf '%s\n' "${1#rust-v}" + ;; + v*) + printf '%s\n' "${1#v}" + ;; + *) + printf '%s\n' "$1" + ;; + esac +} + +validate_version() { + version="$1" + + if [ "$version" = "latest" ]; then + return + fi + + if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-alpha(\.[0-9]+){0,2}|-beta(\.[0-9]+)?)?$'; then + echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]." >&2 + return 1 + fi +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --release) + if [ "$#" -lt 2 ]; then + echo "--release requires a value." >&2 + exit 1 + fi + RELEASE="$2" + shift + ;; + --help | -h) + cat <&2 + exit 1 + ;; + esac + shift + done +} + +download_file() { + url="$1" + output="$2" + + if command -v curl >/dev/null 2>&1; then + case "$url" in + "$RELEASES_BASE_URL"/*) + curl -fsSL --connect-timeout "$RELEASES_CONNECT_TIMEOUT" --max-time "$RELEASES_ASSET_TIMEOUT" "$url" -o "$output" + ;; + *) + curl -fsSL "$url" -o "$output" + ;; + esac + return + fi + + if command -v wget >/dev/null 2>&1; then + case "$url" in + "$RELEASES_BASE_URL"/*) + wget -q -t 1 -T "$RELEASES_ASSET_TIMEOUT" -O "$output" "$url" + ;; + *) + wget -q -O "$output" "$url" + ;; + esac + return + fi + + echo "curl or wget is required to install Codex." >&2 + exit 1 +} + +download_text() { + url="$1" + + if command -v curl >/dev/null 2>&1; then + case "$url" in + "$RELEASES_BASE_URL"/*) + curl -fsSL --connect-timeout "$RELEASES_CONNECT_TIMEOUT" --max-time "$RELEASES_METADATA_TIMEOUT" "$url" + ;; + *) + curl -fsSL "$url" + ;; + esac + return + fi + + if command -v wget >/dev/null 2>&1; then + case "$url" in + "$RELEASES_BASE_URL"/*) + wget -q -t 1 -T "$RELEASES_METADATA_TIMEOUT" -O - "$url" + ;; + *) + wget -q -O - "$url" + ;; + esac + return + fi + + echo "curl or wget is required to install Codex." >&2 + exit 1 +} + +download_file_with_fallback() { + primary_url="$1" + fallback_url="$2" + output="$3" + expected_digest="$4" + fallback_asset="$5" + required_manifest_asset="${6:-}" + + if download_file "$primary_url" "$output" && + verify_archive_digest "$output" "$expected_digest" && + { [ -z "$required_manifest_asset" ] || package_archive_digest "$required_manifest_asset" "$output" >/dev/null; }; then + return + fi + + if [ -z "$fallback_url" ]; then + return 1 + fi + + warn "Could not download or verify $primary_url; retrying from GitHub Releases." + download_file "$fallback_url" "$output" + if verify_archive_digest "$output" "$expected_digest" && + { [ -z "$required_manifest_asset" ] || package_archive_digest "$required_manifest_asset" "$output" >/dev/null; }; then + return + fi + + resolve_release_from_github "$resolved_version" + fallback_digest="$(release_asset_digest "$fallback_asset")" + verify_archive_digest "$output" "$fallback_digest" + if [ -n "$required_manifest_asset" ]; then + package_archive_digest "$required_manifest_asset" "$output" >/dev/null + fi +} + +parse_release_metadata() { + # Bound awk's record size so compact, single-line JSON stays fast on every + # supported awk implementation. JSON strings cannot contain literal newlines, + # so the record boundaries inserted by fold do not change the document. + LC_ALL=C fold -b -w 4096 | LC_ALL=C awk ' + function finish_string(value) { + if (object_depth == 1 && key == "tag_name") { + print "tag_name\t" value + } else if (object_depth == asset_object_depth) { + if (key == "name") { + asset_name = value + } else if (key == "digest") { + asset_digest = value + } + } + + expecting_value = 0 + key = "" + } + + { + for (i = 1; i <= length($0); i++) { + char = substr($0, i, 1) + + if (in_string) { + if (escaped) { + token = token "\\" char + escaped = 0 + } else if (char == "\\") { + escaped = 1 + } else if (char == "\"") { + in_string = 0 + if (string_is_value) { + finish_string(token) + } else { + pending_key = token + } + } else { + token = token char + } + continue + } + + if (char == "\"") { + in_string = 1 + token = "" + escaped = 0 + string_is_value = expecting_value + } else if (char == ":" && pending_key != "") { + key = pending_key + pending_key = "" + expecting_value = 1 + } else if (char == "{") { + object_depth++ + if (assets_array_depth != 0 && + array_depth == assets_array_depth && + asset_object_depth == 0) { + asset_object_depth = object_depth + asset_name = "" + asset_digest = "" + } + expecting_value = 0 + key = "" + } else if (char == "}") { + if (object_depth == asset_object_depth) { + if (asset_name != "" && asset_digest != "") { + print "asset\t" asset_name "\t" asset_digest + } + asset_object_depth = 0 + asset_name = "" + asset_digest = "" + } + object_depth-- + expecting_value = 0 + key = "" + pending_key = "" + } else if (char == "[") { + array_depth++ + if (expecting_value && key == "assets" && object_depth == 1) { + assets_array_depth = array_depth + } + expecting_value = 0 + key = "" + } else if (char == "]") { + if (array_depth == assets_array_depth) { + assets_array_depth = 0 + } + array_depth-- + expecting_value = 0 + key = "" + pending_key = "" + } else if (char == ",") { + expecting_value = 0 + key = "" + pending_key = "" + } + } + } + + END { + if (in_string || object_depth != 0 || array_depth != 0) { + exit 1 + } + } + ' +} + +release_url_for_asset() { + asset="$1" + resolved_version="$2" + + printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset" +} + +releases_url_for_asset() { + asset="$1" + resolved_version="$2" + + printf '%s/releases/%s/%s\n' "$RELEASES_BASE_URL" "$resolved_version" "$asset" +} + +release_metadata_url() { + resolved_version="$1" + + printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version" +} + +parse_downloaded_release_metadata() { + requested_release="$1" + source_name="$2" + if ! release_metadata="$(printf '%s\n' "$release_json" | parse_release_metadata)"; then + echo "Could not parse $source_name release metadata for Codex $requested_release." >&2 + return 1 + fi +} + +resolve_metadata_version() { + release_tag="$(printf '%s\n' "$release_metadata" | awk -F '\t' '$1 == "tag_name" { print $2; exit }')" + case "$release_tag" in + rust-v*) metadata_version="${release_tag#rust-v}" ;; + *) metadata_version="" ;; + esac + if [ -z "$metadata_version" ]; then + echo "Failed to resolve the latest Codex release version." >&2 + return 1 + fi + validate_version "$metadata_version" +} + +resolve_release_from_github() { + normalized_version="$1" + if [ "$normalized_version" = "latest" ]; then + requested_release="latest" + metadata_url="https://api.github.com/repos/openai/codex/releases/latest" + else + resolved_version="$normalized_version" + requested_release="$resolved_version" + metadata_url="$(release_metadata_url "$resolved_version")" + fi + + if ! release_json="$(download_text "$metadata_url")"; then + echo "Could not fetch GitHub release metadata for Codex $requested_release. GitHub API may be unavailable or rate limited." >&2 + exit 1 + fi + + parse_downloaded_release_metadata "$requested_release" "GitHub" + + if [ "$normalized_version" = "latest" ]; then + resolve_metadata_version + resolved_version="$metadata_version" + fi + + release_source="github" +} + +resolve_release_from_releases() { + normalized_version="$1" + + if [ "$normalized_version" = "latest" ]; then + requested_release="latest" + metadata_url="$RELEASES_BASE_URL/channels/latest" + else + requested_release="$normalized_version" + metadata_url="$RELEASES_BASE_URL/releases/$normalized_version/release.json" + fi + + if ! release_json="$(download_text "$metadata_url")"; then + return 1 + fi + + if ! parse_downloaded_release_metadata "$requested_release" "releases.openai.com"; then + return 1 + fi + if ! resolve_metadata_version; then + return 1 + fi + if [ "$normalized_version" != "latest" ] && [ "$metadata_version" != "$normalized_version" ]; then + echo "Release metadata version did not match requested Codex version $normalized_version." >&2 + return 1 + fi + resolved_version="$metadata_version" + release_source="releases.openai.com" +} + +resolve_release() { + normalized_version="$(normalize_version "$RELEASE")" + validate_version "$normalized_version" + + case "$PREFER_RELEASES_OPENAI_COM" in + 1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss]) + if resolve_release_from_releases "$normalized_version" && + select_release_assets; then + return + fi + warn "releases.openai.com is unavailable; falling back to GitHub Releases." + ;; + esac + + resolve_release_from_github "$normalized_version" + select_release_assets +} + +release_asset_digest_or_empty() { + asset="$1" + + digest="$(printf '%s\n' "$release_metadata" | awk -F '\t' -v asset="$asset" ' + $1 == "asset" && $2 == asset { + print $3 + exit + } + ')" + + case "$digest" in + sha256:????????????????????????????????????????????????????????????????) + digest="${digest#sha256:}" + case "$digest" in + *[!0-9a-fA-F]*) return 1 ;; + esac + printf '%s\n' "$digest" + ;; + *) + return 1 + ;; + esac +} + +release_asset_exists() { + asset="$1" + + release_asset_digest_or_empty "$asset" >/dev/null 2>&1 +} + +release_asset_digest() { + asset="$1" + + digest="$(release_asset_digest_or_empty "$asset" || true)" + if [ -z "$digest" ]; then + echo "Could not find SHA-256 digest for release asset $asset." >&2 + exit 1 + fi + + printf '%s\n' "$digest" +} + +select_release_assets() { + package_asset="codex-package-$vendor_target.tar.gz" + checksum_asset="codex-package_SHA256SUMS" + download_fallback_url="" + checksum_fallback_url="" + + if release_asset_exists "$package_asset" && + release_asset_exists "$checksum_asset"; then + install_layout="package" + asset="$package_asset" + elif release_asset_exists "codex-npm-$npm_tag-$resolved_version.tgz"; then + install_layout="legacy-platform-npm" + asset="codex-npm-$npm_tag-$resolved_version.tgz" + else + echo "Could not find Codex package or platform npm release assets for Codex $resolved_version." >&2 + return 1 + fi + + if [ "$release_source" = "releases.openai.com" ]; then + download_url="$(releases_url_for_asset "$asset" "$resolved_version")" + download_fallback_url="$(release_url_for_asset "$asset" "$resolved_version")" + if [ "$install_layout" = "package" ]; then + checksum_url="$(releases_url_for_asset "$checksum_asset" "$resolved_version")" + checksum_fallback_url="$(release_url_for_asset "$checksum_asset" "$resolved_version")" + fi + else + download_url="$(release_url_for_asset "$asset" "$resolved_version")" + if [ "$install_layout" = "package" ]; then + checksum_url="$(release_url_for_asset "$checksum_asset" "$resolved_version")" + fi + fi +} + +package_archive_digest() { + asset="$1" + manifest_path="$2" + + digest="$(awk -v asset="$asset" ' + $2 == asset && length($1) == 64 && $1 !~ /[^0-9a-fA-F]/ { + print tolower($1) + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' "$manifest_path" 2>/dev/null || true)" + + if [ -z "$digest" ]; then + echo "Could not find SHA-256 digest for $asset in codex-package_SHA256SUMS." >&2 + return 1 + fi + + printf '%s\n' "$digest" +} + +file_sha256() { + path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | sed 's/^.*= //' + return + fi + + echo "sha256sum, shasum, or openssl is required to verify the Codex download." >&2 + exit 1 +} + +verify_archive_digest() { + archive_path="$1" + expected_digest="$2" + actual_digest="$(file_sha256 "$archive_path")" + + if [ "$actual_digest" != "$expected_digest" ]; then + echo "Downloaded Codex archive checksum did not match expected digest." >&2 + echo "expected: $expected_digest" >&2 + echo "actual: $actual_digest" >&2 + return 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "$1 is required to install Codex." >&2 + exit 1 + fi +} + +pick_profile() { + # Use the same shell-specific split Homebrew documents because there is no + # universal startup file across macOS/Linux login and interactive shells. + case "$os:${SHELL:-}" in + darwin:*/zsh) + printf '%s\n' "$HOME/.zprofile" + ;; + darwin:*/bash) + printf '%s\n' "$HOME/.bash_profile" + ;; + linux:*/zsh) + printf '%s\n' "$HOME/.zshrc" + ;; + linux:*/bash) + printf '%s\n' "$HOME/.bashrc" + ;; + *) + printf '%s\n' "$HOME/.profile" + ;; + esac +} + +add_to_path() { + path_action="already" + path_profile="" + + case ":$PATH:" in + *":$BIN_DIR:"*) + if [ -z "$conflict_manager" ]; then + return + fi + ;; + esac + + profile="$(pick_profile)" + path_profile="$profile" + begin_marker="# >>> Codex installer >>>" + end_marker="# <<< Codex installer <<<" + path_line="export PATH=\"$BIN_DIR:\$PATH\"" + + if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then + if grep -F "$path_line" "$profile" >/dev/null 2>&1; then + path_action="configured" + return + fi + + if grep -F "$end_marker" "$profile" >/dev/null 2>&1; then + rewrite_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="updated" + return + fi + fi + + append_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="added" +} + +append_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + + { + printf '\n%s\n' "$begin_marker" + printf '%s\n' "$path_line" + printf '%s\n' "$end_marker" + } >>"$profile" +} + +rewrite_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + tmp_profile="$tmp_dir/profile.$$.tmp" + + awk -v begin="$begin_marker" -v end="$end_marker" -v line="$path_line" ' + BEGIN { + in_block = 0 + replaced = 0 + } + $0 == begin { + if (!replaced) { + print begin + print line + print end + replaced = 1 + } + in_block = 1 + next + } + in_block { + if ($0 == end) { + in_block = 0 + } + next + } + { + print + } + END { + if (in_block != 0) { + exit 1 + } + } + ' "$profile" >"$tmp_profile" + mv "$tmp_profile" "$profile" +} + +mkdir_lock_is_stale() { + [ -d "$LOCK_DIR" ] || return 1 + + pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + started_at="$(cat "$LOCK_DIR/started_at" 2>/dev/null || true)" + now="$(date +%s 2>/dev/null || printf '0')" + + case "$started_at" in + ''|*[!0-9]*) + started_at=0 + ;; + esac + + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then + return 0 + fi + + [ $((now - started_at)) -ge "$LOCK_STALE_AFTER_SECS" ] +} + +acquire_install_lock() { + mkdir -p "$STANDALONE_ROOT" + + if [ "$os" = "darwin" ] && command -v lockf >/dev/null 2>&1; then + : >>"$LOCK_FILE" + exec 9<>"$LOCK_FILE" + lockf 9 + lock_kind="lockf" + return + fi + + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" + flock 9 + lock_kind="flock" + return + fi + + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if mkdir_lock_is_stale; then + warn "Removing stale installer lock at $LOCK_DIR" + rm -rf "$LOCK_DIR" + continue + fi + sleep 1 + done + + printf '%s\n' "$$" >"$LOCK_DIR/pid" + date +%s >"$LOCK_DIR/started_at" 2>/dev/null || true + lock_kind="mkdir" +} + +release_install_lock() { + if [ "$lock_kind" = "mkdir" ]; then + rm -rf "$LOCK_DIR" 2>/dev/null || true + elif [ "$lock_kind" = "flock" ] || [ "$lock_kind" = "lockf" ]; then + exec 9>&- 2>/dev/null || true + fi + lock_kind="" +} + +cleanup_stale_install_artifacts() { + mkdir -p "$RELEASES_DIR" "$STANDALONE_ROOT" + + find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -name '.staging.*' -exec rm -rf {} + + find "$STANDALONE_ROOT" -mindepth 1 -maxdepth 1 -name '.current.*' -exec rm -f {} + + + if [ "$DAEMON_ONLY" != "1" ] && [ -d "$BIN_DIR" ]; then + find "$BIN_DIR" -mindepth 1 -maxdepth 1 -name '.codex.*' -exec rm -f {} + + fi +} + +replace_path_with_symlink() { + link_path="$1" + link_target="$2" + tmp_link="$3" + + rm -f "$tmp_link" + ln -s "$link_target" "$tmp_link" + + if mv -Tf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + if mv -hf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + rm -f "$link_path" + mv -f "$tmp_link" "$link_path" +} + +version_from_binary() { + codex_path="$1" + + if [ ! -x "$codex_path" ]; then + return 1 + fi + + "$codex_path" --version 2>/dev/null | sed -n 's/.* \([0-9][0-9A-Za-z.+-]*\)$/\1/p' | head -n 1 +} + +current_installed_version() { + version="$(version_from_binary "$CURRENT_LINK/bin/codex" || true)" + if [ -n "$version" ]; then + printf '%s\n' "$version" + return 0 + fi + + version="$(version_from_binary "$CURRENT_LINK/codex" || true)" + if [ -n "$version" ]; then + printf '%s\n' "$version" + return 0 + fi + + return 0 +} + +resolve_existing_codex() { + command -v codex 2>/dev/null || true +} + +classify_existing_codex() { + existing_path="$1" + + if [ -z "$existing_path" ] || [ "$existing_path" = "$BIN_PATH" ]; then + return 1 + fi + + case "$existing_path" in + /opt/homebrew/* | /usr/local/*) + if [ "$os" = "darwin" ]; then + printf 'brew\n' + return 0 + fi + ;; + esac + + if [ -f "$existing_path" ] && grep -F "#!/usr/bin/env node" "$existing_path" >/dev/null 2>&1; then + case "$existing_path" in + *".bun"*) + printf 'bun\n' + ;; + *) + printf 'npm\n' + ;; + esac + return 0 + fi + + return 1 +} + +prompt_yes_no() { + prompt="$1" + + case "$NON_INTERACTIVE" in + 1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss]) + return 1 + ;; + esac + + if ( : /dev/null; then + printf '%s [y/N] ' "$prompt" >/dev/tty + if ! IFS= read -r answer /dev/null || true)" = \ + "$CURRENT_LINK/bin/codex-code-mode-host" ]; then + rm -f "$CODE_MODE_HOST_BIN_PATH" + fi +} + +verify_visible_command() { + "$BIN_PATH" --version >/dev/null + if [ "$os" = "darwin" ] && [ "$install_layout" = "package" ]; then + [ -x "$CODE_MODE_HOST_BIN_PATH" ] + fi +} + +parse_args "$@" + +require_command mktemp +require_command tar + +case "$(uname -s)" in + Darwin) + os="darwin" + ;; + Linux) + os="linux" + ;; + *) + echo "install.sh supports macOS and Linux. Use install.ps1 on Windows." >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) + arch="x86_64" + ;; + arm64 | aarch64) + arch="aarch64" + ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +if [ "$os" = "darwin" ] && [ "$arch" = "x86_64" ]; then + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then + arch="aarch64" + fi +fi + +if [ "$os" = "darwin" ]; then + if [ "$arch" = "aarch64" ]; then + npm_tag="darwin-arm64" + vendor_target="aarch64-apple-darwin" + platform_label="macOS (Apple Silicon)" + else + npm_tag="darwin-x64" + vendor_target="x86_64-apple-darwin" + platform_label="macOS (Intel)" + fi +else + if [ "$arch" = "aarch64" ]; then + npm_tag="linux-arm64" + vendor_target="aarch64-unknown-linux-musl" + platform_label="Linux (ARM64)" + else + npm_tag="linux-x64" + vendor_target="x86_64-unknown-linux-musl" + platform_label="Linux (x64)" + fi +fi + +resolve_release +release_name="$resolved_version-$vendor_target" +release_dir="$RELEASES_DIR/$release_name" +current_version="$(current_installed_version)" + +if [ -n "$current_version" ] && [ "$current_version" != "$resolved_version" ]; then + step "Updating Codex CLI from $current_version to $resolved_version" +elif [ -n "$current_version" ]; then + step "Updating Codex CLI" +else + step "Installing Codex CLI" +fi +step "Detected platform: $platform_label" +step "Resolved version: $resolved_version" + +if [ "$DAEMON_ONLY" != "1" ]; then + detect_conflicting_install +fi + +tmp_dir="$(mktemp -d)" +cleanup() { + release_install_lock + if [ -n "$tmp_dir" ]; then + rm -rf "$tmp_dir" + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +acquire_install_lock +if [ "${CODEX_INSTALL_DEFER_SELECTION:-0}" = "1" ] && + { [ -e "$STANDALONE_ROOT/current" ] || [ -L "$STANDALONE_ROOT/current" ]; }; then + echo "A dedicated daemon is already selected; retry the update." >&2 + exit 1 +fi +updater_record="$CODEX_HOME_DIR/app-server-daemon/app-server-updater.pid" +if [ "$DAEMON_ONLY" = "1" ]; then + updater_record="$CODEX_HOME_DIR/app-server-daemon/daemon-updater.pid" +fi +old_updater_parent="false" +if [ "${CODEX_INSTALL_IF_LATEST:-}" != "1" ] && [ "${CODEX_INSTALL_IF_CURRENT:-}" != "1" ] && [ -f "$updater_record" ]; then + updater_pid="$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$updater_record" | head -n 1)" + recorded_start="$(sed -n 's/.*"processStartTime"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$updater_record" | head -n 1)" + if [ -r "/proc/$$/stat" ]; then + parent_pid="$(sed 's/^.*) //' "/proc/$$/stat" | awk '{ print $2 }')" + else + parent_pid="$(ps -p "$$" -o ppid= 2>/dev/null)" || parent_pid="" + parent_pid="$(printf '%s' "$parent_pid" | tr -d ' ')" + fi + if [ -n "$updater_pid" ] && [ "$updater_pid" = "$parent_pid" ]; then + actual_details="$(ps -p "$updater_pid" -o stat= -o lstart= 2>/dev/null)" || actual_details="" + actual_start="$(printf '%s' "$actual_details" | sed 's/^[^[:space:]]*[[:space:]]*//; s/[[:space:]]*$//')" + if [ -n "$recorded_start" ] && [ "$recorded_start" = "$actual_start" ]; then + old_updater_parent="true" + fi + fi + if [ "$RELEASE" = "latest" ] && [ -n "$updater_pid" ] && + { [ -z "$parent_pid" ] || { [ "$updater_pid" = "$parent_pid" ] && + { [ -z "$recorded_start" ] || [ -z "$actual_start" ]; }; }; } && + kill -0 "$updater_pid" 2>/dev/null; then + warn "Cannot verify whether an older updater launched this installer; skipping latest update." + exit 0 + fi +fi +if [ "${CODEX_INSTALL_IF_LATEST:-}" = "1" ] || [ "${CODEX_INSTALL_IF_CURRENT:-}" = "1" ] || [ "$old_updater_parent" = "true" ]; then + guarded_release="${CODEX_UPDATE_FROM_RELEASE:-}" + if [ "$old_updater_parent" = "true" ]; then + guarded_release="$(cat "$AUTO_UPDATE_VERSION" 2>/dev/null || true)" + fi + current_release_dir="$(cd -P "$CURRENT_LINK" 2>/dev/null && pwd)" || exit 0 + releases_dir="$(cd -P "$RELEASES_DIR" 2>/dev/null && pwd)" || exit 0 + if [ "$RELEASE" != "latest" ] || [ -z "$guarded_release" ] || + [ "$current_release_dir" != "$releases_dir/$guarded_release" ]; then + if [ "${CODEX_INSTALL_IF_CURRENT:-}" = "1" ]; then + echo "Daemon selection changed; retry the update." >&2 + exit 1 + fi + exit 0 + fi + # An explicit daemon update may leave a local or pinned release. Scheduled + # updates still require the selected release to follow the latest channel. + if [ "${CODEX_INSTALL_IF_CURRENT:-}" != "1" ] && + [ "$(cat "$AUTO_UPDATE_VERSION" 2>/dev/null || true)" != "$guarded_release" ]; then + exit 0 + fi +fi +cleanup_stale_install_artifacts + +if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" "$install_layout"; then + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + if [ "$DAEMON_ONLY" = "1" ]; then + echo "Refusing to overwrite existing daemon release $release_dir." >&2 + exit 1 + fi + warn "Found incomplete existing release at $release_dir; reinstalling." + fi + + archive_path="$tmp_dir/$asset" + checksum_path="$tmp_dir/$checksum_asset" + + step "Downloading Codex CLI" + if [ "$install_layout" = "package" ]; then + checksum_digest="$(release_asset_digest "$checksum_asset")" + download_file_with_fallback "$checksum_url" "$checksum_fallback_url" "$checksum_path" "$checksum_digest" "$checksum_asset" "$asset" + expected_digest="$(package_archive_digest "$asset" "$checksum_path")" + else + expected_digest="$(release_asset_digest "$asset")" + fi + download_file_with_fallback "$download_url" "$download_fallback_url" "$archive_path" "$expected_digest" "$asset" + + step "Installing standalone package to $release_dir" + if [ "$install_layout" = "package" ]; then + install_package_release "$release_dir" "$archive_path" + else + install_legacy_platform_npm_release "$release_dir" "$archive_path" "$vendor_target" + fi +fi +if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" "$install_layout"; then + echo "Installed Codex command did not report expected version $resolved_version." >&2 + exit 1 +fi +if [ "$DAEMON_ONLY" = "1" ] && [ "${CODEX_INSTALL_DEFER_SELECTION:-0}" != "1" ]; then + installed_codex="$release_dir/codex" + if [ "$install_layout" = "package" ]; then + installed_codex="$release_dir/bin/codex" + fi + if ! "$installed_codex" app-server daemon pid-update-loop --check-package-ownership >/dev/null 2>&1; then + echo "The production release does not support daemon-owned packages; the current selection was left unchanged." >&2 + exit 1 + fi +fi +update_current_link "$release_dir" +if [ "$RELEASE" = "latest" ]; then + printf '%s' "$release_name" > "$AUTO_UPDATE_VERSION.tmp.$$" + mv -f "$AUTO_UPDATE_VERSION.tmp.$$" "$AUTO_UPDATE_VERSION" +else + rm -f "$AUTO_UPDATE_VERSION" +fi +if [ "$DAEMON_ONLY" = "1" ]; then + release_install_lock + exit 0 +fi +update_visible_command "$release_dir" +add_to_path +verify_visible_command +release_install_lock +handle_conflicting_install + +case "$path_action" in + added) + print_launch_instructions + ;; + updated) + print_launch_instructions + ;; + configured) + print_launch_instructions + ;; + *) + step "$BIN_DIR is already on PATH" + print_launch_instructions + ;; +esac + +printf 'Codex CLI %s installed successfully.\n' "$resolved_version" +maybe_launch_codex_now diff --git a/scripts/install/test_install_sh.py b/scripts/install/test_install_sh.py new file mode 100644 index 0000000000000000000000000000000000000000..06465d9372a302302facef4794cb6b9d58edf625 --- /dev/null +++ b/scripts/install/test_install_sh.py @@ -0,0 +1,1068 @@ +#!/usr/bin/env python3 + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tarfile +import tempfile +import textwrap +import unittest + + +INSTALL_SCRIPT = Path(__file__).with_name("install.sh") +VERSION = "0.142.5" +MISMATCH_VERSION = "0.145.0" + + +class InstallShTest(unittest.TestCase): + def test_metadata_fetch_failure_is_not_reported_as_missing_assets(self) -> None: + result, requests = run_installer(VERSION, metadata_failure=True) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}" + ], + ) + self.assertIn( + f"Could not fetch GitHub release metadata for Codex {VERSION}", + result.stderr, + ) + self.assertNotIn("Could not find Codex package", result.stderr) + + def test_exact_release_opt_out_uses_github_metadata_once(self) -> None: + result, requests = run_installer(VERSION, use_mirror=False) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + def test_alpha_hotfix_release_is_valid(self) -> None: + version = "0.145.0-alpha.23.1" + result, requests = run_installer(version) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{version}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{version}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {version}", result.stdout) + + def test_latest_release_reuses_version_metadata(self) -> None: + result, requests = run_installer("latest") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/latest", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + def test_compact_metadata_is_independent_of_field_order(self) -> None: + result, requests = run_installer( + "latest", metadata_json=release_metadata(compact=True, reorder=True) + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://api.github.com/repos/openai/codex/releases/latest", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + ], + ) + self.assertIn(f"Resolved version: {VERSION}", result.stdout) + + def test_json_like_strings_and_nested_fields_do_not_define_assets(self) -> None: + result, requests = run_installer( + VERSION, metadata_json=legacy_release_metadata_with_decoys() + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(requests), 2) + self.assertIn("/codex-npm-", requests[1]) + self.assertNotIn("codex-package_SHA256SUMS", requests[1]) + + def test_macos_install_exposes_code_mode_host_beside_codex(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, _requests = run_installer_in( + root, + VERSION, + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + install_bin = root / "install-bin" + current = root / "codex-home" / "packages" / "standalone" / "current" + codex_path = install_bin / "codex" + host_path = install_bin / "codex-code-mode-host" + self.assertEqual(os.readlink(codex_path), str(current / "bin" / "codex")) + self.assertEqual( + os.readlink(host_path), + str(current / "bin" / "codex-code-mode-host"), + ) + self.assertTrue(os.access(host_path, os.X_OK)) + + def test_releases_latest_installs_verified_package_by_default(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + + def test_daemon_install_preserves_visible_cli_and_profile(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir).resolve() + archive, checksum, metadata = create_package_release(root) + options = dict( + metadata_json=metadata, + archive_path=archive, + checksum_path=checksum, + force_macos=True, + ) + pinned, _ = run_installer_in(root, VERSION, **options) + self.assertEqual(pinned.returncode, 0, pinned.stderr) + visible = root / "install-bin/codex" + original = visible.resolve() + pending = visible.with_name(".codex.12345") + pending.symlink_to(original) + profile = root / "home/.profile" + profile.write_text("unchanged profile\n") + + installed, _ = run_installer_in(root, "latest", daemon_only=True, **options) + self.assertEqual(installed.returncode, 0, installed.stderr) + daemon = root / "codex-home/packages/app-server-daemon" + release_name = f"{VERSION}-aarch64-apple-darwin" + self.assertEqual( + (daemon / "current").resolve(), daemon / "releases" / release_name + ) + self.assertEqual((daemon / "auto-update-version").read_text(), release_name) + options["daemon_only"] = True + local = daemon / "releases/local-development" + local.mkdir() + (daemon / "current").unlink() + (daemon / "current").symlink_to(local) + marker = daemon / "auto-update-version" + marker.unlink() + + scheduled, _ = run_installer_in( + root, "latest", update_guard_from_release=local.name, **options + ) + self.assertEqual(scheduled.returncode, 0, scheduled.stderr) + self.assertEqual((daemon / "current").resolve(), local) + self.assertFalse(marker.exists()) + + raced, _ = run_installer_in( + root, + "latest", + update_guard_from_release="a-different-release", + manual_update=True, + **options, + ) + self.assertNotEqual(raced.returncode, 0, raced.stderr) + self.assertEqual((daemon / "current").resolve(), local) + self.assertFalse(marker.exists()) + + binary = daemon / "releases" / release_name / "bin/codex" + contents = binary.read_text() + binary.write_text( + '#!/bin/sh\n[ "$1" = "--version" ] || exit 2\n' + f"echo codex-cli {VERSION}\n" + ) + incompatible, _ = run_installer_in( + root, + "latest", + update_guard_from_release=local.name, + manual_update=True, + **options, + ) + self.assertNotEqual(incompatible.returncode, 0) + self.assertIn("does not support daemon-owned packages", incompatible.stderr) + self.assertEqual((daemon / "current").resolve(), local) + self.assertFalse(marker.exists()) + binary.write_text(contents) + + restored, _ = run_installer_in( + root, + "latest", + update_guard_from_release=local.name, + manual_update=True, + **options, + ) + self.assertEqual(restored.returncode, 0, restored.stderr) + self.assertEqual( + (daemon / "current").resolve(), daemon / "releases" / release_name + ) + self.assertEqual(marker.read_text(), release_name) + self.assertEqual(visible.resolve(), original) + self.assertTrue(pending.is_symlink()) + self.assertEqual(profile.read_text(), "unchanged profile\n") + self.assertFalse( + (root / "codex-home/packages/standalone/auto-update-version").exists() + ) + + def test_explicit_release_pins_even_the_current_latest_version(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive, checksum, metadata = create_package_release(root) + options = dict( + metadata_json=metadata, + archive_path=archive, + checksum_path=checksum, + force_macos=True, + ) + marker = root / "codex-home/packages/standalone/auto-update-version" + latest, _ = run_installer_in(root, "latest", **options) + self.assertEqual(latest.returncode, 0, latest.stderr) + release_name = f"{VERSION}-aarch64-apple-darwin" + self.assertEqual(marker.read_text(), release_name) + + pinned, _ = run_installer_in(root, VERSION, **options) + self.assertEqual(pinned.returncode, 0, pinned.stderr) + self.assertFalse(marker.exists()) + + updater_record = ( + root / "codex-home/app-server-daemon/app-server-updater.pid" + ) + updater_record.parent.mkdir(parents=True) + updater_record.write_text( + json.dumps( + {"pid": os.getpid(), "processStartTime": process_start_time()} + ) + ) + old_updater, _ = run_installer_in( + root, "latest", old_updater_parent_pid=os.getpid(), **options + ) + self.assertEqual(old_updater.returncode, 0, old_updater.stderr) + self.assertFalse(marker.exists()) + + skipped, _ = run_installer_in( + root, "latest", update_guard_from_release=release_name, **options + ) + self.assertEqual(skipped.returncode, 0, skipped.stderr) + self.assertFalse(marker.exists()) + + updater_record.write_text( + json.dumps({"pid": os.getpid(), "processStartTime": "stale"}) + ) + latest_again, _ = run_installer_in( + root, "latest", old_updater_parent_pid=os.getpid(), **options + ) + self.assertEqual(latest_again.returncode, 0, latest_again.stderr) + self.assertEqual(marker.read_text(), release_name) + + managed = ( + root + / f"codex-home/packages/standalone/releases/{release_name}/bin/codex" + ) + managed.unlink() + guarded, _ = run_installer_in( + root, + "latest", + update_guard_from_release=release_name, + **options, + ) + self.assertEqual(guarded.returncode, 0, guarded.stderr) + self.assertTrue(managed.exists()) + + def test_uninspectable_legacy_updater_does_not_clear_pin(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive, checksum, metadata = create_package_release(root) + options = dict( + metadata_json=metadata, + archive_path=archive, + checksum_path=checksum, + force_macos=True, + ) + pinned, _ = run_installer_in(root, VERSION, **options) + self.assertEqual(pinned.returncode, 0, pinned.stderr) + updater_record = ( + root / "codex-home/app-server-daemon/app-server-updater.pid" + ) + updater_record.parent.mkdir(parents=True) + updater_record.write_text( + json.dumps( + {"pid": os.getpid(), "processStartTime": process_start_time()} + ) + ) + + attempted, _ = run_installer_in( + root, + "latest", + old_updater_parent_pid=os.getpid(), + fail_ps=True, + **options, + ) + self.assertEqual(attempted.returncode, 0, attempted.stderr) + self.assertFalse( + (root / "codex-home/packages/standalone/auto-update-version").exists() + ) + + def test_releases_unusable_metadata_falls_back_to_github(self) -> None: + unusable_metadata = { + "html": "proxy error", + "empty": "", + "malformed_json": '{"tag_name":', + "missing_tag": json.dumps({"assets": []}), + "missing_assets": json.dumps( + {"tag_name": f"rust-v{VERSION}", "assets": []} + ), + "invalid_checksum_digest": json.dumps( + { + "tag_name": f"rust-v{VERSION}", + "assets": [ + { + "name": "codex-package-aarch64-apple-darwin.tar.gz", + "digest": "sha256:" + "a" * 64, + }, + { + "name": "codex-package_SHA256SUMS", + "digest": "sha256:" + "z" * 64, + }, + ], + } + ), + "invalid_version": json.dumps({"tag_name": "rust-vinvalid"}), + } + + for name, releases_metadata_json in unusable_metadata.items(): + with self.subTest(metadata=name): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release( + root + ) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + releases_metadata_json=releases_metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + "https://api.github.com/repos/openai/codex/releases/latest", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("falling back to GitHub Releases", result.stderr) + + def test_releases_exact_metadata_version_mismatch_falls_back_to_github( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + releases_metadata = json.loads(metadata_json) + releases_metadata["tag_name"] = f"rust-v{MISMATCH_VERSION}" + + result, requests = run_installer_in( + root, + VERSION, + metadata_json=metadata_json, + releases_metadata_json=json.dumps(releases_metadata), + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + f"https://releases.openai.com/codex/releases/{VERSION}/release.json", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("falling back to GitHub Releases", result.stderr) + + def test_releases_asset_download_falls_back_to_github(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + releases_mode="asset_fallback", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("retrying from GitHub Releases", result.stderr) + + def test_releases_corrupt_assets_fall_back_to_github(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + releases_mode="corrupt_assets", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("checksum did not match expected digest", result.stderr) + self.assertIn("retrying from GitHub Releases", result.stderr) + + def test_releases_wrong_checksum_digest_uses_github_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + mirror_metadata = json.loads(metadata_json) + for release_asset in mirror_metadata["assets"]: + if release_asset["name"] == "codex-package_SHA256SUMS": + release_asset["digest"] = "sha256:" + "0" * 64 + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + releases_metadata_json=json.dumps(mirror_metadata), + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("checksum did not match expected digest", result.stderr) + + def test_releases_incomplete_checksum_manifest_falls_back_to_github(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + mirror_checksum_path = root / "mirror-SHA256SUMS" + mirror_checksum_path.write_text( + f"{'a' * 64} codex-package-other-platform.tar.gz\n", + encoding="utf-8", + ) + mirror_metadata = json.loads(metadata_json) + for release_asset in mirror_metadata["assets"]: + if release_asset["name"] == "codex-package_SHA256SUMS": + release_asset["digest"] = ( + "sha256:" + + hashlib.sha256(mirror_checksum_path.read_bytes()).hexdigest() + ) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + releases_metadata_json=json.dumps(mirror_metadata), + archive_path=archive_path, + checksum_path=checksum_path, + releases_checksum_path=mirror_checksum_path, + force_macos=True, + use_mirror=None, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn("retrying from GitHub Releases", result.stderr) + + def test_releases_corrupt_github_fallback_still_fails(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release(root) + + result, requests = run_installer_in( + root, + "latest", + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=None, + releases_mode="corrupt_checksum_and_github", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + "https://releases.openai.com/codex/channels/latest", + f"https://releases.openai.com/codex/releases/{VERSION}/codex-package_SHA256SUMS", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-package_SHA256SUMS", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + ], + ) + self.assertIn("checksum did not match expected digest", result.stderr) + + def test_releases_exact_rejects_wrong_binary_version(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, checksum_path, metadata_json = create_package_release( + root, + metadata_version=MISMATCH_VERSION, + ) + + result, requests = run_installer_in( + root, + MISMATCH_VERSION, + metadata_json=metadata_json, + archive_path=archive_path, + checksum_path=checksum_path, + force_macos=True, + use_mirror=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual( + requests, + [ + f"https://releases.openai.com/codex/releases/{MISMATCH_VERSION}/release.json", + f"https://releases.openai.com/codex/releases/{MISMATCH_VERSION}/codex-package_SHA256SUMS", + f"https://releases.openai.com/codex/releases/{MISMATCH_VERSION}/codex-package-aarch64-apple-darwin.tar.gz", + ], + ) + self.assertIn( + f"did not report expected version {MISMATCH_VERSION}", + result.stderr, + ) + self.assertNotIn("installed successfully", result.stdout) + + def test_releases_exact_legacy_fallback_reuses_offline_install(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + archive_path, metadata_json = create_legacy_release(root) + + first_result, first_requests = run_installer_in( + root, + VERSION, + metadata_json=metadata_json, + legacy_archive_path=archive_path, + force_macos=True, + use_mirror=True, + releases_mode="channel_failure", + ) + + self.assertEqual(first_result.returncode, 0, first_result.stderr) + self.assertEqual( + first_requests, + [ + f"https://releases.openai.com/codex/releases/{VERSION}/release.json", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + "https://github.com/openai/codex/releases/download/" + f"rust-v{VERSION}/codex-npm-darwin-arm64-{VERSION}.tgz", + ], + ) + + (root / "requests.log").unlink() + second_result, second_requests = run_installer_in( + root, + VERSION, + metadata_json=metadata_json, + force_macos=True, + use_mirror=True, + releases_mode="channel_failure", + ) + + self.assertEqual(second_result.returncode, 0, second_result.stderr) + self.assertEqual( + second_requests, + [ + f"https://releases.openai.com/codex/releases/{VERSION}/release.json", + "https://api.github.com/repos/openai/codex/releases/tags/" + f"rust-v{VERSION}", + ], + ) + self.assertNotIn("Downloading Codex CLI", second_result.stdout) + + +def run_installer( + release: str, + *, + metadata_failure: bool = False, + metadata_json: str | None = None, + use_mirror: bool | None = False, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + with tempfile.TemporaryDirectory() as temp_dir: + return run_installer_in( + Path(temp_dir), + release, + metadata_failure=metadata_failure, + metadata_json=metadata_json, + use_mirror=use_mirror, + ) + + +def run_installer_in( + root: Path, + release: str, + *, + metadata_failure: bool = False, + metadata_json: str | None = None, + releases_metadata_json: str | None = None, + archive_path: Path | None = None, + checksum_path: Path | None = None, + releases_checksum_path: Path | None = None, + legacy_archive_path: Path | None = None, + force_macos: bool = False, + use_mirror: bool | None = False, + releases_mode: str = "", + update_guard_from_release: str | None = None, + old_updater_parent_pid: int | None = None, + fail_ps: bool = False, + daemon_only: bool = False, + manual_update: bool = False, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + bin_dir = root / "bin" + bin_dir.mkdir(exist_ok=True) + request_log = root / "requests.log" + fake_curl = bin_dir / "curl" + fake_curl.write_text( + textwrap.dedent( + """\ + #!/bin/sh + url="" + output="" + previous="" + for arg in "$@"; do + case "$arg" in + https://*) url="$arg" ;; + esac + if [ "$previous" = "-o" ]; then + output="$arg" + fi + previous="$arg" + done + printf '%s\n' "$url" >>"$CODEX_TEST_REQUEST_LOG" + + case "$url" in + https://api.github.com/*) + if [ "$CODEX_TEST_METADATA_FAILURE" = "1" ]; then + echo "curl: (22) The requested URL returned error: 403" >&2 + exit 22 + fi + printf '%s\n' "$CODEX_TEST_METADATA_JSON" + ;; + https://releases.openai.com/codex/channels/latest|https://releases.openai.com/codex/releases/*/release.json) + if [ "$CODEX_TEST_RELEASES_MODE" = "channel_failure" ]; then + exit 22 + fi + printf '%s\n' "$CODEX_TEST_RELEASES_METADATA_JSON" + ;; + https://releases.openai.com/codex/releases/*/codex-package_SHA256SUMS) + if [ "$CODEX_TEST_RELEASES_MODE" = "asset_fallback" ]; then + exit 22 + fi + if [ "$CODEX_TEST_RELEASES_MODE" = "corrupt_assets" ] || + [ "$CODEX_TEST_RELEASES_MODE" = "corrupt_checksum_and_github" ]; then + printf 'proxy error\n' >"$output" + exit 0 + fi + if [ -n "$CODEX_TEST_RELEASES_CHECKSUM_PATH" ]; then + cp "$CODEX_TEST_RELEASES_CHECKSUM_PATH" "$output" + else + exit 22 + fi + ;; + https://releases.openai.com/codex/releases/*/codex-package-*.tar.gz) + if [ "$CODEX_TEST_RELEASES_MODE" = "asset_fallback" ]; then + exit 22 + fi + if [ "$CODEX_TEST_RELEASES_MODE" = "corrupt_assets" ]; then + printf 'proxy error\n' >"$output" + exit 0 + fi + if [ -n "$CODEX_TEST_ARCHIVE_PATH" ]; then + cp "$CODEX_TEST_ARCHIVE_PATH" "$output" + else + exit 22 + fi + ;; + https://github.com/openai/codex/releases/download/*/codex-package_SHA256SUMS) + if [ "$CODEX_TEST_RELEASES_MODE" = "corrupt_checksum_and_github" ]; then + printf 'proxy error\n' >"$output" + exit 0 + fi + if [ -n "$CODEX_TEST_CHECKSUM_PATH" ]; then + cp "$CODEX_TEST_CHECKSUM_PATH" "$output" + else + exit 22 + fi + ;; + https://github.com/openai/codex/releases/download/*/codex-package-*.tar.gz) + if [ -n "$CODEX_TEST_ARCHIVE_PATH" ]; then + cp "$CODEX_TEST_ARCHIVE_PATH" "$output" + else + exit 22 + fi + ;; + https://github.com/openai/codex/releases/download/*/codex-npm-*.tgz) + if [ -n "$CODEX_TEST_LEGACY_ARCHIVE_PATH" ]; then + cp "$CODEX_TEST_LEGACY_ARCHIVE_PATH" "$output" + else + exit 22 + fi + ;; + *) + exit 22 + ;; + esac + """ + ), + encoding="utf-8", + ) + fake_curl.chmod(0o755) + if force_macos: + fake_uname = bin_dir / "uname" + fake_uname.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + " -s) printf 'Darwin\\n' ;;\n" + " -m) printf 'arm64\\n' ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_uname.chmod(0o755) + if old_updater_parent_pid is not None: + fake_ps = bin_dir / "ps" + fake_ps.write_text( + "#!/bin/sh\nexit 1\n" + if fail_ps + else "#!/bin/sh\n" + 'case "$*" in\n' + ' *lstart*) printf "S %s\\n" "$CODEX_TEST_PARENT_START" ;;\n' + ' *) printf "%s\\n" "$CODEX_TEST_PARENT_PID" ;;\n' + "esac\n", + encoding="utf-8", + ) + fake_ps.chmod(0o755) + + home = root / "home" + home.mkdir(exist_ok=True) + env = os.environ.copy() + env.update( + { + "CODEX_HOME": str(root / "codex-home"), + "CODEX_INSTALL_DIR": str(root / "install-bin"), + "CODEX_NON_INTERACTIVE": "1", + "CODEX_RELEASE": release, + "CODEX_INSTALL_DAEMON_ONLY": "1" if daemon_only else "0", + "CODEX_TEST_ARCHIVE_PATH": str(archive_path or ""), + "CODEX_TEST_CHECKSUM_PATH": str(checksum_path or ""), + "CODEX_TEST_RELEASES_CHECKSUM_PATH": str( + releases_checksum_path or checksum_path or "" + ), + "CODEX_TEST_LEGACY_ARCHIVE_PATH": str(legacy_archive_path or ""), + "CODEX_TEST_METADATA_FAILURE": "1" if metadata_failure else "0", + "CODEX_TEST_METADATA_JSON": ( + metadata_json if metadata_json is not None else release_metadata() + ), + "CODEX_TEST_RELEASES_METADATA_JSON": ( + releases_metadata_json + if releases_metadata_json is not None + else metadata_json + if metadata_json is not None + else release_metadata() + ), + "CODEX_TEST_RELEASES_MODE": releases_mode, + "CODEX_TEST_REQUEST_LOG": str(request_log), + "HOME": str(home), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "SHELL": "/bin/sh", + } + ) + env["CODEX_INSTALL_IF_CURRENT"] = "1" if manual_update else "0" + if update_guard_from_release is None: + env.pop("CODEX_INSTALL_IF_LATEST", None) + env.pop("CODEX_UPDATE_FROM_RELEASE", None) + else: + env["CODEX_INSTALL_IF_LATEST"] = "0" if manual_update else "1" + env["CODEX_UPDATE_FROM_RELEASE"] = update_guard_from_release + if old_updater_parent_pid is not None: + env["CODEX_TEST_PARENT_PID"] = str(old_updater_parent_pid) + env["CODEX_TEST_PARENT_START"] = process_start_time() + if use_mirror is None: + env.pop("CODEX_INSTALLER_USE_RELEASES_OPENAI_COM", None) + else: + env["CODEX_INSTALLER_USE_RELEASES_OPENAI_COM"] = ( + "TRUE" if use_mirror else "false" + ) + result = subprocess.run( + ["/bin/sh", str(INSTALL_SCRIPT)], + capture_output=True, + check=False, + env=env, + text=True, + ) + requests = ( + request_log.read_text(encoding="utf-8").splitlines() + if request_log.exists() + else [] + ) + return result, requests + + +def process_start_time() -> str: + details = subprocess.check_output( + ["ps", "-p", str(os.getpid()), "-o", "stat=", "-o", "lstart="], text=True + ).strip() + return details.split(maxsplit=1)[1] + + +def create_package_release( + root: Path, + *, + metadata_version: str = VERSION, +) -> tuple[Path, Path, str]: + package_dir = root / "package" + (package_dir / "bin").mkdir(parents=True) + (package_dir / "codex-path").mkdir() + (package_dir / "codex-package.json").write_text("{}\n", encoding="utf-8") + write_executable( + package_dir / "bin" / "codex", + f"#!/bin/sh\nprintf 'codex-cli {VERSION}\\n'\n", + ) + write_executable( + package_dir / "bin" / "codex-code-mode-host", + "#!/bin/sh\nexit 0\n", + ) + write_executable(package_dir / "codex-path" / "rg", "#!/bin/sh\nexit 0\n") + + asset = "codex-package-aarch64-apple-darwin.tar.gz" + archive_path = root / asset + with tarfile.open(archive_path, "w:gz") as archive: + for path in package_dir.iterdir(): + archive.add(path, arcname=path.name) + + archive_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() + checksum_path = root / "codex-package_SHA256SUMS" + checksum_path.write_text(f"{archive_digest} {asset}\n", encoding="utf-8") + checksum_digest = hashlib.sha256(checksum_path.read_bytes()).hexdigest() + metadata_json = json.dumps( + { + "assets": [ + {"name": asset, "digest": f"sha256:{archive_digest}"}, + { + "name": "codex-package_SHA256SUMS", + "digest": f"sha256:{checksum_digest}", + }, + ], + "tag_name": f"rust-v{metadata_version}", + }, + indent=2, + ) + return archive_path, checksum_path, metadata_json + + +def create_legacy_release(root: Path) -> tuple[Path, str]: + package_dir = root / "legacy-package" + vendor_dir = package_dir / "package" / "vendor" / "aarch64-apple-darwin" + (vendor_dir / "codex").mkdir(parents=True) + (vendor_dir / "path").mkdir() + write_executable( + vendor_dir / "codex" / "codex", + f"#!/bin/sh\nprintf 'codex-cli {VERSION}\\n'\n", + ) + write_executable(vendor_dir / "path" / "rg", "#!/bin/sh\nexit 0\n") + + asset = f"codex-npm-darwin-arm64-{VERSION}.tgz" + archive_path = root / asset + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(package_dir / "package", arcname="package") + + archive_digest = hashlib.sha256(archive_path.read_bytes()).hexdigest() + metadata_json = json.dumps( + { + "assets": [{"name": asset, "digest": f"sha256:{archive_digest}"}], + "tag_name": f"rust-v{VERSION}", + }, + indent=2, + ) + return archive_path, metadata_json + + +def write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def release_metadata(*, compact: bool = False, reorder: bool = False) -> str: + assets = [ + asset_metadata( + f"codex-package-{target}.tar.gz", + f"sha256:{'a' * 64}", + reorder=reorder, + ) + for target in ( + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-musl", + "x86_64-unknown-linux-musl", + ) + ] + assets.append( + asset_metadata( + "codex-package_SHA256SUMS", + f"sha256:{'b' * 64}", + reorder=reorder, + ) + ) + separators = (",", ":") if compact else None + return json.dumps( + {"assets": assets, "body": "braces: { } [ ]", "tag_name": f"rust-v{VERSION}"}, + indent=None if compact else 2, + separators=separators, + ) + + +def asset_metadata(name: str, digest: str, *, reorder: bool) -> dict[str, str]: + if reorder: + return {"digest": digest, "name": name} + return {"name": name, "digest": digest} + + +def legacy_release_metadata_with_decoys() -> str: + fake_digest = f"sha256:{'0' * 64}" + assets = [ + { + "metadata": { + "name": "codex-package-x86_64-unknown-linux-musl.tar.gz", + "digest": fake_digest, + }, + "digest": f"sha256:{'c' * 64}", + "name": f"codex-npm-{target}-{VERSION}.tgz", + } + for target in ("darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64") + ] + return json.dumps( + { + "body": ( + f'fake: {{"name":"codex-package_SHA256SUMS","digest":"{fake_digest}"}}' + ), + "assets": assets, + "tag_name": f"rust-v{VERSION}", + }, + separators=(",", ":"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/list-bazel-clippy-targets.sh b/scripts/list-bazel-clippy-targets.sh new file mode 100644 index 0000000000000000000000000000000000000000..d12a256d00673350b1a10fd8070f7001840e65c4 --- /dev/null +++ b/scripts/list-bazel-clippy-targets.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +windows_cross_compile=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --windows-cross-compile) + windows_cross_compile=1 + shift + ;; + *) + echo "Usage: $0 [--windows-cross-compile]" >&2 + exit 1 + ;; + esac +done + +# Resolve the dynamic targets before printing anything so callers do not +# continue with a partial list if `bazel query` fails. Target discovery is +# local on all platforms. +manual_rust_test_targets="$( + ./.github/scripts/run-bazel-query-ci.sh \ + --output=label \ + -- 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))' +)" +if [[ "${RUNNER_OS:-}" != "Windows" ]]; then + # Non-Windows clippy jobs lint the native test binaries; the + # Windows-cross binaries exist only for the fast Windows test leg. + manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-windows-cross-bin$' || true)" +elif [[ $windows_cross_compile -eq 1 ]]; then + # `bazel query` is intentionally pre-analysis and does not remove targets + # made incompatible by `target_compatible_with`. Sharded integration tests + # add native-only manual helpers such as `core-all-test-bin`, plus separate + # `core-all-test-windows-cross-bin` helpers for the Windows cross leg. Keep + # the Windows helpers and unit-test helpers, but do not pass the native-only + # sharded integration helpers as explicit clippy targets. + manual_rust_test_targets="$(printf '%s\n' "${manual_rust_test_targets}" | grep -v -- '-test-bin$' || true)" +fi + +printf '%s\n' \ + "//codex-rs/..." \ + "-//codex-rs/v8-poc:all" + +# `--config=clippy` on the `workspace_root_test` wrappers does not lint the +# underlying `rust_test` binaries. Add the internal manual `*-unit-tests-bin` +# targets explicitly so inline `#[cfg(test)]` code is linted like +# `cargo clippy --tests`. +printf '%s\n' "${manual_rust_test_targets}" diff --git a/scripts/list-bazel-release-targets.sh b/scripts/list-bazel-release-targets.sh new file mode 100644 index 0000000000000000000000000000000000000000..154f0b3580f3ba3216b6e4b840a3a7364e24e007 --- /dev/null +++ b/scripts/list-bazel-release-targets.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +# Keep this list focused on first-party Rust targets whose compile surface can +# differ when `cfg(not(debug_assertions))` becomes active. +# +# Exclude the experimental `v8-poc` target because it pulls in expensive V8 +# build machinery that is unrelated to the release-only Rust regression this +# workflow is meant to catch. +# The normal test job covers the Wine smoke test; omit its downloaded runtime +# and cross-compile from this build-only release sweep. +printf '%s\n' \ + "//codex-rs/..." \ + "-//codex-rs/core/tests/remote_env_windows:smoke-test" \ + "-//codex-rs/v8-poc:all" diff --git a/scripts/mcp_conformance/README.md b/scripts/mcp_conformance/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d75aa1f3b89618aa57c698bdbaf50482d893c054 --- /dev/null +++ b/scripts/mcp_conformance/README.md @@ -0,0 +1,121 @@ +# MCP client conformance + +This directory tests the actual Codex executable against the official Model +Context Protocol client conformance suite. It exercises the shipping legacy, +intermediate `2025-11-25`, and modern `2026-07-28` protocols, localhost HTTP, +stdio, OAuth, and additional transport and security regression fixtures. + +The official upstream suite is pinned to +`modelcontextprotocol/conformance@49103de6ed70804e940637bf3e9e29e4a3f54e64`. +Use Node.js 22 and Python 3.10 or later. + +## Run the conformance gate + +First install the frozen workspace dependencies and build Codex: + +```bash +pnpm install --frozen-lockfile +cargo build --locked --manifest-path codex-rs/Cargo.toml -p codex-cli --bin codex +``` + +From a published Codex checkout, run: + +```bash +python3 scripts/mcp_conformance/run_codex_compliance.py \ + codex-rs/target/debug/codex \ + --conformance-cli node_modules/@modelcontextprotocol/conformance/dist/index.js \ + --baseline-report scripts/mcp_conformance/regression-baseline-v1.json \ + --report /tmp/codex-mcp-conformance.json +``` + +The positional executable can also point to an already built Codex binary. +`--conformance-cli` selects the exact, lockfile-installed upstream JavaScript +runner instead of downloading a moving version during a test. + +## What the baseline means + +`regression-baseline-v1.json` is a compact, reviewed snapshot of the upstream +revision, required protocol versions, HTTP and stdio transports, enabled modern +feature, OAuth coverage, and individual passing and failing check identities. + +The gate exits successfully only when: + +- The upstream suite and modern feature match the committed baseline. +- The shipping legacy, intermediate, and modern protocols are actually tested. +- The required HTTP, stdio, and authentication scenarios are actually run. +- Every previously passing check still passes. +- No additional check fails. + +Existing known failures remain visible in the complete JSON report. In +particular, `success` describes complete upstream conformance and +`regressionGate.success` describes the no-new-regressions merge gate; the gate +does not relabel an existing failure as a pass. + +Create a compact baseline from a reviewed complete report without contacting +the upstream suite again: + +```bash +python3 scripts/mcp_conformance/run_codex_compliance.py \ + /absolute/path/to/codex \ + --baseline-report /absolute/path/to/full-conformance-report.json \ + --extract-baseline /tmp/mcp-conformance-regression-baseline-v1.json +``` + +Alternatively, add `--write-baseline /tmp/mcp-conformance-regression-baseline-v1.json` +to a complete conformance run. Review every baseline change; do not regenerate +it to conceal a regression. + +## Run the production reviewer regression gate + +The separate reviewer gate tests the real Codex app-server across all three +shipping, legacy, and modern protocol modes. It covers stdio and localhost +HTTP, exact-integer tool and elicitation schemas, bounded multi-round requests, +malformed discovery response IDs, repeated pagination cursors, SSE framing and +keepalives, and catalog boundaries. In a published Codex checkout, run: + +```bash +python3 scripts/mcp_conformance/review_regressions.py \ + /absolute/path/to/codex \ + --mode all \ + --baseline-report scripts/mcp_conformance/review-regression-baseline-v1.json \ + --report /tmp/codex-mcp-review-regressions.json +``` + +A complete, main-derived baseline records all 186 real check identities and all +21 required cases. Existing failures remain explicitly visible in the complete +report; `regressionGate.success: true` means there are no newly failing or +missing checks. Improvements are recorded under `fixedChecks`. The gate never +classifies an existing failure as a passing check. + +Extract a compact deterministic reviewer baseline from a reviewed complete +production report without rerunning the client: + +```bash +python3 scripts/mcp_conformance/review_regressions.py \ + /absolute/path/to/codex \ + --baseline-report /absolute/path/to/full-review-regressions.json \ + --extract-baseline /tmp/review-regression-baseline-v1.json +``` + +Review every baseline update. Do not regenerate a baseline to hide a regression. + +## Run the fixture self-tests + +```bash +env PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 \ + python3 -m pytest -q scripts/mcp_conformance +``` + +## Run the required SDK integration + +The existing required SDK workflow runs the complete Python fixture self-tests. +Its TypeScript job builds the actual Codex executable, sets `CODEX_EXEC_PATH`, +installs the pinned upstream conformance runner, and runs both the official +authenticated suite and the separate production reviewer regression matrix. +Neither gate can be skipped. To reproduce the focused integration locally: + +```bash +CODEX_EXEC_PATH=/absolute/path/to/codex \ + pnpm --filter @openai/codex-sdk test -- \ + --runInBand tests/mcpConformance.test.ts +``` diff --git a/scripts/mcp_conformance/codex_conformance_adapter.py b/scripts/mcp_conformance/codex_conformance_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..74df275c4aaa938d0219b2619ef11090d566e8f0 --- /dev/null +++ b/scripts/mcp_conformance/codex_conformance_adapter.py @@ -0,0 +1,1000 @@ +#!/usr/bin/env python3 +"""Adapt one official MCP client scenario to Codex app-server requests.""" + +import ipaddress +import json +import os +import sys +import traceback +import urllib.error +import urllib.parse +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Mapping, Sequence +from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener # noqa: TID251 + +_MODULE_DIR = Path(__file__).resolve().parent +if str(_MODULE_DIR) not in sys.path: + sys.path.insert(0, str(_MODULE_DIR)) + +from run_codex_compliance import ( # noqa: E402 - direct scripts must first add their sibling directory. + MODERN_VERSION, + TEST_SERVER_NAME, + AppServerClient, + AppServerError, + _call_tool, + _command_detail, + _isolated_environment, + _response_result, + _run_command, +) + + +@dataclass +class Step: + name: str + success: bool + detail: str + + +class AdapterFailure(RuntimeError): + pass + + +CIMD_CLIENT_METADATA_URL = "https://conformance-test.local/client-metadata.json" +PRE_REGISTERED_CLIENT_SECRET_ENV_VAR = "MCP_CONFORMANCE_CLIENT_SECRET" +CLIENT_REGISTRATION_OVERRIDE_ENV_VAR = "CODEX_CONFORMANCE_CLIENT_REGISTRATION" +AUTH_COMPLETION_METHOD = "mcpServer/oauthLogin/completed" +EXPECTED_AUTH_REJECTION_SCENARIOS = frozenset( + { + "auth/resource-mismatch", + "auth/iss-supported-missing", + "auth/iss-wrong-issuer", + "auth/iss-unexpected", + "auth/iss-normalized", + "auth/metadata-issuer-mismatch", + } +) + + +def _required_path(name: str) -> Path: + value = os.environ.get(name) + if not value: + raise AdapterFailure(f"{name} is required") + return Path(value).expanduser().resolve() + + +def _required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + raise AdapterFailure(f"{name} is required") + return value + + +def _conformance_context() -> dict[str, object]: + raw = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not raw: + return {} + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + raise AdapterFailure(f"invalid MCP_CONFORMANCE_CONTEXT: {exc}") from exc + if not isinstance(decoded, dict): + raise AdapterFailure("MCP_CONFORMANCE_CONTEXT was not an object") + return decoded + + +def _result_or_raise( + response: Mapping[str, object], + operation: str, +) -> dict[str, object]: + result, detail = _response_result(response) + if result is None: + raise AdapterFailure(f"{operation}: {detail}") + return result + + +def _server_entry(inventory: Mapping[str, object]) -> dict[str, object]: + entries = inventory.get("data") + if not isinstance(entries, list): + raise AdapterFailure("mcpServerStatus/list returned no data array") + for entry in entries: + if isinstance(entry, dict) and entry.get("name") == TEST_SERVER_NAME: + return entry + raise AdapterFailure(f"{TEST_SERVER_NAME!r} was absent from MCP status") + + +def _thread_id(client: AppServerClient, workspace: Path) -> str: + result = _result_or_raise( + client.request( + "thread/start", + {"cwd": str(workspace), "ephemeral": True}, + ), + "thread/start", + ) + thread = result.get("thread") + if not isinstance(thread, dict) or not isinstance(thread.get("id"), str): + raise AdapterFailure("thread/start did not return a thread id") + return str(thread["id"]) + + +def _call( + client: AppServerClient, + *, + thread_id: str, + tool: str, + arguments: Mapping[str, object], +) -> None: + result, detail = _call_tool( + client, + thread_id=thread_id, + tool=tool, + arguments=arguments, + ) + if result is None: + raise AdapterFailure(f"tool {tool}: {detail}") + + +def _elicitation_content( + scenario: str, + _params: Mapping[str, object], +) -> Mapping[str, object]: + if scenario == "elicitation-sep1034-client-defaults": + # The official scenario deliberately supplies no values. Codex, as the + # MCP client under test, must materialize the JSON Schema defaults. + return {} + if scenario == "sep-2322-client-request-state": + return {"confirmed": True} + return {"confirmation": "confirmed"} + + +def _context_tool_calls() -> list[tuple[str, dict[str, object]]]: + context = _conformance_context() + if not context: + raise AdapterFailure( + "official scenario did not provide MCP_CONFORMANCE_CONTEXT" + ) + calls = context.get("toolCalls") + if not isinstance(calls, list): + raise AdapterFailure("official scenario context did not contain toolCalls") + result: list[tuple[str, dict[str, object]]] = [] + for call in calls: + if ( + not isinstance(call, dict) + or not isinstance(call.get("name"), str) + or not isinstance(call.get("arguments"), dict) + ): + raise AdapterFailure("official scenario contained an invalid tool call") + result.append((str(call["name"]), dict(call["arguments"]))) + return result + + +def _is_loopback_hostname(hostname: str | None) -> bool: + if hostname is None: + return False + if hostname.lower() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _validated_callback_url(authorization_url: str, location: str) -> str: + authorization = urllib.parse.urlsplit(authorization_url) + query = urllib.parse.parse_qs(authorization.query, keep_blank_values=True) + redirect_values = query.get("redirect_uri") + state_values = query.get("state") + if not redirect_values or len(redirect_values) != 1: + raise AdapterFailure( + "authorization request did not contain exactly one redirect_uri" + ) + if not state_values or len(state_values) != 1: + raise AdapterFailure("authorization request did not contain exactly one state") + + redirect = urllib.parse.urlsplit(redirect_values[0]) + if ( + redirect.scheme != "http" + or not _is_loopback_hostname(redirect.hostname) + or redirect.username is not None + or redirect.password is not None + or redirect.port is None + or redirect.query + or redirect.fragment + ): + raise AdapterFailure("OAuth redirect_uri was not a safe loopback HTTP endpoint") + + callback_url = urllib.parse.urljoin(authorization_url, location) + callback = urllib.parse.urlsplit(callback_url) + expected_endpoint = ( + redirect.scheme, + redirect.hostname, + redirect.port, + redirect.path, + ) + actual_endpoint = ( + callback.scheme, + callback.hostname, + callback.port, + callback.path, + ) + if ( + actual_endpoint != expected_endpoint + or callback.username is not None + or callback.password is not None + or callback.fragment + ): + raise AdapterFailure( + "authorization server redirect did not target Codex's exact loopback callback" + ) + + callback_query = urllib.parse.parse_qs(callback.query, keep_blank_values=True) + callback_states = callback_query.get("state") + if callback_states != state_values: + raise AdapterFailure( + "authorization server redirect did not preserve OAuth state" + ) + for parameter in ("code", "error", "iss"): + values = callback_query.get(parameter) + if values is not None and (len(values) != 1 or not values[0]): + raise AdapterFailure( + f"authorization server redirect must contain exactly one nonempty {parameter}" + ) + if ("code" in callback_query) == ("error" in callback_query): + raise AdapterFailure( + "authorization server redirect must contain exactly one of code or error" + ) + return callback_url + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request( + self, + req: Request, + fp: object, + code: int, + msg: str, + headers: Mapping[str, str], + newurl: str, + ) -> Request | None: + del req, fp, code, msg, headers, newurl + return None + + +def _open_without_redirects(url: str, timeout_seconds: float) -> tuple[int, str | None]: + opener = build_opener( + ProxyHandler({}), + _NoRedirect(), + ) + request = Request( + url, + method="GET", + headers={"Accept": "text/html,application/xhtml+xml"}, + ) + try: + with opener.open(request, timeout=timeout_seconds) as response: + return response.status, response.headers.get("Location") + except urllib.error.HTTPError as exc: + # With redirects disabled urllib represents 3xx as HTTPError. Closing + # the body promptly prevents an authorization page from being retained. + try: + return exc.code, exc.headers.get("Location") + finally: + exc.close() + + +def _drive_headless_authorization( + authorization_url: str, + *, + timeout_seconds: float, +) -> None: + status, location = _open_without_redirects(authorization_url, timeout_seconds) + if status not in {301, 302, 303, 307, 308} or not location: + raise AdapterFailure( + "authorization endpoint did not issue the expected callback redirect" + ) + callback_url = _validated_callback_url(authorization_url, location) + callback_status, _ = _open_without_redirects(callback_url, timeout_seconds) + # Error callbacks may intentionally return a 4xx after notifying the OAuth + # waiter. The completion notification is the authoritative outcome. + if not 200 <= callback_status < 500: + raise AdapterFailure("Codex OAuth callback endpoint returned an invalid status") + + +def _oauth_client_id( + scenario: str, + context: Mapping[str, object], +) -> str | None: + if scenario == "auth/basic-cimd": + return CIMD_CLIENT_METADATA_URL + if scenario == "auth/pre-registration": + client_id = context.get("client_id") + if not isinstance(client_id, str) or not client_id: + raise AdapterFailure("pre-registration context did not contain client_id") + return client_id + return None + + +def _client_registration_override( + scenario: str, + *, + require_automatic_auth: bool, +) -> str | None: + requested = os.environ.get(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR) + if requested: + if requested not in {"auto", "cimd", "dcr"}: + raise AdapterFailure( + f"{CLIENT_REGISTRATION_OVERRIDE_ENV_VAR} must be auto, cimd, or dcr" + ) + return requested + if scenario == "auth/offline-access-scope" and not require_automatic_auth: + return "dcr" + return None + + +def _write_auth_registration( + config_path: Path, + *, + server_url: str, + oauth_client_id: str | None, + oauth_client_secret_env_var: str | None = None, +) -> None: + existing = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + block = f"\n[mcp_servers.{TEST_SERVER_NAME}]\nurl = {json.dumps(server_url, ensure_ascii=False)}\n" + if oauth_client_id is not None: + block += ( + f"\n[mcp_servers.{TEST_SERVER_NAME}.oauth]\n" + f"client_id = {json.dumps(oauth_client_id, ensure_ascii=False)}\n" + ) + if oauth_client_secret_env_var is not None: + block += ( + "client_secret_env_var = " + f"{json.dumps(oauth_client_secret_env_var, ensure_ascii=False)}\n" + ) + config_path.write_text(existing.rstrip() + "\n" + block, encoding="utf-8") + + +def _validate_oauth_secret_not_persisted(codex_home: Path, client_secret: str) -> None: + if not client_secret: + return + + candidates = [codex_home / "config.toml", *codex_home.rglob(".credentials.json")] + secret_bytes = client_secret.encode("utf-8") + for candidate in dict.fromkeys(candidates): + if not candidate.is_file(): + continue + if secret_bytes in candidate.read_bytes(): + raise AdapterFailure( + "environment-provided OAuth client secret was persisted in " + f"{candidate.relative_to(codex_home)}" + ) + + +def _oauth_login( + client: AppServerClient, + *, + scopes: Sequence[str] | None, + timeout_seconds: float, + client_registration: str | None = None, +) -> tuple[bool, str | None]: + event_index = len(client.events) + params: dict[str, object] = { + "name": TEST_SERVER_NAME, + "timeoutSecs": max(1, round(timeout_seconds)), + } + if scopes is not None: + params["scopes"] = list(scopes) + if client_registration is not None: + params["clientRegistration"] = client_registration + response = client.request("mcpServer/oauth/login", params) + result, detail = _response_result(response) + if result is None: + return False, detail + authorization_url = result.get("authorizationUrl") + if not isinstance(authorization_url, str): + raise AdapterFailure("mcpServer/oauth/login returned no authorization URL") + + _drive_headless_authorization( + authorization_url, + timeout_seconds=timeout_seconds, + ) + event = client.wait_for_notification( + AUTH_COMPLETION_METHOD, + predicate=lambda params: params.get("name") == TEST_SERVER_NAME, + after_event_index=event_index, + ) + params_value = event.get("params") + if not isinstance(params_value, dict): + raise AdapterFailure("OAuth completion notification did not contain params") + success = params_value.get("success") is True + error = params_value.get("error") + return success, str(error) if error is not None else None + + +def _reload_mcp(client: AppServerClient) -> None: + _result_or_raise( + client.request("config/mcpServer/reload", None), + "config/mcpServer/reload", + ) + + +def _auth_inventory(client: AppServerClient) -> dict[str, object]: + inventory = _result_or_raise( + client.request("mcpServerStatus/list", {"detail": "full"}), + "mcpServerStatus/list", + ) + _server_entry(inventory) + return inventory + + +def _auth_tool_call(client: AppServerClient, workspace: Path) -> None: + thread_id = _thread_id(client, workspace) + _call( + client, + thread_id=thread_id, + tool="test-tool", + arguments={}, + ) + + +def _login_reload_and_call( + client: AppServerClient, + *, + workspace: Path, + timeout_seconds: float, + scopes: Sequence[str] | None = None, + client_registration: str | None = None, +) -> None: + success, error = _oauth_login( + client, + scopes=scopes, + timeout_seconds=timeout_seconds, + client_registration=client_registration, + ) + if not success: + raise AdapterFailure(f"OAuth login failed: {error or 'unknown error'}") + _reload_mcp(client) + _auth_inventory(client) + _auth_tool_call(client, workspace) + + +def _exercise_auth_scenario( + client: AppServerClient, + *, + scenario: str, + workspace: Path, + timeout_seconds: float, + require_automatic_auth: bool = False, + client_registration: str | None = None, +) -> str: + if scenario in EXPECTED_AUTH_REJECTION_SCENARIOS: + success, error = _oauth_login( + client, + scopes=None, + timeout_seconds=timeout_seconds, + ) + if success: + raise AdapterFailure( + "OAuth flow unexpectedly accepted authorization metadata that must be rejected" + ) + return f"rejected invalid authorization flow: {error or 'request rejected'}" + + if scenario == "auth/scope-step-up": + success, error = _oauth_login( + client, + scopes=None, + timeout_seconds=timeout_seconds, + ) + if not success: + raise AdapterFailure( + f"initial OAuth login failed: {error or 'unknown error'}" + ) + _reload_mcp(client) + _auth_inventory(client) + if require_automatic_auth: + _auth_tool_call(client, workspace) + return "Codex automatically recovered from the challenged OAuth scope" + try: + _auth_tool_call(client, workspace) + except AdapterFailure: + pass + else: + raise AdapterFailure( + "scope-step-up tool call did not request additional scope" + ) + + # The resource server challenges with only the missing scope. The Rust + # client must union it with the previously granted scope. + _login_reload_and_call( + client, + workspace=workspace, + timeout_seconds=timeout_seconds, + scopes=("mcp:write",), + ) + return "completed initial and scope-upgrade authorization flows" + + if scenario == "auth/scope-retry-limit": + if require_automatic_auth: + success, error = _oauth_login( + client, + scopes=None, + timeout_seconds=timeout_seconds, + ) + if not success: + raise AdapterFailure( + f"initial OAuth login failed: {error or 'unknown error'}" + ) + _reload_mcp(client) + _auth_inventory(client) + try: + _auth_tool_call(client, workspace) + except AdapterFailure: + return "observed Codex's production OAuth retry-limit behavior" + raise AdapterFailure( + "retry-limit scenario unexpectedly completed the tool call" + ) + for attempt in range(3): + success, error = _oauth_login( + client, + scopes=None if attempt == 0 else ("mcp:write",), + timeout_seconds=timeout_seconds, + ) + if not success: + raise AdapterFailure(f"OAuth retry failed: {error or 'unknown error'}") + _reload_mcp(client) + _auth_inventory(client) + try: + _auth_tool_call(client, workspace) + except AdapterFailure: + continue + raise AdapterFailure( + "retry-limit scenario unexpectedly completed the tool call" + ) + return "stopped after three unsuccessful authorization attempts" + + if scenario == "auth/authorization-server-migration": + success, error = _oauth_login( + client, + scopes=None, + timeout_seconds=timeout_seconds, + ) + if not success: + raise AdapterFailure( + f"initial OAuth login failed: {error or 'unknown error'}" + ) + _reload_mcp(client) + _auth_inventory(client) + if require_automatic_auth: + _auth_tool_call(client, workspace) + return ( + "Codex automatically registered with the migrated authorization server" + ) + try: + _auth_tool_call(client, workspace) + except AdapterFailure: + pass + else: + raise AdapterFailure("migration scenario did not require re-authorization") + _login_reload_and_call( + client, + workspace=workspace, + timeout_seconds=timeout_seconds, + ) + return ( + "re-authorized after the protected resource changed authorization servers" + ) + + _login_reload_and_call( + client, + workspace=workspace, + timeout_seconds=timeout_seconds, + client_registration=client_registration, + ) + return "completed OAuth login, authenticated discovery, and tool call" + + +def _exercise_scenario( + client: AppServerClient, + *, + scenario: str, + workspace: Path, + inventory: Mapping[str, object], +) -> str: + thread_id = _thread_id(client, workspace) + + if scenario == "tools_call": + _call( + client, + thread_id=thread_id, + tool="add_numbers", + arguments={"a": 2, "b": 3}, + ) + return "called add_numbers" + + if scenario == "elicitation-sep1034-client-defaults": + _call( + client, + thread_id=thread_id, + tool="test_client_elicitation_defaults", + arguments={}, + ) + return "completed legacy elicitation with omitted optional fields" + + if scenario == "sse-retry": + _call( + client, + thread_id=thread_id, + tool="test_reconnection", + arguments={}, + ) + return "completed the SSE reconnection tool call" + + if scenario == "sep-2322-client-request-state": + for tool in ( + "test_mrtr_unrelated", + "test_mrtr_no_result_type", + "test_mrtr_echo_state", + "test_mrtr_no_state", + ): + _call(client, thread_id=thread_id, tool=tool, arguments={}) + return "completed all four MRTR flows" + + if scenario == "http-standard-headers": + _call( + client, + thread_id=thread_id, + tool="test_headers", + arguments={}, + ) + entry = _server_entry(inventory) + resources = entry.get("resources") + if not isinstance(resources, list) or not resources: + raise AdapterFailure("standard-header scenario exposed no resources") + first = resources[0] + if not isinstance(first, dict) or not isinstance(first.get("uri"), str): + raise AdapterFailure("standard-header scenario resource had no URI") + _result_or_raise( + client.request( + "mcpServer/resource/read", + { + "threadId": thread_id, + "server": TEST_SERVER_NAME, + "uri": first["uri"], + }, + ), + "mcpServer/resource/read", + ) + return "called a tool and read a resource" + + if scenario == "http-custom-headers": + for tool, arguments in _context_tool_calls(): + _call( + client, + thread_id=thread_id, + tool=tool, + arguments=arguments, + ) + return "called both custom-header tools with official values" + + if scenario == "http-invalid-tool-headers": + _call( + client, + thread_id=thread_id, + tool="valid_tool", + arguments={"region": "us-west1"}, + ) + return "called the valid tool after filtering malformed definitions" + + if scenario in { + "initialize", + "request-metadata", + "json-schema-ref-no-deref", + }: + # Discovery performed by mcpServerStatus/list is the behavior these + # scenarios observe. Starting a thread also exercises the initialized + # server through the same public app-server interface as the other + # scenarios. + return "completed MCP discovery" + + raise AdapterFailure(f"unsupported official scenario: {scenario}") + + +def run_adapter(server_url: str) -> dict[str, object]: + codex_binary = _required_path("CODEX_CONFORMANCE_BINARY") + codex_home = _required_path("CODEX_CONFORMANCE_HOME") + scenario = _required_env("MCP_CONFORMANCE_SCENARIO") + protocol_version = _required_env("MCP_CONFORMANCE_PROTOCOL_VERSION") + timeout_seconds = float(os.environ.get("CODEX_CONFORMANCE_TIMEOUT", "30")) + enable_modern_feature = ( + os.environ.get("CODEX_CONFORMANCE_ENABLE_MODERN_FEATURE", "1") != "0" + ) + require_automatic_auth = ( + os.environ.get("CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH", "0") == "1" + ) + context = _conformance_context() + + codex_home.mkdir(parents=True, exist_ok=True) + workspace = codex_home / "workspace" + workspace.mkdir() + env = _isolated_environment(codex_home) + # Scenario context can contain ephemeral OAuth client secrets. The adapter + # consumes it directly and does not expose the full blob to Codex. + env.pop("MCP_CONFORMANCE_CONTEXT", None) + steps: list[Step] = [] + registered = False + error: str | None = None + + try: + if scenario.startswith("auth/"): + config_path = codex_home / "config.toml" + config_path.write_text( + 'mcp_oauth_credentials_store = "file"\n', + encoding="utf-8", + ) + steps.append( + Step( + "oauth_store_configuration", + True, + "configured the isolated file OAuth credential store", + ) + ) + + if protocol_version == MODERN_VERSION and enable_modern_feature: + feature = _run_command( + [ + str(codex_binary), + "features", + "enable", + "mcp_2026_07_28", + ], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + feature_configured = feature.returncode == 0 + steps.append( + Step( + "modern_feature_configuration", + feature_configured, + "configured mcp_2026_07_28 before MCP startup" + if feature_configured + else _command_detail(feature), + ) + ) + if not feature_configured: + raise AdapterFailure("could not configure the modern MCP feature") + + oauth_client_id = _oauth_client_id(scenario, context) + oauth_client_secret = context.get("client_secret") + oauth_client_secret_env_var = None + if ( + scenario == "auth/pre-registration" + and isinstance(oauth_client_secret, str) + and oauth_client_secret + ): + env[PRE_REGISTERED_CLIENT_SECRET_ENV_VAR] = oauth_client_secret + oauth_client_secret_env_var = PRE_REGISTERED_CLIENT_SECRET_ENV_VAR + if scenario.startswith("auth/"): + _write_auth_registration( + codex_home / "config.toml", + server_url=server_url, + oauth_client_id=oauth_client_id, + oauth_client_secret_env_var=oauth_client_secret_env_var, + ) + registered = True + steps.append( + Step( + "mcp_registration", + True, + "wrote isolated registration without triggering CLI auto-login", + ) + ) + else: + add = _run_command( + [ + str(codex_binary), + "mcp", + "add", + TEST_SERVER_NAME, + "--url", + server_url, + ], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + registered = add.returncode == 0 + steps.append(Step("mcp_add", registered, _command_detail(add))) + if not registered: + raise AdapterFailure("codex mcp add failed") + + get = _run_command( + [ + str(codex_binary), + "mcp", + "get", + TEST_SERVER_NAME, + "--json", + ], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + registration_ok = False + if get.returncode == 0: + try: + decoded = json.loads(get.stdout) + transport = ( + decoded.get("transport") if isinstance(decoded, dict) else None + ) + registration_ok = ( + isinstance(transport, dict) + and transport.get("type") == "streamable_http" + and transport.get("url") == server_url + ) + except json.JSONDecodeError: + pass + steps.append( + Step( + "mcp_get", + registration_ok, + "registered official scenario URL" + if registration_ok + else _command_detail(get), + ) + ) + if not registration_ok: + raise AdapterFailure("Codex registration did not preserve the scenario URL") + + with AppServerClient( + codex_binary, + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + elicitation_content=lambda params: _elicitation_content(scenario, params), + ) as client: + initialize = _result_or_raise( + client.request( + "initialize", + { + "clientInfo": { + "name": "official-mcp-conformance-adapter", + "title": "Official MCP conformance adapter", + "version": "1.0.0", + }, + "capabilities": { + "experimentalApi": True, + "requestAttestation": False, + "mcpServerOpenaiFormElicitation": True, + }, + }, + ), + "app-server initialize", + ) + steps.append(Step("app_server_initialize", bool(initialize), "initialized")) + client.notify("initialized") + + if protocol_version == MODERN_VERSION and enable_modern_feature: + feature = _result_or_raise( + client.request( + "experimentalFeature/enablement/set", + {"enablement": {"mcp_2026_07_28": True}}, + ), + "experimentalFeature/enablement/set", + ) + enabled = ( + isinstance(feature.get("enablement"), dict) + and feature["enablement"].get("mcp_2026_07_28") is True + ) + steps.append( + Step( + "modern_feature_enablement", + enabled, + "enabled mcp_2026_07_28" + if enabled + else f"unexpected response: {feature!r}", + ) + ) + if not enabled: + raise AdapterFailure("could not enable the modern MCP feature") + + if scenario.startswith("auth/"): + client_registration = _client_registration_override( + scenario, + require_automatic_auth=require_automatic_auth, + ) + detail = _exercise_auth_scenario( + client, + scenario=scenario, + workspace=workspace, + timeout_seconds=timeout_seconds, + require_automatic_auth=require_automatic_auth, + client_registration=client_registration, + ) + if scenario == "auth/pre-registration" and isinstance( + oauth_client_secret, + str, + ): + _validate_oauth_secret_not_persisted( + codex_home, oauth_client_secret + ) + steps.append( + Step( + "oauth_client_secret_not_persisted", + True, + "environment-provided confidential-client secret was not " + "written to configuration or file-backed credentials", + ) + ) + steps.append(Step("authentication", True, detail)) + else: + inventory = _result_or_raise( + client.request("mcpServerStatus/list", {"detail": "full"}), + "mcpServerStatus/list", + ) + _server_entry(inventory) + steps.append(Step("inventory", True, "official server discovered")) + detail = _exercise_scenario( + client, + scenario=scenario, + workspace=workspace, + inventory=inventory, + ) + steps.append(Step("scenario", True, detail)) + except (AdapterFailure, AppServerError, OSError, ValueError) as exc: + error = str(exc) + if not steps or steps[-1].success: + steps.append(Step("scenario", False, error)) + finally: + if registered: + remove = _run_command( + [str(codex_binary), "mcp", "remove", TEST_SERVER_NAME], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + steps.append( + Step("mcp_remove", remove.returncode == 0, _command_detail(remove)) + ) + + success = bool(steps) and all(step.success for step in steps) + return { + "success": success, + "scenario": scenario, + "protocolVersion": protocol_version, + "automaticAuthRequired": require_automatic_auth, + "serverUrl": server_url, + "steps": [asdict(step) for step in steps], + "error": error, + } + + +def main(argv: Sequence[str] | None = None) -> int: + values = list(sys.argv[1:] if argv is None else argv) + report_path_value = os.environ.get("CODEX_CONFORMANCE_ADAPTER_REPORT") + report: dict[str, object] + try: + if len(values) != 1: + raise AdapterFailure("expected exactly one official scenario server URL") + report = run_adapter(values[0]) + except Exception as exc: # Preserve a diagnostic artifact for the parent. + report = { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(limit=20), + "steps": [], + } + + if report_path_value: + report_path = Path(report_path_value) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(json.dumps(report, ensure_ascii=False, sort_keys=True)) + return 0 if report.get("success") is True else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_conformance/official_conformance.py b/scripts/mcp_conformance/official_conformance.py new file mode 100644 index 0000000000000000000000000000000000000000..a6cee00a17de38a474c2ee7e18c04053f2974771 --- /dev/null +++ b/scripts/mcp_conformance/official_conformance.py @@ -0,0 +1,518 @@ +"""Driver for the upstream MCP client conformance suite. + +The upstream suite owns the localhost HTTP server and the wire-level checks. +This module deliberately invokes one scenario at a time because the upstream +parallel suite path does not currently propagate the client adapter result. +""" + +import json +import os +import re +import shlex +import signal +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping, Sequence + +SHIPPING_LEGACY_VERSION = "2025-06-18" +LEGACY_VERSION = "2025-11-25" +MODERN_VERSION = "2026-07-28" + +# Reviewed on 2026-07-29. Pinning a commit instead of an npm prerelease keeps +# the scenario definitions and result semantics reproducible. +OFFICIAL_CONFORMANCE_GIT_REF = "49103de6ed70804e940637bf3e9e29e4a3f54e64" +OFFICIAL_CONFORMANCE_REPOSITORY = "modelcontextprotocol/conformance" + +OFFICIAL_NON_AUTH_SCENARIOS: Mapping[str, tuple[str, ...]] = { + SHIPPING_LEGACY_VERSION: ( + "initialize", + "tools_call", + ), + LEGACY_VERSION: ( + "initialize", + "tools_call", + "elicitation-sep1034-client-defaults", + "sse-retry", + ), + MODERN_VERSION: ( + "tools_call", + "request-metadata", + "sep-2322-client-request-state", + "http-standard-headers", + "http-custom-headers", + "http-invalid-tool-headers", + "json-schema-ref-no-deref", + ), +} + +OFFICIAL_AUTH_SCENARIOS: Mapping[str, tuple[str, ...]] = { + SHIPPING_LEGACY_VERSION: ( + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + ), + LEGACY_VERSION: ( + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/basic-cimd", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + "auth/pre-registration", + ), + MODERN_VERSION: ( + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/basic-cimd", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + "auth/pre-registration", + "auth/resource-mismatch", + "auth/offline-access-scope", + "auth/offline-access-not-supported", + "auth/authorization-server-migration", + "auth/iss-supported", + "auth/iss-not-advertised", + "auth/iss-supported-missing", + "auth/iss-wrong-issuer", + "auth/iss-unexpected", + "auth/iss-normalized", + "auth/metadata-issuer-mismatch", + ), +} + +# These scenarios cover separately negotiated protocol extensions rather than +# either dated release. Keep them visible for future adapters, but do not mix +# them into versioned release conformance percentages. +OFFICIAL_AUTH_EXTENSION_SCENARIOS: tuple[str, ...] = ( + "auth/client-credentials-jwt", + "auth/client-credentials-basic", + "auth/enterprise-managed-authorization", + "auth/dpop", + "auth/dpop-nonce", + "auth/wif-jwt-bearer", +) + +_SENSITIVE_JSON_FIELD = re.compile( + r'("(?:client_secret|private_key_pem|valid_jwt|wrong_audience_jwt|' + r'expired_jwt|idp_id_token)"\s*:\s*)"(?:\\.|[^"\\])*"' +) +_SENSITIVE_ESCAPED_JSON_FIELD = re.compile( + r'(\\"(?:client_secret|private_key_pem|valid_jwt|wrong_audience_jwt|' + r'expired_jwt|idp_id_token)\\"\s*:\s*)\\"(?:\\\\.|[^"\\])*\\"' +) +_SENSITIVE_URL_PARAMETER = re.compile( + r"([?&](?:code|state|code_challenge|code_verifier|access_token|" + r"""refresh_token|client_secret)=)[^&\s"'\\<>]+""", + re.IGNORECASE, +) +_BEARER_TOKEN = re.compile(r"(\bBearer\s+)[A-Za-z0-9._~+/=-]+", re.IGNORECASE) + + +def redact_sensitive_text(value: str) -> str: + value = _SENSITIVE_JSON_FIELD.sub(r'\1"[REDACTED]"', value) + value = _SENSITIVE_ESCAPED_JSON_FIELD.sub(r'\1\\"[REDACTED]\\"', value) + value = _SENSITIVE_URL_PARAMETER.sub(r"\1[REDACTED]", value) + return _BEARER_TOKEN.sub(r"\1[REDACTED]", value) + + +def _scrub_retained_artifacts(scenario_dir: Path) -> None: + for name in ("stdout.txt", "stderr.txt"): + for path in scenario_dir.rglob(name): + try: + original = path.read_text(encoding="utf-8") + redacted = redact_sensitive_text(original) + if redacted != original: + path.write_text(redacted, encoding="utf-8") + except OSError: + continue + for path in scenario_dir.rglob(".credentials.json"): + try: + path.unlink(missing_ok=True) + except OSError: + continue + + +@dataclass(frozen=True) +class OfficialCheck: + scenario: str + check_id: str + name: str + status: str + description: str + error_message: str | None = None + + +@dataclass +class OfficialScenarioResult: + scenario: str + success: bool + checks: list[OfficialCheck] = field(default_factory=list) + adapter_success: bool = False + adapter_detail: str = "" + runner_detail: str = "" + + +def default_conformance_command() -> list[str]: + return [ + "npx", + "--yes", + (f"github:{OFFICIAL_CONFORMANCE_REPOSITORY}#{OFFICIAL_CONFORMANCE_GIT_REF}"), + ] + + +def scenarios_for_mode( + mode: str, + requested: Sequence[str] | None = None, + *, + include_auth: bool = True, +) -> tuple[str, ...]: + non_auth = OFFICIAL_NON_AUTH_SCENARIOS.get(mode) + auth = OFFICIAL_AUTH_SCENARIOS.get(mode) + if non_auth is None or auth is None: + raise ValueError(f"unsupported MCP protocol version: {mode}") + available = non_auth + (auth if include_auth else ()) + if not requested: + return available + all_scenarios = { + scenario + for scenario_map in ( + OFFICIAL_NON_AUTH_SCENARIOS, + OFFICIAL_AUTH_SCENARIOS, + ) + for mode_scenarios in scenario_map.values() + for scenario in mode_scenarios + } + unknown = sorted(set(requested) - all_scenarios) + if unknown: + raise ValueError( + "scenarios are not part of the pinned versioned client suite: " + + ", ".join(unknown) + ) + disabled = sorted( + set(requested) + & { + scenario + for mode_scenarios in OFFICIAL_AUTH_SCENARIOS.values() + for scenario in mode_scenarios + } + if not include_auth + else () + ) + if disabled: + raise ValueError( + "authentication scenarios require auth coverage to be enabled: " + + ", ".join(disabled) + ) + requested_set = set(requested) + selected = tuple(scenario for scenario in available if scenario in requested_set) + if not selected: + raise ValueError( + f"requested scenarios are unavailable for MCP protocol version {mode}: " + + ", ".join(sorted(requested_set)) + ) + return selected + + +def _safe_scenario_name(scenario: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]+", "-", scenario) + + +def _make_adapter_launcher( + adapter_script: Path, + *, + require_automatic_auth: bool = False, +) -> Path: + # The official runner currently splits --command on spaces before spawning + # it. Put a one-token launcher in the system temp directory so arbitrary + # checkout paths (including paths with spaces) remain supported. + launcher_dir = Path(tempfile.mkdtemp(prefix="mcp-conformance-adapter-")) + automatic_auth = "1" if require_automatic_auth else "0" + if sys.platform == "win32": + launcher = launcher_dir / "client.cmd" + adapter_command = subprocess.list2cmdline([sys.executable, str(adapter_script)]) + launcher.write_text( + "@echo off\n" + f'set "CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH={automatic_auth}"\n' + f"{adapter_command} %*\n", + encoding="utf-8", + ) + else: + launcher = launcher_dir / "client" + launcher.write_text( + "#!/bin/sh\n" + f"export CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH={automatic_auth}\n" + f'exec {shlex.quote(sys.executable)} {shlex.quote(str(adapter_script))} "$@"\n', + encoding="utf-8", + ) + launcher.chmod(0o755) + return launcher + + +def _terminate_process_group( + process: subprocess.Popen[str], + *, + force: bool = False, +) -> None: + if sys.platform == "win32": + command = ["taskkill", "/T", "/PID", str(process.pid)] + if force: + command.insert(1, "/F") + try: + result = subprocess.run( + command, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + return + except OSError: + pass + try: + if force: + process.kill() + else: + process.terminate() + except (OSError, ProcessLookupError): + pass + return + + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except (OSError, ProcessLookupError): + pass + + +def _load_checks(result_dir: Path, scenario: str) -> list[OfficialCheck]: + check_files = sorted(result_dir.rglob("checks.json")) + if len(check_files) != 1: + return [] + try: + decoded = json.loads(check_files[0].read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + if not isinstance(decoded, list): + return [] + + checks: list[OfficialCheck] = [] + for index, raw in enumerate(decoded, start=1): + if not isinstance(raw, dict): + continue + check_id = str(raw.get("id") or f"unnamed-{index}") + name = str(raw.get("name") or check_id) + status = str(raw.get("status") or "FAILURE").upper() + # INFO entries are the suite's HTTP trace, not conformance assertions. + # They remain available in the retained upstream checks.json artifact. + if status == "INFO": + continue + checks.append( + OfficialCheck( + scenario=scenario, + check_id=check_id, + name=name, + status=status, + description=str(raw.get("description") or name), + error_message=( + redact_sensitive_text(str(raw["errorMessage"])) + if raw.get("errorMessage") is not None + else None + ), + ) + ) + return checks + + +def _load_adapter_report(report_path: Path) -> tuple[bool, str]: + try: + decoded = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return False, f"Codex adapter did not write a valid report: {exc}" + if not isinstance(decoded, dict): + return False, "Codex adapter report was not an object" + success = decoded.get("success") is True + steps = decoded.get("steps") + failed_steps: list[str] = [] + if isinstance(steps, list): + for step in steps: + if isinstance(step, dict) and step.get("success") is not True: + failed_steps.append( + f"{step.get('name')}: {step.get('detail', 'failed')}" + ) + if success: + return True, "Codex adapter completed the scenario" + if failed_steps: + return False, redact_sensitive_text("; ".join(failed_steps))[-4_000:] + return False, redact_sensitive_text( + str(decoded.get("error") or "Codex adapter failed") + )[-4_000:] + + +def _run_scenario( + *, + conformance_command: Sequence[str], + adapter_launcher: Path, + codex_binary: Path, + mode: str, + scenario: str, + output_dir: Path, + timeout_seconds: float, + base_env: Mapping[str, str], + process_grace_seconds: float = 15, +) -> OfficialScenarioResult: + scenario_dir = output_dir / _safe_scenario_name(scenario) + scenario_dir.mkdir(parents=True, exist_ok=True) + adapter_report = scenario_dir / "codex-adapter.json" + adapter_home = scenario_dir / "codex-home" + adapter_home.mkdir() + + env = dict(base_env) + env.update( + { + "CODEX_CONFORMANCE_BINARY": str(codex_binary), + "CODEX_CONFORMANCE_HOME": str(adapter_home), + "CODEX_CONFORMANCE_ADAPTER_REPORT": str(adapter_report), + } + ) + command = [ + *conformance_command, + "client", + "--command", + str(adapter_launcher), + "--scenario", + scenario, + "--spec-version", + mode, + "--timeout", + str(round(timeout_seconds * 1_000)), + "--output-dir", + str(scenario_dir), + ] + process: subprocess.Popen[str] | None = None + try: + process = subprocess.Popen( + command, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=sys.platform != "win32", + creationflags=( + getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + if sys.platform == "win32" + else 0 + ), + ) + try: + process_timeout = timeout_seconds + process_grace_seconds + stdout, stderr = process.communicate(timeout=process_timeout) + runner_detail = redact_sensitive_text((stderr or stdout).strip())[-8_000:] + returncode = process.returncode + except subprocess.TimeoutExpired: + _terminate_process_group(process) + try: + stdout, stderr = process.communicate(timeout=3) + except subprocess.TimeoutExpired: + _terminate_process_group(process, force=True) + stdout, stderr = process.communicate() + runner_detail = redact_sensitive_text( + (stderr or stdout) + + f"\nofficial scenario timed out after {process_timeout:g}s" + ).strip()[-8_000:] + returncode = 124 + except OSError as exc: + runner_detail = redact_sensitive_text(str(exc)) + returncode = 127 + + # A crashing upstream runner can exit before its still-running adapter has + # atomically written the diagnostic report. Give that report a short grace + # period so an upstream fixture crash is not mislabeled as an adapter + # failure, then terminate any orphaned descendants in the runner's process + # group. + if returncode != 0 and not adapter_report.is_file(): + report_deadline = time.monotonic() + min(process_grace_seconds, 10) + while time.monotonic() < report_deadline and not adapter_report.is_file(): + time.sleep(0.05) + if returncode != 0 and process is not None: + _terminate_process_group(process) + + _scrub_retained_artifacts(scenario_dir) + checks = _load_checks(scenario_dir, scenario) + adapter_success, adapter_detail = _load_adapter_report(adapter_report) + official_failure = any(check.status in {"FAILURE", "WARNING"} for check in checks) + success = ( + returncode == 0 and bool(checks) and adapter_success and not official_failure + ) + if not checks: + runner_detail = ( + "official runner did not produce exactly one checks.json; " + runner_detail + ).strip() + elif returncode != 0 and not official_failure and adapter_success: + runner_detail = ( + f"official runner exited with code {returncode}; " + runner_detail + ).strip() + return OfficialScenarioResult( + scenario=scenario, + success=success, + checks=checks, + adapter_success=adapter_success, + adapter_detail=adapter_detail, + runner_detail=runner_detail, + ) + + +def run_official_mode( + *, + conformance_command: Sequence[str], + adapter_script: Path, + codex_binary: Path, + mode: str, + scenarios: Sequence[str], + output_dir: Path, + timeout_seconds: float, + base_env: Mapping[str, str], +) -> list[OfficialScenarioResult]: + launcher = _make_adapter_launcher( + adapter_script, + require_automatic_auth=base_env.get("CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH") + == "1", + ) + try: + return [ + _run_scenario( + conformance_command=conformance_command, + adapter_launcher=launcher, + codex_binary=codex_binary, + mode=mode, + scenario=scenario, + output_dir=output_dir, + timeout_seconds=timeout_seconds, + base_env=base_env, + ) + for scenario in scenarios + ] + finally: + try: + launcher.unlink() + launcher.parent.rmdir() + except OSError: + pass diff --git a/scripts/mcp_conformance/regression-baseline-v1.json b/scripts/mcp_conformance/regression-baseline-v1.json new file mode 100644 index 0000000000000000000000000000000000000000..dc936a64689e5a52c44fc258a68b0f37875fcc8d --- /dev/null +++ b/scripts/mcp_conformance/regression-baseline-v1.json @@ -0,0 +1,4222 @@ +{ + "automaticAuthRequired": false, + "baselineKind": "mcp-conformance-regression-baseline-v1", + "checks": { + "failing": [ + { + "check_id": "harness/auth/metadata-var3/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/metadata-var3", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/pre-registration/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-from-www-authenticate/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-step-up/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pre-registration-auth", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-from-www-authenticate", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-step-up-initial", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-elicitation-sep1034-boolean-default", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-elicitation-sep1034-enum-default", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-elicitation-sep1034-integer-default", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-elicitation-sep1034-number-default", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-elicitation-sep1034-string-default", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-var3/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/metadata-var3", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/pre-registration/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-from-www-authenticate/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-step-up/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var3", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pre-registration-auth", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-from-www-authenticate", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-step-up-initial", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2350-scope-union-on-reauth", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + } + ], + "passing": [ + { + "check_id": "harness/auth/token-endpoint-auth-basic/codex-adapter", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-none/codex-adapter", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-post/codex-adapter", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/initialize/codex-adapter", + "mode": "2025-06-18", + "scenario": "initialize", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/tools_call/codex-adapter", + "mode": "2025-06-18", + "scenario": "tools_call", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-06-18", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "mcp-client-initialization", + "mode": "2025-06-18", + "scenario": "initialize", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "tool-add-numbers", + "mode": "2025-06-18", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "wire-schema-valid", + "mode": "2025-06-18", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "app_server_initialize", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "echo_tool", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "ephemeral_thread", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "inventory", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "isolated_config_cleanup", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_add", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_get", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_remove", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "unicode_resource_read", + "mode": "2025-06-18", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "harness/auth/basic-cimd/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-default/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-var1/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-var2/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-from-scopes-supported/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-omitted-when-undefined/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-retry-limit/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-basic/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-none/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-post/codex-adapter", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/elicitation-sep1034-client-defaults/codex-adapter", + "mode": "2025-11-25", + "scenario": "elicitation-sep1034-client-defaults", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/initialize/codex-adapter", + "mode": "2025-11-25", + "scenario": "initialize", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/sse-retry/codex-adapter", + "mode": "2025-11-25", + "scenario": "sse-retry", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/tools_call/codex-adapter", + "mode": "2025-11-25", + "scenario": "tools_call", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "cimd-client-id-used", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-from-scopes-supported", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-omitted-when-undefined", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-retry-limit", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-step-up-escalation", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2025-11-25", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "mcp-client-initialization", + "mode": "2025-11-25", + "scenario": "initialize", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-sse-graceful-reconnect", + "mode": "2025-11-25", + "scenario": "sse-retry", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-sse-last-event-id", + "mode": "2025-11-25", + "scenario": "sse-retry", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-sse-retry-timing", + "mode": "2025-11-25", + "scenario": "sse-retry", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "tool-add-numbers", + "mode": "2025-11-25", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "wire-schema-valid", + "mode": "2025-11-25", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "app_server_initialize", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "echo_tool", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "ephemeral_thread", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "inventory", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "isolated_config_cleanup", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_add", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_get", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_remove", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "unicode_resource_read", + "mode": "2025-11-25", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "harness/auth/authorization-server-migration/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/basic-cimd/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-normalized/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-not-advertised/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-supported/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-supported-missing/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-unexpected/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/iss-wrong-issuer/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-default/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-issuer-mismatch/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/metadata-issuer-mismatch", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-var1/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/metadata-var2/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/offline-access-not-supported/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/offline-access-scope/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/resource-mismatch/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/resource-mismatch", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-from-scopes-supported/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-omitted-when-undefined/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/scope-retry-limit/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-basic/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-none/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/auth/token-endpoint-auth-post/codex-adapter", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/http-custom-headers/codex-adapter", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/http-invalid-tool-headers/codex-adapter", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/http-standard-headers/codex-adapter", + "mode": "2026-07-28", + "scenario": "http-standard-headers", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/json-schema-ref-no-deref/codex-adapter", + "mode": "2026-07-28", + "scenario": "json-schema-ref-no-deref", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/request-metadata/codex-adapter", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/sep-2322-client-request-state/codex-adapter", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "harness/tools_call/codex-adapter", + "mode": "2026-07-28", + "scenario": "tools_call", + "source": "harness", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2352-no-cross-as-credential-reuse", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2352-no-reuse-on-as-change", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2352-reregister-on-as-change", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/authorization-server-migration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "cimd-client-id-used", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/basic-cimd", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-no-normalization", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-normalized", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-proceed-no-iss", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/iss-not-advertised", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-compare-iss-supported", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/iss-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-reject-missing-iss", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-supported-missing", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-compare-iss-unadvertised", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-unexpected", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-compare-iss-supported", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/iss-wrong-issuer", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/metadata-default", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/metadata-issuer-mismatch", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/metadata-issuer-mismatch", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2468-client-validate-metadata-issuer", + "mode": "2026-07-28", + "scenario": "auth/metadata-issuer-mismatch", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/metadata-var1", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/metadata-var2", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2207-offline-access-not-requested", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/offline-access-not-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2207-client-metadata-grant-types", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2207-offline-access-requested", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/pre-registration", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/resource-mismatch", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-mismatch-rejected", + "mode": "2026-07-28", + "scenario": "auth/resource-mismatch", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-from-scopes-supported", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/scope-from-scopes-supported", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/scope-from-www-authenticate", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-omitted-when-undefined", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/scope-omitted-when-undefined", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-retry-limit", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/scope-retry-limit", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "scope-step-up-escalation", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/scope-step-up", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-basic", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-none", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "authorization-server-metadata", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "client-registration", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-challenge-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-code-verifier-sent", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-s256-method-used", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "pkce-verifier-matches-challenge", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "prm-pathbased-requested", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-consistency", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-authorization", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-in-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "resource-parameter-valid-uri", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-837-application-type-present", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-endpoint-auth-method", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "token-request", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "valid-bearer-token", + "mode": "2026-07-28", + "scenario": "auth/token-endpoint-auth-post", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-base64-unsafe", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-encode-values", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-mirrors-designated-params", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-omit-null", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-supports-custom-headers", + "mode": "2026-07-28", + "scenario": "http-custom-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-reject-invalid-tool", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-x-mcp-header-charset", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-x-mcp-header-not-empty", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-x-mcp-header-primitive-only", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-x-mcp-header-unique", + "mode": "2026-07-28", + "scenario": "http-invalid-tool-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2243-client-includes-standard-headers", + "mode": "2026-07-28", + "scenario": "http-standard-headers", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2106-no-network-ref-deref", + "mode": "2026-07-28", + "scenario": "json-schema-ref-no-deref", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-client-declares-elicitation-capability", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-client-populates-meta", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-client-retry-supported-version", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-client-sends-client-info", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-http-client-sends-version-header", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2575-http-version-header-matches-meta", + "mode": "2026-07-28", + "scenario": "request-metadata", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2322-client-jsonrpc-id-different", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2322-client-no-state-omitted", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2322-client-parallel-isolation", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2322-client-request-state-echoed", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "sep-2322-default-result-type-complete", + "mode": "2026-07-28", + "scenario": "sep-2322-client-request-state", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "tool-add-numbers", + "mode": "2026-07-28", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "wire-schema-valid", + "mode": "2026-07-28", + "scenario": "tools_call", + "source": "official", + "transport": "official-http" + }, + { + "check_id": "auto_cimd", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "supplemental", + "transport": "official-http" + }, + { + "check_id": "forced_cimd", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "supplemental", + "transport": "official-http" + }, + { + "check_id": "app_server_initialize", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "echo_tool", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "ephemeral_thread", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "inventory", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "isolated_config_cleanup", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_add", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_get", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "mcp_remove", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "modern_feature_enablement", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "multi_round_trip_request", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "per_request_metadata", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "request_scoped_notifications", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + }, + { + "check_id": "unicode_resource_read", + "mode": "2026-07-28", + "scenario": "", + "source": "supplemental", + "transport": "stdio" + } + ] + }, + "modernFeatureEnablement": true, + "officialConformance": { + "authenticationIncluded": true, + "gitRef": "49103de6ed70804e940637bf3e9e29e4a3f54e64", + "repository": "modelcontextprotocol/conformance", + "scenarios": { + "2025-06-18": [ + "initialize", + "tools_call", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none" + ], + "2025-11-25": [ + "initialize", + "tools_call", + "elicitation-sep1034-client-defaults", + "sse-retry", + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/basic-cimd", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + "auth/pre-registration" + ], + "2026-07-28": [ + "tools_call", + "request-metadata", + "sep-2322-client-request-state", + "http-standard-headers", + "http-custom-headers", + "http-invalid-tool-headers", + "json-schema-ref-no-deref", + "auth/metadata-default", + "auth/metadata-var1", + "auth/metadata-var2", + "auth/metadata-var3", + "auth/basic-cimd", + "auth/scope-from-www-authenticate", + "auth/scope-from-scopes-supported", + "auth/scope-omitted-when-undefined", + "auth/scope-step-up", + "auth/scope-retry-limit", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + "auth/pre-registration", + "auth/resource-mismatch", + "auth/offline-access-scope", + "auth/offline-access-not-supported", + "auth/authorization-server-migration", + "auth/iss-supported", + "auth/iss-not-advertised", + "auth/iss-supported-missing", + "auth/iss-wrong-issuer", + "auth/iss-unexpected", + "auth/iss-normalized", + "auth/metadata-issuer-mismatch" + ] + } + }, + "requiredModes": [ + "2025-06-18", + "2025-11-25", + "2026-07-28" + ], + "schemaVersion": 4, + "transports": [ + "stdio", + "official-http" + ], + "versionCheck": { + "success": true + } +} diff --git a/scripts/mcp_conformance/review-regression-baseline-v1.json b/scripts/mcp_conformance/review-regression-baseline-v1.json new file mode 100644 index 0000000000000000000000000000000000000000..5688eafc0426cb16eb27ad78947f1d8cbfa45a42 --- /dev/null +++ b/scripts/mcp_conformance/review-regression-baseline-v1.json @@ -0,0 +1,1039 @@ +{ + "baselineKind": "mcp-review-regression-baseline-v1", + "checks": { + "failing": [ + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/exact-large-integer-elicitation-round-trip", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/exact-large-integer-elicitation-schema", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/mrtr-input-requests-bounded-to-64", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/catalog-over-limit/exact-limit-error", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/server-info", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/startup-failed", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/tool-count", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/exact-large-integer-elicitation-round-trip", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-elicitation-schema", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/mrtr-input-requests-bounded-to-64", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + } + ], + "passing": [ + { + "check_id": "review/app-server-initialize", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-round-trip", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-schema", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/legacy-reserved-stdio-environment-preserved", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/server-discovery", + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-round-trip", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-schema", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/legacy-reserved-stdio-environment-preserved", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/mcp-registration", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/server-discovery", + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/reject-mismatched-discovery-response-id", + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/reject-null-discovery-response-id", + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/repeated-pagination-cursor-bounded", + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-round-trip", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-schema", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/modern-paginated-tools-page-two", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/server-discovery", + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/sse-comment-flood-excluded-from-event-size-limit", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/sse-cr-discovery", + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/sse-carriage-return-and-comment-framing", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/sse-cr-discovery", + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/configured-server", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/server-info", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/startup-ready", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/catalog-at-limit/tool-count", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/catalog-over-limit/configured-server", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "check_id": "review/app-server-initialize", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/ephemeral-thread", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-round-trip", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/exact-large-integer-tool-schema", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/isolated-registration-cleanup", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/mcp-registration", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/modern-feature-configuration", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/modern-feature-enablement", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/modern-paginated-tools-page-two", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + }, + { + "check_id": "review/server-discovery", + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + } + ] + }, + "requiredCases": [ + { + "mode": "2025-06-18", + "transport": "review-http:catalog-max" + }, + { + "mode": "2025-06-18", + "transport": "review-http:catalog-over-limit" + }, + { + "mode": "2025-06-18", + "transport": "review-stdio:catalog-max" + }, + { + "mode": "2025-06-18", + "transport": "review-stdio:catalog-over-limit" + }, + { + "mode": "2025-06-18", + "transport": "review-stdio:review-regressions" + }, + { + "mode": "2025-11-25", + "transport": "review-http:catalog-max" + }, + { + "mode": "2025-11-25", + "transport": "review-http:catalog-over-limit" + }, + { + "mode": "2025-11-25", + "transport": "review-stdio:catalog-max" + }, + { + "mode": "2025-11-25", + "transport": "review-stdio:catalog-over-limit" + }, + { + "mode": "2025-11-25", + "transport": "review-stdio:review-regressions" + }, + { + "mode": "2026-07-28", + "transport": "review-http:catalog-max" + }, + { + "mode": "2026-07-28", + "transport": "review-http:catalog-over-limit" + }, + { + "mode": "2026-07-28", + "transport": "review-http:discovery-mismatched-id" + }, + { + "mode": "2026-07-28", + "transport": "review-http:discovery-null-id" + }, + { + "mode": "2026-07-28", + "transport": "review-http:repeated-cursor" + }, + { + "mode": "2026-07-28", + "transport": "review-http:review-regressions" + }, + { + "mode": "2026-07-28", + "transport": "review-http:sse-comment-flood" + }, + { + "mode": "2026-07-28", + "transport": "review-http:sse-cr-comments" + }, + { + "mode": "2026-07-28", + "transport": "review-stdio:catalog-max" + }, + { + "mode": "2026-07-28", + "transport": "review-stdio:catalog-over-limit" + }, + { + "mode": "2026-07-28", + "transport": "review-stdio:review-regressions" + } + ], + "requiredModes": [ + "2025-06-18", + "2025-11-25", + "2026-07-28" + ], + "reviewer": "codex-mcp-regression-suite", + "schemaVersion": 1, + "summary": { + "casesPassed": 13, + "casesTotal": 21, + "failed": 30, + "passed": 156, + "total": 186 + } +} diff --git a/scripts/mcp_conformance/review_regressions.py b/scripts/mcp_conformance/review_regressions.py new file mode 100644 index 0000000000000000000000000000000000000000..7a9d059e45427d52175475bbe2c0b2e8836c65aa --- /dev/null +++ b/scripts/mcp_conformance/review_regressions.py @@ -0,0 +1,1294 @@ +#!/usr/bin/env python3 +"""Run reviewer-derived MCP regressions against a real Codex app-server.""" + +import argparse +import json +import os +import shutil +import sys +import tempfile +import threading +import time +from contextlib import contextmanager, nullcontext +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterator, Mapping, Sequence + +_MODULE_DIR = Path(__file__).resolve().parent +if str(_MODULE_DIR) not in sys.path: + sys.path.insert(0, str(_MODULE_DIR)) + +from run_codex_compliance import ( # noqa: E402 - direct scripts must first add their sibling directory. + LEGACY_VERSION, + MISMATCHED_DISCOVERY_ID_PROFILE, + MODERN_VERSION, + NULL_DISCOVERY_ID_PROFILE, + REPEATED_CURSOR_PROFILE, + REVIEW_EXACT_INTEGER, + REVIEW_MRTR_INPUT_REQUEST_COUNT, + REVIEW_PROFILE, + SHIPPING_LEGACY_VERSION, + SSE_COMMENT_FLOOD_PROFILE, + SSE_CR_COMMENTS_PROFILE, + TEST_SERVER_NAME, + AppServerClient, + AppServerError, + CaseResult, + ProtocolServer, + _call_tool, + _command_detail, + _isolated_environment, + _response_result, + _run_command, + make_http_server, +) +from server import ( # noqa: E402 - direct scripts must first add their sibling directory. + CATALOG_MAX_PROFILE, + CATALOG_OVER_LIMIT_PROFILE, + MAX_CATALOG_ITEMS, +) + +REVIEW_MODES = (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) +REVIEW_REPORT_SCHEMA_VERSION = 1 +REVIEW_BASELINE_KIND = "mcp-review-regression-baseline-v1" +REVIEWER = "codex-mcp-regression-suite" +LEGACY_ENVIRONMENT_SENTINEL = "review-legacy-protocol-environment" +MRTR_REQUEST_LIMIT = 64 +CATALOG_BOUNDARY_PROFILES = (CATALOG_MAX_PROFILE, CATALOG_OVER_LIMIT_PROFILE) +CATALOG_BOUNDARY_TRANSPORTS = ("stdio", "http") +CATALOG_LIMIT_ERROR = ( + f"tools/list exceeded the catalog limit of {MAX_CATALOG_ITEMS} items" +) + + +@dataclass(frozen=True, order=True) +class _ReviewCheckIdentity: + mode: str + transport: str + check_id: str + + +def _required_review_cases() -> set[tuple[str, str]]: + cases = {(mode, f"review-stdio:{REVIEW_PROFILE}") for mode in REVIEW_MODES} + cases.update( + (MODERN_VERSION, f"review-http:{profile}") + for profile in ( + REVIEW_PROFILE, + REPEATED_CURSOR_PROFILE, + MISMATCHED_DISCOVERY_ID_PROFILE, + NULL_DISCOVERY_ID_PROFILE, + SSE_CR_COMMENTS_PROFILE, + SSE_COMMENT_FLOOD_PROFILE, + ) + ) + cases.update( + (mode, f"review-{transport}:{profile}") + for mode in REVIEW_MODES + for transport in CATALOG_BOUNDARY_TRANSPORTS + for profile in CATALOG_BOUNDARY_PROFILES + ) + return cases + + +@contextmanager +def _running_review_http_fixture(mode: str, profile: str) -> Iterator[str]: + server = make_http_server( + ProtocolServer(mode, profile=profile), + "127.0.0.1", + 0, + log_requests=False, + ) + thread = threading.Thread( + target=server.serve_forever, + name=f"mcp-review-{profile}-{mode}", + daemon=True, + ) + thread.start() + try: + _, port = server.server_address + yield f"http://127.0.0.1:{port}/mcp" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +def _review_registration_command( + codex_binary: Path, + server_script: Path, + *, + transport: str, + mode: str, + profile: str, + http_url: str | None, +) -> list[str]: + command = [str(codex_binary), "mcp", "add", TEST_SERVER_NAME] + if transport == "stdio": + protocol_environment = ( + MODERN_VERSION if mode == MODERN_VERSION else LEGACY_ENVIRONMENT_SENTINEL + ) + command.extend( + [ + "--env", + f"CODEX_MCP_PROTOCOL_VERSION={protocol_environment}", + "--", + sys.executable, + str(server_script), + "--mode", + mode, + "--transport", + "stdio", + "--profile", + profile, + ] + ) + else: + if http_url is None: + raise ValueError("review HTTP transport requires a fixture URL") + command.extend(["--url", http_url]) + return command + + +def _review_inventory_entry(result: Mapping[str, object]) -> dict[str, object] | None: + entries = result.get("data") + if not isinstance(entries, list): + return None + for entry in entries: + if isinstance(entry, dict) and entry.get("name") == TEST_SERVER_NAME: + return entry + return None + + +def _tool_input_schema( + entry: Mapping[str, object], + tool_name: str, +) -> Mapping[str, object] | None: + tools = entry.get("tools") + if not isinstance(tools, dict): + return None + tool = tools.get(tool_name) + if not isinstance(tool, dict): + return None + schema = tool.get("inputSchema") + if not isinstance(schema, dict): + schema = tool.get("input_schema") + return schema if isinstance(schema, dict) else None + + +def _exact_integer_property(schema: Mapping[str, object] | None) -> bool: + if schema is None: + return False + properties = schema.get("properties") + if not isinstance(properties, dict): + return False + value = properties.get("value") + return ( + isinstance(value, dict) + and value.get("type") == "integer" + and isinstance(value.get("minimum"), int) + and not isinstance(value.get("minimum"), bool) + and value.get("minimum") == REVIEW_EXACT_INTEGER + and isinstance(value.get("default"), int) + and not isinstance(value.get("default"), bool) + and value.get("default") == REVIEW_EXACT_INTEGER + ) + + +def _elicitation_schema(request: Mapping[str, object]) -> Mapping[str, object] | None: + for key in ("requestedSchema", "requested_schema", "schema"): + schema = request.get(key) + if isinstance(schema, dict): + return schema + for key in ("request", "elicitation", "params"): + nested = request.get(key) + if isinstance(nested, dict): + found = _elicitation_schema(nested) + if found is not None: + return found + return None + + +def _review_elicitation_content(params: Mapping[str, object]) -> Mapping[str, object]: + schema = _elicitation_schema(params) + if _exact_integer_property(schema): + return {"value": REVIEW_EXACT_INTEGER} + return {"value": "review-confirmed", "confirmation": "confirmed"} + + +def _mrtr_budget_is_bounded( + request_count: int, + *, + elapsed_seconds: float, + timeout_seconds: float, +) -> bool: + return ( + 0 <= request_count <= MRTR_REQUEST_LIMIT + and elapsed_seconds <= timeout_seconds + 1 + ) + + +def _initialize_review_client( + case: CaseResult, + client: AppServerClient, + *, + mode: str, +) -> bool: + response, detail = _response_result( + client.request( + "initialize", + { + "clientInfo": { + "name": "mcp-review-regression-runner", + "title": "MCP review regression runner", + "version": "1.0.0", + }, + "capabilities": { + "experimentalApi": True, + "requestAttestation": False, + "mcpServerOpenaiFormElicitation": True, + }, + }, + ) + ) + if not case.check("review/app-server-initialize", response is not None, detail): + return False + client.notify("initialized") + if mode != MODERN_VERSION: + return True + + feature, feature_detail = _response_result( + client.request( + "experimentalFeature/enablement/set", + {"enablement": {"mcp_2026_07_28": True}}, + ) + ) + enabled = ( + feature is not None + and isinstance(feature.get("enablement"), dict) + and feature["enablement"].get("mcp_2026_07_28") is True + ) + return case.check( + "review/modern-feature-enablement", + enabled, + "enabled the production modern MCP feature" if enabled else feature_detail, + ) + + +def _review_thread_id( + case: CaseResult, + client: AppServerClient, + workspace: Path, +) -> str | None: + response, detail = _response_result( + client.request("thread/start", {"cwd": str(workspace), "ephemeral": True}) + ) + thread = response.get("thread") if response is not None else None + thread_id = thread.get("id") if isinstance(thread, dict) else None + if not case.check( + "review/ephemeral-thread", + isinstance(thread_id, str), + "created isolated review thread" if isinstance(thread_id, str) else detail, + ): + return None + return str(thread_id) + + +def _run_normal_review_checks( + case: CaseResult, + client: AppServerClient, + *, + mode: str, + workspace: Path, +) -> None: + inventory, inventory_detail = _response_result( + client.request("mcpServerStatus/list", {"detail": "full"}) + ) + entry = _review_inventory_entry(inventory) if inventory is not None else None + if not case.check( + "review/server-discovery", + entry is not None, + "discovered the adversarial review fixture" + if entry is not None + else inventory_detail, + ): + return + assert entry is not None + + tools = entry.get("tools") + if not isinstance(tools, dict): + tools = {} + if mode == MODERN_VERSION: + required_page_two = { + "review_integer_elicitation", + "review_large_integer", + "review_mrtr_cap", + "review_protocol_env", + } + missing = sorted(required_page_two - set(tools)) + case.check( + "review/modern-paginated-tools-page-two", + not missing, + "all protected second-page tools reached the app-server inventory" + if not missing + else "second-page tools missing: " + ", ".join(missing), + ) + + schema = _tool_input_schema(entry, "review_large_integer") + exact_schema = _exact_integer_property(schema) + case.check( + "review/exact-large-integer-tool-schema", + exact_schema, + "preserved integer minimum and default 9007199254740993" + if exact_schema + else "the app-server rounded or omitted the 2^53+1 tool schema", + ) + + thread_id = _review_thread_id(case, client, workspace) + if thread_id is None: + return + + integer, integer_detail = _call_tool( + client, + thread_id=thread_id, + tool="review_large_integer", + arguments={"value": REVIEW_EXACT_INTEGER}, + ) + content = integer.get("structuredContent") if integer is not None else None + exact_value = ( + isinstance(content, dict) + and isinstance(content.get("value"), int) + and not isinstance(content.get("value"), bool) + and content.get("value") == REVIEW_EXACT_INTEGER + ) + case.check( + "review/exact-large-integer-tool-round-trip", + exact_value, + "round-tripped 9007199254740993 without floating-point conversion" + if exact_value + else integer_detail, + ) + + if mode != MODERN_VERSION: + environment, environment_detail = _call_tool( + client, + thread_id=thread_id, + tool="review_protocol_env", + arguments={}, + ) + observed = ( + environment.get("structuredContent") if environment is not None else None + ) + preserved = ( + isinstance(observed, dict) + and observed.get("value") == LEGACY_ENVIRONMENT_SENTINEL + ) + case.check( + "review/legacy-reserved-stdio-environment-preserved", + preserved, + "forwarded the explicitly configured legacy protocol environment unchanged" + if preserved + else environment_detail, + ) + return + + before = len(client.elicitation_requests) + elicitation, elicitation_detail = _call_tool( + client, + thread_id=thread_id, + tool="review_integer_elicitation", + arguments={}, + ) + requests = client.elicitation_requests[before:] + exact_elicitation = any( + _exact_integer_property(_elicitation_schema(request)) for request in requests + ) + case.check( + "review/exact-large-integer-elicitation-schema", + exact_elicitation, + "app-server preserved 9007199254740993 in the actual elicitation schema" + if exact_elicitation + else "the production elicitation schema rounded or omitted the 2^53+1 integer", + ) + elicitation_content = ( + elicitation.get("structuredContent") if elicitation is not None else None + ) + completed = ( + isinstance(elicitation_content, dict) + and elicitation_content.get("value") == REVIEW_EXACT_INTEGER + ) + case.check( + "review/exact-large-integer-elicitation-round-trip", + completed, + "completed elicitation using the exact 2^53+1 integer" + if completed + else elicitation_detail, + ) + + mrtr_before = len(client.elicitation_requests) + started = time.monotonic() + mrtr_result = None + try: + mrtr_result, mrtr_detail = _call_tool( + client, + thread_id=thread_id, + tool="review_mrtr_cap", + arguments={}, + ) + except AppServerError as exc: + mrtr_detail = str(exc) + elapsed = time.monotonic() - started + observed = len(client.elicitation_requests) - mrtr_before + bounded = mrtr_result is None and _mrtr_budget_is_bounded( + observed, + elapsed_seconds=elapsed, + timeout_seconds=client.timeout_seconds, + ) + case.check( + "review/mrtr-input-requests-bounded-to-64", + bounded, + ( + f"rejected {REVIEW_MRTR_INPUT_REQUEST_COUNT} simultaneous input requests; " + f"observed {observed} elicitations in {elapsed:.2f}s" + if bounded + else ( + f"observed {observed} elicitation requests for a " + f"{REVIEW_MRTR_INPUT_REQUEST_COUNT}-request response in " + f"{elapsed:.2f}s; {mrtr_detail}" + ) + ), + ) + + +def _run_malformed_discovery_check( + case: CaseResult, + client: AppServerClient, + *, + profile: str, +) -> None: + name = ( + "review/reject-null-discovery-response-id" + if profile == NULL_DISCOVERY_ID_PROFILE + else "review/reject-mismatched-discovery-response-id" + ) + try: + inventory, detail = _response_result( + client.request("mcpServerStatus/list", {"detail": "full"}) + ) + except AppServerError as exc: + case.check(name, True, f"rejected malformed discovery response: {exc}") + return + entry = _review_inventory_entry(inventory) if inventory is not None else None + tools = entry.get("tools") if entry is not None else None + rejected = ( + inventory is None or entry is None or not isinstance(tools, dict) or not tools + ) + case.check( + name, + rejected, + "rejected the uncorrelated discovery response without silently downgrading" + if rejected + else "accepted an uncorrelated discovery response and exposed server tools", + ) + + +def _run_repeated_cursor_check(case: CaseResult, client: AppServerClient) -> None: + started = time.monotonic() + try: + inventory, _ = _response_result( + client.request("mcpServerStatus/list", {"detail": "full"}) + ) + entry = _review_inventory_entry(inventory) if inventory is not None else None + tools = entry.get("tools") if entry is not None else None + rejected = ( + inventory is None + or entry is None + or not isinstance(tools, dict) + or not tools + ) + detail = ( + "rejected the repeated pagination cursor within the startup deadline" + if rejected + else "accepted or exposed a catalog with a repeated pagination cursor" + ) + except AppServerError as exc: + rejected = True + detail = f"terminated repeated-cursor pagination: {exc}" + elapsed = time.monotonic() - started + case.check( + "review/repeated-pagination-cursor-bounded", + rejected and elapsed <= client.timeout_seconds + 2, + f"{detail}; elapsed={elapsed:.2f}s", + ) + + +def _run_catalog_boundary_checks( + case: CaseResult, + client: AppServerClient, + *, + profile: str, + workspace: Path, +) -> None: + if profile not in CATALOG_BOUNDARY_PROFILES: + raise ValueError(f"unknown catalog boundary profile: {profile}") + + at_limit = profile == CATALOG_MAX_PROFILE + check_prefix = ( + "review/catalog-at-limit" if at_limit else "review/catalog-over-limit" + ) + expected_status = "ready" if at_limit else "failed" + event_index = len(client.events) + if _review_thread_id(case, client, workspace) is None: + return + + try: + event = client.wait_for_notification( + "mcpServer/startupStatus/updated", + predicate=lambda params: ( + params.get("name") == TEST_SERVER_NAME + and params.get("status") in {"ready", "failed"} + ), + after_event_index=event_index, + ) + except AppServerError as exc: + case.check( + f"{check_prefix}/startup-{expected_status}", + False, + f"did not observe the required {expected_status} startup: {exc}", + ) + return + + params = event.get("params") + case.check( + f"{check_prefix}/startup-{expected_status}", + isinstance(params, dict) and params.get("status") == expected_status, + f"observed the expected {expected_status} MCP server startup" + if isinstance(params, dict) and params.get("status") == expected_status + else ( + "observed MCP server startup " + f"{params.get('status')!r}; expected {expected_status!r}" + if isinstance(params, dict) + else "MCP server startup notification did not contain parameters" + ), + ) + if not isinstance(params, dict): + return + + if not at_limit: + error = params.get("error") + case.check( + f"{check_prefix}/exact-limit-error", + isinstance(error, str) and CATALOG_LIMIT_ERROR in error, + error + if isinstance(error, str) + else f"startup failure did not report {CATALOG_LIMIT_ERROR!r}", + ) + + inventory, detail = _response_result( + client.request("mcpServerStatus/list", {"detail": "full"}) + ) + entry = _review_inventory_entry(inventory) if inventory is not None else None + if not case.check( + f"{check_prefix}/configured-server", + entry is not None, + "retained the configured catalog-boundary server" + if entry is not None + else detail, + ): + return + assert entry is not None + + server_info = entry.get("serverInfo") + expected_server_info = ( + isinstance(server_info, dict) if at_limit else server_info is None + ) + case.check( + f"{check_prefix}/server-info", + expected_server_info, + "exposed initialized server information" + if at_limit and expected_server_info + else "left rejected server information null" + if not at_limit and expected_server_info + else "catalog server information did not match its startup status", + ) + + tools = entry.get("tools") + actual_count = len(tools) if isinstance(tools, dict) else None + expected_count = MAX_CATALOG_ITEMS if at_limit else 0 + case.check( + f"{check_prefix}/tool-count", + isinstance(tools, dict) and actual_count == expected_count, + f"discovered exactly {expected_count} tools" + if actual_count == expected_count + else f"expected {expected_count} discovered tools, observed {actual_count}", + ) + + +def _run_sse_check( + case: CaseResult, + client: AppServerClient, + workspace: Path, + *, + profile: str, +) -> None: + inventory, detail = _response_result( + client.request("mcpServerStatus/list", {"detail": "full"}) + ) + entry = _review_inventory_entry(inventory) if inventory is not None else None + if not case.check( + "review/sse-cr-discovery", + entry is not None, + "discovered the CR-only SSE fixture" if entry is not None else detail, + ): + return + thread_id = _review_thread_id(case, client, workspace) + if thread_id is None: + return + result, detail = _call_tool( + client, + thread_id=thread_id, + tool="progress", + arguments={}, + meta={"progressToken": "review-cr-only-sse"}, + ) + content = result.get("content") if result is not None else None + accepted = isinstance(content, list) and bool(content) + flood = profile == SSE_COMMENT_FLOOD_PROFILE + case.check( + "review/sse-comment-flood-excluded-from-event-size-limit" + if flood + else "review/sse-carriage-return-and-comment-framing", + accepted, + "accepted more than 8 MiB of valid SSE comment keepalives" + if accepted and flood + else "accepted valid CR-only SSE lines and ignored comment keepalives" + if accepted + else detail, + ) + + +def _run_review_case( + codex_binary: Path, + server_script: Path, + *, + mode: str, + profile: str, + transport: str, + case_home: Path, + timeout_seconds: float, +) -> CaseResult: + started = time.monotonic() + case = CaseResult(transport=f"review-{transport}:{profile}", mode=mode) + case_home.mkdir(parents=True, exist_ok=True) + workspace = case_home / "workspace" + workspace.mkdir(exist_ok=True) + env = _isolated_environment(case_home) + registered = False + fixture = ( + _running_review_http_fixture(mode, profile) + if transport == "http" + else nullcontext(None) + ) + + try: + if mode == MODERN_VERSION: + feature = _run_command( + [str(codex_binary), "features", "enable", "mcp_2026_07_28"], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + if not case.check( + "review/modern-feature-configuration", + feature.returncode == 0, + _command_detail(feature), + ): + return case + + with fixture as http_url: + registration = _run_command( + _review_registration_command( + codex_binary, + server_script, + transport=transport, + mode=mode, + profile=profile, + http_url=http_url, + ), + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + registered = case.check( + "review/mcp-registration", + registration.returncode == 0, + _command_detail(registration), + ) + if not registered: + return case + + with AppServerClient( + codex_binary, + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + elicitation_content=_review_elicitation_content, + ) as client: + if not _initialize_review_client(case, client, mode=mode): + return case + if profile in ( + MISMATCHED_DISCOVERY_ID_PROFILE, + NULL_DISCOVERY_ID_PROFILE, + ): + _run_malformed_discovery_check(case, client, profile=profile) + elif profile == REPEATED_CURSOR_PROFILE: + _run_repeated_cursor_check(case, client) + elif profile in CATALOG_BOUNDARY_PROFILES: + _run_catalog_boundary_checks( + case, client, profile=profile, workspace=workspace + ) + elif profile in (SSE_CR_COMMENTS_PROFILE, SSE_COMMENT_FLOOD_PROFILE): + _run_sse_check(case, client, workspace, profile=profile) + else: + _run_normal_review_checks( + case, client, mode=mode, workspace=workspace + ) + except (AppServerError, OSError, ValueError) as exc: + case.check("review/client-runtime", False, str(exc)) + finally: + if registered: + removal = _run_command( + [str(codex_binary), "mcp", "remove", TEST_SERVER_NAME], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + case.check( + "review/isolated-registration-cleanup", + removal.returncode == 0, + _command_detail(removal), + ) + case.finish(started) + return case + + +def run_review_regressions( + codex_binary: Path, + *, + modes: Sequence[str] = REVIEW_MODES, + server_script: Path | None = None, + timeout_seconds: float = 8, + artifact_parent: Path | None = None, + keep_artifacts: bool = False, +) -> dict[str, object]: + server_script = server_script or _MODULE_DIR / "server.py" + root = Path(tempfile.mkdtemp(prefix="codex-mcp-review-", dir=artifact_parent)) + cases: list[CaseResult] = [] + try: + for mode in modes: + cases.append( + _run_review_case( + codex_binary, + server_script, + mode=mode, + profile=REVIEW_PROFILE, + transport="stdio", + case_home=root / f"review-stdio-{mode}", + timeout_seconds=timeout_seconds, + ) + ) + if mode != MODERN_VERSION: + continue + for profile in ( + REVIEW_PROFILE, + REPEATED_CURSOR_PROFILE, + MISMATCHED_DISCOVERY_ID_PROFILE, + NULL_DISCOVERY_ID_PROFILE, + SSE_CR_COMMENTS_PROFILE, + SSE_COMMENT_FLOOD_PROFILE, + ): + cases.append( + _run_review_case( + codex_binary, + server_script, + mode=mode, + profile=profile, + transport="http", + case_home=root / f"review-http-{mode}-{profile}", + timeout_seconds=timeout_seconds, + ) + ) + + for mode in modes: + for transport in CATALOG_BOUNDARY_TRANSPORTS: + for profile in CATALOG_BOUNDARY_PROFILES: + cases.append( + _run_review_case( + codex_binary, + server_script, + mode=mode, + profile=profile, + transport=transport, + case_home=root / f"review-{transport}-{mode}-{profile}", + timeout_seconds=timeout_seconds, + ) + ) + + checks = [check for case in cases for check in case.checks] + failures = [check for check in checks if not check.success] + return { + "schemaVersion": REVIEW_REPORT_SCHEMA_VERSION, + "success": bool(cases) and all(case.success for case in cases), + "codexBinary": str(codex_binary), + "reviewer": REVIEWER, + "modes": list(modes), + "summary": { + "passed": len(checks) - len(failures), + "failed": len(failures), + "total": len(checks), + "casesPassed": sum(case.success for case in cases), + "casesTotal": len(cases), + }, + "cases": [asdict(case) for case in cases], + "artifacts": str(root) if keep_artifacts else None, + } + finally: + if not keep_artifacts: + shutil.rmtree(root, ignore_errors=True) + + +def _required_review_checks( + report: Mapping[str, object], + *, + label: str, + errors: list[str], +) -> dict[_ReviewCheckIdentity, bool]: + if report.get("schemaVersion") != REVIEW_REPORT_SCHEMA_VERSION: + errors.append( + f"{label} does not use reviewer report schema " + f"{REVIEW_REPORT_SCHEMA_VERSION}" + ) + if report.get("reviewer") != REVIEWER: + errors.append(f"{label} does not identify the production reviewer probes") + + compact = report.get("baselineKind") is not None + modes = report.get("requiredModes" if compact else "modes") + if modes != list(REVIEW_MODES): + errors.append(f"{label} does not run all three required MCP protocol modes") + + expected_cases = _required_review_cases() + observed_cases: set[tuple[str, str]] = set() + identities: dict[_ReviewCheckIdentity, bool] = {} + + if compact: + if report.get("baselineKind") != REVIEW_BASELINE_KIND: + errors.append(f"{label} uses an unsupported reviewer baseline format") + + raw_cases = report.get("requiredCases") + if not isinstance(raw_cases, list): + errors.append(f"{label} does not contain required reviewer cases") + raw_cases = [] + for case in raw_cases: + if not isinstance(case, dict): + errors.append(f"{label} contains a malformed reviewer case") + continue + mode = case.get("mode") + transport = case.get("transport") + if not isinstance(mode, str) or not isinstance(transport, str): + errors.append(f"{label} contains a malformed reviewer case") + continue + identity = (mode, transport) + if identity in observed_cases: + errors.append(f"{label} contains a duplicate reviewer case") + continue + observed_cases.add(identity) + + raw_checks = report.get("checks") + if not isinstance(raw_checks, dict): + errors.append(f"{label} does not contain reviewer check identities") + raw_checks = {} + for bucket, success in (("passing", True), ("failing", False)): + records = raw_checks.get(bucket) + if not isinstance(records, list): + errors.append(f"{label} does not contain {bucket} reviewer checks") + continue + for record in records: + if not isinstance(record, dict): + errors.append( + f"{label} contains a malformed {bucket} reviewer check" + ) + continue + mode = record.get("mode") + transport = record.get("transport") + check_id = record.get("check_id") + if ( + not isinstance(mode, str) + or not isinstance(transport, str) + or not isinstance(check_id, str) + or not check_id.startswith("review/") + or (mode, transport) not in expected_cases + ): + errors.append( + f"{label} contains a malformed {bucket} reviewer check" + ) + continue + identity = _ReviewCheckIdentity(mode, transport, check_id) + if identity in identities: + errors.append(f"{label} contains a duplicate reviewer check") + continue + identities[identity] = success + else: + raw_cases = report.get("cases") + if not isinstance(raw_cases, list): + errors.append(f"{label} does not contain production reviewer cases") + raw_cases = [] + for case in raw_cases: + if not isinstance(case, dict): + errors.append(f"{label} contains a malformed reviewer case") + continue + mode = case.get("mode") + transport = case.get("transport") + if ( + not isinstance(mode, str) + or not isinstance(transport, str) + or (mode, transport) not in expected_cases + ): + errors.append(f"{label} contains an unexpected reviewer case") + continue + case_identity = (mode, transport) + if case_identity in observed_cases: + errors.append(f"{label} contains a duplicate reviewer case") + continue + observed_cases.add(case_identity) + + checks = case.get("checks") + if not isinstance(checks, list) or not checks: + errors.append( + f"{label} has no reviewer checks for {transport} / {mode}" + ) + continue + case_success = True + for check in checks: + if not isinstance(check, dict): + errors.append(f"{label} contains a malformed reviewer check") + continue + check_id = check.get("name") + success = check.get("success") + if ( + not isinstance(check_id, str) + or not check_id.startswith("review/") + or not isinstance(success, bool) + ): + errors.append(f"{label} contains a malformed reviewer check") + continue + identity = _ReviewCheckIdentity(mode, transport, check_id) + if identity in identities: + errors.append(f"{label} contains a duplicate reviewer check") + continue + identities[identity] = success + case_success = case_success and success + if case.get("success") is not case_success: + errors.append( + f"{label} reports inconsistent reviewer case success " + f"for {transport} / {mode}" + ) + + missing_cases = sorted(expected_cases - observed_cases) + unexpected_cases = sorted(observed_cases - expected_cases) + for mode, transport in missing_cases: + errors.append(f"{label} is missing required reviewer case {transport} / {mode}") + for mode, transport in unexpected_cases: + errors.append(f"{label} contains unexpected reviewer case {transport} / {mode}") + + summary = report.get("summary") + passing = sum(identities.values()) + failed = len(identities) - passing + cases_passed = sum( + all( + success + for identity, success in identities.items() + if (identity.mode, identity.transport) == case + ) + and any((identity.mode, identity.transport) == case for identity in identities) + for case in observed_cases + ) + expected_summary = { + "passed": passing, + "failed": failed, + "total": len(identities), + "casesPassed": cases_passed, + "casesTotal": len(observed_cases), + } + if not isinstance(summary, dict) or any( + summary.get(name) != value for name, value in expected_summary.items() + ): + errors.append(f"{label} reports inconsistent reviewer check totals") + if not compact and report.get("success") is not ( + bool(observed_cases) and failed == 0 + ): + errors.append(f"{label} reports inconsistent overall reviewer success") + return identities + + +def _compact_review_regression_baseline( + report: Mapping[str, object], +) -> dict[str, object]: + errors: list[str] = [] + checks = _required_review_checks(report, label="baseline", errors=errors) + if errors: + raise ValueError("; ".join(errors)) + + summary = report.get("summary") + assert isinstance(summary, dict) + return { + "baselineKind": REVIEW_BASELINE_KIND, + "schemaVersion": REVIEW_REPORT_SCHEMA_VERSION, + "reviewer": REVIEWER, + "requiredModes": list(REVIEW_MODES), + "requiredCases": [ + {"mode": mode, "transport": transport} + for mode, transport in sorted(_required_review_cases()) + ], + "summary": { + key: summary[key] + for key in ("passed", "failed", "total", "casesPassed", "casesTotal") + }, + "checks": { + "passing": [ + asdict(identity) + for identity, success in sorted(checks.items()) + if success + ], + "failing": [ + asdict(identity) + for identity, success in sorted(checks.items()) + if not success + ], + }, + } + + +def _evaluate_review_regression_gate( + report: Mapping[str, object], + baseline_report: Mapping[str, object], + *, + baseline_path: Path | None = None, +) -> dict[str, object]: + errors: list[str] = [] + baseline_checks = _required_review_checks( + baseline_report, label="baseline", errors=errors + ) + candidate_checks = _required_review_checks(report, label="candidate", errors=errors) + known_failures: list[dict[str, object]] = [] + new_failures: list[dict[str, object]] = [] + missing_checks: list[dict[str, object]] = [] + fixed_checks: list[dict[str, object]] = [] + + for identity, success in sorted(candidate_checks.items()): + if success: + continue + if baseline_checks.get(identity) is False: + known_failures.append(asdict(identity)) + else: + new_failures.append(asdict(identity)) + + for identity, baseline_success in sorted(baseline_checks.items()): + candidate_success = candidate_checks.get(identity) + if candidate_success is None: + missing_checks.append(asdict(identity)) + elif not baseline_success and candidate_success: + fixed_checks.append(asdict(identity)) + + return { + "success": not errors and not new_failures and not missing_checks, + "requiredModes": list(REVIEW_MODES), + "baselineReport": str(baseline_path) if baseline_path is not None else None, + "configurationErrors": errors, + "knownFailures": known_failures, + "newFailures": new_failures, + "missingChecks": missing_checks, + "fixedChecks": fixed_checks, + } + + +def _write_review_json(path: Path, value: Mapping[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run reviewer-derived MCP client regression tests against Codex." + ) + parser.add_argument("codex_binary", type=Path) + parser.add_argument("--mode", choices=("all", *REVIEW_MODES), default="all") + parser.add_argument("--timeout", type=float, default=8) + parser.add_argument("--server-script", type=Path, default=_MODULE_DIR / "server.py") + parser.add_argument("--report", type=Path) + parser.add_argument( + "--baseline-report", + type=Path, + help="Compare every production reviewer check with a reviewed baseline.", + ) + parser.add_argument( + "--write-baseline", + type=Path, + help="Write a compact reviewer baseline from the completed full matrix.", + ) + parser.add_argument( + "--extract-baseline", + type=Path, + help="Extract a compact reviewer baseline from --baseline-report and exit.", + ) + parser.add_argument("--artifact-parent", type=Path) + parser.add_argument("--keep-artifacts", action="store_true") + parser.add_argument("--json", action="store_true") + return parser.parse_args(argv) + + +def _print_report(report: Mapping[str, object]) -> None: + print( + "Codex MCP reviewer regressions: " + + ("PASS" if report.get("success") is True else "FAIL") + ) + print(f"Binary: {report.get('codexBinary')}") + cases = report.get("cases") + if isinstance(cases, list): + for case in cases: + if not isinstance(case, dict): + continue + print(f"\n{case.get('transport')} / {case.get('mode')}") + for check in case.get("checks", []): + if isinstance(check, dict): + status = "PASS" if check.get("success") else "FAIL" + print(f" {status} {check.get('name')}: {check.get('detail')}") + summary = report.get("summary") + if isinstance(summary, dict): + print( + f"\nSummary: {summary.get('passed')}/{summary.get('total')} checks; " + f"{summary.get('casesPassed')}/{summary.get('casesTotal')} cases" + ) + gate = report.get("regressionGate") + if isinstance(gate, dict): + status = "PASS" if gate.get("success") is True else "FAIL" + known = gate.get("knownFailures") + new = gate.get("newFailures") + missing = gate.get("missingChecks") + fixed = gate.get("fixedChecks") + print( + f"Regression gate: {status}; " + f"{len(known) if isinstance(known, list) else 0} known, " + f"{len(new) if isinstance(new, list) else 0} new, " + f"{len(missing) if isinstance(missing, list) else 0} missing, " + f"{len(fixed) if isinstance(fixed, list) else 0} fixed" + ) + errors = gate.get("configurationErrors") + if isinstance(errors, list): + for error in errors: + print(f" Configuration error: {error}") + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + codex_binary = args.codex_binary.expanduser().resolve() + server_script = args.server_script.expanduser().resolve() + if not codex_binary.is_file() or not os.access(codex_binary, os.X_OK): + print(f"error: Codex binary is not executable: {codex_binary}", file=sys.stderr) + return 2 + if not server_script.is_file(): + print(f"error: review fixture does not exist: {server_script}", file=sys.stderr) + return 2 + if args.timeout <= 0: + print("error: --timeout must be positive", file=sys.stderr) + return 2 + if args.extract_baseline is not None and args.baseline_report is None: + print("error: --extract-baseline requires --baseline-report", file=sys.stderr) + return 2 + if args.extract_baseline is not None and args.write_baseline is not None: + print( + "error: --extract-baseline cannot be combined with --write-baseline", + file=sys.stderr, + ) + return 2 + + baseline_report: dict[str, object] | None = None + baseline_path: Path | None = None + if args.baseline_report is not None: + baseline_path = args.baseline_report.expanduser().resolve() + try: + loaded_baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + print( + f"error: cannot read reviewer baseline {baseline_path}: {exc}", + file=sys.stderr, + ) + return 2 + if not isinstance(loaded_baseline, dict): + print( + f"error: reviewer baseline must be a JSON object: {baseline_path}", + file=sys.stderr, + ) + return 2 + baseline_report = loaded_baseline + + if args.extract_baseline is not None: + assert baseline_report is not None + output_path = args.extract_baseline.expanduser().resolve() + try: + _write_review_json( + output_path, _compact_review_regression_baseline(baseline_report) + ) + except (OSError, ValueError) as exc: + print(f"error: cannot extract reviewer baseline: {exc}", file=sys.stderr) + return 2 + print(f"Reviewer regression baseline: {output_path}") + return 0 + + parent = None + if args.artifact_parent is not None: + parent = args.artifact_parent.expanduser().resolve() + parent.mkdir(parents=True, exist_ok=True) + + modes = REVIEW_MODES if args.mode == "all" else (args.mode,) + report = run_review_regressions( + codex_binary, + modes=modes, + server_script=server_script, + timeout_seconds=args.timeout, + artifact_parent=parent, + keep_artifacts=args.keep_artifacts, + ) + if baseline_report is not None: + report["regressionGate"] = _evaluate_review_regression_gate( + report, + baseline_report, + baseline_path=baseline_path, + ) + if args.write_baseline is not None: + output_path = args.write_baseline.expanduser().resolve() + try: + _write_review_json(output_path, _compact_review_regression_baseline(report)) + except (OSError, ValueError) as exc: + print(f"error: cannot write reviewer baseline: {exc}", file=sys.stderr) + return 2 + if args.report is not None: + path = args.report.expanduser().resolve() + _write_review_json(path, report) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + _print_report(report) + if args.report is not None: + print(f"JSON report: {args.report.expanduser().resolve()}") + if args.write_baseline is not None: + print(f"Reviewer regression baseline: {args.write_baseline.resolve()}") + if baseline_report is not None: + gate = report.get("regressionGate") + return 0 if isinstance(gate, dict) and gate.get("success") is True else 1 + return 0 if report.get("success") is True else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_conformance/run_codex_compliance.py b/scripts/mcp_conformance/run_codex_compliance.py new file mode 100644 index 0000000000000000000000000000000000000000..f29e22ff4524fcd10373f8c13ceafd0ae6069036 --- /dev/null +++ b/scripts/mcp_conformance/run_codex_compliance.py @@ -0,0 +1,2265 @@ +#!/usr/bin/env python3 +"""Black-box MCP compliance runner for a supplied Codex binary.""" + +import argparse +import importlib.util +import json +import os +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from collections import deque +from contextlib import contextmanager, nullcontext +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Iterator, Mapping, Sequence + +_MODULE_DIR = Path(__file__).resolve().parent +if str(_MODULE_DIR) not in sys.path: + # Some execution environments set PYTHONSAFEPATH. Add only this + # trusted package directory so direct `python path/to/script.py` usage + # continues to work alongside the Bazel entry point. + sys.path.insert(0, str(_MODULE_DIR)) + +from official_conformance import ( # noqa: E402 - direct scripts must first add their sibling directory. + OFFICIAL_CONFORMANCE_GIT_REF, + OFFICIAL_CONFORMANCE_REPOSITORY, + OfficialScenarioResult, + default_conformance_command, + run_official_mode, + scenarios_for_mode, +) + +_FIXTURE_MODULE_PATH = Path(__file__).resolve().with_name("server.py") +_FIXTURE_SPEC = importlib.util.spec_from_file_location( + "_mcp_spec_test_fixture_server", + _FIXTURE_MODULE_PATH, +) +if _FIXTURE_SPEC is None or _FIXTURE_SPEC.loader is None: + raise RuntimeError(f"could not load MCP fixture module: {_FIXTURE_MODULE_PATH}") +_FIXTURE_MODULE = importlib.util.module_from_spec(_FIXTURE_SPEC) +sys.modules[_FIXTURE_SPEC.name] = _FIXTURE_MODULE +_FIXTURE_SPEC.loader.exec_module(_FIXTURE_MODULE) + +LEGACY_VERSION = _FIXTURE_MODULE.LEGACY_VERSION +SHIPPING_LEGACY_VERSION = _FIXTURE_MODULE.SHIPPING_LEGACY_VERSION +MODERN_VERSION = _FIXTURE_MODULE.MODERN_VERSION +MISMATCHED_DISCOVERY_ID_PROFILE = _FIXTURE_MODULE.MISMATCHED_DISCOVERY_ID_PROFILE +NULL_DISCOVERY_ID_PROFILE = _FIXTURE_MODULE.NULL_DISCOVERY_ID_PROFILE +REPEATED_CURSOR_PROFILE = _FIXTURE_MODULE.REPEATED_CURSOR_PROFILE +REVIEW_EXACT_INTEGER = _FIXTURE_MODULE.REVIEW_EXACT_INTEGER +REVIEW_MRTR_INPUT_REQUEST_COUNT = _FIXTURE_MODULE.REVIEW_MRTR_INPUT_REQUEST_COUNT +REVIEW_PROFILE = _FIXTURE_MODULE.REVIEW_PROFILE +RESOURCE_URIS = _FIXTURE_MODULE.RESOURCE_URIS +SERVER_NAME = _FIXTURE_MODULE.SERVER_NAME +SERVER_VERSION = _FIXTURE_MODULE.SERVER_VERSION +SSE_COMMENT_FLOOD_PROFILE = _FIXTURE_MODULE.SSE_COMMENT_FLOOD_PROFILE +SSE_CR_COMMENTS_PROFILE = _FIXTURE_MODULE.SSE_CR_COMMENTS_PROFILE +ProtocolServer = _FIXTURE_MODULE.ProtocolServer +make_http_server = _FIXTURE_MODULE.make_http_server + +REPORT_SCHEMA_VERSION = 4 +COMPACT_REGRESSION_BASELINE_KIND = "mcp-conformance-regression-baseline-v1" +REQUIRED_REGRESSION_MODES = ( + SHIPPING_LEGACY_VERSION, + LEGACY_VERSION, + MODERN_VERSION, +) +TEST_SERVER_NAME = "mcp_spec_fixture" +TEST_META_KEY = "com.openai/mcp-spec-test" +CHECK_ORDER = ( + "mcp_add", + "mcp_get", + "app_server_initialize", + "modern_feature_enablement", + "inventory", + "ephemeral_thread", + "echo_tool", + "unicode_resource_read", + "per_request_metadata", + "request_scoped_notifications", + "multi_round_trip_request", + "http_header_mirroring", + "app_server_protocol", + "case_runtime", + "mcp_remove", + "isolated_config_cleanup", +) + + +@dataclass +class CommandResult: + returncode: int + stdout: str + stderr: str + + +@dataclass +class CheckResult: + name: str + success: bool + detail: str + status: str | None = None + source: str = "supplemental" + scenario: str | None = None + check_id: str | None = None + category: str | None = None + + def __post_init__(self) -> None: + if self.status is None: + self.status = "PASS" if self.success else "FAIL" + + +@dataclass +class CaseResult: + transport: str + mode: str + success: bool = False + duration_seconds: float = 0.0 + checks: list[CheckResult] = field(default_factory=list) + diagnostics: str | None = None + + def check(self, name: str, success: bool, detail: str) -> bool: + self.checks.append( + CheckResult( + name=name, + success=success, + detail=detail, + status="PASS" if success else "FAIL", + ) + ) + return success + + def finish(self, started_at: float) -> None: + self.duration_seconds = round(time.monotonic() - started_at, 3) + self.success = bool(self.checks) and all(check.success for check in self.checks) + + +@dataclass(frozen=True, order=True) +class _RegressionCheckIdentity: + mode: str + transport: str + source: str + scenario: str + check_id: str + + +class AppServerError(RuntimeError): + pass + + +class AppServerClient: + """Minimal JSONL client for the Codex app-server protocol.""" + + def __init__( + self, + codex_binary: Path, + *, + env: Mapping[str, str], + cwd: Path, + timeout_seconds: float, + elicitation_content: ( + Callable[[Mapping[str, object]], Mapping[str, object]] | None + ) = None, + ) -> None: + self.timeout_seconds = timeout_seconds + self._elicitation_content = elicitation_content + self._next_id = 1 + self._messages: queue.Queue[dict[str, object] | None] = queue.Queue() + self._write_lock = threading.Lock() + self._stderr: deque[str] = deque(maxlen=200) + self._parse_errors: deque[str] = deque(maxlen=20) + self.events: list[dict[str, object]] = [] + self.elicitation_requests: list[dict[str, object]] = [] + self.process = subprocess.Popen( + [str(codex_binary), "app-server"], + cwd=cwd, + env=dict(env), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + self._stdout_thread = threading.Thread( + target=self._read_stdout, + name="codex-app-server-stdout", + daemon=True, + ) + self._stderr_thread = threading.Thread( + target=self._read_stderr, + name="codex-app-server-stderr", + daemon=True, + ) + self._stdout_thread.start() + self._stderr_thread.start() + + def __enter__(self) -> "AppServerClient": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + for line in self.process.stdout: + stripped = line.strip() + if not stripped: + continue + try: + message = json.loads(stripped) + except json.JSONDecodeError: + self._parse_errors.append(stripped[:500]) + continue + if isinstance(message, dict): + self._messages.put(message) + self._messages.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + for line in self.process.stderr: + self._stderr.append(line.rstrip()) + + def _send(self, message: Mapping[str, object]) -> None: + if self.process.poll() is not None: + raise AppServerError( + f"Codex app-server exited with code {self.process.returncode}" + ) + assert self.process.stdin is not None + encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + with self._write_lock: + self.process.stdin.write(encoded + "\n") + self.process.stdin.flush() + + def notify(self, method: str) -> None: + self._send({"method": method}) + + def request( + self, + method: str, + params: Mapping[str, object] | None, + ) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + message: dict[str, object] = {"id": request_id, "method": method} + if params is not None: + message["params"] = dict(params) + self._send(message) + + deadline = time.monotonic() + self.timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AppServerError( + f"timed out waiting for app-server response to {method}" + ) + try: + message = self._messages.get(timeout=remaining) + except queue.Empty as exc: + raise AppServerError( + f"timed out waiting for app-server response to {method}" + ) from exc + if message is None: + raise AppServerError( + f"Codex app-server closed stdout while handling {method}" + ) + if message.get("id") == request_id and "method" not in message: + return message + if "id" in message and isinstance(message.get("method"), str): + self._handle_server_request(message) + else: + self.events.append(message) + + def wait_for_notification( + self, + method: str, + *, + predicate: Callable[[Mapping[str, object]], bool] | None = None, + after_event_index: int = 0, + ) -> dict[str, object]: + for event in self.events[after_event_index:]: + if event.get("method") != method: + continue + params = event.get("params") + if isinstance(params, dict) and (predicate is None or predicate(params)): + return event + + deadline = time.monotonic() + self.timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AppServerError( + f"timed out waiting for app-server notification {method}" + ) + try: + message = self._messages.get(timeout=remaining) + except queue.Empty as exc: + raise AppServerError( + f"timed out waiting for app-server notification {method}" + ) from exc + if message is None: + raise AppServerError( + f"Codex app-server closed stdout while waiting for {method}" + ) + if "id" in message and isinstance(message.get("method"), str): + self._handle_server_request(message) + continue + self.events.append(message) + if message.get("method") != method: + continue + params = message.get("params") + if isinstance(params, dict) and (predicate is None or predicate(params)): + return message + + def _handle_server_request(self, message: dict[str, object]) -> None: + self.events.append(message) + request_id = message.get("id") + method = message.get("method") + if method == "mcpServer/elicitation/request": + params = message.get("params") + if isinstance(params, dict): + self.elicitation_requests.append(params) + else: + params = {} + content = ( + dict(self._elicitation_content(params)) + if self._elicitation_content is not None + else {"confirmation": "confirmed"} + ) + self._send( + { + "id": request_id, + "result": { + "action": "accept", + "content": content, + "_meta": None, + }, + } + ) + return + self._send( + { + "id": request_id, + "error": { + "code": -32601, + "message": f"unsupported test client request: {method}", + }, + } + ) + + def diagnostic_text(self) -> str: + parts: list[str] = [] + if self._parse_errors: + parts.append("non-JSON stdout: " + " | ".join(self._parse_errors)) + startup_failures = [ + event + for event in self.events + if event.get("method") == "mcpServer/startupStatus/updated" + and isinstance(event.get("params"), dict) + and event["params"].get("status") not in ("starting", "ready") + ] + if startup_failures: + parts.append( + "startup events: " + + json.dumps(startup_failures[-3:], ensure_ascii=False) + ) + if self._stderr: + parts.append("stderr:\n" + "\n".join(self._stderr)) + return "\n".join(parts)[-8_000:] + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=3) + for stream in (self.process.stdin, self.process.stdout, self.process.stderr): + if stream is not None: + stream.close() + self._stdout_thread.join(timeout=1) + self._stderr_thread.join(timeout=1) + + +def _run_command( + command: Sequence[str], + *, + env: Mapping[str, str], + cwd: Path, + timeout_seconds: float, +) -> CommandResult: + try: + completed = subprocess.run( + list(command), + cwd=cwd, + env=dict(env), + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) + return CommandResult( + completed.returncode, + completed.stdout.strip(), + completed.stderr.strip(), + ) + except subprocess.TimeoutExpired as exc: + stdout = ( + exc.stdout.decode() if isinstance(exc.stdout, bytes) else exc.stdout or "" + ) + stderr = ( + exc.stderr.decode() if isinstance(exc.stderr, bytes) else exc.stderr or "" + ) + return CommandResult( + 124, + stdout.strip(), + (stderr + f"\ncommand timed out after {timeout_seconds:g}s").strip(), + ) + + +def _response_result( + response: Mapping[str, object], +) -> tuple[dict[str, object] | None, str]: + error = response.get("error") + if isinstance(error, dict): + return None, json.dumps(error, ensure_ascii=False, sort_keys=True) + result = response.get("result") + if not isinstance(result, dict): + return None, "response did not contain an object result" + return result, "ok" + + +def _command_detail(result: CommandResult) -> str: + detail = result.stdout or result.stderr or f"exit code {result.returncode}" + return detail[-2_000:] + + +def _result_detail( + result: Mapping[str, object] | None, + fallback: str, +) -> str: + if result is None: + return fallback + return ( + "unexpected result: " + + json.dumps(result, ensure_ascii=False, sort_keys=True, default=str) + )[-2_000:] + + +def _isolated_environment(codex_home: Path) -> dict[str, str]: + env = dict(os.environ) + env["CODEX_HOME"] = str(codex_home) + # Direct app-server MCP calls do not need a model or credentials. + for name in ("CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_API_KEY"): + env.pop(name, None) + return env + + +@contextmanager +def _running_http_fixture(mode: str) -> Iterator[str]: + httpd = make_http_server( + ProtocolServer(mode), + "127.0.0.1", + 0, + log_requests=False, + ) + thread = threading.Thread( + target=httpd.serve_forever, + name=f"mcp-fixture-{mode}", + daemon=True, + ) + thread.start() + try: + _, port = httpd.server_address + yield f"http://127.0.0.1:{port}/mcp" + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=3) + + +def _registration_command( + codex_binary: Path, + server_script: Path, + *, + transport: str, + mode: str, + http_url: str | None, +) -> list[str]: + command = [str(codex_binary), "mcp", "add", TEST_SERVER_NAME] + if transport == "stdio": + if mode == MODERN_VERSION: + command.extend(["--env", f"CODEX_MCP_PROTOCOL_VERSION={MODERN_VERSION}"]) + return [ + *command, + "--", + sys.executable, + str(server_script), + "--mode", + mode, + "--transport", + "stdio", + ] + assert http_url is not None + return [*command, "--url", http_url] + + +def _validate_registration( + config: Mapping[str, object], + *, + transport: str, + mode: str, + http_url: str | None, +) -> tuple[bool, str]: + if config.get("name") != TEST_SERVER_NAME or config.get("enabled") is not True: + return False, "registered server name or enabled state was incorrect" + value = config.get("transport") + if not isinstance(value, dict): + return False, "registered server did not contain a transport object" + if transport == "stdio": + args = value.get("args") + if value.get("type") != "stdio" or not isinstance(args, list): + return False, f"unexpected stdio transport: {value!r}" + if mode not in args or "stdio" not in args: + return False, f"stdio registration omitted mode or transport: {args!r}" + env = value.get("env") + modern_opt_in = ( + isinstance(env, dict) + and env.get("CODEX_MCP_PROTOCOL_VERSION") == MODERN_VERSION + ) + if mode == MODERN_VERSION and not modern_opt_in: + return False, "modern stdio registration omitted its protocol opt-in" + if mode in (SHIPPING_LEGACY_VERSION, LEGACY_VERSION) and modern_opt_in: + return ( + False, + "legacy stdio registration unexpectedly enabled the modern protocol", + ) + elif value.get("type") != "streamable_http" or value.get("url") != http_url: + return False, f"unexpected HTTP transport: {value!r}" + return True, f"registered {value.get('type')} transport" + + +def _validate_inventory( + result: Mapping[str, object], + *, + mode: str, +) -> tuple[bool, str]: + entries = result.get("data") + if not isinstance(entries, list): + return False, "mcpServerStatus/list result did not contain data" + entry = next( + ( + item + for item in entries + if isinstance(item, dict) and item.get("name") == TEST_SERVER_NAME + ), + None, + ) + if not isinstance(entry, dict): + return False, f"{TEST_SERVER_NAME!r} was absent from MCP status" + + missing: list[str] = [] + server_info = entry.get("serverInfo") + if ( + not isinstance(server_info, dict) + or server_info.get("name") != SERVER_NAME + or server_info.get("version") != SERVER_VERSION + ): + missing.append("server identity") + tools = entry.get("tools") + expected_tools = {"echo", "client_metadata", "progress"} + if mode == MODERN_VERSION: + expected_tools.add("request_input") + if not isinstance(tools, dict): + missing.append("tool inventory") + else: + absent_tools = sorted(expected_tools - set(tools)) + if absent_tools: + missing.append("tools " + ", ".join(absent_tools)) + resources = entry.get("resources") + resource_uris = ( + { + item.get("uri") + for item in resources + if isinstance(item, dict) and isinstance(item.get("uri"), str) + } + if isinstance(resources, list) + else set() + ) + absent_resources = sorted(set(RESOURCE_URIS) - resource_uris) + if absent_resources: + missing.append("paginated resources " + ", ".join(absent_resources)) + if missing: + return False, "missing " + "; ".join(missing) + return True, "identity, tools, and both paginated resources discovered" + + +def _validate_echo( + result: Mapping[str, object], + sentinel: str, +) -> tuple[bool, str]: + structured = result.get("structuredContent") + if isinstance(structured, dict) and structured.get("text") == sentinel: + return True, "echo returned the exact sentinel" + return False, f"unexpected echo result: {dict(result)!r}" + + +def _ratio(passed: int, total: int) -> dict[str, int | float]: + percentage = round((passed / total) * 100, 1) if total else 0.0 + return { + "passed": passed, + "total": total, + "percentage": percentage, + } + + +def _check_ratio(checks: Sequence[CheckResult]) -> dict[str, int | float]: + applicable = [check for check in checks if check.status != "SKIP"] + return _ratio( + sum(check.success for check in applicable), + len(applicable), + ) + + +def _scenario_ratio( + checks: Sequence[CheckResult], + *, + category: str, +) -> dict[str, int | float]: + by_scenario: dict[str, list[CheckResult]] = {} + for check in checks: + if ( + check.scenario is None + or check.category != category + or check.source not in {"official", "harness"} + ): + continue + by_scenario.setdefault(check.scenario, []).append(check) + return _ratio( + sum( + all(check.success for check in scenario_checks) + for scenario_checks in by_scenario.values() + ), + len(by_scenario), + ) + + +def _summarize_modes(cases: Sequence[CaseResult]) -> list[dict[str, object]]: + summaries: list[dict[str, object]] = [] + modes = dict.fromkeys(case.mode for case in cases) + for mode in modes: + mode_cases = [case for case in cases if case.mode == mode] + checks = [check for case in mode_cases for check in case.checks] + official_checks = [check for check in checks if check.source == "official"] + official_non_auth_checks = [ + check for check in official_checks if check.category == "non-auth" + ] + official_auth_checks = [ + check for check in official_checks if check.category == "auth" + ] + harness_checks = [check for check in checks if check.source == "harness"] + supplemental_checks = [ + check for check in checks if check.source == "supplemental" + ] + summaries.append( + { + "mode": mode, + "checks": _check_ratio(checks), + "officialChecks": _check_ratio(official_checks), + "officialNonAuthChecks": _check_ratio(official_non_auth_checks), + "officialAuthChecks": _check_ratio(official_auth_checks), + "officialNonAuthScenarios": _scenario_ratio( + checks, + category="non-auth", + ), + "officialAuthScenarios": _scenario_ratio( + checks, + category="auth", + ), + "harnessChecks": _check_ratio(harness_checks), + "supplementalChecks": _check_ratio(supplemental_checks), + "transportCases": _ratio( + sum(case.success for case in mode_cases), + len(mode_cases), + ), + } + ) + return summaries + + +def _build_test_matrix(cases: Sequence[CaseResult]) -> dict[str, object]: + columns = [ + { + "key": f"{case.transport}:{case.mode}", + "transport": case.transport, + "mode": case.mode, + } + for case in cases + ] + checks_by_case = { + f"{case.transport}:{case.mode}": {check.name: check for check in case.checks} + for case in cases + } + observed = {check.name for case in cases for check in case.checks} + ordered_names = [name for name in CHECK_ORDER if name in observed] + ordered_names.extend(sorted(observed - set(ordered_names))) + rows: list[dict[str, object]] = [] + for name in ordered_names: + results: dict[str, str] = {} + for column in columns: + key = str(column["key"]) + check = checks_by_case[key].get(name) + results[key] = "N/A" if check is None else check.status + rows.append({"test": name, "results": results}) + return {"columns": columns, "rows": rows} + + +def _call_tool( + client: AppServerClient, + *, + thread_id: str, + tool: str, + arguments: Mapping[str, object], + meta: Mapping[str, object] | None = None, +) -> tuple[dict[str, object] | None, str]: + params: dict[str, object] = { + "threadId": thread_id, + "server": TEST_SERVER_NAME, + "tool": tool, + "arguments": dict(arguments), + } + if meta is not None: + params["_meta"] = dict(meta) + return _response_result(client.request("mcpServer/tool/call", params)) + + +def _exercise_app_server( + case: CaseResult, + client: AppServerClient, + *, + transport: str, + mode: str, + workspace: Path, + enable_modern_feature: bool, +) -> None: + initialize, initialize_detail = _response_result( + client.request( + "initialize", + { + "clientInfo": { + "name": "mcp-spec-compliance-runner", + "title": "MCP spec compliance runner", + "version": "1.0.0", + }, + "capabilities": { + "experimentalApi": True, + "requestAttestation": False, + "mcpServerOpenaiFormElicitation": True, + }, + }, + ) + ) + if not case.check( + "app_server_initialize", + initialize is not None, + initialize_detail, + ): + return + client.notify("initialized") + + if mode == MODERN_VERSION and enable_modern_feature: + feature_result, feature_detail = _response_result( + client.request( + "experimentalFeature/enablement/set", + {"enablement": {"mcp_2026_07_28": True}}, + ) + ) + enabled = ( + feature_result is not None + and isinstance(feature_result.get("enablement"), dict) + and feature_result["enablement"].get("mcp_2026_07_28") is True + ) + if not case.check( + "modern_feature_enablement", + enabled, + ( + "runtime feature mcp_2026_07_28 enabled" + if enabled + else _result_detail(feature_result, feature_detail) + ), + ): + return + + inventory, inventory_detail = _response_result( + client.request("mcpServerStatus/list", {}) + ) + if inventory is None: + case.check("inventory", False, inventory_detail) + else: + valid, detail = _validate_inventory(inventory, mode=mode) + case.check("inventory", valid, detail) + + thread_result, thread_detail = _response_result( + client.request( + "thread/start", + {"cwd": str(workspace), "ephemeral": True}, + ) + ) + thread_id: str | None = None + if thread_result is not None: + thread_value = thread_result.get("thread") + if isinstance(thread_value, dict) and isinstance(thread_value.get("id"), str): + thread_id = str(thread_value["id"]) + if not case.check( + "ephemeral_thread", + thread_id is not None, + "ephemeral thread created" if thread_id is not None else thread_detail, + ): + return + assert thread_id is not None + + sentinel = f"codex-mcp-{transport}-{mode}" + echo, echo_detail = _call_tool( + client, + thread_id=thread_id, + tool="echo", + arguments={"text": sentinel}, + meta={TEST_META_KEY: sentinel}, + ) + if echo is None: + case.check("echo_tool", False, echo_detail) + else: + valid, detail = _validate_echo(echo, sentinel) + case.check("echo_tool", valid, detail) + + resource, resource_detail = _response_result( + client.request( + "mcpServer/resource/read", + { + "threadId": thread_id, + "server": TEST_SERVER_NAME, + "uri": RESOURCE_URIS[1], + }, + ) + ) + expected_resource_text = f"fixture contents for {RESOURCE_URIS[1]}" + contents = resource.get("contents") if resource is not None else None + resource_ok = ( + isinstance(contents, list) + and len(contents) == 1 + and isinstance(contents[0], dict) + and contents[0].get("text") == expected_resource_text + ) + case.check( + "unicode_resource_read", + resource_ok, + ( + "read the Unicode resource" + if resource_ok + else _result_detail(resource, resource_detail) + ), + ) + + if mode != MODERN_VERSION: + return + + metadata, metadata_detail = _call_tool( + client, + thread_id=thread_id, + tool="client_metadata", + arguments={}, + meta={TEST_META_KEY: sentinel}, + ) + structured = metadata.get("structuredContent") if metadata is not None else None + metadata_ok = ( + isinstance(structured, dict) + and structured.get("io.modelcontextprotocol/protocolVersion") == MODERN_VERSION + and isinstance( + structured.get("io.modelcontextprotocol/clientCapabilities"), + dict, + ) + and structured.get(TEST_META_KEY) == sentinel + ) + case.check( + "per_request_metadata", + metadata_ok, + ( + "required and caller metadata were preserved" + if metadata_ok + else _result_detail(metadata, metadata_detail) + ), + ) + + progress, progress_detail = _call_tool( + client, + thread_id=thread_id, + tool="progress", + arguments={}, + meta={ + "progressToken": "fixture-progress", + "io.modelcontextprotocol/logLevel": "info", + }, + ) + progress_content = progress.get("content") if progress is not None else None + progress_ok = isinstance(progress_content, list) and bool(progress_content) + case.check( + "request_scoped_notifications", + progress_ok, + ( + "progress and log notifications were consumed before the result" + if progress_ok + else _result_detail(progress, progress_detail) + ), + ) + + elicitations_before = len(client.elicitation_requests) + mrtr, mrtr_detail = _call_tool( + client, + thread_id=thread_id, + tool="request_input", + arguments={}, + ) + mrtr_structured = mrtr.get("structuredContent") if mrtr is not None else None + new_elicitations = client.elicitation_requests[elicitations_before:] + mrtr_ok = ( + isinstance(mrtr_structured, dict) + and mrtr_structured.get("confirmation") == "confirmed" + and any( + item.get("serverName") == TEST_SERVER_NAME and item.get("mode") == "form" + for item in new_elicitations + ) + ) + case.check( + "multi_round_trip_request", + mrtr_ok, + ( + "elicitation was surfaced, answered, retried, and completed" + if mrtr_ok + else _result_detail(mrtr, mrtr_detail) + ), + ) + + if transport == "http": + arguments = { + "region": "us-west1", + "attempt": 3, + "enabled": True, + "greeting": "Hello, 世界", + } + mirrored, mirrored_detail = _call_tool( + client, + thread_id=thread_id, + tool="header_echo", + arguments=arguments, + ) + mirrored_structured = ( + mirrored.get("structuredContent") if mirrored is not None else None + ) + mirrored_ok = mirrored_structured == arguments + case.check( + "http_header_mirroring", + mirrored_ok, + ( + "method, name, scalar, and Base64 headers matched" + if mirrored_ok + else _result_detail(mirrored, mirrored_detail) + ), + ) + + +def _run_case( + codex_binary: Path, + server_script: Path, + *, + transport: str, + mode: str, + case_home: Path, + timeout_seconds: float, + enable_modern_feature: bool, +) -> CaseResult: + started_at = time.monotonic() + case = CaseResult(transport=transport, mode=mode) + case_home.mkdir(parents=True) + workspace = case_home / "workspace" + workspace.mkdir() + env = _isolated_environment(case_home) + client: AppServerClient | None = None + registered = False + + http_context = ( + _running_http_fixture(mode) if transport == "http" else nullcontext(None) + ) + try: + with http_context as http_url: + add = _run_command( + _registration_command( + codex_binary, + server_script, + transport=transport, + mode=mode, + http_url=http_url, + ), + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + registered = case.check( + "mcp_add", + add.returncode == 0, + _command_detail(add), + ) + if registered: + get = _run_command( + [ + str(codex_binary), + "mcp", + "get", + TEST_SERVER_NAME, + "--json", + ], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + config: dict[str, object] | None = None + if get.returncode == 0: + try: + decoded = json.loads(get.stdout) + if isinstance(decoded, dict): + config = decoded + except json.JSONDecodeError: + pass + if config is None: + case.check("mcp_get", False, _command_detail(get)) + else: + valid, detail = _validate_registration( + config, + transport=transport, + mode=mode, + http_url=http_url, + ) + case.check("mcp_get", valid, detail) + + client = AppServerClient( + codex_binary, + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + try: + _exercise_app_server( + case, + client, + transport=transport, + mode=mode, + workspace=workspace, + enable_modern_feature=enable_modern_feature, + ) + except (AppServerError, OSError) as exc: + case.check("app_server_protocol", False, str(exc)) + finally: + client.close() + diagnostic_text = client.diagnostic_text() + if diagnostic_text and any( + not check.success for check in case.checks + ): + case.diagnostics = diagnostic_text + except OSError as exc: + case.check("case_runtime", False, str(exc)) + finally: + if client is not None and client.process.poll() is None: + client.close() + if registered: + remove = _run_command( + [str(codex_binary), "mcp", "remove", TEST_SERVER_NAME], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + case.check( + "mcp_remove", + remove.returncode == 0, + _command_detail(remove), + ) + listed = _run_command( + [str(codex_binary), "mcp", "list", "--json"], + env=env, + cwd=workspace, + timeout_seconds=timeout_seconds, + ) + empty = False + if listed.returncode == 0: + try: + empty = json.loads(listed.stdout) == [] + except json.JSONDecodeError: + pass + case.check( + "isolated_config_cleanup", + empty, + "no MCP servers remained" if empty else _command_detail(listed), + ) + case.finish(started_at) + return case + + +def _official_check_result( + result: OfficialScenarioResult, + *, + index: int, + duplicate: bool, +) -> CheckResult: + check = result.checks[index] + if check.status == "SUCCESS": + status = "PASS" + success = True + elif check.status in {"SKIPPED", "INFO"}: + status = "SKIP" + success = True + else: + status = "FAIL" + success = False + suffix = f"{check.name}#{index + 1}" if duplicate else check.name + name = f"official/{result.scenario}/{suffix}" + detail = check.error_message or check.description + return CheckResult( + name=name, + success=success, + detail=detail, + status=status, + source="official", + scenario=result.scenario, + check_id=check.check_id, + category="auth" if result.scenario.startswith("auth/") else "non-auth", + ) + + +def _run_official_case( + codex_binary: Path, + adapter_script: Path, + *, + conformance_command: Sequence[str], + mode: str, + scenarios: Sequence[str], + case_home: Path, + timeout_seconds: float, + enable_modern_feature: bool, + require_automatic_auth: bool = False, +) -> CaseResult: + started_at = time.monotonic() + case = CaseResult(transport="official-http", mode=mode) + case_home.mkdir(parents=True) + env = _isolated_environment(case_home) + env["CODEX_CONFORMANCE_TIMEOUT"] = str(timeout_seconds) + env["CODEX_CONFORMANCE_ENABLE_MODERN_FEATURE"] = ( + "1" if enable_modern_feature else "0" + ) + env["CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH"] = ( + "1" if require_automatic_auth else "0" + ) + results = run_official_mode( + conformance_command=conformance_command, + adapter_script=adapter_script, + codex_binary=codex_binary, + mode=mode, + scenarios=scenarios, + output_dir=case_home / "official-results", + timeout_seconds=timeout_seconds, + base_env=env, + ) + + diagnostics: list[str] = [] + for result in results: + counts: dict[str, int] = {} + for check in result.checks: + counts[check.name] = counts.get(check.name, 0) + 1 + for index, check in enumerate(result.checks): + case.checks.append( + _official_check_result( + result, + index=index, + duplicate=counts[check.name] > 1, + ) + ) + + case.checks.append( + CheckResult( + name=f"harness/{result.scenario}/codex-adapter", + success=result.adapter_success, + detail=result.adapter_detail, + status="PASS" if result.adapter_success else "FAIL", + source="harness", + scenario=result.scenario, + category=( + "auth" if result.scenario.startswith("auth/") else "non-auth" + ), + ) + ) + if ( + result.adapter_success + and not result.success + and not any( + check.status in {"FAILURE", "WARNING"} for check in result.checks + ) + ): + case.checks.append( + CheckResult( + name=f"harness/{result.scenario}/official-runner", + success=False, + detail=result.runner_detail or "official runner failed", + status="FAIL", + source="harness", + scenario=result.scenario, + category=( + "auth" if result.scenario.startswith("auth/") else "non-auth" + ), + ) + ) + if not result.success and result.runner_detail: + diagnostics.append(f"{result.scenario}:\n{result.runner_detail}") + + if not results: + case.checks.append( + CheckResult( + name="harness/official-scenario-selection", + success=False, + detail=f"no official scenarios selected for {mode}", + status="FAIL", + source="harness", + ) + ) + if mode == MODERN_VERSION and "auth/offline-access-scope" in scenarios: + case.checks.extend( + _cimd_registration_checks( + codex_binary, + adapter_script, + conformance_command=conformance_command, + case_home=case_home / "cimd-registration", + timeout_seconds=timeout_seconds, + enable_modern_feature=enable_modern_feature, + ) + ) + if diagnostics: + case.diagnostics = "\n\n".join(diagnostics)[-16_000:] + case.finish(started_at) + return case + + +def _cimd_registration_checks( + codex_binary: Path, + adapter_script: Path, + *, + conformance_command: Sequence[str], + case_home: Path, + timeout_seconds: float, + enable_modern_feature: bool, +) -> list[CheckResult]: + checks: list[CheckResult] = [] + case_home.mkdir(parents=True) + for check_name, client_registration in ( + ("auto_cimd", None), + ("forced_cimd", "cimd"), + ): + env = _isolated_environment(case_home / check_name) + env["CODEX_CONFORMANCE_TIMEOUT"] = str(timeout_seconds) + env["CODEX_CONFORMANCE_ENABLE_MODERN_FEATURE"] = ( + "1" if enable_modern_feature else "0" + ) + env["CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH"] = "1" + if client_registration is not None: + env["CODEX_CONFORMANCE_CLIENT_REGISTRATION"] = client_registration + results = run_official_mode( + conformance_command=conformance_command, + adapter_script=adapter_script, + codex_binary=codex_binary, + mode=MODERN_VERSION, + scenarios=("auth/offline-access-scope",), + output_dir=case_home / check_name / "official-results", + timeout_seconds=timeout_seconds, + base_env=env, + ) + result = results[0] if len(results) == 1 else None + success = result is not None and result.success + checks.append( + CheckResult( + name=f"supplemental/auth/offline-access-scope/{check_name}", + success=success, + detail=( + result.adapter_detail or result.runner_detail + if result is not None + else "supplemental offline-access-scope scenario did not run" + ), + source="supplemental", + scenario="auth/offline-access-scope", + check_id=check_name, + category="auth", + ) + ) + return checks + + +def run_compliance( + codex_binary: Path, + *, + server_script: Path, + adapter_script: Path, + conformance_command: Sequence[str], + official_scenarios: Sequence[str] | None, + modes: Sequence[str], + transports: Sequence[str], + timeout_seconds: float, + artifact_parent: Path | None, + keep_artifacts: bool, + enable_modern_feature: bool = True, + include_auth: bool = True, + require_automatic_auth: bool = False, +) -> tuple[dict[str, object], Path | None]: + started_at = datetime.now(timezone.utc) + run_root = Path( + tempfile.mkdtemp( + prefix="codex-mcp-compliance-", + dir=artifact_parent, + ) + ) + version_env = _isolated_environment(run_root) + version = _run_command( + [str(codex_binary), "--version"], + env=version_env, + cwd=run_root, + timeout_seconds=timeout_seconds, + ) + + cases: list[CaseResult] = [] + selected_scenarios: dict[str, tuple[str, ...]] = { + mode: scenarios_for_mode( + mode, + official_scenarios, + include_auth=include_auth, + ) + for mode in modes + } + + # Transport order is intentional: supplemental local stdio first, then the + # official suite's loopback HTTP server. + for transport in transports: + for mode in modes: + if transport == "stdio": + case_home = run_root / f"stdio-{mode}" + cases.append( + _run_case( + codex_binary, + server_script, + transport="stdio", + mode=mode, + case_home=case_home, + timeout_seconds=timeout_seconds, + enable_modern_feature=enable_modern_feature, + ) + ) + else: + if not selected_scenarios[mode]: + continue + case_home = run_root / f"official-http-{mode}" + cases.append( + _run_official_case( + codex_binary, + adapter_script, + conformance_command=conformance_command, + mode=mode, + scenarios=selected_scenarios[mode], + case_home=case_home, + timeout_seconds=timeout_seconds, + enable_modern_feature=enable_modern_feature, + require_automatic_auth=require_automatic_auth, + ) + ) + + passed = sum(case.success for case in cases) + finished_at = datetime.now(timezone.utc) + report: dict[str, object] = { + "schemaVersion": REPORT_SCHEMA_VERSION, + "success": version.returncode == 0 and passed == len(cases), + "startedAt": started_at.isoformat(), + "finishedAt": finished_at.isoformat(), + "codexBinary": str(codex_binary), + "codexVersion": version.stdout or None, + "modernFeatureEnablement": enable_modern_feature, + "automaticAuthRequired": require_automatic_auth, + "officialConformance": { + "repository": OFFICIAL_CONFORMANCE_REPOSITORY, + "gitRef": OFFICIAL_CONFORMANCE_GIT_REF, + "scope": ( + "all versioned client scenarios" + if include_auth + else "versioned non-auth client scenarios" + ), + "authenticationIncluded": include_auth, + "scenarios": selected_scenarios, + }, + "versionCheck": { + "success": version.returncode == 0, + "detail": _command_detail(version), + }, + "summary": { + "passed": passed, + "failed": len(cases) - passed, + "total": len(cases), + }, + "modeSummaries": _summarize_modes(cases), + "testMatrix": _build_test_matrix(cases), + "cases": [asdict(case) for case in cases], + "artifacts": str(run_root) if keep_artifacts else None, + } + + retained: Path | None = run_root if keep_artifacts else None + if not keep_artifacts: + shutil.rmtree(run_root, ignore_errors=True) + return report, retained + + +def _regression_check_identity( + mode: str, + transport: str, + check: Mapping[str, object], +) -> _RegressionCheckIdentity | None: + source = check.get("source") + scenario = check.get("scenario") + name = check.get("name") + check_id = check.get("check_id") + if source not in {"official", "harness", "supplemental"}: + return None + if not isinstance(name, str) or not name: + return None + if scenario is not None and (not isinstance(scenario, str) or not scenario): + return None + if check_id is not None and (not isinstance(check_id, str) or not check_id): + return None + + # The upstream runner numbers repeated request observations by arrival + # order. Neither that number nor the number of passing retries is a stable + # protocol assertion, so prefer the upstream check ID when it exists. + stable_name = name + stem, separator, suffix = name.rpartition("#") + if separator and stem and suffix.isdecimal(): + stable_name = stem + return _RegressionCheckIdentity( + mode=mode, + transport=transport, + source=source, + scenario=scenario if isinstance(scenario, str) else "", + check_id=check_id if isinstance(check_id, str) else stable_name, + ) + + +def _required_regression_checks( + report: Mapping[str, object], + *, + label: str, + errors: list[str], +) -> dict[_RegressionCheckIdentity, set[str]]: + if report.get("schemaVersion") != REPORT_SCHEMA_VERSION: + errors.append(f"{label} does not use report schema {REPORT_SCHEMA_VERSION}") + + version_check = report.get("versionCheck") + if not isinstance(version_check, dict) or version_check.get("success") is not True: + errors.append(f"{label} does not contain a successful Codex version check") + if report.get("modernFeatureEnablement") is not True: + errors.append(f"{label} did not enable the modern MCP feature") + + official = report.get("officialConformance") + selected: Mapping[str, object] = {} + if not isinstance(official, dict): + errors.append(f"{label} does not contain official conformance metadata") + else: + if official.get("repository") != OFFICIAL_CONFORMANCE_REPOSITORY: + errors.append(f"{label} uses a different upstream conformance repository") + if official.get("gitRef") != OFFICIAL_CONFORMANCE_GIT_REF: + errors.append( + f"{label} uses a different pinned upstream conformance revision" + ) + if official.get("authenticationIncluded") is not True: + errors.append(f"{label} does not include the required OAuth scenarios") + scenarios = official.get("scenarios") + if isinstance(scenarios, dict): + selected = scenarios + else: + errors.append(f"{label} does not contain a versioned scenario catalog") + + baseline_kind = report.get("baselineKind") + if baseline_kind is not None: + if baseline_kind != COMPACT_REGRESSION_BASELINE_KIND: + errors.append(f"{label} uses an unsupported compact baseline format") + return {} + if report.get("requiredModes") != list(REQUIRED_REGRESSION_MODES): + errors.append( + f"{label} does not require the shipping, intermediate, and modern MCP versions" + ) + transports = report.get("transports") + if ( + not isinstance(transports, list) + or len(transports) != 2 + or set(transports) != {"stdio", "official-http"} + ): + errors.append(f"{label} does not require both stdio and official HTTP") + + raw_identities = report.get("checks") + if not isinstance(raw_identities, dict): + errors.append(f"{label} does not contain compact baseline check identities") + return {} + + identities: dict[_RegressionCheckIdentity, set[str]] = {} + for bucket, status in (("passing", "PASS"), ("failing", "FAIL")): + records = raw_identities.get(bucket) + if not isinstance(records, list): + errors.append(f"{label} does not contain {bucket} baseline identities") + continue + seen: set[_RegressionCheckIdentity] = set() + for record in records: + if not isinstance(record, dict): + errors.append( + f"{label} contains a malformed {bucket} baseline identity" + ) + continue + mode = record.get("mode") + transport = record.get("transport") + source = record.get("source") + scenario = record.get("scenario") + check_id = record.get("check_id") + if ( + mode not in REQUIRED_REGRESSION_MODES + or transport not in {"stdio", "official-http"} + or source not in {"official", "harness", "supplemental"} + or not isinstance(scenario, str) + or not isinstance(check_id, str) + or not check_id + ): + errors.append( + f"{label} contains a malformed {bucket} baseline identity" + ) + continue + identity = _RegressionCheckIdentity( + mode, transport, source, scenario, check_id + ) + if identity in seen: + errors.append( + f"{label} contains a duplicate {bucket} baseline identity" + ) + continue + seen.add(identity) + identities.setdefault(identity, set()).add(status) + + for mode in REQUIRED_REGRESSION_MODES: + expected_scenarios = scenarios_for_mode(mode, include_auth=True) + actual_scenarios = selected.get(mode) + if ( + not isinstance(actual_scenarios, (list, tuple)) + or tuple(actual_scenarios) != expected_scenarios + ): + errors.append( + f"{label} does not run the complete authenticated {mode} scenario catalog" + ) + for transport in ("stdio", "official-http"): + if not any( + identity.mode == mode and identity.transport == transport + for identity in identities + ): + errors.append( + f"{label} is missing the required {transport} case for {mode}" + ) + observed = { + identity.scenario + for identity in identities + if identity.mode == mode + and identity.transport == "official-http" + and identity.source in {"official", "harness"} + } + missing = sorted(set(expected_scenarios) - observed) + unexpected = sorted(observed - set(expected_scenarios)) + if missing: + errors.append( + f"{label} did not observe {mode} scenarios: {', '.join(missing)}" + ) + if unexpected: + errors.append( + f"{label} observed unexpected {mode} scenarios: {', '.join(unexpected)}" + ) + return identities + + case_list = report.get("cases") + cases: dict[tuple[str, str], Mapping[str, object]] = {} + if not isinstance(case_list, list): + errors.append(f"{label} does not contain transport cases") + case_list = [] + for case in case_list: + if not isinstance(case, dict): + errors.append(f"{label} contains a malformed transport case") + continue + mode = case.get("mode") + transport = case.get("transport") + if mode not in REQUIRED_REGRESSION_MODES: + continue + if not isinstance(mode, str) or not isinstance(transport, str): + errors.append(f"{label} contains a malformed required transport case") + continue + key = (mode, transport) + if key in cases: + errors.append(f"{label} contains a duplicate {transport} case for {mode}") + continue + cases[key] = case + + identities: dict[_RegressionCheckIdentity, set[str]] = {} + for mode in REQUIRED_REGRESSION_MODES: + expected_scenarios = scenarios_for_mode(mode, include_auth=True) + actual_scenarios = selected.get(mode) + if ( + not isinstance(actual_scenarios, (list, tuple)) + or tuple(actual_scenarios) != expected_scenarios + ): + errors.append( + f"{label} does not run the complete authenticated {mode} scenario catalog" + ) + + for transport in ("stdio", "official-http"): + case = cases.get((mode, transport)) + if case is None: + errors.append( + f"{label} is missing the required {transport} case for {mode}" + ) + continue + raw_checks = case.get("checks") + if not isinstance(raw_checks, list) or not raw_checks: + errors.append(f"{label} has no {transport} checks for {mode}") + continue + + observed_scenarios: set[str] = set() + for check in raw_checks: + if not isinstance(check, dict): + errors.append( + f"{label} contains a malformed {transport} check for {mode}" + ) + continue + identity = _regression_check_identity(mode, transport, check) + status = check.get("status") + success = check.get("success") + if ( + identity is None + or status not in {"PASS", "FAIL", "SKIP"} + or not isinstance(success, bool) + or success != (status != "FAIL") + ): + errors.append( + f"{label} contains a malformed {transport} check for {mode}" + ) + continue + identities.setdefault(identity, set()).add(status) + if transport == "official-http" and identity.source in { + "official", + "harness", + }: + observed_scenarios.add(identity.scenario) + + if transport == "official-http": + expected = set(expected_scenarios) + missing = sorted(expected - observed_scenarios) + unexpected = sorted(observed_scenarios - expected) + if missing: + errors.append( + f"{label} did not observe {mode} scenarios: {', '.join(missing)}" + ) + if unexpected: + errors.append( + f"{label} observed unexpected {mode} scenarios: {', '.join(unexpected)}" + ) + + return identities + + +def _compact_regression_baseline(report: Mapping[str, object]) -> dict[str, object]: + errors: list[str] = [] + identities = _required_regression_checks(report, label="baseline", errors=errors) + automatic_auth = report.get("automaticAuthRequired") + if not isinstance(automatic_auth, bool): + errors.append("baseline does not record its production OAuth policy") + if any( + identity.mode == SHIPPING_LEGACY_VERSION and "FAIL" in statuses + for identity, statuses in identities.items() + ): + errors.append("baseline contains a shipping MCP regression") + if errors: + raise ValueError("; ".join(errors)) + + official = report.get("officialConformance") + assert isinstance(official, dict) + scenarios = official.get("scenarios") + assert isinstance(scenarios, dict) + return { + "baselineKind": COMPACT_REGRESSION_BASELINE_KIND, + "schemaVersion": REPORT_SCHEMA_VERSION, + "requiredModes": list(REQUIRED_REGRESSION_MODES), + "transports": ["stdio", "official-http"], + "versionCheck": {"success": True}, + "modernFeatureEnablement": True, + "automaticAuthRequired": automatic_auth, + "officialConformance": { + "repository": OFFICIAL_CONFORMANCE_REPOSITORY, + "gitRef": OFFICIAL_CONFORMANCE_GIT_REF, + "authenticationIncluded": True, + "scenarios": {mode: scenarios[mode] for mode in REQUIRED_REGRESSION_MODES}, + }, + "checks": { + "passing": [ + asdict(identity) + for identity, statuses in sorted(identities.items()) + if "PASS" in statuses + ], + "failing": [ + asdict(identity) + for identity, statuses in sorted(identities.items()) + if "FAIL" in statuses + ], + }, + } + + +def _write_json_file(path: Path, value: Mapping[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def _write_compact_regression_baseline( + report: Mapping[str, object], + path: Path, +) -> None: + _write_json_file(path, _compact_regression_baseline(report)) + + +def _evaluate_regression_gate( + report: Mapping[str, object], + baseline_report: Mapping[str, object], + *, + baseline_path: Path | None = None, +) -> dict[str, object]: + errors: list[str] = [] + baseline_checks = _required_regression_checks( + baseline_report, label="baseline", errors=errors + ) + candidate_checks = _required_regression_checks( + report, label="candidate", errors=errors + ) + + baseline_auth = baseline_report.get("automaticAuthRequired") + candidate_auth = report.get("automaticAuthRequired") + if not isinstance(baseline_auth, bool) or not isinstance(candidate_auth, bool): + errors.append( + "baseline and candidate must record their production OAuth policy" + ) + elif baseline_auth != candidate_auth: + errors.append("baseline and candidate use different production OAuth policies") + + new_failures: list[dict[str, object]] = [] + missing_checks: list[dict[str, object]] = [] + known_failures: list[dict[str, object]] = [] + fixed_checks: list[dict[str, object]] = [] + + for identity, statuses in sorted(candidate_checks.items()): + baseline_statuses = baseline_checks.get(identity, set()) + if "FAIL" not in statuses: + continue + if identity.mode == SHIPPING_LEGACY_VERSION: + errors.append(f"shipping MCP check failed: {identity.check_id}") + if "FAIL" in baseline_statuses: + known_failures.append(asdict(identity)) + else: + new_failures.append(asdict(identity)) + + for identity, baseline_statuses in sorted(baseline_checks.items()): + candidate_statuses = candidate_checks.get(identity, set()) + if identity.mode == SHIPPING_LEGACY_VERSION and "FAIL" in baseline_statuses: + errors.append( + f"baseline shipping MCP check is not passing: {identity.check_id}" + ) + if "PASS" in baseline_statuses and "PASS" not in candidate_statuses: + missing_checks.append(asdict(identity)) + elif ( + "FAIL" in baseline_statuses + and "FAIL" not in candidate_statuses + and "PASS" not in candidate_statuses + ): + missing_checks.append(asdict(identity)) + elif "FAIL" in baseline_statuses and "FAIL" not in candidate_statuses: + fixed_checks.append(asdict(identity)) + + return { + "success": not errors and not new_failures and not missing_checks, + "requiredModes": list(REQUIRED_REGRESSION_MODES), + "baselineReport": str(baseline_path) if baseline_path is not None else None, + "configurationErrors": errors, + "newFailures": new_failures, + "missingChecks": missing_checks, + "knownFailures": known_failures, + "fixedChecks": fixed_checks, + } + + +def _format_ratio(value: Mapping[str, object]) -> str: + passed = value.get("passed", 0) + total = value.get("total", 0) + if total == 0: + return "0/0 (N/A)" + percentage = value.get("percentage", 0) + return f"{passed}/{total} ({percentage:g}%)" + + +def _print_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> None: + widths = [ + max(len(headers[index]), *(len(row[index]) for row in rows)) + for index in range(len(headers)) + ] + + def print_row(row: Sequence[str], separator: str = " | ") -> None: + print( + separator.join( + value.ljust(widths[index]) for index, value in enumerate(row) + ) + ) + + print_row(headers) + print("-+-".join("-" * width for width in widths)) + for row in rows: + print_row(row) + + +def _print_human_report(report: Mapping[str, object]) -> None: + success = report.get("success") is True + print(f"Codex MCP compliance: {'PASS' if success else 'FAIL'}") + print(f"Binary: {report.get('codexBinary')}") + print(f"Version: {report.get('codexVersion') or 'unknown'}") + failure_details: list[tuple[str, str, str]] = [] + cases = report.get("cases") + if isinstance(cases, list): + for case in cases: + if not isinstance(case, dict): + continue + label = f"{case.get('transport')} / {case.get('mode')}" + print(f" {'PASS' if case.get('success') else 'FAIL'} {label}") + checks = case.get("checks") + if not isinstance(checks, list): + continue + for check in checks: + if isinstance(check, dict) and check.get("success") is not True: + failure_details.append( + ( + label, + str(check.get("name")), + str(check.get("detail")), + ) + ) + + mode_summaries = report.get("modeSummaries") + if isinstance(mode_summaries, list) and mode_summaries: + print("\nPass rates by protocol mode") + summary_rows: list[list[str]] = [] + for item in mode_summaries: + if not isinstance(item, dict): + continue + checks = item.get("checks") + official_checks = item.get("officialChecks") + official_non_auth_checks = item.get("officialNonAuthChecks") + official_auth_checks = item.get("officialAuthChecks") + official_non_auth_scenarios = item.get("officialNonAuthScenarios") + official_auth_scenarios = item.get("officialAuthScenarios") + harness_checks = item.get("harnessChecks") + supplemental_checks = item.get("supplementalChecks") + transport_cases = item.get("transportCases") + if not all( + isinstance(value, dict) + for value in ( + checks, + official_checks, + official_non_auth_checks, + official_auth_checks, + official_non_auth_scenarios, + official_auth_scenarios, + harness_checks, + supplemental_checks, + transport_cases, + ) + ): + continue + summary_rows.append( + [ + str(item.get("mode")), + _format_ratio(official_non_auth_scenarios), + _format_ratio(official_auth_scenarios), + _format_ratio(official_checks), + _format_ratio(harness_checks), + _format_ratio(supplemental_checks), + _format_ratio(checks), + _format_ratio(transport_cases), + ] + ) + _print_table( + [ + "Mode", + "Non-auth scenarios", + "Auth scenarios", + "Official assertions", + "Harness", + "Supplemental", + "All checks", + "Transport cases", + ], + summary_rows, + ) + + test_matrix = report.get("testMatrix") + if isinstance(test_matrix, dict): + columns = test_matrix.get("columns") + rows = test_matrix.get("rows") + if isinstance(columns, list) and isinstance(rows, list) and columns: + matrix_headers = ["Test"] + column_keys: list[str] = [] + for column in columns: + if not isinstance(column, dict): + continue + key = str(column.get("key")) + column_keys.append(key) + matrix_headers.append(f"{column.get('transport')} {column.get('mode')}") + matrix_rows: list[list[str]] = [] + for row in rows: + if not isinstance(row, dict) or not isinstance( + row.get("results"), dict + ): + continue + results = row["results"] + matrix_rows.append( + [ + str(row.get("test")), + *(str(results.get(key, "N/A")) for key in column_keys), + ] + ) + print("\nPer-test results") + _print_table(matrix_headers, matrix_rows) + + if failure_details: + print("\nFailure details") + for label, name, detail in failure_details: + print(f" {label} / {name}: {detail}") + + summary = report.get("summary") + if isinstance(summary, dict): + print(f"Summary: {summary.get('passed')}/{summary.get('total')} cases passed") + gate = report.get("regressionGate") + if isinstance(gate, dict): + passed = gate.get("success") is True + known = gate.get("knownFailures") + known_count = len(known) if isinstance(known, list) else 0 + print( + f"Regression gate: {'PASS' if passed else 'FAIL'} " + f"({known_count} acknowledged baseline failures)" + ) + for category in ("configurationErrors", "newFailures", "missingChecks"): + values = gate.get(category) + if isinstance(values, list): + for value in values: + print(f" {category}: {value}") + artifacts = report.get("artifacts") + if isinstance(artifacts, str): + print(f"Artifacts: {artifacts}") + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run the full versioned official MCP client conformance suite against a " + "supplied Codex binary, plus the supplemental local stdio suite." + ) + ) + parser.add_argument("codex_binary", type=Path) + parser.add_argument( + "--mode", + choices=("all", SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION), + default="all", + help="Protocol era to test (default: both).", + ) + parser.add_argument( + "--transport", + choices=("all", "stdio", "http"), + default="all", + help=( + "Transport to test: supplemental stdio, official localhost HTTP, " + "or both (default: both)." + ), + ) + parser.add_argument( + "--server-script", + type=Path, + default=Path(__file__).resolve().with_name("server.py"), + help="Path to the MCP fixture server.py.", + ) + parser.add_argument( + "--adapter-script", + type=Path, + default=Path(__file__).resolve().with_name("codex_conformance_adapter.py"), + help="Path to the Codex adapter used by the official client suite.", + ) + parser.add_argument( + "--conformance-cli", + type=Path, + help=( + "Use an existing official conformance executable. By default the " + "runner bootstraps the pinned upstream Git commit with npx." + ), + ) + parser.add_argument( + "--official-scenario", + action="append", + help=( + "Run only this official versioned scenario. Repeat to select more " + "(default: every applicable scenario for the selected mode)." + ), + ) + parser.add_argument( + "--auth", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Include official OAuth authorization scenarios in HTTP coverage " + "(default: enabled). Use --no-auth for a faster non-auth slice." + ), + ) + parser.add_argument( + "--require-automatic-auth", + action="store_true", + help=( + "Require Codex to recover from OAuth scope escalation and " + "authorization-server migration without harness-injected re-login; " + "also exercise production client-metadata selection." + ), + ) + parser.add_argument( + "--timeout", + type=float, + default=45.0, + help="Per-command and per-request timeout in seconds.", + ) + parser.add_argument( + "--report", + type=Path, + help="Also write the complete JSON report to this path.", + ) + parser.add_argument( + "--baseline-report", + type=Path, + help=( + "Require the complete shipping, intermediate, and modern authenticated " + "HTTP and stdio matrices to preserve every passing check from this report. " + "Known baseline failures remain visible and new failures fail the gate." + ), + ) + parser.add_argument( + "--write-baseline", + type=Path, + help="Write a compact, deterministic regression baseline from the completed run.", + ) + parser.add_argument( + "--extract-baseline", + type=Path, + help=( + "Write a compact, deterministic baseline from --baseline-report " + "and exit without rerunning conformance." + ), + ) + parser.add_argument( + "--json", + action="store_true", + help="Print the report as JSON instead of the human summary.", + ) + parser.add_argument( + "--artifact-parent", + type=Path, + help="Parent directory for isolated temporary Codex homes.", + ) + parser.add_argument( + "--keep-artifacts", + action="store_true", + help="Keep isolated Codex homes and include their path in the report.", + ) + parser.add_argument( + "--enable-modern-feature", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Configure mcp_2026_07_28 before MCP startup and verify it through " + "the app-server runtime setter for modern cases (default: enabled)." + ), + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + codex_binary = args.codex_binary.expanduser().resolve() + server_script = args.server_script.expanduser().resolve() + adapter_script = args.adapter_script.expanduser().resolve() + modes = ( + (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) + if args.mode == "all" + else (args.mode,) + ) + transports = ("stdio", "http") if args.transport == "all" else (args.transport,) + if not codex_binary.is_file() or not os.access(codex_binary, os.X_OK): + print(f"error: Codex binary is not executable: {codex_binary}", file=sys.stderr) + return 2 + if "stdio" in transports and not server_script.is_file(): + print(f"error: fixture server does not exist: {server_script}", file=sys.stderr) + return 2 + if "http" in transports and not adapter_script.is_file(): + print( + f"error: official conformance adapter does not exist: {adapter_script}", + file=sys.stderr, + ) + return 2 + if args.timeout <= 0: + print("error: --timeout must be positive", file=sys.stderr) + return 2 + if args.extract_baseline is not None and args.baseline_report is None: + print("error: --extract-baseline requires --baseline-report", file=sys.stderr) + return 2 + if args.extract_baseline is not None and args.write_baseline is not None: + print( + "error: --extract-baseline cannot be combined with --write-baseline", + file=sys.stderr, + ) + return 2 + + baseline_report: dict[str, object] | None = None + baseline_path: Path | None = None + if args.baseline_report is not None: + baseline_path = args.baseline_report.expanduser().resolve() + try: + loaded_baseline = json.loads(baseline_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print( + f"error: cannot read baseline report {baseline_path}: {exc}", + file=sys.stderr, + ) + return 2 + if not isinstance(loaded_baseline, dict): + print( + f"error: baseline report must be a JSON object: {baseline_path}", + file=sys.stderr, + ) + return 2 + baseline_report = loaded_baseline + + if args.extract_baseline is not None: + assert baseline_report is not None + output_path = args.extract_baseline.expanduser().resolve() + try: + _write_compact_regression_baseline(baseline_report, output_path) + except (OSError, ValueError) as exc: + print( + f"error: cannot extract regression baseline {output_path}: {exc}", + file=sys.stderr, + ) + return 2 + print(f"Regression baseline: {output_path}") + return 0 + + conformance_command: list[str] + if args.conformance_cli is None: + conformance_command = default_conformance_command() + else: + conformance_cli = args.conformance_cli.expanduser().resolve() + if not conformance_cli.is_file(): + print( + f"error: official conformance CLI does not exist: {conformance_cli}", + file=sys.stderr, + ) + return 2 + if os.access(conformance_cli, os.X_OK): + conformance_command = [str(conformance_cli)] + elif conformance_cli.suffix in {".js", ".mjs"} and shutil.which("node"): + conformance_command = [str(shutil.which("node")), str(conformance_cli)] + else: + print( + "error: --conformance-cli must be executable (or a JavaScript " + f"file runnable with node): {conformance_cli}", + file=sys.stderr, + ) + return 2 + + try: + for mode in modes: + scenarios_for_mode( + mode, + args.official_scenario, + include_auth=args.auth, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + artifact_parent: Path | None = None + if args.artifact_parent is not None: + artifact_parent = args.artifact_parent.expanduser().resolve() + artifact_parent.mkdir(parents=True, exist_ok=True) + + report, _ = run_compliance( + codex_binary, + server_script=server_script, + adapter_script=adapter_script, + conformance_command=conformance_command, + official_scenarios=args.official_scenario, + modes=modes, + transports=transports, + timeout_seconds=args.timeout, + artifact_parent=artifact_parent, + keep_artifacts=args.keep_artifacts, + enable_modern_feature=args.enable_modern_feature, + include_auth=args.auth, + require_automatic_auth=args.require_automatic_auth, + ) + if baseline_report is not None: + report["regressionGate"] = _evaluate_regression_gate( + report, + baseline_report, + baseline_path=baseline_path, + ) + if args.write_baseline is not None: + output_path = args.write_baseline.expanduser().resolve() + try: + _write_compact_regression_baseline(report, output_path) + except (OSError, ValueError) as exc: + print( + f"error: cannot write regression baseline {output_path}: {exc}", + file=sys.stderr, + ) + return 2 + + if args.report is not None: + report_path = args.report.expanduser().resolve() + _write_json_file(report_path, report) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + _print_human_report(report) + if args.report is not None: + print(f"JSON report: {args.report.expanduser().resolve()}") + if baseline_report is not None: + gate = report.get("regressionGate") + return 0 if isinstance(gate, dict) and gate.get("success") is True else 1 + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_conformance/server.py b/scripts/mcp_conformance/server.py new file mode 100644 index 0000000000000000000000000000000000000000..294fcb4c480fa4de9e8f7e6a192a4fd3d69c5e58 --- /dev/null +++ b/scripts/mcp_conformance/server.py @@ -0,0 +1,1744 @@ +#!/usr/bin/env python3 +"""MCP test server for shipping and draft legacy protocols and MCP 2026-07-28.""" + +import argparse +import base64 +import binascii +import json +import os +import sys +import threading +import uuid +from dataclasses import dataclass, field +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import IO, Mapping, Sequence +from urllib.parse import urlsplit + +SHIPPING_LEGACY_VERSION = "2025-06-18" +LEGACY_VERSION = "2025-11-25" +MODERN_VERSION = "2026-07-28" +SERVER_NAME = "openai-mcp-spec-test-server" +SERVER_VERSION = "0.1.0" + +DEFAULT_PROFILE = "default" +REVIEW_PROFILE = "review-regressions" +REPEATED_CURSOR_PROFILE = "repeated-cursor" +MISMATCHED_DISCOVERY_ID_PROFILE = "discovery-mismatched-id" +NULL_DISCOVERY_ID_PROFILE = "discovery-null-id" +SSE_CR_COMMENTS_PROFILE = "sse-cr-comments" +SSE_COMMENT_FLOOD_PROFILE = "sse-comment-flood" +CATALOG_MAX_PROFILE = "catalog-max" +CATALOG_OVER_LIMIT_PROFILE = "catalog-over-limit" +FIXTURE_PROFILES = ( + DEFAULT_PROFILE, + REVIEW_PROFILE, + REPEATED_CURSOR_PROFILE, + MISMATCHED_DISCOVERY_ID_PROFILE, + NULL_DISCOVERY_ID_PROFILE, + SSE_CR_COMMENTS_PROFILE, + SSE_COMMENT_FLOOD_PROFILE, + CATALOG_MAX_PROFILE, + CATALOG_OVER_LIMIT_PROFILE, +) +MAX_CATALOG_ITEMS = 1_024 +REVIEW_EXACT_INTEGER = 9_007_199_254_740_993 +REVIEW_MAX_INTEGER = 9_223_372_036_854_775_807 +REVIEW_MRTR_INPUT_REQUEST_COUNT = 65 +REVIEW_PAGE_CURSOR = "review-page-2" +REVIEW_SSE_EVENT_LIMIT_BYTES = 8 * 1_024 * 1_024 +REVIEW_SSE_COMMENT_LINE_BYTES = 2_048 +REVIEW_SSE_COMMENT_LINE_COUNT = ( + REVIEW_SSE_EVENT_LIMIT_BYTES // REVIEW_SSE_COMMENT_LINE_BYTES + 1 +) + +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 +INTERNAL_ERROR = -32603 +HEADER_MISMATCH = -32020 +MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 +UNSUPPORTED_PROTOCOL_VERSION = -32022 + +SERVER_INFO_META = { + "io.modelcontextprotocol/serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + } +} + +RESOURCE_URIS = ( + "test://fixture/readme", + "test://fixture/こんにちは", +) + + +@dataclass +class ConnectionState: + initialize_seen: bool = False + initialized: bool = False + + +@dataclass +class ResponsePlan: + response: dict[str, object] | None + notifications: list[dict[str, object]] = field(default_factory=list) + http_status: int = HTTPStatus.OK + force_sse: bool = False + + +class HeaderValidationError(ValueError): + pass + + +def _error_response( + request_id: object, + code: int, + message: str, + *, + data: dict[str, object] | None = None, +) -> dict[str, object]: + error: dict[str, object] = {"code": code, "message": message} + if data is not None: + error["data"] = data + return {"jsonrpc": "2.0", "id": request_id, "error": error} + + +def _response(request_id: object, result: dict[str, object]) -> dict[str, object]: + return {"jsonrpc": "2.0", "id": request_id, "result": result} + + +def _modern_result( + fields: Mapping[str, object] | None = None, + *, + cache_scope: str | None = None, + ttl_ms: int | None = None, + meta: Mapping[str, object] | None = None, +) -> dict[str, object]: + result: dict[str, object] = { + "resultType": "complete", + "_meta": dict(meta or SERVER_INFO_META), + } + if fields is not None: + result.update(fields) + if cache_scope is not None: + result["cacheScope"] = cache_scope + if ttl_ms is not None: + result["ttlMs"] = ttl_ms + return result + + +def _tool_definitions( + modern: bool, + *, + profile: str = DEFAULT_PROFILE, +) -> list[dict[str, object]]: + definitions: list[dict[str, object]] = [ + { + "name": "client_metadata", + "description": "Return the per-request MCP client metadata observed by the server.", + "inputSchema": {"type": "object", "additionalProperties": False}, + "outputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "echo", + "description": "Echo text through a normal MCP tool result.", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": False, + }, + "outputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "fail", + "description": "Return a tool execution error without using a JSON-RPC error.", + "inputSchema": {"type": "object", "additionalProperties": False}, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "header_echo", + "description": "Echo values that modern HTTP clients must mirror into headers.", + "inputSchema": { + "type": "object", + "properties": { + "region": {"type": "string", "x-mcp-header": "Region"}, + "attempt": { + "type": "integer", + "minimum": -9_007_199_254_740_991, + "maximum": 9_007_199_254_740_991, + "x-mcp-header": "Attempt", + }, + "enabled": {"type": "boolean", "x-mcp-header": "Enabled"}, + "greeting": {"type": "string", "x-mcp-header": "Greeting"}, + }, + "required": ["region", "attempt", "enabled", "greeting"], + "additionalProperties": False, + }, + "outputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "progress", + "description": "Emit request-scoped progress and opt-in log notifications.", + "inputSchema": {"type": "object", "additionalProperties": False}, + "annotations": {"readOnlyHint": True}, + }, + ] + if modern: + # This tool first returns InputRequiredResult, not CallToolResult. Keep + # output-schema coverage on echo so the MRTR fixture tests one concern. + definitions.append( + { + "name": "request_input", + "description": "Exercise the 2026-07-28 multi round-trip request pattern.", + "inputSchema": {"type": "object", "additionalProperties": False}, + "annotations": {"readOnlyHint": True}, + } + ) + if profile in (REVIEW_PROFILE, REPEATED_CURSOR_PROFILE): + definitions.extend( + [ + { + "name": "review_large_integer", + "description": "Echo an integer without losing JSON-schema precision.", + "inputSchema": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": REVIEW_EXACT_INTEGER, + "maximum": REVIEW_MAX_INTEGER, + "default": REVIEW_EXACT_INTEGER, + } + }, + "required": ["value"], + "additionalProperties": False, + }, + "outputSchema": { + "type": "object", + "properties": {"value": {"type": "integer"}}, + "required": ["value"], + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "review_protocol_env", + "description": "Report the MCP protocol-version environment variable.", + "inputSchema": {"type": "object", "additionalProperties": False}, + "outputSchema": { + "type": "object", + "properties": {"value": {"type": ["string", "null"]}}, + "required": ["value"], + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + ] + ) + if modern: + definitions.extend( + [ + { + "name": "review_integer_elicitation", + "description": "Request an integer form without losing schema precision.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + { + "name": "review_mrtr_cap", + "description": ( + "Return more simultaneous input requests than the client cap." + ), + "inputSchema": { + "type": "object", + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + }, + ] + ) + if profile in (CATALOG_MAX_PROFILE, CATALOG_OVER_LIMIT_PROFILE): + catalog_size = MAX_CATALOG_ITEMS + if profile == CATALOG_OVER_LIMIT_PROFILE: + catalog_size += 1 + definitions.extend( + { + "name": f"catalog_boundary_{index:04d}", + "description": "Exercise the MCP catalog item boundary.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + }, + "annotations": {"readOnlyHint": True}, + } + for index in range(catalog_size - len(definitions)) + ) + return sorted(definitions, key=lambda tool: str(tool["name"])) + + +class ProtocolServer: + def __init__(self, mode: str, *, profile: str = DEFAULT_PROFILE) -> None: + if mode not in (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION): + raise ValueError(f"unsupported mode: {mode}") + if profile not in FIXTURE_PROFILES: + raise ValueError(f"unsupported profile: {profile}") + self.mode = mode + self.profile = profile + + @property + def modern(self) -> bool: + return self.mode == MODERN_VERSION + + def handle( + self, + message: object, + state: ConnectionState, + ) -> ResponsePlan: + if not isinstance(message, dict): + return ResponsePlan( + _error_response(None, INVALID_REQUEST, "Request must be a JSON object"), + http_status=HTTPStatus.BAD_REQUEST, + ) + + if message.get("jsonrpc") != "2.0" or not isinstance( + message.get("method"), str + ): + return ResponsePlan( + _error_response( + message.get("id"), + INVALID_REQUEST, + "Invalid JSON-RPC 2.0 request", + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + method = str(message["method"]) + is_notification = "id" not in message + request_id = message.get("id") + params = message.get("params", {}) + if not isinstance(params, dict): + return ResponsePlan( + None + if is_notification + else _error_response( + request_id, INVALID_PARAMS, "params must be an object" + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + if is_notification: + return self._handle_notification(method, params, state) + + if self.modern: + return self._handle_modern(request_id, method, params) + return self._handle_legacy(request_id, method, params, state) + + def _handle_notification( + self, + method: str, + params: dict[str, object], + state: ConnectionState, + ) -> ResponsePlan: + if not self.modern and method == "notifications/initialized": + if state.initialize_seen: + state.initialized = True + return ResponsePlan(None, http_status=HTTPStatus.ACCEPTED) + if method == "notifications/cancelled": + return ResponsePlan(None, http_status=HTTPStatus.ACCEPTED) + return ResponsePlan(None, http_status=HTTPStatus.ACCEPTED) + + def _handle_modern( + self, + request_id: object, + method: str, + params: dict[str, object], + ) -> ResponsePlan: + if method == "initialize": + return ResponsePlan( + _error_response( + request_id, + METHOD_NOT_FOUND, + f"initialize is not supported; this server speaks MCP {MODERN_VERSION}", + data={"supported": [MODERN_VERSION]}, + ), + http_status=HTTPStatus.NOT_FOUND, + ) + + meta = params.get("_meta") + if not isinstance(meta, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "params._meta is required on every 2026-07-28 request", + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + version = meta.get("io.modelcontextprotocol/protocolVersion") + capabilities = meta.get("io.modelcontextprotocol/clientCapabilities") + if not isinstance(version, str) or not isinstance(capabilities, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "_meta must include protocolVersion and clientCapabilities", + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + if version != MODERN_VERSION: + return ResponsePlan( + _error_response( + request_id, + UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + data={"supported": [MODERN_VERSION], "requested": version}, + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + if method == "server/discover": + response_id = request_id + if self.profile == MISMATCHED_DISCOVERY_ID_PROFILE: + response_id = f"review-mismatched-{request_id}" + elif self.profile == NULL_DISCOVERY_ID_PROFILE: + response_id = None + return ResponsePlan( + _response( + response_id, + _modern_result( + { + "supportedVersions": [MODERN_VERSION], + "capabilities": self._modern_capabilities(), + "instructions": ( + "Use echo for a basic call, header_echo for HTTP " + "header mirroring, request_input for MRTR, and " + "progress for request-scoped notifications." + ), + }, + cache_scope="public", + ttl_ms=60_000, + ), + ) + ) + if method == "tools/list": + return self._list_tools(request_id, params) + if method == "tools/call": + return self._call_tool(request_id, params, meta) + if method == "resources/list": + return self._list_resources(request_id, params) + if method == "resources/templates/list": + return ResponsePlan( + _response( + request_id, + _modern_result( + { + "resourceTemplates": [ + { + "name": "fixture-by-name", + "uriTemplate": "test://fixture/{name}", + "description": "Read a named fixture resource.", + "mimeType": "text/plain", + } + ] + }, + cache_scope="public", + ttl_ms=5_000, + ), + ) + ) + if method == "resources/read": + return self._read_resource(request_id, params) + if method == "prompts/list": + return ResponsePlan( + _response( + request_id, + _modern_result( + {"prompts": self._prompts()}, + cache_scope="public", + ttl_ms=5_000, + ), + ) + ) + if method == "prompts/get": + return self._get_prompt(request_id, params) + if method == "subscriptions/listen": + return self._listen(request_id, params) + + return ResponsePlan( + _error_response(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}"), + http_status=HTTPStatus.NOT_FOUND, + ) + + def _handle_legacy( + self, + request_id: object, + method: str, + params: dict[str, object], + state: ConnectionState, + ) -> ResponsePlan: + if method == "initialize": + requested_version = params.get("protocolVersion") + if not isinstance(requested_version, str): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "initialize requires protocolVersion", + ) + ) + state.initialize_seen = True + return ResponsePlan( + _response( + request_id, + { + "protocolVersion": self.mode, + "capabilities": self._legacy_capabilities(), + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + }, + "instructions": f"Legacy {self.mode} compatibility fixture.", + }, + ) + ) + + if method == "server/discover": + return ResponsePlan( + _error_response( + request_id, + METHOD_NOT_FOUND, + "Unknown method: server/discover", + ), + http_status=HTTPStatus.NOT_FOUND, + ) + if not state.initialized: + return ResponsePlan( + _error_response( + request_id, + INVALID_REQUEST, + "Server has not received notifications/initialized", + ) + ) + if method == "ping": + return ResponsePlan(_response(request_id, {})) + if method == "logging/setLevel": + return ResponsePlan(_response(request_id, {})) + if method == "tools/list": + return self._list_tools(request_id, params) + if method == "tools/call": + return self._call_tool(request_id, params, {}) + if method == "resources/list": + return self._list_resources(request_id, params) + if method == "resources/templates/list": + return ResponsePlan( + _response( + request_id, + { + "resourceTemplates": [ + { + "name": "fixture-by-name", + "uriTemplate": "test://fixture/{name}", + "description": "Read a named fixture resource.", + "mimeType": "text/plain", + } + ] + }, + ) + ) + if method == "resources/read": + return self._read_resource(request_id, params) + if method == "prompts/list": + return ResponsePlan(_response(request_id, {"prompts": self._prompts()})) + if method == "prompts/get": + return self._get_prompt(request_id, params) + return ResponsePlan( + _error_response(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}"), + http_status=HTTPStatus.NOT_FOUND, + ) + + def _list_tools( + self, + request_id: object, + params: dict[str, object], + ) -> ResponsePlan: + definitions = _tool_definitions(self.modern, profile=self.profile) + fields: dict[str, object] + if self.modern and self.profile in (REVIEW_PROFILE, REPEATED_CURSOR_PROFILE): + cursor = params.get("cursor") + if cursor is None: + fields = { + "tools": definitions[:2], + "nextCursor": REVIEW_PAGE_CURSOR, + } + elif cursor == REVIEW_PAGE_CURSOR: + fields = {"tools": definitions[2:]} + if self.profile == REPEATED_CURSOR_PROFILE: + fields["nextCursor"] = REVIEW_PAGE_CURSOR + else: + return ResponsePlan( + _error_response(request_id, INVALID_PARAMS, "Invalid tool cursor") + ) + else: + fields = {"tools": definitions} + + result = ( + _modern_result(fields, cache_scope="public", ttl_ms=1_000) + if self.modern + else fields + ) + return ResponsePlan(_response(request_id, result)) + + @staticmethod + def _modern_capabilities() -> dict[str, object]: + return { + "tools": {"listChanged": True}, + "resources": {"subscribe": True, "listChanged": True}, + "prompts": {"listChanged": True}, + "logging": {}, + "extensions": {"com.openai/mcp-spec-test": {}}, + } + + @staticmethod + def _legacy_capabilities() -> dict[str, object]: + return { + "tools": {"listChanged": True}, + "resources": {"subscribe": True, "listChanged": True}, + "prompts": {"listChanged": True}, + "logging": {}, + } + + def _call_tool( + self, + request_id: object, + params: dict[str, object], + request_meta: dict[str, object], + ) -> ResponsePlan: + name = params.get("name") + arguments = params.get("arguments", {}) + if not isinstance(name, str) or not isinstance(arguments, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "tools/call requires a string name and object arguments", + ) + ) + known = { + str(tool["name"]) + for tool in _tool_definitions(self.modern, profile=self.profile) + } + if name not in known: + return ResponsePlan( + _error_response(request_id, INVALID_PARAMS, f"Unknown tool: {name}") + ) + + if name == "request_input": + return self._request_input(request_id, params, request_meta) + if name == "review_integer_elicitation": + return self._review_integer_elicitation(request_id, params, request_meta) + if name == "review_mrtr_cap": + return self._review_mrtr_cap(request_id, request_meta) + + notifications: list[dict[str, object]] = [] + if name == "progress": + progress_token = request_meta.get("progressToken") + if isinstance(progress_token, (str, int)) and not isinstance( + progress_token, bool + ): + notifications.append( + { + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { + "progressToken": progress_token, + "progress": 1, + "total": 1, + "message": "fixture complete", + }, + } + ) + if isinstance(request_meta.get("io.modelcontextprotocol/logLevel"), str): + notifications.append( + { + "jsonrpc": "2.0", + "method": "notifications/message", + "params": { + "level": "info", + "logger": SERVER_NAME, + "data": "request-scoped fixture log", + }, + } + ) + + if name == "review_large_integer": + value = arguments.get("value") + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not REVIEW_EXACT_INTEGER <= value <= REVIEW_MAX_INTEGER + ): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "review_large_integer requires an exactly representable JSON integer", + ) + ) + structured = {"value": value} + fields: dict[str, object] = { + "content": [{"type": "text", "text": json.dumps(structured)}], + "structuredContent": structured, + } + elif name == "review_protocol_env": + structured = {"value": os.environ.get("CODEX_MCP_PROTOCOL_VERSION")} + fields = { + "content": [{"type": "text", "text": json.dumps(structured)}], + "structuredContent": structured, + } + elif name == "echo": + text = arguments.get("text") + if not isinstance(text, str): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "echo requires a string text argument", + ) + ) + fields = { + "content": [{"type": "text", "text": text}], + "structuredContent": {"text": text}, + } + elif name == "client_metadata": + fields = { + "content": [ + { + "type": "text", + "text": json.dumps(request_meta, sort_keys=True), + } + ], + "structuredContent": request_meta, + } + elif name == "header_echo": + required = { + "region": str, + "attempt": int, + "enabled": bool, + "greeting": str, + } + if any( + key not in arguments + or not isinstance(arguments[key], expected_type) + or (expected_type is int and isinstance(arguments[key], bool)) + for key, expected_type in required.items() + ) or not ( + -9_007_199_254_740_991 + <= int(arguments["attempt"]) + <= 9_007_199_254_740_991 + ): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "header_echo arguments have invalid types", + ) + ) + fields = { + "content": [ + { + "type": "text", + "text": json.dumps( + arguments, ensure_ascii=False, sort_keys=True + ), + } + ], + "structuredContent": arguments, + } + elif name == "fail": + fields = { + "content": [ + { + "type": "text", + "text": "Intentional tool execution failure", + } + ], + "isError": True, + } + else: + fields = { + "content": [{"type": "text", "text": "progress complete"}], + } + + result = _modern_result(fields) if self.modern else fields + return ResponsePlan( + _response(request_id, result), + notifications=notifications, + force_sse=bool(notifications), + ) + + def _review_integer_elicitation( + self, + request_id: object, + params: dict[str, object], + request_meta: dict[str, object], + ) -> ResponsePlan: + capabilities = request_meta.get( + "io.modelcontextprotocol/clientCapabilities", {} + ) + if not isinstance(capabilities, dict) or not isinstance( + capabilities.get("elicitation"), dict + ): + return ResponsePlan( + _error_response( + request_id, + MISSING_REQUIRED_CLIENT_CAPABILITY, + "review_integer_elicitation requires elicitation support", + data={"requiredCapabilities": {"elicitation": {"form": {}}}}, + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + input_responses = params.get("inputResponses") + request_state = params.get("requestState") + expected_state = "opaque:review_integer_elicitation:v1" + if input_responses is None and request_state is None: + return ResponsePlan( + _response( + request_id, + { + "resultType": "input_required", + "_meta": dict(SERVER_INFO_META), + "inputRequests": { + "large_integer": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Return the exact large integer.", + "requestedSchema": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": REVIEW_EXACT_INTEGER, + "maximum": REVIEW_MAX_INTEGER, + "default": REVIEW_EXACT_INTEGER, + } + }, + "required": ["value"], + }, + }, + } + }, + "requestState": expected_state, + }, + ) + ) + + if request_state != expected_state or not isinstance(input_responses, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "integer elicitation retry must echo requestState and inputResponses", + ) + ) + + response = input_responses.get("large_integer") + if not isinstance(response, dict) or response.get("action") != "accept": + return ResponsePlan( + _error_response( + request_id, INVALID_PARAMS, "integer input must be accepted" + ) + ) + + content = response.get("content") + value = content.get("value") if isinstance(content, dict) else None + if ( + not isinstance(value, int) + or isinstance(value, bool) + or not REVIEW_EXACT_INTEGER <= value <= REVIEW_MAX_INTEGER + ): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "integer elicitation requires the exact integer form response", + ) + ) + + return ResponsePlan( + _response( + request_id, + _modern_result( + { + "content": [{"type": "text", "text": str(value)}], + "structuredContent": {"value": value}, + } + ), + ) + ) + + def _review_mrtr_cap( + self, + request_id: object, + request_meta: dict[str, object], + ) -> ResponsePlan: + capabilities = request_meta.get( + "io.modelcontextprotocol/clientCapabilities", {} + ) + if not isinstance(capabilities, dict) or not isinstance( + capabilities.get("elicitation"), dict + ): + return ResponsePlan( + _error_response( + request_id, + MISSING_REQUIRED_CLIENT_CAPABILITY, + "review_mrtr_cap requires elicitation support", + data={"requiredCapabilities": {"elicitation": {"form": {}}}}, + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + return ResponsePlan( + _response( + request_id, + { + "resultType": "input_required", + "_meta": dict(SERVER_INFO_META), + "inputRequests": { + f"review-input-{index:02d}": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": f"Review MRTR input {index}.", + "requestedSchema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + }, + } + for index in range(REVIEW_MRTR_INPUT_REQUEST_COUNT) + }, + "requestState": "opaque:review_mrtr_cap:v1", + }, + ) + ) + + def _request_input( + self, + request_id: object, + params: dict[str, object], + request_meta: dict[str, object], + ) -> ResponsePlan: + capabilities = request_meta.get( + "io.modelcontextprotocol/clientCapabilities", {} + ) + if not isinstance(capabilities, dict) or not isinstance( + capabilities.get("elicitation"), dict + ): + return ResponsePlan( + _error_response( + request_id, + MISSING_REQUIRED_CLIENT_CAPABILITY, + "request_input requires elicitation support", + data={"requiredCapabilities": {"elicitation": {"form": {}}}}, + ), + http_status=HTTPStatus.BAD_REQUEST, + ) + + input_responses = params.get("inputResponses") + request_state = params.get("requestState") + expected_state = "opaque:request_input:v1" + if input_responses is None and request_state is None: + return ResponsePlan( + _response( + request_id, + { + "resultType": "input_required", + "_meta": dict(SERVER_INFO_META), + "inputRequests": { + "confirmation": { + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Confirm the MCP 2026-07-28 MRTR test.", + "requestedSchema": { + "type": "object", + "properties": { + "confirmation": { + "type": "string", + "default": "confirmed", + } + }, + "required": ["confirmation"], + }, + }, + } + }, + "requestState": expected_state, + }, + ) + ) + + if request_state != expected_state or not isinstance(input_responses, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "MRTR retry must echo requestState and provide inputResponses", + ) + ) + confirmation = input_responses.get("confirmation") + if not isinstance(confirmation, dict) or confirmation.get("action") != "accept": + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "confirmation input must be accepted", + ) + ) + content = confirmation.get("content") + if not isinstance(content, dict) or not isinstance( + content.get("confirmation"), str + ): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "confirmation content is required", + ) + ) + value = str(content["confirmation"]) + return ResponsePlan( + _response( + request_id, + _modern_result( + { + "content": [{"type": "text", "text": value}], + "structuredContent": {"confirmation": value}, + } + ), + ) + ) + + def _list_resources( + self, + request_id: object, + params: dict[str, object], + ) -> ResponsePlan: + cursor = params.get("cursor") + if cursor is None: + resources = [self._resource(RESOURCE_URIS[0])] + next_cursor: str | None = "page-2" + elif cursor == "page-2": + resources = [self._resource(RESOURCE_URIS[1])] + next_cursor = None + else: + return ResponsePlan( + _error_response(request_id, INVALID_PARAMS, "Invalid resource cursor") + ) + + fields: dict[str, object] = {"resources": resources} + if next_cursor is not None: + fields["nextCursor"] = next_cursor + result = ( + _modern_result(fields, cache_scope="public", ttl_ms=2_000) + if self.modern + else fields + ) + return ResponsePlan(_response(request_id, result)) + + @staticmethod + def _resource(uri: str) -> dict[str, object]: + return { + "name": uri.rsplit("/", 1)[-1], + "title": f"Fixture resource: {uri.rsplit('/', 1)[-1]}", + "uri": uri, + "description": "A deterministic text resource from the MCP spec fixture.", + "mimeType": "text/plain", + } + + def _read_resource( + self, + request_id: object, + params: dict[str, object], + ) -> ResponsePlan: + uri = params.get("uri") + if uri not in RESOURCE_URIS: + return ResponsePlan( + _error_response(request_id, INVALID_PARAMS, "Resource not found") + ) + fields: dict[str, object] = { + "contents": [ + { + "uri": uri, + "mimeType": "text/plain", + "text": f"fixture contents for {uri}", + } + ] + } + result = ( + _modern_result(fields, cache_scope="private", ttl_ms=500) + if self.modern + else fields + ) + return ResponsePlan(_response(request_id, result)) + + @staticmethod + def _prompts() -> list[dict[str, object]]: + return [ + { + "name": "fixture.greeting", + "title": "Fixture greeting", + "description": "Create a deterministic greeting.", + "arguments": [ + { + "name": "name", + "description": "Name to greet.", + "required": True, + } + ], + } + ] + + def _get_prompt( + self, + request_id: object, + params: dict[str, object], + ) -> ResponsePlan: + if params.get("name") != "fixture.greeting": + return ResponsePlan( + _error_response(request_id, INVALID_PARAMS, "Unknown prompt") + ) + arguments = params.get("arguments", {}) + if not isinstance(arguments, dict) or not isinstance( + arguments.get("name"), str + ): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "fixture.greeting requires the name argument", + ) + ) + fields: dict[str, object] = { + "description": "A deterministic fixture greeting.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": f"Hello, {arguments['name']}!", + }, + } + ], + } + result = _modern_result(fields) if self.modern else fields + return ResponsePlan(_response(request_id, result)) + + def _listen( + self, + request_id: object, + params: dict[str, object], + ) -> ResponsePlan: + requested = params.get("notifications") + if not isinstance(requested, dict): + return ResponsePlan( + _error_response( + request_id, + INVALID_PARAMS, + "subscriptions/listen requires a notifications filter", + ) + ) + + acknowledged: dict[str, object] = {} + if requested.get("toolsListChanged") is True: + acknowledged["toolsListChanged"] = True + if requested.get("promptsListChanged") is True: + acknowledged["promptsListChanged"] = True + if requested.get("resourcesListChanged") is True: + acknowledged["resourcesListChanged"] = True + subscriptions = requested.get("resourceSubscriptions") + valid_subscriptions = ( + [uri for uri in subscriptions if uri in RESOURCE_URIS] + if isinstance(subscriptions, list) + and all(isinstance(uri, str) for uri in subscriptions) + else [] + ) + if valid_subscriptions: + acknowledged["resourceSubscriptions"] = valid_subscriptions + + subscription_meta = { + "io.modelcontextprotocol/subscriptionId": request_id, + } + notifications: list[dict[str, object]] = [ + { + "jsonrpc": "2.0", + "method": "notifications/subscriptions/acknowledged", + "params": { + "_meta": subscription_meta, + "notifications": acknowledged, + }, + } + ] + notification_methods = ( + ("toolsListChanged", "notifications/tools/list_changed"), + ("promptsListChanged", "notifications/prompts/list_changed"), + ("resourcesListChanged", "notifications/resources/list_changed"), + ) + for filter_name, method in notification_methods: + if acknowledged.get(filter_name) is True: + notifications.append( + { + "jsonrpc": "2.0", + "method": method, + "params": {"_meta": subscription_meta}, + } + ) + for uri in valid_subscriptions: + notifications.append( + { + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": {"_meta": subscription_meta, "uri": uri}, + } + ) + + closing_meta = { + **SERVER_INFO_META, + "io.modelcontextprotocol/subscriptionId": request_id, + } + return ResponsePlan( + _response( + request_id, + _modern_result(meta=closing_meta), + ), + notifications=notifications, + force_sse=True, + ) + + +def _decode_mirrored_header(value: str) -> str: + if value.startswith("=?base64?") and value.endswith("?="): + encoded = value[len("=?base64?") : -len("?=")] + try: + return base64.b64decode(encoded, validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as exc: + raise HeaderValidationError("invalid Base64 mirrored header") from exc + if value.startswith("=?base64?") or value.endswith("?="): + raise HeaderValidationError("malformed Base64 sentinel") + if value != value.strip() or any( + char != "\t" and (ord(char) < 0x20 or ord(char) > 0x7E) for char in value + ): + raise HeaderValidationError("unsafe plain mirrored header") + return value + + +def validate_modern_http_headers( + message: dict[str, object], + headers: Mapping[str, str], +) -> None: + normalized = {name.lower(): value for name, value in headers.items()} + accept = { + value.strip().split(";", 1)[0].lower() + for value in normalized.get("accept", "").split(",") + } + if not {"application/json", "text/event-stream"}.issubset(accept): + raise HeaderValidationError( + "Accept must include application/json and text/event-stream" + ) + + method = message.get("method") + params = message.get("params") + if not isinstance(method, str) or not isinstance(params, dict): + return + meta = params.get("_meta") + + header_version = normalized.get("mcp-protocol-version") + if header_version is None: + raise HeaderValidationError("missing MCP-Protocol-Version header") + if isinstance(meta, dict): + body_version = meta.get("io.modelcontextprotocol/protocolVersion") + if isinstance(body_version, str) and body_version != header_version: + raise HeaderValidationError( + "MCP-Protocol-Version header does not match request _meta" + ) + + header_method = normalized.get("mcp-method") + if header_method is None: + raise HeaderValidationError("missing Mcp-Method header") + if header_method != method: + raise HeaderValidationError("Mcp-Method header does not match request method") + + name_source: object | None = None + if method in ("tools/call", "prompts/get"): + name_source = params.get("name") + elif method == "resources/read": + name_source = params.get("uri") + if name_source is not None: + header_name = normalized.get("mcp-name") + if header_name is None: + raise HeaderValidationError("missing Mcp-Name header") + if ( + not isinstance(name_source, str) + or _decode_mirrored_header(header_name) != name_source + ): + raise HeaderValidationError("Mcp-Name header does not match request body") + + if method != "tools/call" or params.get("name") != "header_echo": + return + arguments = params.get("arguments", {}) + if not isinstance(arguments, dict): + return + mirrored = { + "region": "region", + "attempt": "attempt", + "enabled": "enabled", + "greeting": "greeting", + } + for argument_name, header_suffix in mirrored.items(): + header_key = f"mcp-param-{header_suffix}" + argument = arguments.get(argument_name) + header_value = normalized.get(header_key) + if argument is None: + if header_value is not None: + raise HeaderValidationError( + f"Mcp-Param-{header_suffix} must be omitted" + ) + continue + if header_value is None: + raise HeaderValidationError(f"missing Mcp-Param-{header_suffix} header") + decoded = _decode_mirrored_header(header_value) + if isinstance(argument, bool): + expected = "true" if argument else "false" + elif isinstance(argument, int): + expected = str(argument) + elif isinstance(argument, str): + expected = argument + else: + raise HeaderValidationError( + f"unsupported mirrored argument type for {argument_name}" + ) + if decoded != expected: + raise HeaderValidationError( + f"Mcp-Param-{header_suffix} header does not match request body" + ) + + +def _encode_sse_messages( + messages: Sequence[Mapping[str, object]], + *, + profile: str, +) -> bytes: + if profile == SSE_COMMENT_FLOOD_PROFILE: + comment_prefix = b": reviewer keepalive " + comment = ( + comment_prefix + + b"x" * (REVIEW_SSE_COMMENT_LINE_BYTES - len(comment_prefix) - 1) + + b"\r" + ) + prefix = comment * REVIEW_SSE_COMMENT_LINE_COUNT + separator = "\r\r" + elif profile == SSE_CR_COMMENTS_PROFILE: + prefix = b": reviewer keepalive\r" + separator = "\r\r" + else: + prefix = b"" + separator = "\n\n" + + return prefix + b"".join( + ( + f"data: {json.dumps(message, ensure_ascii=False, separators=(',', ':'))}" + f"{separator}" + ).encode() + for message in messages + ) + + +class FixtureHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__( + self, + address: tuple[str, int], + fixture: ProtocolServer, + *, + endpoint: str, + allowed_origins: Sequence[str], + log_requests: bool, + ) -> None: + self.fixture = fixture + self.endpoint = endpoint + self.allowed_origins = set(allowed_origins) + self.log_requests = log_requests + self.sessions: dict[str, ConnectionState] = {} + self.sessions_lock = threading.Lock() + super().__init__(address, FixtureRequestHandler) + + +class FixtureRequestHandler(BaseHTTPRequestHandler): + server: FixtureHTTPServer + + def do_GET(self) -> None: + if self.path == "/healthz": + body = json.dumps( + {"status": "ok", "mode": self.server.fixture.mode} + ).encode() + self._send_bytes(HTTPStatus.OK, "application/json", body) + return + self._send_bytes( + HTTPStatus.METHOD_NOT_ALLOWED, + "application/json", + b"", + extra_headers={"Allow": "POST"}, + ) + + def do_DELETE(self) -> None: + if self.path != self.server.endpoint or self.server.fixture.modern: + self._send_bytes( + HTTPStatus.METHOD_NOT_ALLOWED, + "application/json", + b"", + extra_headers={"Allow": "POST"}, + ) + return + session_id = self.headers.get("Mcp-Session-Id") + with self.server.sessions_lock: + existed = ( + session_id is not None + and self.server.sessions.pop(session_id, None) is not None + ) + self._send_bytes( + HTTPStatus.OK if existed else HTTPStatus.NOT_FOUND, + "application/json", + b"", + ) + + def do_POST(self) -> None: + if self.path != self.server.endpoint: + self._send_json_error( + None, + HTTPStatus.NOT_FOUND, + METHOD_NOT_FOUND, + f"Unknown MCP endpoint: {self.path}", + ) + return + if not self._origin_is_allowed(): + self._send_json_error( + None, + HTTPStatus.FORBIDDEN, + INVALID_REQUEST, + "Origin is not allowed", + ) + return + if self.headers.get_content_type() != "application/json": + self._send_json_error( + None, + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + INVALID_REQUEST, + "Content-Type must be application/json", + ) + return + try: + content_length = int(self.headers.get("Content-Length", "")) + except ValueError: + content_length = -1 + if content_length < 0 or content_length > 1_048_576: + self._send_json_error( + None, + HTTPStatus.BAD_REQUEST, + INVALID_REQUEST, + "Invalid Content-Length", + ) + return + try: + message = json.loads(self.rfile.read(content_length)) + except (json.JSONDecodeError, UnicodeDecodeError): + self._send_json_error( + None, + HTTPStatus.BAD_REQUEST, + PARSE_ERROR, + "Parse error", + ) + return + if not isinstance(message, dict): + self._send_json_error( + None, + HTTPStatus.BAD_REQUEST, + INVALID_REQUEST, + "Request must be a JSON object", + ) + return + + request_id = message.get("id") + if self.server.fixture.modern: + if "id" in message: + try: + validate_modern_http_headers(message, self.headers) + except HeaderValidationError as exc: + self._send_json_error( + request_id, + HTTPStatus.BAD_REQUEST, + HEADER_MISMATCH, + f"Header mismatch: {exc}", + ) + return + state = ConnectionState() + response_headers: dict[str, str] = {} + else: + state, response_headers = self._legacy_state(message) + if state is None: + return + + plan = self.server.fixture.handle(message, state) + if plan.response is None: + self._send_bytes( + plan.http_status, + "application/json", + b"", + extra_headers=response_headers, + ) + return + if plan.force_sse: + messages = [*plan.notifications, plan.response] + body = _encode_sse_messages(messages, profile=self.server.fixture.profile) + self._send_bytes( + plan.http_status, + "text/event-stream", + body, + extra_headers={ + **response_headers, + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + return + body = json.dumps( + plan.response, ensure_ascii=False, separators=(",", ":") + ).encode() + self._send_bytes( + plan.http_status, + "application/json", + body, + extra_headers=response_headers, + ) + + def _legacy_state( + self, + message: dict[str, object], + ) -> tuple[ConnectionState | None, dict[str, str]]: + method = message.get("method") + if method == "initialize": + state = ConnectionState() + session_id = uuid.uuid4().hex + with self.server.sessions_lock: + self.server.sessions[session_id] = state + return state, {"Mcp-Session-Id": session_id} + if method == "server/discover": + return ConnectionState(), {} + session_id = self.headers.get("Mcp-Session-Id") + with self.server.sessions_lock: + state = ( + self.server.sessions.get(session_id) if session_id is not None else None + ) + if state is None: + self._send_json_error( + message.get("id"), + HTTPStatus.BAD_REQUEST, + INVALID_REQUEST, + "Missing or unknown Mcp-Session-Id", + ) + return None, {} + return state, {"Mcp-Session-Id": session_id} + + def _origin_is_allowed(self) -> bool: + origin = self.headers.get("Origin") + if origin is None: + return True + if origin in self.server.allowed_origins: + return True + parsed = urlsplit(origin) + return parsed.scheme in ("http", "https") and parsed.hostname in ( + "localhost", + "127.0.0.1", + "::1", + ) + + def _send_json_error( + self, + request_id: object, + status: int, + code: int, + message: str, + ) -> None: + body = json.dumps( + _error_response(request_id, code, message), separators=(",", ":") + ).encode() + self._send_bytes(status, "application/json", body) + + def _send_bytes( + self, + status: int, + content_type: str, + body: bytes, + *, + extra_headers: Mapping[str, str] | None = None, + ) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + if extra_headers is not None: + for name, value in extra_headers.items(): + self.send_header(name, value) + self.end_headers() + if body: + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + if not self.server.log_requests: + return + print( + f"{self.address_string()} - {format % args}", + file=sys.stderr, + flush=True, + ) + + +def make_http_server( + fixture: ProtocolServer, + host: str, + port: int, + *, + endpoint: str = "/mcp", + allowed_origins: Sequence[str] = (), + log_requests: bool = True, +) -> FixtureHTTPServer: + return FixtureHTTPServer( + (host, port), + fixture, + endpoint=endpoint, + allowed_origins=allowed_origins, + log_requests=log_requests, + ) + + +def run_stdio( + fixture: ProtocolServer, + stdin: IO[str], + stdout: IO[str], +) -> None: + state = ConnectionState() + for line in stdin: + try: + message = json.loads(line) + except json.JSONDecodeError: + stdout.write( + json.dumps( + _error_response(None, PARSE_ERROR, "Parse error"), + separators=(",", ":"), + ) + + "\n" + ) + stdout.flush() + continue + plan = fixture.handle(message, state) + for notification in plan.notifications: + stdout.write( + json.dumps(notification, ensure_ascii=False, separators=(",", ":")) + + "\n" + ) + if plan.response is not None: + stdout.write( + json.dumps(plan.response, ensure_ascii=False, separators=(",", ":")) + + "\n" + ) + stdout.flush() + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Run a deterministic MCP server for testing shipping 2025-06-18, " + "2025-11-25 compatibility, or 2026-07-28 draft compliance." + ) + ) + parser.add_argument( + "--mode", + choices=(SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION), + default=MODERN_VERSION, + ) + parser.add_argument( + "--transport", + choices=("stdio", "http"), + default="stdio", + ) + parser.add_argument( + "--profile", + choices=FIXTURE_PROFILES, + default=DEFAULT_PROFILE, + help="Select an optional deterministic reviewer-regression fixture profile.", + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--endpoint", default="/mcp") + parser.add_argument( + "--allowed-origin", + action="append", + default=[], + help="Additional exact Origin value accepted by the HTTP transport.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + fixture = ProtocolServer(args.mode, profile=args.profile) + if args.transport == "stdio": + print( + f"{SERVER_NAME} starting stdio mode={args.mode}", + file=sys.stderr, + flush=True, + ) + run_stdio(fixture, sys.stdin, sys.stdout) + return 0 + + server = make_http_server( + fixture, + args.host, + args.port, + endpoint=args.endpoint, + allowed_origins=args.allowed_origin, + ) + print( + f"{SERVER_NAME} listening on http://{args.host}:{args.port}{args.endpoint} " + f"mode={args.mode}", + file=sys.stderr, + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp_conformance/test_codex_compliance.py b/scripts/mcp_conformance/test_codex_compliance.py new file mode 100644 index 0000000000000000000000000000000000000000..91acf83e912190bcf5c0c2c2a7b8f28e07d7eb57 --- /dev/null +++ b/scripts/mcp_conformance/test_codex_compliance.py @@ -0,0 +1,1290 @@ +import os +import sys +import json +from copy import deepcopy +from pathlib import Path + +import pytest +from run_codex_compliance import ( + COMPACT_REGRESSION_BASELINE_KIND, + LEGACY_VERSION, + MODERN_VERSION, + OFFICIAL_CONFORMANCE_GIT_REF, + OFFICIAL_CONFORMANCE_REPOSITORY, + REPORT_SCHEMA_VERSION, + REQUIRED_REGRESSION_MODES, + RESOURCE_URIS, + SERVER_NAME, + SERVER_VERSION, + SHIPPING_LEGACY_VERSION, + TEST_SERVER_NAME, + CaseResult, + CheckResult, + OfficialScenarioResult, + _build_test_matrix, + _compact_regression_baseline, + _evaluate_regression_gate, + _isolated_environment, + _parse_args, + _registration_command, + _run_official_case, + _scenario_ratio, + _summarize_modes, + _validate_inventory, + _validate_registration, + _write_compact_regression_baseline, + _write_json_file, + main, + scenarios_for_mode, +) + + +def test_registration_commands_select_stdio_then_http_shapes() -> None: + codex = Path("/opt/codex") + server = Path("/src/server.py") + + stdio = _registration_command( + codex, + server, + transport="stdio", + mode=MODERN_VERSION, + http_url=None, + ) + http = _registration_command( + codex, + server, + transport="http", + mode=MODERN_VERSION, + http_url="http://127.0.0.1:8765/mcp", + ) + + assert stdio == [ + "/opt/codex", + "mcp", + "add", + TEST_SERVER_NAME, + "--env", + f"CODEX_MCP_PROTOCOL_VERSION={MODERN_VERSION}", + "--", + sys.executable, + "/src/server.py", + "--mode", + MODERN_VERSION, + "--transport", + "stdio", + ] + assert http == [ + "/opt/codex", + "mcp", + "add", + TEST_SERVER_NAME, + "--url", + "http://127.0.0.1:8765/mcp", + ] + + +def test_registration_validation_checks_transport_and_mode() -> None: + valid, _ = _validate_registration( + { + "name": TEST_SERVER_NAME, + "enabled": True, + "transport": { + "type": "stdio", + "command": sys.executable, + "args": [ + "/src/server.py", + "--mode", + LEGACY_VERSION, + "--transport", + "stdio", + ], + }, + }, + transport="stdio", + mode=LEGACY_VERSION, + http_url=None, + ) + assert valid + + valid, _ = _validate_registration( + { + "name": TEST_SERVER_NAME, + "enabled": True, + "transport": { + "type": "stdio", + "command": sys.executable, + "args": [ + "/src/server.py", + "--mode", + MODERN_VERSION, + "--transport", + "stdio", + ], + "env": {"CODEX_MCP_PROTOCOL_VERSION": MODERN_VERSION}, + }, + }, + transport="stdio", + mode=MODERN_VERSION, + http_url=None, + ) + assert valid + + valid, detail = _validate_registration( + { + "name": TEST_SERVER_NAME, + "enabled": True, + "transport": { + "type": "stdio", + "command": sys.executable, + "args": [ + "/src/server.py", + "--mode", + MODERN_VERSION, + "--transport", + "stdio", + ], + }, + }, + transport="stdio", + mode=MODERN_VERSION, + http_url=None, + ) + assert not valid + assert "protocol opt-in" in detail + + valid, detail = _validate_registration( + { + "name": TEST_SERVER_NAME, + "enabled": True, + "transport": { + "type": "streamable_http", + "url": "http://127.0.0.1:2/mcp", + }, + }, + transport="http", + mode=MODERN_VERSION, + http_url="http://127.0.0.1:1/mcp", + ) + assert not valid + assert "unexpected HTTP transport" in detail + + +def test_inventory_validation_requires_modern_tool_and_all_pages() -> None: + inventory = { + "data": [ + { + "name": TEST_SERVER_NAME, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + }, + "tools": { + name: {"name": name} + for name in ( + "echo", + "client_metadata", + "progress", + "request_input", + ) + }, + "resources": [{"uri": uri} for uri in RESOURCE_URIS], + } + ] + } + + valid, _ = _validate_inventory(inventory, mode=MODERN_VERSION) + assert valid + + inventory["data"][0]["tools"].pop("request_input") + inventory["data"][0]["resources"].pop() + valid, detail = _validate_inventory(inventory, mode=MODERN_VERSION) + assert not valid + assert "request_input" in detail + assert RESOURCE_URIS[1] in detail + + +def test_isolated_environment_drops_model_credentials( + tmp_path: Path, + monkeypatch, +) -> None: + monkeypatch.setenv("CODEX_API_KEY", "secret") + monkeypatch.setenv("CODEX_ACCESS_TOKEN", "secret") + monkeypatch.setenv("OPENAI_API_KEY", "secret") + + env = _isolated_environment(tmp_path) + + assert env["CODEX_HOME"] == str(tmp_path) + assert "CODEX_API_KEY" not in env + assert "CODEX_ACCESS_TOKEN" not in env + assert "OPENAI_API_KEY" not in env + assert env.get("PATH") == os.environ.get("PATH") + + +def test_mode_summaries_separate_official_and_supplemental_percentages() -> None: + legacy = CaseResult( + transport="stdio", + mode=LEGACY_VERSION, + success=True, + checks=[ + CheckResult("mcp_add", True, "ok"), + CheckResult("inventory", True, "ok"), + CheckResult("echo_tool", True, "ok"), + ], + ) + modern = CaseResult( + transport="http", + mode=MODERN_VERSION, + success=False, + checks=[ + CheckResult("mcp_add", True, "ok"), + CheckResult("modern_feature_enablement", True, "ok"), + CheckResult("inventory", False, "failed"), + CheckResult("http_header_mirroring", False, "failed"), + CheckResult( + "official/example/pass", + True, + "ok", + source="official", + category="non-auth", + scenario="example-pass", + ), + CheckResult( + "official/example/skipped", + True, + "not exercised", + status="SKIP", + source="official", + category="auth", + scenario="example-skipped", + ), + ], + ) + + summaries = _summarize_modes([legacy, modern]) + + assert summaries == [ + { + "mode": LEGACY_VERSION, + "checks": {"passed": 3, "total": 3, "percentage": 100.0}, + "officialChecks": {"passed": 0, "total": 0, "percentage": 0.0}, + "officialNonAuthChecks": { + "passed": 0, + "total": 0, + "percentage": 0.0, + }, + "officialAuthChecks": { + "passed": 0, + "total": 0, + "percentage": 0.0, + }, + "officialNonAuthScenarios": { + "passed": 0, + "total": 0, + "percentage": 0.0, + }, + "officialAuthScenarios": { + "passed": 0, + "total": 0, + "percentage": 0.0, + }, + "harnessChecks": {"passed": 0, "total": 0, "percentage": 0.0}, + "supplementalChecks": { + "passed": 3, + "total": 3, + "percentage": 100.0, + }, + "transportCases": {"passed": 1, "total": 1, "percentage": 100.0}, + }, + { + "mode": MODERN_VERSION, + "checks": {"passed": 3, "total": 5, "percentage": 60.0}, + "officialChecks": { + "passed": 1, + "total": 1, + "percentage": 100.0, + }, + "officialNonAuthChecks": { + "passed": 1, + "total": 1, + "percentage": 100.0, + }, + "officialAuthChecks": { + "passed": 0, + "total": 0, + "percentage": 0.0, + }, + "officialNonAuthScenarios": { + "passed": 1, + "total": 1, + "percentage": 100.0, + }, + "officialAuthScenarios": { + "passed": 1, + "total": 1, + "percentage": 100.0, + }, + "harnessChecks": {"passed": 0, "total": 0, "percentage": 0.0}, + "supplementalChecks": { + "passed": 2, + "total": 4, + "percentage": 50.0, + }, + "transportCases": {"passed": 0, "total": 1, "percentage": 0.0}, + }, + ] + + +def test_matrix_reports_pass_fail_and_not_applicable() -> None: + legacy = CaseResult( + transport="stdio", + mode=LEGACY_VERSION, + checks=[ + CheckResult("inventory", True, "ok"), + CheckResult("echo_tool", True, "ok"), + ], + ) + modern = CaseResult( + transport="http", + mode=MODERN_VERSION, + checks=[ + CheckResult("inventory", False, "failed"), + CheckResult("http_header_mirroring", False, "failed"), + ], + ) + + matrix = _build_test_matrix([legacy, modern]) + + assert matrix["columns"] == [ + { + "key": f"stdio:{LEGACY_VERSION}", + "transport": "stdio", + "mode": LEGACY_VERSION, + }, + { + "key": f"http:{MODERN_VERSION}", + "transport": "http", + "mode": MODERN_VERSION, + }, + ] + assert matrix["rows"] == [ + { + "test": "inventory", + "results": { + f"stdio:{LEGACY_VERSION}": "PASS", + f"http:{MODERN_VERSION}": "FAIL", + }, + }, + { + "test": "echo_tool", + "results": { + f"stdio:{LEGACY_VERSION}": "PASS", + f"http:{MODERN_VERSION}": "N/A", + }, + }, + { + "test": "http_header_mirroring", + "results": { + f"stdio:{LEGACY_VERSION}": "N/A", + f"http:{MODERN_VERSION}": "FAIL", + }, + }, + ] + + +def test_scenario_ratio_includes_adapter_failures() -> None: + checks = [ + CheckResult( + "official/auth/example/assertion", + True, + "ok", + source="official", + scenario="auth/example", + category="auth", + ), + CheckResult( + "harness/auth/example/codex-adapter", + False, + "adapter failed", + source="harness", + scenario="auth/example", + category="auth", + ), + CheckResult( + "official/auth/other/assertion", + True, + "ok", + source="official", + scenario="auth/other", + category="auth", + ), + ] + + assert _scenario_ratio(checks, category="auth") == { + "passed": 1, + "total": 2, + "percentage": 50.0, + } + + +@pytest.mark.parametrize("mode", [LEGACY_VERSION, MODERN_VERSION]) +@pytest.mark.parametrize( + ("adapter_success", "expected_status"), + [(False, "FAIL"), (True, "PASS")], +) +def test_official_case_preserves_adapter_check_identity_on_failure_and_success( + mode: str, + adapter_success: bool, + expected_status: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + scenario = "auth/pre-registration" + result = OfficialScenarioResult( + scenario=scenario, + success=adapter_success, + adapter_success=adapter_success, + adapter_detail="adapter succeeded" if adapter_success else "adapter failed", + ) + monkeypatch.setattr( + "run_codex_compliance.run_official_mode", + lambda **_kwargs: [result], + ) + + case = _run_official_case( + Path("/opt/codex"), + Path("/src/codex_conformance_adapter.py"), + conformance_command=["conformance"], + mode=mode, + scenarios=[scenario], + case_home=tmp_path / mode, + timeout_seconds=1.0, + enable_modern_feature=True, + ) + + assert case.checks == [ + CheckResult( + name=f"harness/{scenario}/codex-adapter", + success=adapter_success, + detail=result.adapter_detail, + status=expected_status, + source="harness", + scenario=scenario, + category="auth", + ) + ] + assert case.success is adapter_success + + +@pytest.mark.parametrize("mode", [LEGACY_VERSION, MODERN_VERSION]) +def test_official_case_preserves_independent_runner_failure_with_successful_adapter( + mode: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + scenario = "auth/pre-registration" + result = OfficialScenarioResult( + scenario=scenario, + success=False, + adapter_success=True, + adapter_detail="adapter succeeded", + runner_detail="official runner failed", + ) + monkeypatch.setattr( + "run_codex_compliance.run_official_mode", + lambda **_kwargs: [result], + ) + + case = _run_official_case( + Path("/opt/codex"), + Path("/src/codex_conformance_adapter.py"), + conformance_command=["conformance"], + mode=mode, + scenarios=[scenario], + case_home=tmp_path / mode, + timeout_seconds=1.0, + enable_modern_feature=True, + ) + + assert case.checks == [ + CheckResult( + name=f"harness/{scenario}/codex-adapter", + success=True, + detail="adapter succeeded", + status="PASS", + source="harness", + scenario=scenario, + category="auth", + ), + CheckResult( + name=f"harness/{scenario}/official-runner", + success=False, + detail="official runner failed", + status="FAIL", + source="harness", + scenario=scenario, + category="auth", + ), + ] + assert case.success is False + assert case.diagnostics == f"{scenario}:\nofficial runner failed" + + +def test_official_case_keeps_cimd_registration_checks_in_regression_gated_http_case( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + def run_official_mode(**kwargs: object) -> list[OfficialScenarioResult]: + calls.append(kwargs) + scenarios = kwargs["scenarios"] + assert isinstance(scenarios, (tuple, list)) + scenario = scenarios[0] + assert isinstance(scenario, str) + return [ + OfficialScenarioResult( + scenario=scenario, + success=True, + adapter_success=True, + adapter_detail="adapter succeeded", + ) + ] + + monkeypatch.setattr( + "run_codex_compliance.run_official_mode", + run_official_mode, + ) + + case = _run_official_case( + Path("/opt/codex"), + Path("/src/codex_conformance_adapter.py"), + conformance_command=["conformance"], + mode=MODERN_VERSION, + scenarios=["auth/offline-access-scope"], + case_home=tmp_path / "case", + timeout_seconds=1.0, + enable_modern_feature=True, + ) + + assert case.transport == "official-http" + supplemental = [check for check in case.checks if check.source == "supplemental"] + assert [check.check_id for check in supplemental] == ["auto_cimd", "forced_cimd"] + assert len(calls) == 3 + auto_env = calls[1]["base_env"] + forced_env = calls[2]["base_env"] + assert isinstance(auto_env, dict) + assert isinstance(forced_env, dict) + assert auto_env.get("CODEX_CONFORMANCE_CLIENT_REGISTRATION") is None + assert forced_env["CODEX_CONFORMANCE_CLIENT_REGISTRATION"] == "cimd" + + +def _regression_report() -> dict[str, object]: + cases: list[dict[str, object]] = [] + known_modern = { + "auth/metadata-var3": "authorization-server-metadata", + "auth/scope-from-www-authenticate": "scope-from-www-authenticate", + "auth/scope-step-up": "scope-step-up-initial", + "auth/pre-registration": "pre-registration-auth", + } + scenarios = { + mode: list(scenarios_for_mode(mode, include_auth=True)) + for mode in REQUIRED_REGRESSION_MODES + } + + for mode in REQUIRED_REGRESSION_MODES: + cases.append( + { + "mode": mode, + "transport": "stdio", + "success": True, + "checks": [ + { + "name": "mcp_add", + "success": True, + "status": "PASS", + "source": "supplemental", + "scenario": None, + "check_id": None, + } + ], + } + ) + official_checks: list[dict[str, object]] = [] + for scenario in scenarios[mode]: + if mode == MODERN_VERSION and scenario == "request-metadata": + official_checks.append( + { + "name": "harness/request-metadata/official-runner", + "success": False, + "status": "FAIL", + "source": "harness", + "scenario": scenario, + "check_id": None, + } + ) + continue + + known = mode == MODERN_VERSION and scenario in known_modern + official_checks.append( + { + "name": f"official/{scenario}/assertion", + "success": not known, + "status": "FAIL" if known else "PASS", + "source": "official", + "scenario": scenario, + "check_id": ( + known_modern[scenario] if known else f"assertion-{scenario}" + ), + } + ) + if known: + official_checks.append( + { + "name": f"harness/{scenario}/codex-adapter", + "success": False, + "status": "FAIL", + "source": "harness", + "scenario": scenario, + "check_id": None, + } + ) + if mode == MODERN_VERSION: + official_checks.extend( + { + "name": f"supplemental/auth/offline-access-scope/{check_id}", + "success": True, + "status": "PASS", + "source": "supplemental", + "scenario": "auth/offline-access-scope", + "check_id": check_id, + } + for check_id in ("auto_cimd", "forced_cimd") + ) + cases.append( + { + "mode": mode, + "transport": "official-http", + "success": mode == SHIPPING_LEGACY_VERSION, + "checks": official_checks, + } + ) + + return { + "schemaVersion": REPORT_SCHEMA_VERSION, + "success": False, + "versionCheck": {"success": True}, + "modernFeatureEnablement": True, + "automaticAuthRequired": False, + "officialConformance": { + "repository": OFFICIAL_CONFORMANCE_REPOSITORY, + "gitRef": OFFICIAL_CONFORMANCE_GIT_REF, + "authenticationIncluded": True, + "scenarios": scenarios, + }, + "cases": cases, + } + + +def _regression_case( + report: dict[str, object], + mode: str, + transport: str, +) -> dict[str, object]: + cases = report["cases"] + assert isinstance(cases, list) + return next( + case + for case in cases + if isinstance(case, dict) + and case["mode"] == mode + and case["transport"] == transport + ) + + +def _regression_scenario_check( + report: dict[str, object], + *, + mode: str = MODERN_VERSION, + scenario: str, + source: str = "official", +) -> dict[str, object]: + checks = _regression_case(report, mode, "official-http")["checks"] + assert isinstance(checks, list) + return next( + check + for check in checks + if isinstance(check, dict) + and check["scenario"] == scenario + and check["source"] == source + ) + + +def test_regression_gate_preserves_truthful_known_modern_failures() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + + gate = _evaluate_regression_gate(candidate, baseline) + + assert candidate["success"] is False + assert gate["success"] is True + assert gate["requiredModes"] == [ + SHIPPING_LEGACY_VERSION, + LEGACY_VERSION, + MODERN_VERSION, + ] + assert len(gate["knownFailures"]) == 9 + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + assert gate["configurationErrors"] == [] + + +def test_regression_gate_rejects_a_new_modern_failure() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + check = _regression_scenario_check(candidate, scenario="tools_call") + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any(item["scenario"] == "tools_call" for item in gate["newFailures"]) + + +@pytest.mark.parametrize("check_id", ["auto_cimd", "forced_cimd"]) +def test_regression_gate_rejects_cimd_registration_failures( + check_id: str, +) -> None: + baseline = _compact_regression_baseline(_regression_report()) + candidate = _regression_report() + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + check = next( + item + for item in checks + if isinstance(item, dict) + and item.get("source") == "supplemental" + and item.get("check_id") == check_id + ) + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + item["source"] == "supplemental" and item["check_id"] == check_id + for item in gate["newFailures"] + ) + + +def test_regression_gate_rejects_a_new_intermediate_oauth_failure() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + check = _regression_scenario_check( + candidate, + mode=LEGACY_VERSION, + scenario="auth/metadata-default", + ) + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + item["mode"] == LEGACY_VERSION and item["scenario"] == "auth/metadata-default" + for item in gate["newFailures"] + ) + + +def test_regression_gate_rejects_new_failures_inside_a_known_failing_scenario() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + checks.append( + { + "name": "official/auth/metadata-var3/new assertion", + "success": False, + "status": "FAIL", + "source": "official", + "scenario": "auth/metadata-var3", + "check_id": "previously-passing-metadata-assertion", + } + ) + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + item["check_id"] == "previously-passing-metadata-assertion" + for item in gate["newFailures"] + ) + + +def test_regression_gate_accepts_fixed_known_failures() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + check = _regression_scenario_check(candidate, scenario="auth/metadata-var3") + check["success"] = True + check["status"] = "PASS" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert any( + item["check_id"] == "authorization-server-metadata" + for item in gate["fixedChecks"] + ) + + +def test_regression_gate_accepts_fixed_oauth_official_and_adapter_checks() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + scenario = "auth/pre-registration" + for source in ("official", "harness"): + check = _regression_scenario_check( + candidate, + scenario=scenario, + source=source, + ) + check["success"] = True + check["status"] = "PASS" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert gate["configurationErrors"] == [] + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + assert gate["fixedChecks"] == [ + { + "mode": MODERN_VERSION, + "transport": "official-http", + "source": "harness", + "scenario": scenario, + "check_id": f"harness/{scenario}/codex-adapter", + }, + { + "mode": MODERN_VERSION, + "transport": "official-http", + "source": "official", + "scenario": scenario, + "check_id": "pre-registration-auth", + }, + ] + + +def test_regression_gate_rejects_a_missing_fixed_oauth_adapter_check() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + scenario = "auth/pre-registration" + check = _regression_scenario_check(candidate, scenario=scenario) + check["success"] = True + check["status"] = "PASS" + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + checks[:] = [ + item + for item in checks + if item.get("scenario") != scenario or item.get("source") != "harness" + ] + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert gate["configurationErrors"] == [] + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [ + { + "mode": MODERN_VERSION, + "transport": "official-http", + "source": "harness", + "scenario": scenario, + "check_id": f"harness/{scenario}/codex-adapter", + } + ] + assert gate["fixedChecks"] == [ + { + "mode": MODERN_VERSION, + "transport": "official-http", + "source": "official", + "scenario": scenario, + "check_id": "pre-registration-auth", + } + ] + + +def test_regression_gate_rejects_a_skipped_previous_failure() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + check = _regression_scenario_check(candidate, scenario="auth/metadata-var3") + check["success"] = True + check["status"] = "SKIP" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + item["check_id"] == "authorization-server-metadata" + for item in gate["missingChecks"] + ) + + +def test_regression_gate_rejects_a_missing_previously_passing_check() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + checks[:] = [check for check in checks if check.get("scenario") != "tools_call"] + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any(item["scenario"] == "tools_call" for item in gate["missingChecks"]) + assert any("did not observe" in item for item in gate["configurationErrors"]) + + +@pytest.mark.parametrize("mode", REQUIRED_REGRESSION_MODES) +@pytest.mark.parametrize("transport", ["stdio", "official-http"]) +def test_regression_gate_requires_every_version_and_both_transports( + mode: str, + transport: str, +) -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + cases = candidate["cases"] + assert isinstance(cases, list) + cases[:] = [ + case + for case in cases + if case.get("mode") != mode or case.get("transport") != transport + ] + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + f"missing the required {transport} case for {mode}" in item + for item in gate["configurationErrors"] + ) + + +def test_regression_gate_requires_the_complete_modern_authenticated_catalog() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + official = candidate["officialConformance"] + assert isinstance(official, dict) + scenarios = official["scenarios"] + assert isinstance(scenarios, dict) + scenarios[MODERN_VERSION] = [ + scenario + for scenario in scenarios[MODERN_VERSION] + if scenario != "auth/pre-registration" + ] + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any("complete authenticated" in item for item in gate["configurationErrors"]) + + +def test_regression_gate_requires_modern_feature_enablement() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + candidate["modernFeatureEnablement"] = False + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any("modern MCP feature" in item for item in gate["configurationErrors"]) + + +def test_regression_gate_requires_the_same_pinned_upstream_suite() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + official = candidate["officialConformance"] + assert isinstance(official, dict) + official["gitRef"] = "0" * 40 + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any("pinned upstream" in item for item in gate["configurationErrors"]) + + +def test_regression_gate_requires_oauth_scenarios() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + official = candidate["officialConformance"] + assert isinstance(official, dict) + official["authenticationIncluded"] = False + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any("required OAuth" in item for item in gate["configurationErrors"]) + + +def test_regression_gate_rejects_different_production_oauth_policies() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + candidate["automaticAuthRequired"] = True + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + "different production OAuth" in item for item in gate["configurationErrors"] + ) + + +def test_regression_gate_rejects_unsupported_report_schema() -> None: + baseline = _regression_report() + baseline["schemaVersion"] = REPORT_SCHEMA_VERSION + 1 + + gate = _evaluate_regression_gate(_regression_report(), baseline) + + assert gate["success"] is False + assert any("report schema" in item for item in gate["configurationErrors"]) + + +def test_regression_gate_rejects_any_shipping_failure() -> None: + baseline = _regression_report() + candidate = deepcopy(baseline) + check = _regression_scenario_check( + candidate, + mode=SHIPPING_LEGACY_VERSION, + scenario="initialize", + ) + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + "shipping MCP check failed" in item for item in gate["configurationErrors"] + ) + + +def test_regression_gate_ignores_retry_observation_renumbering() -> None: + baseline = _regression_report() + check = _regression_scenario_check(baseline, scenario="tools_call") + check["name"] = "official/tools_call/authorization metadata#4" + candidate = deepcopy(baseline) + candidate_check = _regression_scenario_check(candidate, scenario="tools_call") + candidate_check["name"] = "official/tools_call/authorization metadata#57" + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + repeated = deepcopy(candidate_check) + repeated["name"] = "official/tools_call/authorization metadata#58" + checks.append(repeated) + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + + +def test_regression_cli_accepts_a_baseline_report() -> None: + args = _parse_args( + ["/opt/codex", "--baseline-report", "/tmp/codex-mcp-baseline.json"] + ) + + assert args.baseline_report == Path("/tmp/codex-mcp-baseline.json") + assert args.mode == "all" + assert args.auth is True + assert args.enable_modern_feature is True + + +def test_regression_gate_accepts_a_compact_baseline() -> None: + report = _regression_report() + + compact = _compact_regression_baseline(report) + gate = _evaluate_regression_gate(deepcopy(report), compact) + + assert compact["baselineKind"] == COMPACT_REGRESSION_BASELINE_KIND + assert compact["requiredModes"] == [ + SHIPPING_LEGACY_VERSION, + LEGACY_VERSION, + MODERN_VERSION, + ] + assert compact["transports"] == ["stdio", "official-http"] + assert "cases" not in compact + assert gate["success"] is True + assert len(gate["knownFailures"]) == 9 + + +def test_regression_gate_accepts_the_real_in_memory_tuple_scenario_catalog() -> None: + baseline = _compact_regression_baseline(_regression_report()) + candidate = _regression_report() + official = candidate["officialConformance"] + assert isinstance(official, dict) + scenarios = official["scenarios"] + assert isinstance(scenarios, dict) + official["scenarios"] = { + mode: tuple(selected) for mode, selected in scenarios.items() + } + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert gate["configurationErrors"] == [] + + +def test_regression_gate_rejects_a_new_failure_against_a_compact_baseline() -> None: + baseline = _compact_regression_baseline(_regression_report()) + candidate = _regression_report() + check = _regression_scenario_check(candidate, scenario="tools_call") + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any(item["scenario"] == "tools_call" for item in gate["newFailures"]) + + +def test_regression_gate_rejects_malformed_compact_identities() -> None: + compact = _compact_regression_baseline(_regression_report()) + checks = compact["checks"] + assert isinstance(checks, dict) + passing = checks["passing"] + assert isinstance(passing, list) + passing[0]["check_id"] = "" + + gate = _evaluate_regression_gate(_regression_report(), compact) + + assert gate["success"] is False + assert any( + "malformed passing baseline identity" in e for e in gate["configurationErrors"] + ) + + +def test_regression_gate_rejects_an_unknown_compact_baseline_format() -> None: + compact = _compact_regression_baseline(_regression_report()) + compact["baselineKind"] = "unrecognized-baseline" + + gate = _evaluate_regression_gate(_regression_report(), compact) + + assert gate["success"] is False + assert any("unsupported compact baseline" in e for e in gate["configurationErrors"]) + + +def test_json_artifacts_are_sorted_human_readable_and_newline_terminated( + tmp_path: Path, +) -> None: + path = tmp_path / "nested" / "report.json" + payload = {"z": {"z": "café", "a": [2, 1]}, "a": True} + + _write_json_file(path, payload) + + assert path.read_text(encoding="utf-8") == ( + "{\n" + ' "a": true,\n' + ' "z": {\n' + ' "a": [\n' + " 2,\n" + " 1\n" + " ],\n" + ' "z": "café"\n' + " }\n" + "}\n" + ) + assert json.loads(path.read_text(encoding="utf-8")) == payload + + +def test_committed_regression_baseline_is_sorted_human_readable_json() -> None: + baseline = Path(__file__).with_name("regression-baseline-v1.json") + content = baseline.read_text(encoding="utf-8") + + assert content == ( + json.dumps(json.loads(content), ensure_ascii=False, indent=2, sort_keys=True) + + "\n" + ) + + +def test_compact_regression_baselines_are_deterministic(tmp_path: Path) -> None: + first = tmp_path / "first.json" + second = tmp_path / "second.json" + + _write_compact_regression_baseline(_regression_report(), first) + _write_compact_regression_baseline(_regression_report(), second) + + assert first.read_bytes() == second.read_bytes() + content = first.read_text(encoding="utf-8") + assert content == ( + json.dumps(json.loads(content), ensure_ascii=False, indent=2, sort_keys=True) + + "\n" + ) + assert json.loads(content)["baselineKind"] == (COMPACT_REGRESSION_BASELINE_KIND) + + +def test_regression_cli_extracts_compact_baseline_without_starting_conformance( + tmp_path: Path, +) -> None: + codex = tmp_path / "codex" + codex.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8") + codex.chmod(0o755) + source = tmp_path / "full-report.json" + source.write_text(json.dumps(_regression_report()), encoding="utf-8") + extracted = tmp_path / "compact.json" + + assert ( + main( + [ + str(codex), + "--baseline-report", + str(source), + "--extract-baseline", + str(extracted), + ] + ) + == 0 + ) + assert json.loads(extracted.read_text(encoding="utf-8"))["baselineKind"] == ( + COMPACT_REGRESSION_BASELINE_KIND + ) + + +def test_regression_cli_rejects_extraction_without_a_source_report( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + codex = tmp_path / "codex" + codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + codex.chmod(0o755) + + assert main([str(codex), "--extract-baseline", str(tmp_path / "compact.json")]) == 2 + assert "requires --baseline-report" in capsys.readouterr().err + + +@pytest.mark.parametrize("payload", ["{", "[]", "null"]) +def test_regression_cli_rejects_malformed_baselines( + payload: str, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + codex = tmp_path / "codex" + codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + codex.chmod(0o755) + baseline = tmp_path / "baseline.json" + baseline.write_text(payload, encoding="utf-8") + + assert main([str(codex), "--baseline-report", str(baseline)]) == 2 + assert "baseline report" in capsys.readouterr().err + + +def test_regression_cli_rejects_missing_baseline_before_starting_conformance( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + codex = tmp_path / "codex" + codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + codex.chmod(0o755) + + assert main([str(codex), "--baseline-report", str(tmp_path / "missing.json")]) == 2 + assert "cannot read baseline report" in capsys.readouterr().err diff --git a/scripts/mcp_conformance/test_official_conformance.py b/scripts/mcp_conformance/test_official_conformance.py new file mode 100644 index 0000000000000000000000000000000000000000..10da8ce31cf07f0da19aea0f7c2130d0717cd205 --- /dev/null +++ b/scripts/mcp_conformance/test_official_conformance.py @@ -0,0 +1,697 @@ +import json +import subprocess +import sys +import urllib.parse +from pathlib import Path + +import codex_conformance_adapter +import official_conformance +import pytest +import run_codex_compliance +from codex_conformance_adapter import ( + CIMD_CLIENT_METADATA_URL, + CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, + AdapterFailure, + _client_registration_override, + _exercise_auth_scenario, + _oauth_client_id, + _validate_oauth_secret_not_persisted, + _validated_callback_url, + _write_auth_registration, +) +from official_conformance import ( + LEGACY_VERSION, + MODERN_VERSION, + OFFICIAL_CONFORMANCE_GIT_REF, + SHIPPING_LEGACY_VERSION, + _make_adapter_launcher, + _run_scenario, + _scrub_retained_artifacts, + _terminate_process_group, + default_conformance_command, + redact_sensitive_text, + run_official_mode, + scenarios_for_mode, +) + + +def _authorization_url(redirect_uri: str, state: str = "test-state") -> str: + return "http://127.0.0.1:8765/authorize?" + urllib.parse.urlencode( + { + "client_id": "test-client", + "redirect_uri": redirect_uri, + "state": state, + } + ) + + +def test_auth_adapter_only_accepts_exact_loopback_callback() -> None: + redirect_uri = "http://127.0.0.1:32123/callback" + authorization_url = _authorization_url(redirect_uri) + callback_url = redirect_uri + "?code=test-code&state=test-state" + + assert _validated_callback_url(authorization_url, callback_url) == callback_url + + with pytest.raises(AdapterFailure, match="exact loopback"): + _validated_callback_url( + authorization_url, + "http://127.0.0.1:32124/callback?code=test-code&state=test-state", + ) + with pytest.raises(AdapterFailure, match="preserve OAuth state"): + _validated_callback_url( + authorization_url, + redirect_uri + "?code=test-code&state=wrong-state", + ) + with pytest.raises(AdapterFailure, match="safe loopback"): + _validated_callback_url( + _authorization_url("https://example.com/callback"), + "https://example.com/callback?code=test-code&state=test-state", + ) + + +@pytest.mark.parametrize( + ("query", "message"), + [ + ("code=first&code=second&state=test-state", "exactly one nonempty code"), + ("code=&state=test-state", "exactly one nonempty code"), + ("error=first&error=second&state=test-state", "exactly one nonempty error"), + ("error=&state=test-state", "exactly one nonempty error"), + ("code=test-code&error=access_denied&state=test-state", "exactly one of"), + ("code=test-code&state=test-state&state=test-state", "preserve OAuth state"), + ( + "code=test-code&state=test-state&iss=first&iss=second", + "exactly one nonempty iss", + ), + ("code=test-code&state=test-state&iss=", "exactly one nonempty iss"), + ("state=test-state", "exactly one of"), + ], +) +def test_auth_adapter_rejects_ambiguous_oauth_callback_parameters( + query: str, + message: str, +) -> None: + redirect_uri = "http://127.0.0.1:32123/callback" + + with pytest.raises(AdapterFailure, match=message): + _validated_callback_url( + _authorization_url(redirect_uri), + f"{redirect_uri}?{query}", + ) + + +def test_auth_adapter_allows_a_single_error_callback_for_client_validation() -> None: + redirect_uri = "http://127.0.0.1:32123/callback" + callback_url = redirect_uri + "?error=access_denied&state=test-state&iss=issuer" + + assert ( + _validated_callback_url(_authorization_url(redirect_uri), callback_url) + == callback_url + ) + + +def test_official_runner_is_pinned_instead_of_using_npm_latest() -> None: + command = default_conformance_command() + + assert OFFICIAL_CONFORMANCE_GIT_REF in command[-1] + assert "@latest" not in command[-1] + + +def test_official_adapter_launcher_preserves_strict_production_auth_mode( + tmp_path: Path, +) -> None: + adapter = tmp_path / "show_automatic_auth.py" + adapter.write_text( + 'import os\nprint(os.environ.get("CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH", ""))\n', + encoding="utf-8", + ) + launcher = _make_adapter_launcher(adapter, require_automatic_auth=True) + + try: + result = subprocess.run( + [str(launcher)], + capture_output=True, + check=True, + text=True, + ) + finally: + launcher.unlink() + launcher.parent.rmdir() + + assert result.stdout.strip() == "1" + + +@pytest.mark.parametrize("require_automatic_auth", [False, True]) +def test_windows_official_adapter_launcher_quotes_checkout_paths( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + require_automatic_auth: bool, +) -> None: + adapter = tmp_path / "checkout with spaces" / "adapter script.py" + adapter.parent.mkdir() + adapter.write_text("raise AssertionError('launcher generation only')\n") + monkeypatch.setattr(official_conformance.sys, "platform", "win32") + + launcher = _make_adapter_launcher( + adapter, + require_automatic_auth=require_automatic_auth, + ) + try: + contents = launcher.read_text(encoding="utf-8") + finally: + launcher.unlink() + launcher.parent.rmdir() + + expected_auth = "1" if require_automatic_auth else "0" + assert launcher.name == "client.cmd" + assert contents.startswith("@echo off\n") + assert ( + f'set "CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH={expected_auth}"\n' in contents + ) + assert subprocess.list2cmdline([sys.executable, str(adapter)]) + " %*\n" in contents + + +@pytest.mark.parametrize( + ("force", "expected_command"), + [ + (False, ["taskkill", "/T", "/PID", "417"]), + (True, ["taskkill", "/F", "/T", "/PID", "417"]), + ], +) +def test_windows_official_timeout_terminates_the_entire_process_tree( + monkeypatch: pytest.MonkeyPatch, + force: bool, + expected_command: list[str], +) -> None: + observed: list[list[str]] = [] + + class FakeProcess: + pid = 417 + + def terminate(self) -> None: + raise AssertionError("successful taskkill must own tree termination") + + def kill(self) -> None: + raise AssertionError("successful taskkill must own tree termination") + + def fake_run( + command: list[str], + *, + check: bool, + stdout: int, + stderr: int, + ) -> subprocess.CompletedProcess[str]: + observed.append(command) + assert check is False + assert stdout == subprocess.DEVNULL + assert stderr == subprocess.DEVNULL + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(official_conformance.sys, "platform", "win32") + monkeypatch.setattr(official_conformance.subprocess, "run", fake_run) + + _terminate_process_group( + FakeProcess(), # type: ignore[arg-type] - validate the Popen process contract. + force=force, + ) + + assert observed == [expected_command] + + +@pytest.mark.parametrize( + "scenario", + ["auth/scope-step-up", "auth/authorization-server-migration"], +) +def test_strict_auth_does_not_inject_a_second_login( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + scenario: str, +) -> None: + manual_reauthorizations: list[object] = [] + + monkeypatch.setattr( + codex_conformance_adapter, + "_oauth_login", + lambda _client, **_kwargs: (True, None), + ) + monkeypatch.setattr(codex_conformance_adapter, "_reload_mcp", lambda _client: None) + monkeypatch.setattr( + codex_conformance_adapter, "_auth_inventory", lambda _client: {} + ) + + def fail_tool_call(_client: object, _workspace: Path) -> None: + raise AdapterFailure("reauthorization is required") + + monkeypatch.setattr(codex_conformance_adapter, "_auth_tool_call", fail_tool_call) + monkeypatch.setattr( + codex_conformance_adapter, + "_login_reload_and_call", + lambda *args, **kwargs: manual_reauthorizations.append((args, kwargs)), + ) + + with pytest.raises(AdapterFailure, match="reauthorization is required"): + _exercise_auth_scenario( + object(), # type: ignore[arg-type] + scenario=scenario, + workspace=tmp_path, + timeout_seconds=1, + require_automatic_auth=True, + ) + + assert manual_reauthorizations == [] + + +@pytest.mark.parametrize( + "scenario", + ["auth/scope-step-up", "auth/authorization-server-migration"], +) +def test_strict_auth_accepts_product_owned_reauthentication( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + scenario: str, +) -> None: + tool_calls: list[Path] = [] + manual_reauthorizations: list[object] = [] + + monkeypatch.setattr( + codex_conformance_adapter, + "_oauth_login", + lambda _client, **_kwargs: (True, None), + ) + monkeypatch.setattr(codex_conformance_adapter, "_reload_mcp", lambda _client: None) + monkeypatch.setattr( + codex_conformance_adapter, "_auth_inventory", lambda _client: {} + ) + monkeypatch.setattr( + codex_conformance_adapter, + "_auth_tool_call", + lambda _client, workspace: tool_calls.append(workspace), + ) + monkeypatch.setattr( + codex_conformance_adapter, + "_login_reload_and_call", + lambda *args, **kwargs: manual_reauthorizations.append((args, kwargs)), + ) + + detail = _exercise_auth_scenario( + object(), # type: ignore[arg-type] + scenario=scenario, + workspace=tmp_path, + timeout_seconds=1, + require_automatic_auth=True, + ) + + assert "automatically" in detail + assert tool_calls == [tmp_path] + assert manual_reauthorizations == [] + + +@pytest.mark.parametrize("filename", ["config.toml", ".credentials.json"]) +def test_oauth_client_secret_persistence_is_detected_without_disclosing_it( + tmp_path: Path, + filename: str, +) -> None: + secret = "reviewer-confidential-client-secret" + (tmp_path / filename).write_text( + json.dumps({"client_secret": secret}), + encoding="utf-8", + ) + + with pytest.raises(AdapterFailure, match="client secret was persisted") as error: + _validate_oauth_secret_not_persisted(tmp_path, secret) + + assert filename in str(error.value) + assert secret not in str(error.value) + + +def test_oauth_client_secret_environment_reference_is_not_a_persisted_secret( + tmp_path: Path, +) -> None: + (tmp_path / "config.toml").write_text( + 'client_secret_env_var = "MCP_CONFORMANCE_CLIENT_SECRET"\n', + encoding="utf-8", + ) + + _validate_oauth_secret_not_persisted( + tmp_path, "reviewer-confidential-client-secret" + ) + + +def test_auth_adapter_selects_only_scenario_provided_client_ids() -> None: + assert _oauth_client_id("auth/basic-cimd", {}) == CIMD_CLIENT_METADATA_URL + assert ( + _oauth_client_id( + "auth/pre-registration", + {"client_id": "pre-registered", "client_secret": "do-not-log"}, + ) + == "pre-registered" + ) + assert _oauth_client_id("auth/metadata-default", {}) is None + with pytest.raises(AdapterFailure, match="client_id"): + _oauth_client_id("auth/pre-registration", {}) + + +@pytest.mark.parametrize( + ("scenario", "require_automatic_auth", "override", "expected"), + [ + ("auth/offline-access-not-supported", False, None, None), + ("auth/offline-access-scope", False, None, "dcr"), + ("auth/offline-access-scope", True, None, None), + ("auth/offline-access-scope", False, "cimd", "cimd"), + ], +) +def test_auth_adapter_scopes_client_registration_overrides( + monkeypatch: pytest.MonkeyPatch, + scenario: str, + require_automatic_auth: bool, + override: str | None, + expected: str | None, +) -> None: + if override is None: + monkeypatch.delenv(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, raising=False) + else: + monkeypatch.setenv(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, override) + assert ( + _client_registration_override( + scenario, + require_automatic_auth=require_automatic_auth, + ) + == expected + ) + + +def test_auth_registration_does_not_persist_context_secret(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('mcp_oauth_credentials_store = "file"\n', encoding="utf-8") + + _write_auth_registration( + config_path, + server_url="http://127.0.0.1:8765/mcp", + oauth_client_id="pre-registered-client", + oauth_client_secret_env_var="MCP_CONFORMANCE_CLIENT_SECRET", + ) + + config = config_path.read_text(encoding="utf-8") + assert 'url = "http://127.0.0.1:8765/mcp"' in config + assert 'client_id = "pre-registered-client"' in config + assert 'client_secret_env_var = "MCP_CONFORMANCE_CLIENT_SECRET"' in config + assert "do-not-log" not in config + assert "client_secret =" not in config + + +def test_official_diagnostics_redact_oauth_secrets() -> None: + diagnostic = ( + 'With context: {"client_id":"visible","client_secret":"secret-value",' + '"private_key_pem":"private-value"}\n' + "Authorize at http://localhost/authorize?state=state-value&code_challenge=pkce-value\n" + "Authorization: Bearer token-value" + ) + + redacted = redact_sensitive_text(diagnostic) + + assert '"client_id":"visible"' in redacted + assert '"client_secret":"[REDACTED]"' in redacted + assert '"private_key_pem":"[REDACTED]"' in redacted + assert "state=[REDACTED]" in redacted + assert "code_challenge=[REDACTED]" in redacted + assert "Bearer [REDACTED]" in redacted + serialized = redact_sensitive_text(json.dumps({"diagnostic": diagnostic})) + assert json.loads(serialized)["diagnostic"] + for secret in ( + "secret-value", + "private-value", + "state-value", + "pkce-value", + "token-value", + ): + assert secret not in redacted + assert secret not in serialized + + +def test_retained_artifacts_remove_oauth_credential_store(tmp_path: Path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + credentials = codex_home / ".credentials.json" + credentials.write_text('{"client_secret":"do-not-retain"}', encoding="utf-8") + stdout = tmp_path / "stdout.txt" + stdout.write_text("Bearer token-value", encoding="utf-8") + + _scrub_retained_artifacts(tmp_path) + + assert not credentials.exists() + assert stdout.read_text(encoding="utf-8") == "Bearer [REDACTED]" + + +def test_pinned_full_scenarios_are_selected_by_protocol_version() -> None: + shipping_legacy = scenarios_for_mode(SHIPPING_LEGACY_VERSION) + legacy = scenarios_for_mode(LEGACY_VERSION) + modern = scenarios_for_mode(MODERN_VERSION) + + assert shipping_legacy == ( + "initialize", + "tools_call", + "auth/token-endpoint-auth-basic", + "auth/token-endpoint-auth-post", + "auth/token-endpoint-auth-none", + ) + assert scenarios_for_mode(SHIPPING_LEGACY_VERSION, include_auth=False) == ( + "initialize", + "tools_call", + ) + + assert legacy[:4] == ( + "initialize", + "tools_call", + "elicitation-sep1034-client-defaults", + "sse-retry", + ) + assert len(legacy) == 18 + assert len([scenario for scenario in legacy if scenario.startswith("auth/")]) == 14 + assert "auth/pre-registration" in legacy + + assert modern[:7] == ( + "tools_call", + "request-metadata", + "sep-2322-client-request-state", + "http-standard-headers", + "http-custom-headers", + "http-invalid-tool-headers", + "json-schema-ref-no-deref", + ) + assert len(modern) == 32 + assert len([scenario for scenario in modern if scenario.startswith("auth/")]) == 25 + assert "auth/resource-mismatch" in modern + assert "auth/metadata-issuer-mismatch" in modern + + assert scenarios_for_mode(LEGACY_VERSION, include_auth=False) == legacy[:4] + assert scenarios_for_mode(MODERN_VERSION, include_auth=False) == modern[:7] + assert scenarios_for_mode( + MODERN_VERSION, + ["tools_call", "http-custom-headers"], + ) == ("tools_call", "http-custom-headers") + assert len(OFFICIAL_CONFORMANCE_GIT_REF) == 40 + + +@pytest.mark.parametrize( + ("mode", "scenario"), + [ + (SHIPPING_LEGACY_VERSION, "request-metadata"), + (LEGACY_VERSION, "http-standard-headers"), + (MODERN_VERSION, "elicitation-sep1034-client-defaults"), + ], +) +def test_versioned_scenario_selection_rejects_an_incompatible_protocol( + mode: str, scenario: str +) -> None: + with pytest.raises( + ValueError, + match=rf"unavailable for MCP protocol version {mode}.*{scenario}", + ): + scenarios_for_mode(mode, [scenario], include_auth=False) + + +def test_cross_version_auth_subset_remains_available_to_every_mode() -> None: + requested = ( + "auth/token-endpoint-auth-basic", + "auth/resource-mismatch", + "auth/authorization-server-migration", + ) + + assert scenarios_for_mode(SHIPPING_LEGACY_VERSION, requested) == ( + "auth/token-endpoint-auth-basic", + ) + assert scenarios_for_mode(LEGACY_VERSION, requested) == ( + "auth/token-endpoint-auth-basic", + ) + assert scenarios_for_mode(MODERN_VERSION, requested) == requested + + +def test_official_cli_rejects_an_empty_versioned_scenario_run( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def unexpected_run(*_args: object, **_kwargs: object) -> None: + raise AssertionError("an incompatible selection must not execute conformance") + + monkeypatch.setattr(run_codex_compliance, "run_compliance", unexpected_run) + + exit_code = run_codex_compliance.main( + [ + sys.executable, + "--mode", + SHIPPING_LEGACY_VERSION, + "--transport", + "http", + "--official-scenario", + "request-metadata", + "--no-auth", + "--conformance-cli", + sys.executable, + ] + ) + + assert exit_code == 2 + assert ( + "requested scenarios are unavailable for MCP protocol version 2025-06-18: " + "request-metadata" + ) in capsys.readouterr().err + + +def test_official_driver_reads_checks_and_adapter_report(tmp_path: Path) -> None: + fake_cli = tmp_path / "fake_conformance.py" + fake_cli.write_text( + """ +import json +import os +import pathlib +import sys + +args = sys.argv[1:] +scenario = args[args.index("--scenario") + 1] +output = pathlib.Path(args[args.index("--output-dir") + 1]) +result_dir = output / f"{scenario}-timestamp" +result_dir.mkdir(parents=True) +(result_dir / "checks.json").write_text(json.dumps([ + { + "id": "request-trace", + "name": "RequestTrace", + "description": "not a conformance assertion", + "status": "INFO", + }, + { + "id": "official-check", + "name": "OfficialCheck", + "description": "observed", + "status": "SUCCESS", + }, +])) +pathlib.Path(os.environ["CODEX_CONFORMANCE_ADAPTER_REPORT"]).write_text( + json.dumps({"success": True, "steps": []}) +) +""".lstrip(), + encoding="utf-8", + ) + adapter = tmp_path / "adapter.py" + adapter.write_text("raise AssertionError('fake CLI should not run adapter')\n") + + results = run_official_mode( + conformance_command=[sys.executable, str(fake_cli)], + adapter_script=adapter, + codex_binary=Path("/opt/codex"), + mode=MODERN_VERSION, + scenarios=["tools_call"], + output_dir=tmp_path / "results", + timeout_seconds=1, + base_env={}, + ) + + assert len(results) == 1 + assert results[0].success + assert results[0].adapter_success + assert len(results[0].checks) == 1 + assert results[0].checks[0].check_id == "official-check" + + +def test_official_driver_terminates_timed_out_process_group(tmp_path: Path) -> None: + fake_cli = tmp_path / "hanging_conformance.py" + fake_cli.write_text( + """ +import subprocess +import sys +import time + +subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) +time.sleep(60) +""".lstrip(), + encoding="utf-8", + ) + adapter = tmp_path / "adapter.py" + adapter.write_text("raise AssertionError('not reached')\n", encoding="utf-8") + launcher = _make_adapter_launcher(adapter) + try: + result = _run_scenario( + conformance_command=[sys.executable, str(fake_cli)], + adapter_launcher=launcher, + codex_binary=Path("/opt/codex"), + mode=MODERN_VERSION, + scenario="tools_call", + output_dir=tmp_path / "results", + timeout_seconds=0.05, + process_grace_seconds=0.05, + base_env={}, + ) + finally: + launcher.unlink() + launcher.parent.rmdir() + + assert not result.success + assert "timed out" in result.runner_detail + + +def test_official_driver_does_not_mislabel_runner_crash_as_adapter_failure( + tmp_path: Path, +) -> None: + fake_cli = tmp_path / "crashing_conformance.py" + fake_cli.write_text( + """ +import os +import subprocess +import sys + +script = ''' +import json +import os +import pathlib +import time + +time.sleep(0.1) +pathlib.Path(os.environ["CODEX_CONFORMANCE_ADAPTER_REPORT"]).write_text( + json.dumps({"success": True, "steps": []}) +) +''' +subprocess.Popen([sys.executable, "-c", script], env=os.environ) +sys.exit(1) +""".lstrip(), + encoding="utf-8", + ) + adapter = tmp_path / "adapter.py" + adapter.write_text("raise AssertionError('not reached')\n", encoding="utf-8") + launcher = _make_adapter_launcher(adapter) + try: + result = _run_scenario( + conformance_command=[sys.executable, str(fake_cli)], + adapter_launcher=launcher, + codex_binary=Path("/opt/codex"), + mode=MODERN_VERSION, + scenario="request-metadata", + output_dir=tmp_path / "results", + timeout_seconds=1, + process_grace_seconds=1, + base_env={}, + ) + finally: + launcher.unlink() + launcher.parent.rmdir() + + assert not result.success + assert result.adapter_success + assert "did not produce exactly one checks.json" in result.runner_detail diff --git a/scripts/mcp_conformance/test_review_regressions.py b/scripts/mcp_conformance/test_review_regressions.py new file mode 100644 index 0000000000000000000000000000000000000000..609769a3e5ceef3f5564e90c91f803267287d6c9 --- /dev/null +++ b/scripts/mcp_conformance/test_review_regressions.py @@ -0,0 +1,1093 @@ +import json +import sys +import time +from copy import deepcopy +from pathlib import Path +from typing import Callable, Mapping, Sequence + +import pytest +from review_regressions import ( + CATALOG_BOUNDARY_PROFILES, + CATALOG_BOUNDARY_TRANSPORTS, + CATALOG_LIMIT_ERROR, + CATALOG_MAX_PROFILE, + CATALOG_OVER_LIMIT_PROFILE, + LEGACY_ENVIRONMENT_SENTINEL, + LEGACY_VERSION, + MAX_CATALOG_ITEMS, + MISMATCHED_DISCOVERY_ID_PROFILE, + MODERN_VERSION, + NULL_DISCOVERY_ID_PROFILE, + REPEATED_CURSOR_PROFILE, + REVIEW_EXACT_INTEGER, + REVIEW_BASELINE_KIND, + REVIEW_MODES, + REVIEW_PROFILE, + REVIEW_REPORT_SCHEMA_VERSION, + REVIEWER, + SHIPPING_LEGACY_VERSION, + SSE_COMMENT_FLOOD_PROFILE, + SSE_CR_COMMENTS_PROFILE, + TEST_SERVER_NAME, + AppServerError, + CaseResult, + _compact_review_regression_baseline, + _elicitation_schema, + _evaluate_review_regression_gate, + _exact_integer_property, + _mrtr_budget_is_bounded, + _parse_args, + _required_review_cases, + _required_review_checks, + _run_catalog_boundary_checks, + _review_elicitation_content, + _review_inventory_entry, + _review_registration_command, + _tool_input_schema, + _write_review_json, + main, + run_review_regressions, +) + + +def _integer_schema(value: int = REVIEW_EXACT_INTEGER) -> dict[str, object]: + return { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": value, + "maximum": 9_223_372_036_854_775_807, + "default": value, + } + }, + } + + +class _CatalogBoundaryClient: + def __init__( + self, + *, + status: str | None, + tool_count: int, + server_info: dict[str, object] | None, + error: str | None = None, + notification_name: str = TEST_SERVER_NAME, + preceding_statuses: Sequence[str] = (), + ) -> None: + self.events: list[dict[str, object]] = [] + self._status = status + self._error = error + self._notification_name = notification_name + self._preceding_statuses = preceding_statuses + self._entry: dict[str, object] = { + "name": TEST_SERVER_NAME, + "serverInfo": server_info, + "tools": { + f"catalog_boundary_{index:04d}": {} for index in range(tool_count) + }, + } + + def request( + self, method: str, params: Mapping[str, object] | None + ) -> dict[str, object]: + if method == "thread/start": + if self._status is not None: + for status in self._preceding_statuses: + self.events.append( + { + "method": "mcpServer/startupStatus/updated", + "params": { + "name": self._notification_name, + "status": status, + }, + } + ) + notification_params: dict[str, object] = { + "name": self._notification_name, + "status": self._status, + } + if self._error is not None: + notification_params["error"] = self._error + self.events.append( + { + "method": "mcpServer/startupStatus/updated", + "params": notification_params, + } + ) + return {"result": {"thread": {"id": "catalog-boundary-thread"}}} + if method == "mcpServerStatus/list": + return {"result": {"data": [self._entry]}} + raise AssertionError(f"unexpected app-server request: {method}") + + def wait_for_notification( + self, + method: str, + *, + predicate: Callable[[Mapping[str, object]], bool] | None = None, + after_event_index: int = 0, + ) -> dict[str, object]: + for event in self.events[after_event_index:]: + params = event.get("params") + if ( + event.get("method") == method + and isinstance(params, dict) + and (predicate is None or predicate(params)) + ): + return event + raise AppServerError(f"timed out waiting for app-server notification {method}") + + +def test_review_matrix_includes_the_actual_shipping_legacy_protocol() -> None: + assert REVIEW_MODES == ("2025-06-18", "2025-11-25", "2026-07-28") + assert SHIPPING_LEGACY_VERSION == "2025-06-18" + + +def test_exact_integer_schema_does_not_accept_float_rounded_values() -> None: + assert _exact_integer_property(_integer_schema()) + assert not _exact_integer_property(_integer_schema(REVIEW_EXACT_INTEGER - 1)) + + rounded = _integer_schema() + rounded["properties"]["value"]["minimum"] = float(REVIEW_EXACT_INTEGER) # type: ignore[index] + assert not _exact_integer_property(rounded) + + +def test_exact_integer_schema_does_not_accept_boolean_values() -> None: + schema = _integer_schema() + schema["properties"]["value"]["minimum"] = True # type: ignore[index] + + assert not _exact_integer_property(schema) + + +@pytest.mark.parametrize("key", ["requestedSchema", "requested_schema", "schema"]) +def test_extracts_the_actual_elicitation_requested_schema(key: str) -> None: + schema = _integer_schema() + + assert _elicitation_schema({key: schema}) == schema + assert _elicitation_schema({"params": {key: schema}}) == schema + + +def test_elicitation_response_preserves_the_exact_large_integer() -> None: + response = _review_elicitation_content({"requestedSchema": _integer_schema()}) + + assert response == {"value": 9_007_199_254_740_993} + + +@pytest.mark.parametrize( + ("request_count", "elapsed_seconds", "expected"), + [ + (0, 0.1, True), + (64, 1.0, True), + (65, 1.0, False), + (64, 10.1, False), + ], +) +def test_mrtr_budget_requires_both_request_and_time_limits( + request_count: int, + elapsed_seconds: float, + expected: bool, +) -> None: + assert ( + _mrtr_budget_is_bounded( + request_count, + elapsed_seconds=elapsed_seconds, + timeout_seconds=8, + ) + is expected + ) + + +def test_review_inventory_requires_the_registered_server() -> None: + entry = {"name": "mcp_spec_fixture", "tools": {}} + + assert _review_inventory_entry({"data": [entry]}) == entry + assert _review_inventory_entry({"data": [{"name": "another-server"}]}) is None + assert _review_inventory_entry({"data": "invalid"}) is None + + +@pytest.mark.parametrize("schema_key", ["inputSchema", "input_schema"]) +def test_review_inventory_recognizes_both_tool_schema_spellings( + schema_key: str, +) -> None: + schema = _integer_schema() + entry = {"tools": {"review_large_integer": {schema_key: schema}}} + + assert _tool_input_schema(entry, "review_large_integer") == schema + + +def test_shipping_legacy_registration_preserves_the_reserved_protocol_environment() -> ( + None +): + command = _review_registration_command( + Path("/opt/codex"), + Path("/src/server.py"), + transport="stdio", + mode=SHIPPING_LEGACY_VERSION, + profile=REVIEW_PROFILE, + http_url=None, + ) + + assert command == [ + "/opt/codex", + "mcp", + "add", + "mcp_spec_fixture", + "--env", + f"CODEX_MCP_PROTOCOL_VERSION={LEGACY_ENVIRONMENT_SENTINEL}", + "--", + sys.executable, + "/src/server.py", + "--mode", + SHIPPING_LEGACY_VERSION, + "--transport", + "stdio", + "--profile", + REVIEW_PROFILE, + ] + + +def test_modern_registration_explicitly_opts_into_the_modern_protocol() -> None: + command = _review_registration_command( + Path("/opt/codex"), + Path("/src/server.py"), + transport="stdio", + mode=MODERN_VERSION, + profile=REVIEW_PROFILE, + http_url=None, + ) + + assert f"CODEX_MCP_PROTOCOL_VERSION={MODERN_VERSION}" in command + + +def test_http_review_registration_requires_a_real_fixture_url() -> None: + with pytest.raises(ValueError, match="fixture URL"): + _review_registration_command( + Path("/opt/codex"), + Path("/src/server.py"), + transport="http", + mode=MODERN_VERSION, + profile=REVIEW_PROFILE, + http_url=None, + ) + + +def test_review_cli_supports_the_real_shipping_protocol_and_json_report() -> None: + args = _parse_args( + [ + "/opt/codex", + "--mode", + SHIPPING_LEGACY_VERSION, + "--report", + "/tmp/mcp-review-regressions.json", + ] + ) + + assert args.mode == "2025-06-18" + assert args.report == Path("/tmp/mcp-review-regressions.json") + + +def test_review_cli_reports_a_missing_codex_binary( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["/path/to/nonexistent-review-codex"]) == 2 + assert "not executable" in capsys.readouterr().err + + +@pytest.mark.parametrize("mode", REVIEW_MODES) +def test_catalog_at_limit_requires_a_ready_server_and_all_tools( + mode: str, tmp_path: Path +) -> None: + case = CaseResult(transport="review-stdio:catalog-max", mode=mode) + client = _CatalogBoundaryClient( + status="ready", + tool_count=MAX_CATALOG_ITEMS, + server_info={"name": "catalog-boundary-server"}, + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_MAX_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert case.success + assert [check.name for check in case.checks] == [ + "review/ephemeral-thread", + "review/catalog-at-limit/startup-ready", + "review/catalog-at-limit/configured-server", + "review/catalog-at-limit/server-info", + "review/catalog-at-limit/tool-count", + ] + + +@pytest.mark.parametrize("mode", REVIEW_MODES) +def test_clean_catalog_over_limit_rejection_is_an_expected_pass( + mode: str, tmp_path: Path +) -> None: + case = CaseResult(transport="review-http:catalog-over-limit", mode=mode) + client = _CatalogBoundaryClient( + status="failed", + tool_count=0, + server_info=None, + error=f"MCP startup failed: {CATALOG_LIMIT_ERROR}", + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_OVER_LIMIT_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert case.success + assert [check.name for check in case.checks] == [ + "review/ephemeral-thread", + "review/catalog-over-limit/startup-failed", + "review/catalog-over-limit/exact-limit-error", + "review/catalog-over-limit/configured-server", + "review/catalog-over-limit/server-info", + "review/catalog-over-limit/tool-count", + ] + + +@pytest.mark.parametrize( + ("profile", "terminal_status", "tool_count"), + [ + (CATALOG_MAX_PROFILE, "ready", MAX_CATALOG_ITEMS), + (CATALOG_OVER_LIMIT_PROFILE, "failed", 0), + ], +) +def test_catalog_boundary_ignores_starting_until_the_real_terminal_notification( + profile: str, + terminal_status: str, + tool_count: int, + tmp_path: Path, +) -> None: + at_limit = profile == CATALOG_MAX_PROFILE + case = CaseResult(transport=f"review-http:{profile}", mode=MODERN_VERSION) + client = _CatalogBoundaryClient( + status=terminal_status, + tool_count=tool_count, + server_info={"name": "catalog-boundary-server"} if at_limit else None, + error=f"MCP startup failed: {CATALOG_LIMIT_ERROR}" if not at_limit else None, + preceding_statuses=("starting",), + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=profile, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert case.success + assert client.events[0]["params"]["status"] == "starting" # type: ignore[index] + assert client.events[1]["params"]["status"] == terminal_status # type: ignore[index] + + +def test_catalog_over_limit_does_not_accept_an_unrelated_startup_failure( + tmp_path: Path, +) -> None: + case = CaseResult(transport="review-stdio:catalog-over-limit", mode=MODERN_VERSION) + client = _CatalogBoundaryClient( + status="failed", + tool_count=0, + server_info=None, + error="MCP startup failed: connection unexpectedly closed", + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_OVER_LIMIT_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert not case.success + assert [check.name for check in case.checks if not check.success] == [ + "review/catalog-over-limit/exact-limit-error" + ] + + +@pytest.mark.parametrize("mode", REVIEW_MODES) +@pytest.mark.parametrize("transport", CATALOG_BOUNDARY_TRANSPORTS) +def test_catalog_over_limit_records_all_checks_when_startup_incorrectly_succeeds( + mode: str, transport: str, tmp_path: Path +) -> None: + case = CaseResult(transport=f"review-{transport}:catalog-over-limit", mode=mode) + client = _CatalogBoundaryClient( + status="ready", + tool_count=MAX_CATALOG_ITEMS + 1, + server_info={"name": "catalog-boundary-server"}, + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_OVER_LIMIT_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert not case.success + assert [check.name for check in case.checks] == [ + "review/ephemeral-thread", + "review/catalog-over-limit/startup-failed", + "review/catalog-over-limit/exact-limit-error", + "review/catalog-over-limit/configured-server", + "review/catalog-over-limit/server-info", + "review/catalog-over-limit/tool-count", + ] + assert [check.name for check in case.checks if not check.success] == [ + "review/catalog-over-limit/startup-failed", + "review/catalog-over-limit/exact-limit-error", + "review/catalog-over-limit/server-info", + "review/catalog-over-limit/tool-count", + ] + + +def test_catalog_over_limit_does_not_treat_a_missing_notification_as_rejection( + tmp_path: Path, +) -> None: + case = CaseResult(transport="review-stdio:catalog-over-limit", mode=LEGACY_VERSION) + client = _CatalogBoundaryClient( + status=None, + tool_count=0, + server_info=None, + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_OVER_LIMIT_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert not case.success + assert [check.name for check in case.checks if not check.success] == [ + "review/catalog-over-limit/startup-failed" + ] + + +@pytest.mark.parametrize( + ("profile", "status", "failure_name"), + [ + ( + CATALOG_MAX_PROFILE, + "ready", + "review/catalog-at-limit/startup-ready", + ), + ( + CATALOG_OVER_LIMIT_PROFILE, + "failed", + "review/catalog-over-limit/startup-failed", + ), + ], +) +def test_catalog_boundary_does_not_accept_another_servers_startup_notification( + profile: str, status: str, failure_name: str, tmp_path: Path +) -> None: + at_limit = profile == CATALOG_MAX_PROFILE + case = CaseResult(transport=f"review-stdio:{profile}", mode=MODERN_VERSION) + client = _CatalogBoundaryClient( + status=status, + tool_count=MAX_CATALOG_ITEMS if at_limit else 0, + server_info={"name": "catalog-boundary-server"} if at_limit else None, + error=CATALOG_LIMIT_ERROR if not at_limit else None, + notification_name="another-mcp-server", + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=profile, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert not case.success + assert [check.name for check in case.checks if not check.success] == [failure_name] + + +@pytest.mark.parametrize("tool_count", [MAX_CATALOG_ITEMS - 1, MAX_CATALOG_ITEMS + 1]) +def test_catalog_at_limit_rejects_incomplete_or_excessive_discovery( + tool_count: int, tmp_path: Path +) -> None: + case = CaseResult(transport="review-stdio:catalog-max", mode=MODERN_VERSION) + client = _CatalogBoundaryClient( + status="ready", + tool_count=tool_count, + server_info={"name": "catalog-boundary-server"}, + ) + + _run_catalog_boundary_checks( + case, + client, # type: ignore[arg-type] - exercise the real app-server client contract. + profile=CATALOG_MAX_PROFILE, + workspace=tmp_path, + ) + case.finish(time.monotonic()) + + assert not case.success + assert [check.name for check in case.checks if not check.success] == [ + "review/catalog-at-limit/tool-count" + ] + + +@pytest.mark.parametrize("mode", REVIEW_MODES) +@pytest.mark.parametrize("profile", CATALOG_BOUNDARY_PROFILES) +def test_catalog_boundary_stdio_registration_uses_the_exact_fixture_profile( + mode: str, profile: str +) -> None: + command = _review_registration_command( + Path("/opt/codex"), + Path("/src/server.py"), + transport="stdio", + mode=mode, + profile=profile, + http_url=None, + ) + + assert command[-6:] == [ + "--mode", + mode, + "--transport", + "stdio", + "--profile", + profile, + ] + + +def test_review_matrix_appends_all_transport_catalog_boundaries( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + observed: list[tuple[str, str, str]] = [] + + def fake_run_review_case( + codex_binary: Path, + server_script: Path, + *, + mode: str, + profile: str, + transport: str, + case_home: Path, + timeout_seconds: float, + ) -> CaseResult: + observed.append((mode, profile, transport)) + case = CaseResult(transport=f"review-{transport}:{profile}", mode=mode) + case.check("review/fake-preserved-case", True, "ok") + case.finish(time.monotonic()) + return case + + monkeypatch.setattr("review_regressions._run_review_case", fake_run_review_case) + report = run_review_regressions( + Path("/opt/codex"), + modes=REVIEW_MODES, + server_script=Path("/src/server.py"), + artifact_parent=tmp_path, + ) + + assert observed[:9] == [ + (SHIPPING_LEGACY_VERSION, REVIEW_PROFILE, "stdio"), + (LEGACY_VERSION, REVIEW_PROFILE, "stdio"), + (MODERN_VERSION, REVIEW_PROFILE, "stdio"), + (MODERN_VERSION, REVIEW_PROFILE, "http"), + (MODERN_VERSION, REPEATED_CURSOR_PROFILE, "http"), + (MODERN_VERSION, MISMATCHED_DISCOVERY_ID_PROFILE, "http"), + (MODERN_VERSION, NULL_DISCOVERY_ID_PROFILE, "http"), + (MODERN_VERSION, SSE_CR_COMMENTS_PROFILE, "http"), + (MODERN_VERSION, SSE_COMMENT_FLOOD_PROFILE, "http"), + ] + assert observed[9:] == [ + (mode, profile, transport) + for mode in REVIEW_MODES + for transport in CATALOG_BOUNDARY_TRANSPORTS + for profile in CATALOG_BOUNDARY_PROFILES + ] + assert report["success"] is True + assert report["summary"] == { + "passed": 21, + "failed": 0, + "total": 21, + "casesPassed": 21, + "casesTotal": 21, + } + + +_KNOWN_REVIEW_FAILURES = { + (MODERN_VERSION, f"review-{transport}:{REVIEW_PROFILE}", check_id) + for transport in ("stdio", "http") + for check_id in ( + "review/exact-large-integer-elicitation-schema", + "review/exact-large-integer-elicitation-round-trip", + "review/mrtr-input-requests-bounded-to-64", + ) +} + +_KNOWN_MAIN_CATALOG_FAILURES = { + (mode, f"review-{transport}:{CATALOG_OVER_LIMIT_PROFILE}", check_id) + for mode in REVIEW_MODES + for transport in CATALOG_BOUNDARY_TRANSPORTS + for check_id in ( + "review/catalog-over-limit/startup-failed", + "review/catalog-over-limit/exact-limit-error", + "review/catalog-over-limit/server-info", + "review/catalog-over-limit/tool-count", + ) +} + + +def _refresh_review_gate_report(report: dict[str, object]) -> None: + cases = report["cases"] + assert isinstance(cases, list) + passed = 0 + failed = 0 + cases_passed = 0 + for case in cases: + assert isinstance(case, dict) + checks = case["checks"] + assert isinstance(checks, list) + successes = [] + for check in checks: + assert isinstance(check, dict) + success = check["success"] + assert isinstance(success, bool) + successes.append(success) + case_success = bool(checks) and all(successes) + case["success"] = case_success + passed += sum(successes) + failed += len(successes) - sum(successes) + cases_passed += case_success + report["success"] = bool(cases) and failed == 0 + report["summary"] = { + "passed": passed, + "failed": failed, + "total": passed + failed, + "casesPassed": cases_passed, + "casesTotal": len(cases), + } + + +def _review_gate_report( + *, + failures: set[tuple[str, str, str]] | None = None, +) -> dict[str, object]: + failures = _KNOWN_REVIEW_FAILURES if failures is None else failures + cases: list[dict[str, object]] = [] + for mode, transport in sorted(_required_review_cases()): + check_ids = ["review/mcp-registration"] + if transport.endswith(f":{CATALOG_OVER_LIMIT_PROFILE}"): + check_ids.extend( + ( + "review/catalog-over-limit/startup-failed", + "review/catalog-over-limit/exact-limit-error", + "review/catalog-over-limit/configured-server", + "review/catalog-over-limit/server-info", + "review/catalog-over-limit/tool-count", + ) + ) + if mode == MODERN_VERSION and transport in { + f"review-stdio:{REVIEW_PROFILE}", + f"review-http:{REVIEW_PROFILE}", + }: + check_ids.extend( + ( + "review/exact-large-integer-elicitation-schema", + "review/exact-large-integer-elicitation-round-trip", + "review/mrtr-input-requests-bounded-to-64", + ) + ) + cases.append( + { + "mode": mode, + "transport": transport, + "checks": [ + { + "name": check_id, + "success": (mode, transport, check_id) not in failures, + "detail": "synthetic real-probe identity", + } + for check_id in check_ids + ], + } + ) + report: dict[str, object] = { + "schemaVersion": REVIEW_REPORT_SCHEMA_VERSION, + "reviewer": REVIEWER, + "modes": list(REVIEW_MODES), + "cases": cases, + } + _refresh_review_gate_report(report) + return report + + +def test_reviewer_baseline_preserves_all_required_cases_and_known_failures() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + + assert baseline["baselineKind"] == REVIEW_BASELINE_KIND + assert baseline["requiredModes"] == list(REVIEW_MODES) + assert len(baseline["requiredCases"]) == 21 # type: ignore[arg-type] + checks = baseline["checks"] + assert isinstance(checks, dict) + failures = checks["failing"] + assert isinstance(failures, list) + assert { + (failure["mode"], failure["transport"], failure["check_id"]) + for failure in failures + } == _KNOWN_REVIEW_FAILURES + + +def test_committed_reviewer_baseline_records_all_real_production_checks() -> None: + path = Path(__file__).with_name("review-regression-baseline-v1.json") + baseline = json.loads(path.read_text(encoding="utf-8")) + errors: list[str] = [] + checks = _required_review_checks(baseline, label="baseline", errors=errors) + + assert errors == [] + assert len(checks) == 186 + assert sum(checks.values()) == 156 + assert { + (identity.mode, identity.transport, identity.check_id) + for identity, success in checks.items() + if not success + } == _KNOWN_REVIEW_FAILURES | _KNOWN_MAIN_CATALOG_FAILURES + assert baseline["summary"] == { + "passed": 156, + "failed": 30, + "total": 186, + "casesPassed": 13, + "casesTotal": 21, + } + + +def test_reviewer_gate_reports_known_failures_without_calling_them_passes() -> None: + report = _review_gate_report() + baseline = _compact_review_regression_baseline(report) + + gate = _evaluate_review_regression_gate(report, baseline) + + assert report["success"] is False + assert gate["success"] is True + assert gate["configurationErrors"] == [] + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + assert gate["fixedChecks"] == [] + known = gate["knownFailures"] + assert isinstance(known, list) + assert { + (failure["mode"], failure["transport"], failure["check_id"]) + for failure in known + } == _KNOWN_REVIEW_FAILURES + + +def test_reviewer_gate_preserves_catalog_failures_observed_on_current_main() -> None: + main_failures = _KNOWN_REVIEW_FAILURES | _KNOWN_MAIN_CATALOG_FAILURES + report = _review_gate_report(failures=main_failures) + baseline = _compact_review_regression_baseline(report) + + gate = _evaluate_review_regression_gate(report, baseline) + + assert report["success"] is False + assert gate["success"] is True + assert gate["configurationErrors"] == [] + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + known = gate["knownFailures"] + assert isinstance(known, list) + assert { + (failure["mode"], failure["transport"], failure["check_id"]) + for failure in known + } == main_failures + + +def test_reviewer_gate_reports_all_future_catalog_boundary_fixes() -> None: + main_failures = _KNOWN_REVIEW_FAILURES | _KNOWN_MAIN_CATALOG_FAILURES + baseline = _compact_review_regression_baseline( + _review_gate_report(failures=main_failures) + ) + candidate = _review_gate_report() + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert gate["configurationErrors"] == [] + assert gate["newFailures"] == [] + assert gate["missingChecks"] == [] + fixed = gate["fixedChecks"] + assert isinstance(fixed, list) + assert { + (identity["mode"], identity["transport"], identity["check_id"]) + for identity in fixed + } == _KNOWN_MAIN_CATALOG_FAILURES + known = gate["knownFailures"] + assert isinstance(known, list) + assert { + (identity["mode"], identity["transport"], identity["check_id"]) + for identity in known + } == _KNOWN_REVIEW_FAILURES + + +def test_reviewer_gate_rejects_a_newly_failing_previously_passing_check() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + new_failure = ( + SHIPPING_LEGACY_VERSION, + f"review-stdio:{REVIEW_PROFILE}", + "review/mcp-registration", + ) + candidate = _review_gate_report(failures=_KNOWN_REVIEW_FAILURES | {new_failure}) + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert gate["newFailures"] == [ + { + "mode": new_failure[0], + "transport": new_failure[1], + "check_id": new_failure[2], + } + ] + + +def test_reviewer_gate_reports_when_a_known_failure_is_fixed() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + fixed = sorted(_KNOWN_REVIEW_FAILURES)[0] + candidate = _review_gate_report(failures=_KNOWN_REVIEW_FAILURES - {fixed}) + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is True + assert gate["fixedChecks"] == [ + {"mode": fixed[0], "transport": fixed[1], "check_id": fixed[2]} + ] + assert len(gate["knownFailures"]) == 5 # type: ignore[arg-type] + + +def test_reviewer_gate_rejects_an_omitted_required_case() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + candidate = _review_gate_report() + cases = candidate["cases"] + assert isinstance(cases, list) + omitted = cases.pop(0) + assert isinstance(omitted, dict) + _refresh_review_gate_report(candidate) + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + "missing required reviewer case" in error + for error in gate["configurationErrors"] # type: ignore[union-attr] + ) + assert any( + missing["mode"] == omitted["mode"] + and missing["transport"] == omitted["transport"] + for missing in gate["missingChecks"] # type: ignore[union-attr] + ) + + +def test_reviewer_gate_rejects_an_omitted_required_check() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + candidate = _review_gate_report() + cases = candidate["cases"] + assert isinstance(cases, list) + case = next( + case + for case in cases + if isinstance(case, dict) + and case["mode"] == MODERN_VERSION + and case["transport"] == f"review-stdio:{REVIEW_PROFILE}" + ) + checks = case["checks"] + assert isinstance(checks, list) + omitted = checks.pop(0) + assert isinstance(omitted, dict) + _refresh_review_gate_report(candidate) + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert gate["missingChecks"] == [ + { + "mode": MODERN_VERSION, + "transport": f"review-stdio:{REVIEW_PROFILE}", + "check_id": omitted["name"], + } + ] + + +@pytest.mark.parametrize("field", ["schemaVersion", "reviewer", "requiredModes"]) +def test_reviewer_gate_rejects_an_incompatible_baseline(field: str) -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + baseline[field] = [] if field == "requiredModes" else "wrong" + + gate = _evaluate_review_regression_gate(_review_gate_report(), baseline) + + assert gate["success"] is False + assert gate["configurationErrors"] + + +def test_reviewer_gate_rejects_duplicate_baseline_check_identities() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + buckets = baseline["checks"] + assert isinstance(buckets, dict) + passing = buckets["passing"] + assert isinstance(passing, list) + passing.append(deepcopy(passing[0])) + + gate = _evaluate_review_regression_gate(_review_gate_report(), baseline) + + assert gate["success"] is False + assert any( + "duplicate reviewer check" in error + for error in gate["configurationErrors"] # type: ignore[union-attr] + ) + + +def test_reviewer_gate_rejects_inconsistent_candidate_totals() -> None: + baseline = _compact_review_regression_baseline(_review_gate_report()) + candidate = _review_gate_report() + candidate["summary"] = {"passed": 999, "failed": 0, "total": 999} + + gate = _evaluate_review_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + "inconsistent reviewer check totals" in error + for error in gate["configurationErrors"] # type: ignore[union-attr] + ) + + +def test_review_json_is_pretty_sorted_and_newline_terminated(tmp_path: Path) -> None: + report_path = tmp_path / "nested" / "review.json" + + _write_review_json(report_path, {"z": 1, "a": {"second": 2, "first": 1}}) + + assert report_path.read_text(encoding="utf-8") == ( + '{\n "a": {\n "first": 1,\n "second": 2\n },\n "z": 1\n}\n' + ) + + +def test_review_cli_supports_a_reviewed_production_baseline() -> None: + args = _parse_args( + [ + "/opt/codex", + "--baseline-report", + "/tmp/review-regression-baseline-v1.json", + "--report", + "/tmp/mcp-review-regressions.json", + ] + ) + + assert args.baseline_report == Path("/tmp/review-regression-baseline-v1.json") + + +def test_review_cli_requires_a_source_report_when_extracting_a_baseline( + capsys: pytest.CaptureFixture[str], +) -> None: + result = main( + [sys.executable, "--extract-baseline", "/tmp/review-baseline-test.json"] + ) + + assert result == 2 + assert "--extract-baseline requires --baseline-report" in capsys.readouterr().err + + +def test_review_cli_exits_successfully_only_for_a_passing_regression_gate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + candidate = _review_gate_report() + baseline_path = tmp_path / "review-baseline.json" + report_path = tmp_path / "review-report.json" + _write_review_json( + baseline_path, _compact_review_regression_baseline(deepcopy(candidate)) + ) + monkeypatch.setattr( + "review_regressions.run_review_regressions", + lambda *args, **kwargs: deepcopy(candidate), + ) + + result = main( + [ + sys.executable, + "--baseline-report", + str(baseline_path), + "--report", + str(report_path), + ] + ) + + report = json.loads(report_path.read_text(encoding="utf-8")) + assert result == 0 + assert report["success"] is False + assert report["regressionGate"]["success"] is True + assert len(report["regressionGate"]["knownFailures"]) == 6 + + +def test_review_cli_does_not_accept_new_failures_as_known_regressions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + baseline_path = tmp_path / "review-baseline.json" + report_path = tmp_path / "review-report.json" + _write_review_json( + baseline_path, _compact_review_regression_baseline(_review_gate_report()) + ) + new_failure = ( + SHIPPING_LEGACY_VERSION, + f"review-stdio:{REVIEW_PROFILE}", + "review/mcp-registration", + ) + monkeypatch.setattr( + "review_regressions.run_review_regressions", + lambda *args, **kwargs: _review_gate_report( + failures=_KNOWN_REVIEW_FAILURES | {new_failure} + ), + ) + + result = main( + [ + sys.executable, + "--baseline-report", + str(baseline_path), + "--report", + str(report_path), + ] + ) + + report = json.loads(report_path.read_text(encoding="utf-8")) + assert result == 1 + assert report["success"] is False + assert report["regressionGate"]["success"] is False + assert len(report["regressionGate"]["newFailures"]) == 1 + + +def test_review_cli_extracts_a_deterministic_complete_baseline( + tmp_path: Path, +) -> None: + source_path = tmp_path / "full-review-report.json" + baseline_path = tmp_path / "review-regression-baseline-v1.json" + report = _review_gate_report() + _write_review_json(source_path, report) + + result = main( + [ + sys.executable, + "--baseline-report", + str(source_path), + "--extract-baseline", + str(baseline_path), + ] + ) + + expected = _compact_review_regression_baseline(report) + assert result == 0 + assert baseline_path.read_text(encoding="utf-8") == ( + json.dumps(expected, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ) diff --git a/scripts/mcp_conformance/test_server.py b/scripts/mcp_conformance/test_server.py new file mode 100644 index 0000000000000000000000000000000000000000..0828ff8db728764ceb4e2f8c62bd3cfb93fe2c73 --- /dev/null +++ b/scripts/mcp_conformance/test_server.py @@ -0,0 +1,1590 @@ +import base64 +import http.client +import io +import json +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import patch + +import pytest + +from server import ( + CATALOG_MAX_PROFILE, + CATALOG_OVER_LIMIT_PROFILE, + DEFAULT_PROFILE, + HEADER_MISMATCH, + INVALID_PARAMS, + LEGACY_VERSION, + MAX_CATALOG_ITEMS, + MISMATCHED_DISCOVERY_ID_PROFILE, + MISSING_REQUIRED_CLIENT_CAPABILITY, + MODERN_VERSION, + NULL_DISCOVERY_ID_PROFILE, + REPEATED_CURSOR_PROFILE, + RESOURCE_URIS, + REVIEW_EXACT_INTEGER, + REVIEW_MAX_INTEGER, + REVIEW_MRTR_INPUT_REQUEST_COUNT, + REVIEW_PAGE_CURSOR, + REVIEW_PROFILE, + REVIEW_SSE_COMMENT_LINE_BYTES, + REVIEW_SSE_COMMENT_LINE_COUNT, + REVIEW_SSE_EVENT_LIMIT_BYTES, + SHIPPING_LEGACY_VERSION, + SSE_COMMENT_FLOOD_PROFILE, + SSE_CR_COMMENTS_PROFILE, + UNSUPPORTED_PROTOCOL_VERSION, + ConnectionState, + ProtocolServer, + ResponsePlan, + _encode_sse_messages, + _parse_args, + make_http_server, + run_stdio, +) + + +def modern_meta( + *, + version: str = MODERN_VERSION, + capabilities: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "io.modelcontextprotocol/protocolVersion": version, + "io.modelcontextprotocol/clientInfo": { + "name": "fixture-test-client", + "version": "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": capabilities or {}, + } + + +def request( + method: str, + *, + request_id: int = 1, + params: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": {"_meta": modern_meta()} if params is None else params, + } + + +def result(plan: ResponsePlan) -> dict[str, object]: + response = plan.response + assert isinstance(response, dict) + value = response.get("result") + assert isinstance(value, dict) + return value + + +def error(plan: ResponsePlan) -> dict[str, object]: + response = plan.response + assert isinstance(response, dict) + value = response.get("error") + assert isinstance(value, dict) + return value + + +def initialize_legacy( + server: ProtocolServer, + state: ConnectionState, + *, + version: str = LEGACY_VERSION, +) -> None: + server.handle( + request( + "initialize", + params={ + "protocolVersion": version, + "capabilities": {}, + "clientInfo": {"name": "legacy-client", "version": "1.0.0"}, + }, + ), + state, + ) + server.handle( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + state, + ) + + +def test_modern_discovery_is_stateless_and_cacheable() -> None: + server = ProtocolServer(MODERN_VERSION) + + plan = server.handle(request("server/discover"), ConnectionState()) + value = result(plan) + + assert value["resultType"] == "complete" + assert value["supportedVersions"] == [MODERN_VERSION] + assert value["ttlMs"] == 60_000 + assert value["cacheScope"] == "public" + assert value["_meta"] == { + "io.modelcontextprotocol/serverInfo": { + "name": "openai-mcp-spec-test-server", + "version": "0.1.0", + } + } + + +def test_modern_request_requires_metadata() -> None: + server = ProtocolServer(MODERN_VERSION) + + plan = server.handle( + request("tools/list", params={}), + ConnectionState(), + ) + + assert error(plan)["code"] == INVALID_PARAMS + assert plan.http_status == 400 + + +def test_modern_request_reports_supported_version() -> None: + server = ProtocolServer(MODERN_VERSION) + message = request( + "tools/list", + params={"_meta": modern_meta(version=LEGACY_VERSION)}, + ) + + plan = server.handle(message, ConnectionState()) + + assert error(plan) == { + "code": UNSUPPORTED_PROTOCOL_VERSION, + "message": "Unsupported protocol version", + "data": { + "supported": [MODERN_VERSION], + "requested": LEGACY_VERSION, + }, + } + + +def test_legacy_probe_falls_back_then_uses_initialize() -> None: + server = ProtocolServer(LEGACY_VERSION) + state = ConnectionState() + + probe = server.handle(request("server/discover"), state) + assert error(probe)["code"] == -32601 + + initialize_legacy(server, state) + tools = result(server.handle(request("tools/list", params={}), state)) + + assert "resultType" not in tools + assert "ttlMs" not in tools + assert [tool["name"] for tool in tools["tools"]] == sorted( + tool["name"] for tool in tools["tools"] + ) + + +def test_shipping_legacy_mode_negotiates_the_real_product_protocol() -> None: + server = ProtocolServer(SHIPPING_LEGACY_VERSION) + state = ConnectionState() + + plan = server.handle( + request( + "initialize", + params={ + "protocolVersion": SHIPPING_LEGACY_VERSION, + "capabilities": {}, + "clientInfo": {"name": "shipping-client", "version": "1.0.0"}, + }, + ), + state, + ) + + assert result(plan)["protocolVersion"] == "2025-06-18" + assert result(plan)["instructions"] == "Legacy 2025-06-18 compatibility fixture." + assert "resultType" not in result(plan) + assert state.initialize_seen + + +def test_shipping_legacy_discovery_falls_back_to_initialized_legacy_tools() -> None: + server = ProtocolServer(SHIPPING_LEGACY_VERSION) + state = ConnectionState() + + probe = server.handle(request("server/discover"), state) + + assert error(probe)["code"] == -32601 + + initialize_legacy(server, state, version=SHIPPING_LEGACY_VERSION) + tools = result(server.handle(request("tools/list", params={}), state)) + + assert "resultType" not in tools + assert "ttlMs" not in tools + assert [tool["name"] for tool in tools["tools"]] == [ + "client_metadata", + "echo", + "fail", + "header_echo", + "progress", + ] + + +def test_cli_accepts_the_shipping_legacy_protocol_mode() -> None: + args = _parse_args(["--mode", SHIPPING_LEGACY_VERSION, "--transport", "stdio"]) + + assert args.mode == "2025-06-18" + assert args.transport == "stdio" + assert args.profile == DEFAULT_PROFILE + + +@pytest.mark.parametrize( + "mode", (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) +) +@pytest.mark.parametrize("transport", ("stdio", "http")) +@pytest.mark.parametrize("profile", (CATALOG_MAX_PROFILE, CATALOG_OVER_LIMIT_PROFILE)) +def test_catalog_boundary_profiles_are_available_from_the_cli( + mode: str, transport: str, profile: str +) -> None: + args = _parse_args(["--mode", mode, "--transport", transport, "--profile", profile]) + + assert (args.mode, args.transport, args.profile) == (mode, transport, profile) + + +def test_mrtr_requires_capability_and_echoes_state() -> None: + server = ProtocolServer(MODERN_VERSION) + tools = result(server.handle(request("tools/list"), ConnectionState()))["tools"] + tools_by_name = {tool["name"]: tool for tool in tools} + assert "outputSchema" not in tools_by_name["request_input"] + assert "outputSchema" in tools_by_name["echo"] + + first = request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "request_input", + "arguments": {}, + }, + ) + + missing_capability = server.handle(first, ConnectionState()) + assert error(missing_capability)["code"] == MISSING_REQUIRED_CLIENT_CAPABILITY + + first["params"]["_meta"] = modern_meta(capabilities={"elicitation": {"form": {}}}) + interim = result(server.handle(first, ConnectionState())) + assert interim["resultType"] == "input_required" + assert interim["_meta"] == { + "io.modelcontextprotocol/serverInfo": { + "name": "openai-mcp-spec-test-server", + "version": "0.1.0", + } + } + assert "structuredContent" not in interim + assert interim["requestState"] == "opaque:request_input:v1" + assert interim["inputRequests"]["confirmation"]["method"] == "elicitation/create" + + retry = request( + "tools/call", + request_id=2, + params={ + "_meta": modern_meta(capabilities={"elicitation": {"form": {}}}), + "name": "request_input", + "arguments": {}, + "requestState": interim["requestState"], + "inputResponses": { + "confirmation": { + "action": "accept", + "content": {"confirmation": "confirmed"}, + } + }, + }, + ) + completed = result(server.handle(retry, ConnectionState())) + + assert completed["resultType"] == "complete" + assert completed["structuredContent"] == {"confirmation": "confirmed"} + + +def test_subscription_is_acknowledged_tagged_and_closed() -> None: + server = ProtocolServer(MODERN_VERSION) + plan = server.handle( + request( + "subscriptions/listen", + request_id=42, + params={ + "_meta": modern_meta(), + "notifications": { + "toolsListChanged": True, + "resourceSubscriptions": [RESOURCE_URIS[0], "test://unknown"], + }, + }, + ), + ConnectionState(), + ) + + assert plan.force_sse + assert plan.notifications[0]["method"] == ( + "notifications/subscriptions/acknowledged" + ) + for notification in plan.notifications: + assert ( + notification["params"]["_meta"]["io.modelcontextprotocol/subscriptionId"] + == 42 + ) + assert result(plan)["_meta"]["io.modelcontextprotocol/subscriptionId"] == 42 + + +def test_progress_and_logging_are_per_request_opt_ins() -> None: + server = ProtocolServer(MODERN_VERSION) + message = request( + "tools/call", + params={ + "_meta": { + **modern_meta(), + "progressToken": "progress-1", + "io.modelcontextprotocol/logLevel": "info", + }, + "name": "progress", + "arguments": {}, + }, + ) + + plan = server.handle(message, ConnectionState()) + + assert [notification["method"] for notification in plan.notifications] == [ + "notifications/progress", + "notifications/message", + ] + assert result(plan)["resultType"] == "complete" + + +def test_stdio_supports_both_eras() -> None: + modern_input = io.StringIO( + json.dumps(request("tools/list"), separators=(",", ":")) + "\n" + ) + modern_output = io.StringIO() + run_stdio(ProtocolServer(MODERN_VERSION), modern_input, modern_output) + modern_response = json.loads(modern_output.getvalue()) + assert modern_response["result"]["resultType"] == "complete" + + legacy_messages = [ + request( + "initialize", + params={ + "protocolVersion": LEGACY_VERSION, + "capabilities": {}, + "clientInfo": {"name": "legacy", "version": "1.0.0"}, + }, + ), + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + request("tools/list", request_id=2, params={}), + ] + legacy_input = io.StringIO( + "".join( + json.dumps(item, separators=(",", ":")) + "\n" for item in legacy_messages + ) + ) + legacy_output = io.StringIO() + run_stdio(ProtocolServer(LEGACY_VERSION), legacy_input, legacy_output) + responses = [json.loads(line) for line in legacy_output.getvalue().splitlines()] + assert len(responses) == 2 + assert "resultType" not in responses[1]["result"] + + +def test_stdio_negotiates_and_lists_tools_for_the_real_shipping_legacy_protocol() -> ( + None +): + messages = [ + request( + "initialize", + params={ + "protocolVersion": SHIPPING_LEGACY_VERSION, + "capabilities": {}, + "clientInfo": {"name": "shipping-legacy", "version": "1.0.0"}, + }, + ), + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + request("tools/list", request_id=2, params={}), + ] + stdin = io.StringIO( + "".join( + json.dumps(message, separators=(",", ":")) + "\n" for message in messages + ) + ) + stdout = io.StringIO() + + run_stdio(ProtocolServer(SHIPPING_LEGACY_VERSION), stdin, stdout) + + responses = [json.loads(line) for line in stdout.getvalue().splitlines()] + assert len(responses) == 2 + assert responses[0]["result"]["protocolVersion"] == "2025-06-18" + assert [tool["name"] for tool in responses[1]["result"]["tools"]] == [ + "client_metadata", + "echo", + "fail", + "header_echo", + "progress", + ] + assert "resultType" not in responses[1]["result"] + + +def test_default_profile_preserves_existing_legacy_and_modern_tool_lists() -> None: + modern = ProtocolServer(MODERN_VERSION) + modern_tools = result(modern.handle(request("tools/list"), ConnectionState())) + + assert modern.profile == DEFAULT_PROFILE + assert [tool["name"] for tool in modern_tools["tools"]] == [ + "client_metadata", + "echo", + "fail", + "header_echo", + "progress", + "request_input", + ] + assert "nextCursor" not in modern_tools + + legacy = ProtocolServer(LEGACY_VERSION) + legacy_state = ConnectionState() + initialize_legacy(legacy, legacy_state) + legacy_tools = result(legacy.handle(request("tools/list", params={}), legacy_state)) + + assert [tool["name"] for tool in legacy_tools["tools"]] == [ + "client_metadata", + "echo", + "fail", + "header_echo", + "progress", + ] + assert "nextCursor" not in legacy_tools + + +@pytest.mark.parametrize( + "mode", (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) +) +@pytest.mark.parametrize( + ("profile", "expected_size"), + ( + (CATALOG_MAX_PROFILE, MAX_CATALOG_ITEMS), + (CATALOG_OVER_LIMIT_PROFILE, MAX_CATALOG_ITEMS + 1), + ), +) +def test_catalog_boundary_profiles_return_exact_valid_unique_tool_counts( + mode: str, profile: str, expected_size: int +) -> None: + server = ProtocolServer(mode, profile=profile) + state = ConnectionState() + if mode != MODERN_VERSION: + initialize_legacy(server, state, version=mode) + + params = {"_meta": modern_meta()} if mode == MODERN_VERSION else {} + catalog = result(server.handle(request("tools/list", params=params), state)) + tools = catalog["tools"] + names = [tool["name"] for tool in tools] + + assert len(tools) == expected_size + assert len(set(names)) == expected_size + assert names == sorted(names) + assert {"client_metadata", "echo", "fail", "header_echo", "progress"} <= set(names) + assert all(tool["inputSchema"]["type"] == "object" for tool in tools) + assert "nextCursor" not in catalog + if mode == MODERN_VERSION: + assert "request_input" in names + assert catalog["resultType"] == "complete" + else: + assert "resultType" not in catalog + + +@pytest.mark.parametrize( + "mode", (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) +) +@pytest.mark.parametrize( + ("profile", "expected_size"), + ( + (CATALOG_MAX_PROFILE, MAX_CATALOG_ITEMS), + (CATALOG_OVER_LIMIT_PROFILE, MAX_CATALOG_ITEMS + 1), + ), +) +def test_catalog_boundary_profiles_round_trip_over_real_stdio( + mode: str, profile: str, expected_size: int +) -> None: + messages: list[dict[str, object]] = [] + if mode != MODERN_VERSION: + messages.extend( + [ + request( + "initialize", + params={ + "protocolVersion": mode, + "capabilities": {}, + "clientInfo": {"name": "catalog-boundary", "version": "1.0.0"}, + }, + ), + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ] + ) + messages.append( + request( + "tools/list", + request_id=2, + params={"_meta": modern_meta()} if mode == MODERN_VERSION else {}, + ) + ) + stdin = io.StringIO( + "".join( + json.dumps(message, separators=(",", ":")) + "\n" for message in messages + ) + ) + stdout = io.StringIO() + + run_stdio(ProtocolServer(mode, profile=profile), stdin, stdout) + + catalog = json.loads(stdout.getvalue().splitlines()[-1])["result"] + names = [tool["name"] for tool in catalog["tools"]] + assert len(names) == expected_size + assert len(set(names)) == expected_size + assert "nextCursor" not in catalog + assert ("resultType" in catalog) is (mode == MODERN_VERSION) + + +def test_review_profile_hides_regression_tools_on_the_second_page() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + first = result(server.handle(request("tools/list"), ConnectionState())) + assert [tool["name"] for tool in first["tools"]] == ["client_metadata", "echo"] + assert first["nextCursor"] == REVIEW_PAGE_CURSOR + assert first["resultType"] == "complete" + + second = result( + server.handle( + request( + "tools/list", + request_id=2, + params={"_meta": modern_meta(), "cursor": first["nextCursor"]}, + ), + ConnectionState(), + ) + ) + + assert [tool["name"] for tool in second["tools"]] == [ + "fail", + "header_echo", + "progress", + "request_input", + "review_integer_elicitation", + "review_large_integer", + "review_mrtr_cap", + "review_protocol_env", + ] + assert "nextCursor" not in second + + +def test_review_profile_rejects_an_invalid_tool_cursor() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/list", + params={"_meta": modern_meta(), "cursor": "not-a-review-cursor"}, + ), + ConnectionState(), + ) + + assert error(plan) == { + "code": INVALID_PARAMS, + "message": "Invalid tool cursor", + } + + +def test_repeated_cursor_profile_deterministically_repeats_the_cursor() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REPEATED_CURSOR_PROFILE) + + first = result(server.handle(request("tools/list"), ConnectionState())) + second = result( + server.handle( + request( + "tools/list", + request_id=2, + params={"_meta": modern_meta(), "cursor": first["nextCursor"]}, + ), + ConnectionState(), + ) + ) + + assert first["nextCursor"] == REVIEW_PAGE_CURSOR + assert second["nextCursor"] == REVIEW_PAGE_CURSOR + assert any(tool["name"] == "review_large_integer" for tool in second["tools"]) + + +def test_review_large_integer_schema_preserves_exact_json_integer_boundaries() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + page = result( + server.handle( + request( + "tools/list", + params={"_meta": modern_meta(), "cursor": REVIEW_PAGE_CURSOR}, + ), + ConnectionState(), + ) + ) + definition = next( + tool for tool in page["tools"] if tool["name"] == "review_large_integer" + ) + schema = definition["inputSchema"]["properties"]["value"] + + assert schema == { + "type": "integer", + "minimum": 9_007_199_254_740_993, + "maximum": 9_223_372_036_854_775_807, + "default": 9_007_199_254_740_993, + } + assert json.loads(json.dumps(schema)) == schema + assert REVIEW_EXACT_INTEGER > 2**53 + assert REVIEW_MAX_INTEGER == 2**63 - 1 + + +def test_review_large_integer_tool_echoes_without_float_rounding() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_large_integer", + "arguments": {"value": REVIEW_EXACT_INTEGER}, + }, + ), + ConnectionState(), + ) + + assert result(plan)["structuredContent"] == {"value": 9_007_199_254_740_993} + assert json.loads(result(plan)["content"][0]["text"]) == { + "value": 9_007_199_254_740_993 + } + + +def test_review_large_integer_rejects_floats_booleans_and_out_of_range_values() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + for invalid_value in ( + float(REVIEW_EXACT_INTEGER), + True, + REVIEW_EXACT_INTEGER - 1, + REVIEW_MAX_INTEGER + 1, + str(REVIEW_EXACT_INTEGER), + ): + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_large_integer", + "arguments": {"value": invalid_value}, + }, + ), + ConnectionState(), + ) + + assert error(plan)["code"] == INVALID_PARAMS + + +def test_review_large_integer_survives_stdio_json_serialization() -> None: + message = request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_large_integer", + "arguments": {"value": REVIEW_EXACT_INTEGER}, + }, + ) + stdin = io.StringIO(json.dumps(message, separators=(",", ":")) + "\n") + stdout = io.StringIO() + + run_stdio(ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE), stdin, stdout) + + raw_response = stdout.getvalue() + assert "9007199254740993" in raw_response + assert json.loads(raw_response)["result"]["structuredContent"] == { + "value": 9_007_199_254_740_993 + } + + +def test_review_integer_elicitation_preserves_the_exact_requested_form_schema() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(capabilities={"elicitation": {"form": {}}}), + "name": "review_integer_elicitation", + "arguments": {}, + }, + ), + ConnectionState(), + ) + value = result(plan) + pending = value["inputRequests"]["large_integer"] + + assert value["resultType"] == "input_required" + assert value["requestState"] == "opaque:review_integer_elicitation:v1" + assert pending["method"] == "elicitation/create" + assert pending["params"]["requestedSchema"]["properties"]["value"] == { + "type": "integer", + "minimum": 9_007_199_254_740_993, + "maximum": 9_223_372_036_854_775_807, + "default": 9_007_199_254_740_993, + } + assert "9007199254740993" in json.dumps(value) + + +def test_review_integer_elicitation_round_trip_keeps_the_exact_integer() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + meta = modern_meta(capabilities={"elicitation": {"form": {}}}) + + first = result( + server.handle( + request( + "tools/call", + params={ + "_meta": meta, + "name": "review_integer_elicitation", + "arguments": {}, + }, + ), + ConnectionState(), + ) + ) + completed = result( + server.handle( + request( + "tools/call", + request_id=2, + params={ + "_meta": meta, + "name": "review_integer_elicitation", + "arguments": {}, + "requestState": first["requestState"], + "inputResponses": { + "large_integer": { + "action": "accept", + "content": {"value": REVIEW_EXACT_INTEGER}, + } + }, + }, + ), + ConnectionState(), + ) + ) + + assert completed["resultType"] == "complete" + assert completed["structuredContent"] == {"value": 9_007_199_254_740_993} + assert completed["content"] == [{"type": "text", "text": "9007199254740993"}] + + +def test_review_integer_elicitation_rejects_float_rounded_form_response() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(capabilities={"elicitation": {"form": {}}}), + "name": "review_integer_elicitation", + "arguments": {}, + "requestState": "opaque:review_integer_elicitation:v1", + "inputResponses": { + "large_integer": { + "action": "accept", + "content": {"value": float(REVIEW_EXACT_INTEGER)}, + } + }, + }, + ), + ConnectionState(), + ) + + assert error(plan)["code"] == INVALID_PARAMS + + +def test_review_integer_elicitation_requires_advertised_form_capability() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_integer_elicitation", + "arguments": {}, + }, + ), + ConnectionState(), + ) + + assert error(plan)["code"] == MISSING_REQUIRED_CLIENT_CAPABILITY + assert plan.http_status == 400 + + +def test_review_mrtr_cap_returns_65_deterministic_pending_requests() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(capabilities={"elicitation": {"form": {}}}), + "name": "review_mrtr_cap", + "arguments": {}, + }, + ), + ConnectionState(), + ) + value = result(plan) + + assert value["resultType"] == "input_required" + assert value["requestState"] == "opaque:review_mrtr_cap:v1" + assert len(value["inputRequests"]) == REVIEW_MRTR_INPUT_REQUEST_COUNT == 65 + assert list(value["inputRequests"]) == [ + f"review-input-{index:02d}" for index in range(65) + ] + assert all( + pending["method"] == "elicitation/create" + for pending in value["inputRequests"].values() + ) + + +def test_review_mrtr_cap_requires_advertised_elicitation_capability() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_mrtr_cap", + "arguments": {}, + }, + ), + ConnectionState(), + ) + + assert error(plan)["code"] == MISSING_REQUIRED_CLIENT_CAPABILITY + assert plan.http_status == 400 + + +def test_review_protocol_env_is_available_to_a_legacy_client() -> None: + server = ProtocolServer(LEGACY_VERSION, profile=REVIEW_PROFILE) + state = ConnectionState() + initialize_legacy(server, state) + tools = result(server.handle(request("tools/list", params={}), state)) + + assert any(tool["name"] == "review_protocol_env" for tool in tools["tools"]) + + with patch.dict( + "os.environ", {"CODEX_MCP_PROTOCOL_VERSION": "legacy-review-sentinel"} + ): + plan = server.handle( + request( + "tools/call", + params={"name": "review_protocol_env", "arguments": {}}, + ), + state, + ) + + assert result(plan)["structuredContent"] == {"value": "legacy-review-sentinel"} + + +def test_review_protocol_env_is_available_to_the_shipping_legacy_client() -> None: + server = ProtocolServer(SHIPPING_LEGACY_VERSION, profile=REVIEW_PROFILE) + state = ConnectionState() + initialize_legacy(server, state, version=SHIPPING_LEGACY_VERSION) + tools = result(server.handle(request("tools/list", params={}), state)) + + assert any(tool["name"] == "review_protocol_env" for tool in tools["tools"]) + + with patch.dict( + "os.environ", {"CODEX_MCP_PROTOCOL_VERSION": "shipping-legacy-sentinel"} + ): + plan = server.handle( + request( + "tools/call", + params={"name": "review_protocol_env", "arguments": {}}, + ), + state, + ) + + assert result(plan)["structuredContent"] == {"value": "shipping-legacy-sentinel"} + + +def test_review_protocol_env_is_available_to_a_modern_client() -> None: + server = ProtocolServer(MODERN_VERSION, profile=REVIEW_PROFILE) + + with patch.dict("os.environ", {"CODEX_MCP_PROTOCOL_VERSION": MODERN_VERSION}): + plan = server.handle( + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_protocol_env", + "arguments": {}, + }, + ), + ConnectionState(), + ) + + assert result(plan)["structuredContent"] == {"value": MODERN_VERSION} + + +def test_mismatched_discovery_profile_returns_the_wrong_jsonrpc_id() -> None: + server = ProtocolServer(MODERN_VERSION, profile=MISMATCHED_DISCOVERY_ID_PROFILE) + + plan = server.handle(request("server/discover", request_id=42), ConnectionState()) + + assert plan.response is not None + assert plan.response["id"] == "review-mismatched-42" + assert result(plan)["supportedVersions"] == [MODERN_VERSION] + + +def test_null_discovery_profile_returns_a_null_jsonrpc_id() -> None: + server = ProtocolServer(MODERN_VERSION, profile=NULL_DISCOVERY_ID_PROFILE) + + plan = server.handle(request("server/discover", request_id=42), ConnectionState()) + + assert plan.response is not None + assert plan.response["id"] is None + assert result(plan)["supportedVersions"] == [MODERN_VERSION] + + +def test_sse_comment_flood_profile_is_available_from_the_cli() -> None: + args = _parse_args( + [ + "--mode", + MODERN_VERSION, + "--transport", + "http", + "--profile", + SSE_COMMENT_FLOOD_PROFILE, + ] + ) + + assert args.profile == "sse-comment-flood" + + +def test_sse_comment_flood_is_valid_bounded_and_exceeds_the_data_event_limit() -> None: + message: dict[str, object] = {"jsonrpc": "2.0", "id": 42, "result": {}} + + body = _encode_sse_messages([message], profile=SSE_COMMENT_FLOOD_PROFILE) + + assert REVIEW_SSE_COMMENT_LINE_COUNT == 4_097 + assert len(body) > REVIEW_SSE_EVENT_LIMIT_BYTES + assert len(body) < ( + REVIEW_SSE_EVENT_LIMIT_BYTES + REVIEW_SSE_COMMENT_LINE_BYTES + 4_096 + ) + assert body.count(b": reviewer keepalive ") == REVIEW_SSE_COMMENT_LINE_COUNT + assert b"\n" not in body + assert body.endswith(b"\r\r") + assert json.loads(body.rsplit(b"data: ", 1)[1].removesuffix(b"\r\r")) == message + + +@contextmanager +def running_http_server( + mode: str, + *, + profile: str = DEFAULT_PROFILE, +) -> Iterator[tuple[str, int]]: + server = make_http_server(ProtocolServer(mode, profile=profile), "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, port = server.server_address + yield str(host), int(port) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def post_json( + address: tuple[str, int], + message: dict[str, object], + headers: dict[str, str], +) -> tuple[int, dict[str, str], dict[str, object] | None]: + connection = http.client.HTTPConnection(*address, timeout=2) + body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode() + connection.request( + "POST", + "/mcp", + body=body, + headers={"Content-Type": "application/json", **headers}, + ) + response = connection.getresponse() + response_body = response.read() + response_headers = {name.lower(): value for name, value in response.getheaders()} + connection.close() + return ( + response.status, + response_headers, + json.loads(response_body) if response_body else None, + ) + + +def modern_headers( + method: str, + *, + name: str | None = None, + extra: dict[str, str] | None = None, +) -> dict[str, str]: + headers = { + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": MODERN_VERSION, + "Mcp-Method": method, + } + if name is not None: + headers["Mcp-Name"] = name + if extra is not None: + headers.update(extra) + return headers + + +@pytest.mark.parametrize( + "mode", (SHIPPING_LEGACY_VERSION, LEGACY_VERSION, MODERN_VERSION) +) +@pytest.mark.parametrize( + ("profile", "expected_size"), + ( + (CATALOG_MAX_PROFILE, MAX_CATALOG_ITEMS), + (CATALOG_OVER_LIMIT_PROFILE, MAX_CATALOG_ITEMS + 1), + ), +) +def test_catalog_boundary_profiles_round_trip_over_localhost_http( + mode: str, profile: str, expected_size: int +) -> None: + with running_http_server(mode, profile=profile) as address: + if mode == MODERN_VERSION: + status, _, body = post_json( + address, + request("tools/list"), + modern_headers("tools/list"), + ) + else: + status, headers, body = post_json( + address, + request( + "initialize", + params={ + "protocolVersion": mode, + "capabilities": {}, + "clientInfo": {"name": "catalog-boundary", "version": "1.0.0"}, + }, + ), + {}, + ) + assert status == 200 + assert body is not None + assert body["result"]["protocolVersion"] == mode + session_headers = {"Mcp-Session-Id": headers["mcp-session-id"]} + + initialized_status, _, initialized_body = post_json( + address, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + session_headers, + ) + assert initialized_status == 202 + assert initialized_body is None + + status, _, body = post_json( + address, + request("tools/list", request_id=2, params={}), + session_headers, + ) + + assert status == 200 + assert body is not None + catalog = body["result"] + names = [tool["name"] for tool in catalog["tools"]] + assert len(names) == expected_size + assert len(set(names)) == expected_size + assert "nextCursor" not in catalog + assert ("resultType" in catalog) is (mode == MODERN_VERSION) + + +def test_modern_http_validates_standard_and_custom_headers() -> None: + with running_http_server(MODERN_VERSION) as address: + message = request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "header_echo", + "arguments": { + "region": "us-west1", + "attempt": 3, + "enabled": True, + "greeting": "Hello, 世界", + }, + }, + ) + encoded_greeting = base64.b64encode("Hello, 世界".encode()).decode() + headers = modern_headers( + "tools/call", + name="header_echo", + extra={ + "Mcp-Param-Region": "us-west1", + "Mcp-Param-Attempt": "3", + "Mcp-Param-Enabled": "true", + "Mcp-Param-Greeting": f"=?base64?{encoded_greeting}?=", + }, + ) + + status, _, body = post_json(address, message, headers) + assert status == 200 + assert body["result"]["resultType"] == "complete" + + headers["Mcp-Name"] = "different" + status, _, body = post_json(address, message, headers) + assert status == 400 + assert body["error"]["code"] == HEADER_MISMATCH + + +def test_modern_http_accepts_base64_mcp_name() -> None: + with running_http_server(MODERN_VERSION) as address: + uri = RESOURCE_URIS[1] + encoded_uri = base64.b64encode(uri.encode()).decode() + message = request( + "resources/read", + params={"_meta": modern_meta(), "uri": uri}, + ) + + status, _, body = post_json( + address, + message, + modern_headers( + "resources/read", + name=f"=?base64?{encoded_uri}?=", + ), + ) + + assert status == 200 + assert body["result"]["contents"][0]["uri"] == uri + + +def test_modern_http_streams_subscription_then_closes_gracefully() -> None: + with running_http_server(MODERN_VERSION) as address: + message = request( + "subscriptions/listen", + request_id=77, + params={ + "_meta": modern_meta(), + "notifications": {"toolsListChanged": True}, + }, + ) + connection = http.client.HTTPConnection(*address, timeout=2) + connection.request( + "POST", + "/mcp", + body=json.dumps(message, separators=(",", ":")), + headers={ + "Content-Type": "application/json", + **modern_headers("subscriptions/listen"), + }, + ) + + response = connection.getresponse() + body = response.read().decode() + connection.close() + events = [ + json.loads(block.removeprefix("data: ")) + for block in body.strip().split("\n\n") + ] + + assert response.status == 200 + assert response.getheader("Content-Type") == "text/event-stream" + assert events[0]["method"] == "notifications/subscriptions/acknowledged" + assert events[1]["method"] == "notifications/tools/list_changed" + assert events[-1]["id"] == 77 + assert events[-1]["result"]["resultType"] == "complete" + + +def test_modern_http_rejects_removed_methods_and_unsupported_versions() -> None: + with running_http_server(MODERN_VERSION) as address: + message = request( + "tools/list", + params={"_meta": modern_meta(version=LEGACY_VERSION)}, + ) + headers = modern_headers("tools/list") + headers["MCP-Protocol-Version"] = LEGACY_VERSION + + status, _, body = post_json(address, message, headers) + assert status == 400 + assert body["error"]["code"] == UNSUPPORTED_PROTOCOL_VERSION + + connection = http.client.HTTPConnection(*address, timeout=2) + connection.request("GET", "/mcp") + get_response = connection.getresponse() + get_response.read() + assert get_response.status == 405 + + connection.request("DELETE", "/mcp") + delete_response = connection.getresponse() + delete_response.read() + connection.close() + assert delete_response.status == 405 + + +def test_modern_http_accepts_notification_without_request_headers() -> None: + with running_http_server(MODERN_VERSION) as address: + status, _, body = post_json( + address, + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": 1, "reason": "fixture test"}, + }, + {}, + ) + + assert status == 202 + assert body is None + + +def test_modern_http_rejects_untrusted_origin() -> None: + with running_http_server(MODERN_VERSION) as address: + status, _, body = post_json( + address, + request("server/discover"), + { + **modern_headers("server/discover"), + "Origin": "https://attacker.example", + }, + ) + + assert status == 403 + assert body["error"]["message"] == "Origin is not allowed" + + +def test_legacy_http_mints_and_terminates_session() -> None: + with running_http_server(LEGACY_VERSION) as address: + initialize = request( + "initialize", + params={ + "protocolVersion": LEGACY_VERSION, + "capabilities": {}, + "clientInfo": {"name": "legacy", "version": "1.0.0"}, + }, + ) + status, headers, body = post_json(address, initialize, {}) + assert status == 200 + assert body["result"]["protocolVersion"] == LEGACY_VERSION + session_id = headers["mcp-session-id"] + + initialized = { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + status, _, body = post_json( + address, initialized, {"Mcp-Session-Id": session_id} + ) + assert status == 202 + assert body is None + + status, _, body = post_json( + address, + request("tools/list", request_id=2, params={}), + {"Mcp-Session-Id": session_id}, + ) + assert status == 200 + assert "resultType" not in body["result"] + + connection = http.client.HTTPConnection(*address, timeout=2) + connection.request( + "DELETE", + "/mcp", + headers={"Mcp-Session-Id": session_id}, + ) + response = connection.getresponse() + response.read() + connection.close() + assert response.status == 200 + + +def test_shipping_legacy_http_negotiates_and_retains_a_real_legacy_session() -> None: + with running_http_server(SHIPPING_LEGACY_VERSION) as address: + status, headers, body = post_json( + address, + request( + "initialize", + params={ + "protocolVersion": SHIPPING_LEGACY_VERSION, + "capabilities": {}, + "clientInfo": {"name": "shipping-legacy", "version": "1.0.0"}, + }, + ), + {}, + ) + + assert status == 200 + assert body is not None + assert body["result"]["protocolVersion"] == "2025-06-18" + session_id = headers["mcp-session-id"] + + status, _, body = post_json( + address, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + {"Mcp-Session-Id": session_id}, + ) + + assert status == 202 + assert body is None + + status, _, body = post_json( + address, + request("tools/list", request_id=2, params={}), + {"Mcp-Session-Id": session_id}, + ) + + assert status == 200 + assert body is not None + assert [tool["name"] for tool in body["result"]["tools"]] == [ + "client_metadata", + "echo", + "fail", + "header_echo", + "progress", + ] + assert "resultType" not in body["result"] + + +def test_review_http_lists_tools_across_two_pages() -> None: + with running_http_server(MODERN_VERSION, profile=REVIEW_PROFILE) as address: + status, _, first_body = post_json( + address, + request("tools/list"), + modern_headers("tools/list"), + ) + + assert status == 200 + assert first_body is not None + first = first_body["result"] + assert [tool["name"] for tool in first["tools"]] == ["client_metadata", "echo"] + assert first["nextCursor"] == REVIEW_PAGE_CURSOR + + status, _, second_body = post_json( + address, + request( + "tools/list", + request_id=2, + params={"_meta": modern_meta(), "cursor": REVIEW_PAGE_CURSOR}, + ), + modern_headers("tools/list"), + ) + + assert status == 200 + assert second_body is not None + assert any( + tool["name"] == "review_large_integer" + for tool in second_body["result"]["tools"] + ) + assert "nextCursor" not in second_body["result"] + + +def test_review_http_preserves_integer_precision_in_schema_and_tool_results() -> None: + with running_http_server(MODERN_VERSION, profile=REVIEW_PROFILE) as address: + status, _, tools_body = post_json( + address, + request( + "tools/list", + params={"_meta": modern_meta(), "cursor": REVIEW_PAGE_CURSOR}, + ), + modern_headers("tools/list"), + ) + + assert status == 200 + assert tools_body is not None + definition = next( + tool + for tool in tools_body["result"]["tools"] + if tool["name"] == "review_large_integer" + ) + assert definition["inputSchema"]["properties"]["value"]["minimum"] == ( + 9_007_199_254_740_993 + ) + + status, _, call_body = post_json( + address, + request( + "tools/call", + params={ + "_meta": modern_meta(), + "name": "review_large_integer", + "arguments": {"value": REVIEW_EXACT_INTEGER}, + }, + ), + modern_headers("tools/call", name="review_large_integer"), + ) + + assert status == 200 + assert call_body is not None + assert call_body["result"]["structuredContent"] == { + "value": 9_007_199_254_740_993 + } + + +def test_review_http_preserves_exact_integer_in_elicitation_form_schema() -> None: + with running_http_server(MODERN_VERSION, profile=REVIEW_PROFILE) as address: + status, _, body = post_json( + address, + request( + "tools/call", + params={ + "_meta": modern_meta(capabilities={"elicitation": {"form": {}}}), + "name": "review_integer_elicitation", + "arguments": {}, + }, + ), + modern_headers("tools/call", name="review_integer_elicitation"), + ) + + assert status == 200 + assert body is not None + pending = body["result"]["inputRequests"]["large_integer"] + assert pending["params"]["requestedSchema"]["properties"]["value"] == { + "type": "integer", + "minimum": 9_007_199_254_740_993, + "maximum": 9_223_372_036_854_775_807, + "default": 9_007_199_254_740_993, + } + + +def test_review_http_returns_mismatched_discovery_response_id() -> None: + with running_http_server( + MODERN_VERSION, + profile=MISMATCHED_DISCOVERY_ID_PROFILE, + ) as address: + status, _, body = post_json( + address, + request("server/discover", request_id=42), + modern_headers("server/discover"), + ) + + assert status == 200 + assert body is not None + assert body["id"] == "review-mismatched-42" + + +def test_review_http_returns_null_discovery_response_id() -> None: + with running_http_server( + MODERN_VERSION, profile=NULL_DISCOVERY_ID_PROFILE + ) as address: + status, _, body = post_json( + address, + request("server/discover", request_id=42), + modern_headers("server/discover"), + ) + + assert status == 200 + assert body is not None + assert body["id"] is None + + +def test_review_http_streams_sse_with_carriage_returns_and_comments() -> None: + with running_http_server( + MODERN_VERSION, profile=SSE_CR_COMMENTS_PROFILE + ) as address: + message = request( + "subscriptions/listen", + request_id=77, + params={ + "_meta": modern_meta(), + "notifications": {"toolsListChanged": True}, + }, + ) + connection = http.client.HTTPConnection(*address, timeout=2) + connection.request( + "POST", + "/mcp", + body=json.dumps(message, separators=(",", ":")), + headers={ + "Content-Type": "application/json", + **modern_headers("subscriptions/listen"), + }, + ) + + response = connection.getresponse() + body = response.read() + connection.close() + + assert response.status == 200 + assert response.getheader("Content-Type") == "text/event-stream" + assert body.startswith(b": reviewer keepalive\rdata: ") + assert b"\n" not in body + events = [ + json.loads(line.removeprefix(b"data: ")) + for line in body.split(b"\r") + if line.startswith(b"data: ") + ] + assert [event.get("method") for event in events[:-1]] == [ + "notifications/subscriptions/acknowledged", + "notifications/tools/list_changed", + ] + assert events[-1]["id"] == 77 + assert events[-1]["result"]["resultType"] == "complete" + + +def test_review_http_streams_bounded_keepalive_flood_before_valid_sse_events() -> None: + with running_http_server( + MODERN_VERSION, profile=SSE_COMMENT_FLOOD_PROFILE + ) as address: + message = request( + "subscriptions/listen", + request_id=77, + params={ + "_meta": modern_meta(), + "notifications": {"toolsListChanged": True}, + }, + ) + connection = http.client.HTTPConnection(*address, timeout=10) + connection.request( + "POST", + "/mcp", + body=json.dumps(message, separators=(",", ":")), + headers={ + "Content-Type": "application/json", + **modern_headers("subscriptions/listen"), + }, + ) + + response = connection.getresponse() + body = response.read() + connection.close() + + assert response.status == 200 + assert response.getheader("Content-Type") == "text/event-stream" + assert response.getheader("Content-Length") == str(len(body)) + assert ( + REVIEW_SSE_EVENT_LIMIT_BYTES + < len(body) + < (REVIEW_SSE_EVENT_LIMIT_BYTES + REVIEW_SSE_COMMENT_LINE_BYTES + 4_096) + ) + assert body.count(b": reviewer keepalive ") == REVIEW_SSE_COMMENT_LINE_COUNT + assert b"\n" not in body + completed = json.loads(body.rsplit(b"data: ", 1)[1].removesuffix(b"\r\r")) + assert completed["id"] == 77 + assert completed["result"]["resultType"] == "complete" diff --git a/scripts/mock_responses_websocket_server.py b/scripts/mock_responses_websocket_server.py new file mode 100644 index 0000000000000000000000000000000000000000..f018bf673dde2369111092845296de0d700ced2e --- /dev/null +++ b/scripts/mock_responses_websocket_server.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +import argparse +import asyncio +import datetime as dt +import json +import sys +from typing import Any + +import websockets + + +HOST = "127.0.0.1" +DEFAULT_PORT = 8765 +PATH = "/v1/responses" + +CALL_ID = "exec-command-call" +FUNCTION_NAME = "exec_command" +FUNCTION_ARGS_JSON = json.dumps({"cmd": "echo websocket"}, separators=(",", ":")) + +ASSISTANT_TEXT = "done" + + +def _utc_iso() -> str: + return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds") + + +def _default_usage() -> dict[str, Any]: + return { + "input_tokens": 0, + "input_tokens_details": None, + "output_tokens": 0, + "output_tokens_details": None, + "total_tokens": 0, + } + + +def _event_response_created(response_id: str) -> dict[str, Any]: + return {"type": "response.created", "response": {"id": response_id}} + + +def _event_response_done() -> dict[str, Any]: + return {"type": "response.done", "response": {"usage": _default_usage()}} + + +def _event_response_completed(response_id: str) -> dict[str, Any]: + return { + "type": "response.completed", + "response": {"id": response_id, "usage": _default_usage()}, + } + + +def _event_function_call( + call_id: str, name: str, arguments_json: str +) -> dict[str, Any]: + return { + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments_json, + }, + } + + +def _event_assistant_message(message_id: str, text: str) -> dict[str, Any]: + return { + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": message_id, + "content": [{"type": "output_text", "text": text}], + }, + } + + +def _dump_json(payload: Any) -> str: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _print_request(prefix: str, payload: Any) -> None: + pretty = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + sys.stdout.write(f"{prefix} {_utc_iso()}\n{pretty}\n") + sys.stdout.flush() + + +async def _handle_connection( + websocket: Any, + *, + expected_path: str = PATH, +) -> None: + # websockets v15 exposes the request path here. + path = getattr(getattr(websocket, "request", None), "path", None) + if path is None: + # Older handler signatures could pass `path` separately; accept if unavailable. + path = "(unknown)" + + sys.stdout.write(f"[conn] {_utc_iso()} connected path={path}\n") + sys.stdout.flush() + + path_no_qs = path.split("?", 1)[0] if path != "(unknown)" else path + if path_no_qs != "(unknown)" and path_no_qs != expected_path: + sys.stdout.write( + f"[conn] {_utc_iso()} rejecting unexpected path (expected {expected_path})\n" + ) + sys.stdout.flush() + await websocket.close(code=1008, reason="unexpected websocket path") + return + + async def recv_json(label: str) -> Any: + msg = await websocket.recv() + if isinstance(msg, bytes): + payload = json.loads(msg.decode("utf-8")) + else: + payload = json.loads(msg) + _print_request(f"[{label}] recv", payload) + return payload + + async def send_event(ev: dict[str, Any]) -> None: + sys.stdout.write(f"[conn] {_utc_iso()} send {_dump_json(ev)}\n") + await websocket.send(_dump_json(ev)) + + # Request 1: provoke a function call (mirrors `codex-rs/core/tests/suite/agent_websocket.rs`). + await recv_json("req1") + await send_event(_event_response_created("resp-1")) + await send_event(_event_function_call(CALL_ID, FUNCTION_NAME, FUNCTION_ARGS_JSON)) + await send_event(_event_response_done()) + + # Request 2: expect appended tool output; send final assistant message. + await recv_json("req2") + await send_event(_event_response_created("resp-2")) + await send_event(_event_assistant_message("msg-1", ASSISTANT_TEXT)) + await send_event(_event_response_completed("resp-2")) + + sys.stdout.write(f"[conn] {_utc_iso()} closing\n") + sys.stdout.flush() + await websocket.close() + + +async def _serve(port: int) -> int: + async def handler(ws: Any) -> None: + try: + await _handle_connection(ws, expected_path=PATH) + except websockets.exceptions.ConnectionClosedOK: + return + + try: + server = await websockets.serve(handler, HOST, port) + except OSError as err: + sys.stderr.write(f"[server] failed to bind ws://{HOST}:{port}: {err}\n") + return 2 + bound_port = server.sockets[0].getsockname()[1] + ws_uri = f"ws://{HOST}:{bound_port}" + + sys.stdout.write("[server] mock Responses WebSocket server running\n") + sys.stdout.write(f"""Add this to your config.toml: + + +[model_providers.localapi_ws] +base_url = "{ws_uri}/v1" +name = "localapi_ws" +wire_api = "responses_websocket" +env_key = "OPENAI_API_KEY_STAGING" + +[profiles.localapi_ws] +model = "gpt-5.2" +model_provider = "localapi_ws" +model_reasoning_effort = "medium" + + +start codex with `codex --profile localapi_ws` +""") + sys.stdout.flush() + + try: + await asyncio.Future() + finally: + server.close() + await server.wait_closed() + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Mock a minimal Responses API WebSocket endpoint for the `test_codex` flow.\n" + f"Binds to {HOST}:{DEFAULT_PORT} by default and logs incoming JSON requests to stdout." + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Bind port (default: {DEFAULT_PORT}; use 0 for random free port).", + ) + args = parser.parse_args() + + try: + return asyncio.run(_serve(args.port)) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..4340b6b78e83971e0032a7c90852f02791d3acbf --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "codex-scripts" +version = "0.0.0" +requires-python = ">=3.10" +dependencies = ["ruff>=0.15.8"] + +[tool.uv] +exclude-newer = "7 days" +index-strategy = "first-index" diff --git a/scripts/readme_toc.py b/scripts/readme_toc.py new file mode 100644 index 0000000000000000000000000000000000000000..f37a69d7c714cd1bd8e4abb8695ca6ddb402ad8c --- /dev/null +++ b/scripts/readme_toc.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +""" +Utility script to verify (and optionally fix) the Table of Contents in a +Markdown file. By default, it checks that the ToC between `` +and `` matches the headings in the file. With --fix, it +rewrites the file to update the ToC. +""" + +import argparse +import sys +import re +import difflib +from pathlib import Path +from typing import List + +# Markers for the Table of Contents section +BEGIN_TOC: str = "" +END_TOC: str = "" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check and optionally fix the README.md Table of Contents." + ) + parser.add_argument( + "file", nargs="?", default="README.md", help="Markdown file to process" + ) + parser.add_argument( + "--fix", action="store_true", help="Rewrite file with updated ToC" + ) + args = parser.parse_args() + path = Path(args.file) + return check_or_fix(path, args.fix) + + +def generate_toc_lines(content: str) -> List[str]: + """ + Generate markdown list lines for headings (## to ######) in content. + """ + lines = content.splitlines() + headings = [] + in_code = False + for line in lines: + if line.strip().startswith("```"): + in_code = not in_code + continue + if in_code: + continue + m = re.match(r"^(#{2,6})\s+(.*)$", line) + if not m: + continue + level = len(m.group(1)) + text = m.group(2).strip() + headings.append((level, text)) + + toc = [] + for level, text in headings: + indent = " " * (level - 2) + slug = text.lower() + # normalize spaces and dashes + slug = slug.replace("\u00a0", " ") + slug = slug.replace("\u2011", "-").replace("\u2013", "-").replace("\u2014", "-") + # drop other punctuation + slug = re.sub(r"[^0-9a-z\s-]", "", slug) + slug = slug.strip().replace(" ", "-") + toc.append(f"{indent}- [{text}](#{slug})") + return toc + + +def check_or_fix(readme_path: Path, fix: bool) -> int: + if not readme_path.is_file(): + print(f"Error: file not found: {readme_path}", file=sys.stderr) + return 1 + content = readme_path.read_text(encoding="utf-8") + lines = content.splitlines() + # locate ToC markers + try: + begin_idx = next(i for i, l in enumerate(lines) if l.strip() == BEGIN_TOC) + end_idx = next(i for i, l in enumerate(lines) if l.strip() == END_TOC) + except StopIteration: + # No ToC markers found; treat as a no-op so repos without a ToC don't fail CI + print( + f"Note: Skipping ToC check; no markers found in {readme_path}.", + ) + return 0 + # extract current ToC list items + current_block = lines[begin_idx + 1 : end_idx] + current = [l for l in current_block if l.lstrip().startswith("- [")] + # generate expected ToC from content without current ToC + toc_content = lines[:begin_idx] + lines[end_idx + 1 :] + expected = generate_toc_lines("\n".join(toc_content)) + if current == expected: + return 0 + if not fix: + print( + "ERROR: README ToC is out of date. Diff between existing and generated ToC:" + ) + # Show full unified diff of current vs expected + diff = difflib.unified_diff( + current, + expected, + fromfile="existing ToC", + tofile="generated ToC", + lineterm="", + ) + for line in diff: + print(line) + return 1 + # rebuild file with updated ToC + prefix = lines[: begin_idx + 1] + suffix = lines[end_idx + 1 :] + new_lines = prefix + [""] + expected + [""] + suffix + readme_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + print(f"Updated ToC in {readme_path}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/start-codex-exec.sh b/scripts/start-codex-exec.sh new file mode 100644 index 0000000000000000000000000000000000000000..8835d4c09514d69cb389b1c9749a7bc7056d4e15 --- /dev/null +++ b/scripts/start-codex-exec.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 HOST [RSYNC_OPTION]..." >&2 +} + +if [[ $# -lt 1 ]]; then + usage + exit 2 +fi + +case "$1" in + -h|--help) + usage + exit 0 + ;; +esac + +remote_host="$1" +shift + +remote_path='~/code/codex-sync' +local_exec_server_port="${CODEX_REMOTE_EXEC_SERVER_LOCAL_PORT:-8765}" +remote_exec_server_start_timeout_seconds="${CODEX_REMOTE_EXEC_SERVER_START_TIMEOUT_SECONDS:-15}" + +remote_exec_server_pid='' +remote_exec_server_log_path='' +remote_exec_server_pid_path='' +remote_repo_root='' + +cleanup() { + local exit_code=$? + + trap - EXIT INT TERM + + if [[ -n "${remote_exec_server_pid_path}" ]]; then + ssh "${remote_host}" \ + "if [[ -f '${remote_exec_server_pid_path}' ]]; then kill \$(cat '${remote_exec_server_pid_path}') >/dev/null 2>&1 || true; fi; rm -f '${remote_exec_server_pid_path}' '${remote_exec_server_log_path}'" \ + >/dev/null 2>&1 || true + fi + + exit "${exit_code}" +} + +trap cleanup EXIT INT TERM + +if ! command -v git >/dev/null 2>&1; then + echo "git is required" >&2 + exit 1 +fi + +if ! command -v ssh >/dev/null 2>&1; then + echo "ssh is required" >&2 + exit 1 +fi + +if ! command -v rsync >/dev/null 2>&1; then + echo "local rsync is required" >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "run this script from inside a git repository" >&2 + exit 1 +} + +ssh "${remote_host}" "mkdir -p ${remote_path}" +ssh "${remote_host}" -C "sudo apt-get install rsync libcap-dev" + +sync_instance_id="$(date +%s)-$$" + +rsync \ + --archive \ + --compress \ + --human-readable \ + --itemize-changes \ + --exclude '.git/' \ + --exclude 'codex-rs/target/' \ + --filter=':- .gitignore' \ + "$@" \ + "${repo_root}/" \ + "${remote_host}:${remote_path}/" \ + >&2 + +remote_exec_server_log_path="/tmp/codex-exec-server-${sync_instance_id}.log" +remote_exec_server_pid_path="/tmp/codex-exec-server-${sync_instance_id}.pid" + +remote_start_output="$( + ssh "${remote_host}" bash -s -- \ + "${remote_exec_server_log_path}" \ + "${remote_exec_server_pid_path}" \ + "${remote_exec_server_start_timeout_seconds}" <<'EOF' +set -euo pipefail + +remote_exec_server_log_path="$1" +remote_exec_server_pid_path="$2" +remote_exec_server_start_timeout_seconds="$3" +remote_repo_root="$HOME/code/codex-sync" +remote_codex_rs="$remote_repo_root/codex-rs" + +cd "${remote_codex_rs}" +cargo build -p codex-cli --bin codex + +rm -f "${remote_exec_server_log_path}" "${remote_exec_server_pid_path}" +nohup ./target/debug/codex exec-server --listen ws://127.0.0.1:0 \ + >"${remote_exec_server_log_path}" 2>&1 & +remote_exec_server_pid="$!" +echo "${remote_exec_server_pid}" >"${remote_exec_server_pid_path}" + +deadline=$((SECONDS + remote_exec_server_start_timeout_seconds)) +while (( SECONDS < deadline )); do + if [[ -s "${remote_exec_server_log_path}" ]]; then + listen_url="$(head -n 1 "${remote_exec_server_log_path}" || true)" + if [[ "${listen_url}" == ws://* ]]; then + printf 'remote_exec_server_pid=%s\n' "${remote_exec_server_pid}" + printf 'remote_exec_server_log_path=%s\n' "${remote_exec_server_log_path}" + printf 'remote_repo_root=%s\n' "${remote_repo_root}" + printf 'listen_url=%s\n' "${listen_url}" + exit 0 + fi + fi + + if ! kill -0 "${remote_exec_server_pid}" >/dev/null 2>&1; then + cat "${remote_exec_server_log_path}" >&2 || true + echo "remote exec server exited before reporting a listen URL" >&2 + exit 1 + fi + + sleep 0.1 +done + +cat "${remote_exec_server_log_path}" >&2 || true +echo "timed out waiting for remote exec server listen URL" >&2 +exit 1 +EOF +)" + +listen_url='' +while IFS='=' read -r key value; do + case "${key}" in + remote_exec_server_pid) + remote_exec_server_pid="${value}" + ;; + remote_exec_server_log_path) + remote_exec_server_log_path="${value}" + ;; + remote_repo_root) + remote_repo_root="${value}" + ;; + listen_url) + listen_url="${value}" + ;; + esac +done <<< "${remote_start_output}" + +if [[ -z "${remote_exec_server_pid}" || -z "${listen_url}" || -z "${remote_repo_root}" ]]; then + echo "failed to parse remote exec server startup output" >&2 + exit 1 +fi + +remote_exec_server_port="${listen_url##*:}" +if [[ -z "${remote_exec_server_port}" || "${remote_exec_server_port}" == "${listen_url}" ]]; then + echo "failed to parse remote exec server port from ${listen_url}" >&2 + exit 1 +fi + +echo "Remote exec server: ${listen_url}" +echo "Remote exec server log: ${remote_exec_server_log_path}" +echo "Press Ctrl-C to stop the SSH tunnel and remote exec server." +echo "Start codex via: " +printf ' CODEX_EXEC_SERVER_URL=ws://127.0.0.1:%s codex -C %q\n' \ + "${local_exec_server_port}" \ + "${remote_repo_root}" + +ssh \ + -nNT \ + -o ControlMaster=no \ + -o ControlPath=none \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -L "${local_exec_server_port}:127.0.0.1:${remote_exec_server_port}" \ + "${remote_host}" diff --git a/scripts/workspace-status.cmd b/scripts/workspace-status.cmd new file mode 100644 index 0000000000000000000000000000000000000000..6a63292c785a70eafe56c4dadded9ff598fbfc08 --- /dev/null +++ b/scripts/workspace-status.cmd @@ -0,0 +1,20 @@ +@echo off +setlocal + +if defined STABLE_GIT_COMMIT ( + echo STABLE_GIT_COMMIT %STABLE_GIT_COMMIT% + exit /b 0 +) + +for /f "delims=" %%I in ('git rev-parse --verify HEAD 2^>nul') do set "BUILD_COMMIT=%%I" +if defined BUILD_COMMIT ( + echo STABLE_GIT_COMMIT %BUILD_COMMIT% + exit /b 0 +) + +if defined GITHUB_SHA ( + echo STABLE_GIT_COMMIT %GITHUB_SHA% + exit /b 0 +) + +echo STABLE_GIT_COMMIT unknown diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fccc92b011a23b3f9b9d0e09846f7e93d52dc996 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,75 @@ +# OpenAI Codex Python SDK + +Build Python applications that start Codex threads, run turns, stream progress, +and control workspace access. + +## Install + +Install the SDK: + +```bash +pip install openai-codex +``` + +## Quickstart + +The SDK reuses your existing Codex authentication when one is already +available: + +```python +from openai_codex import Codex + +with Codex() as codex: + thread = codex.thread_start() + result = thread.run("Explain this repository in three bullets.") + print(result.final_response) +``` + +`thread.run(...)` returns a `TurnResult` containing the final response, +collected items, and token usage. + +## Authentication + +Existing Codex authentication is reused automatically. To start ChatGPT +browser login explicitly: + +```python +from openai_codex import Codex + +with Codex() as codex: + login = codex.login_chatgpt() + print(login.auth_url) + print(login.wait().success) +``` + +For device-code login: + +```python +with Codex() as codex: + login = codex.login_chatgpt_device_code() + print(login.verification_url, login.user_code) + login.wait() +``` + +For API-key login: + +```python +with Codex() as codex: + codex.login_api_key("sk-...") +``` + +## Built-In Help + +Use Python's standard `help(openai_codex)`, `help(Codex)`, or +`python -m pydoc openai_codex` documentation tools. + +## Documentation + +- [Getting started](https://github.com/openai/codex/blob/main/sdk/python/docs/getting-started.md) +- [API reference](https://github.com/openai/codex/blob/main/sdk/python/docs/api-reference.md) +- [Untrusted external messages](https://github.com/openai/codex/blob/main/sdk/python/docs/api-reference.md#externalmessage) +- [FAQ](https://github.com/openai/codex/blob/main/sdk/python/docs/faq.md) +- [Examples](https://github.com/openai/codex/blob/main/sdk/python/examples/README.md) + +The package is licensed under the +[repository Apache License 2.0](https://github.com/openai/codex/blob/main/LICENSE). diff --git a/sdk/python/RELEASING.md b/sdk/python/RELEASING.md new file mode 100644 index 0000000000000000000000000000000000000000..0eba5e9fd4337d545328b8bbda0418df300716c9 --- /dev/null +++ b/sdk/python/RELEASING.md @@ -0,0 +1,38 @@ +# Python SDK releases + +Stable CLI releases trigger `python-sdk-cli-release.yml` after `rust-release` +finishes. Publication requires a successful `release` job and all required runtime +assets on the published stable release; unrelated publisher failures, such as +winget, do not block it. The downstream workflow checks that the release tag still +points to that run's commit, builds the SDK and runtime from that revision, then +publishes and verifies all runtime wheels before publishing and verifying the SDK. +For CLI version X, the SDK version and its exact runtime dependency are both X. +CLI prereleases do not trigger Python publication. Failures in this downstream +workflow do not block CLI completion or `latest-alpha-cli`. + +The downstream workflow must exist on the default branch before GitHub can trigger +it. The CLI release revision must contain the Python build scripts and generated +contracts. It never substitutes newer SDK sources from the default branch. + +To retry independently, rerun the failed downstream jobs, or dispatch +`python-sdk-cli-release.yml` with `cli_run_id` set to the Rust release's GitHub +Actions run ID. The resolver checks the effective release job across run attempts +and verifies the same tag, commit, and required assets again. Existing +PyPI uploads are accepted, and verification still requires the complete release. +Use a new version if already-published package contents need to change. + +Before enabling publication, configure both `openai-codex` and +`openai-codex-cli-bin` on PyPI to trust owner `openai`, repository `codex`, workflow +`python-sdk-cli-release.yml`, environment `pypi`. The GitHub environment must permit +this workflow on the default branch, which is the ref used by `workflow_run` and +manual dispatch even though the build checks out the release commit. These PyPI +and GitHub environment settings are managed outside the repository. + +Independent SDK releases remain available through `python-v*` tags and +`python-sdk-release.yml`. Stable SDK versions must match the checked-in runtime +pin; beta SDK versions such as `python-v0.1.0b1` can use an independently versioned +runtime. Retain the existing trusted-publisher entries for this manual workflow +and `python-runtime-release.yml`. + +See GitHub's [workflow_run documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run) +and PyPI's [trusted-publisher setup](https://docs.pypi.org/trusted-publishers/adding-a-publisher/). diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..e3988fb79503f6e846b43208fc7434f4dd14099e --- /dev/null +++ b/sdk/python/_runtime_setup.py @@ -0,0 +1,372 @@ +import importlib +import importlib.metadata +import importlib.util +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from pathlib import Path + +_SDK_PYTHON_ROOT = str(Path(__file__).resolve().parent) +if _SDK_PYTHON_ROOT not in sys.path: + sys.path.insert(0, _SDK_PYTHON_ROOT) + +from release_version import ( # noqa: E402 + codex_release_tag as _release_tag, + normalize_codex_version as _normalized_package_version, +) + +PACKAGE_NAME = "openai-codex-cli-bin" +SDK_PACKAGE_NAME = "openai-codex" +REPO_SLUG = "openai/codex" + + +class RuntimeSetupError(RuntimeError): + pass + + +def pinned_runtime_version() -> str: + """Return the exact runtime version pinned by the SDK package dependency.""" + source_pin = _source_tree_runtime_dependency_version() + if source_pin is not None: + return _normalized_package_version(source_pin) + + try: + installed_pin = _installed_sdk_runtime_dependency_version() + except importlib.metadata.PackageNotFoundError as exc: + raise RuntimeSetupError( + f"Unable to resolve {SDK_PACKAGE_NAME} metadata for runtime pinning." + ) from exc + if installed_pin is None: + raise RuntimeSetupError( + f"Unable to resolve {PACKAGE_NAME} dependency pin from {SDK_PACKAGE_NAME}." + ) + return _normalized_package_version(installed_pin) + + +def ensure_runtime_package_installed( + python_executable: str | Path, + sdk_python_dir: Path, + install_target: Path | None = None, +) -> str: + requested_version = pinned_runtime_version() + installed_version = None + if install_target is None: + installed_version = _installed_runtime_version(python_executable) + normalized_requested = _normalized_package_version(requested_version) + + if ( + installed_version is not None + and _normalized_package_version(installed_version) == normalized_requested + ): + return requested_version + + with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str: + temp_root = Path(temp_root_str) + archive_path = _download_release_archive(requested_version, temp_root) + staged_runtime_dir = _stage_runtime_package( + sdk_python_dir, + requested_version, + archive_path, + temp_root / "runtime-stage", + ) + _install_runtime_package(python_executable, staged_runtime_dir, install_target) + + if install_target is not None: + return requested_version + + if Path(python_executable).resolve() == Path(sys.executable).resolve(): + importlib.invalidate_caches() + + installed_version = _installed_runtime_version(python_executable) + if ( + installed_version is None + or _normalized_package_version(installed_version) != normalized_requested + ): + raise RuntimeSetupError( + f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, " + f"but found {installed_version!r} after installation." + ) + return requested_version + + +def platform_asset_name() -> str: + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + if machine in {"arm64", "aarch64"}: + return "codex-package-aarch64-apple-darwin.tar.gz" + if machine in {"x86_64", "amd64"}: + return "codex-package-x86_64-apple-darwin.tar.gz" + elif system == "linux": + if machine in {"aarch64", "arm64"}: + return "codex-package-aarch64-unknown-linux-musl.tar.gz" + if machine in {"x86_64", "amd64"}: + return "codex-package-x86_64-unknown-linux-musl.tar.gz" + elif system == "windows": + if machine in {"aarch64", "arm64"}: + return "codex-package-aarch64-pc-windows-msvc.tar.gz" + if machine in {"x86_64", "amd64"}: + return "codex-package-x86_64-pc-windows-msvc.tar.gz" + + raise RuntimeSetupError( + f"Unsupported runtime artifact platform: system={platform.system()!r}, " + f"machine={platform.machine()!r}" + ) + + +def _installed_runtime_version(python_executable: str | Path) -> str | None: + snippet = ( + "import importlib.metadata, json, sys\n" + "try:\n" + " from codex_cli_bin import bundled_codex_path\n" + " bundled_codex_path()\n" + f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n" + "except Exception:\n" + " sys.exit(1)\n" + ) + result = subprocess.run( + [str(python_executable), "-c", snippet], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout)["version"] + + +def _release_metadata(version: str) -> dict[str, object]: + release_tag = _release_tag(version) + url = f"https://api.github.com/repos/{REPO_SLUG}/releases/tags/{release_tag}" + token = _github_token() + attempts = [True, False] if token is not None else [False] + last_error: urllib.error.HTTPError | None = None + + for include_auth in attempts: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "codex-python-runtime-setup", + } + if include_auth and token is not None: + headers["Authorization"] = f"Bearer {token}" + + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + last_error = exc + if include_auth and exc.code == 401: + continue + break + + assert last_error is not None + raise RuntimeSetupError( + f"Failed to resolve release metadata for {release_tag} from {REPO_SLUG}: " + f"{last_error.code} {last_error.reason}" + ) from last_error + + +def _download_release_archive(version: str, temp_root: Path) -> Path: + asset_name = platform_asset_name() + archive_path = temp_root / asset_name + release_tag = _release_tag(version) + + browser_download_url = ( + f"https://github.com/{REPO_SLUG}/releases/download/{release_tag}/{asset_name}" + ) + request = urllib.request.Request( + browser_download_url, + headers={"User-Agent": "codex-python-runtime-setup"}, + ) + try: + with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + pass + + metadata = _release_metadata(version) + assets = metadata.get("assets") + if not isinstance(assets, list): + raise RuntimeSetupError(f"Release {release_tag} returned malformed assets metadata.") + asset = next( + (item for item in assets if isinstance(item, dict) and item.get("name") == asset_name), + None, + ) + if asset is None: + raise RuntimeSetupError( + f"Release {release_tag} does not contain asset {asset_name} for this platform." + ) + + api_url = asset.get("url") + if not isinstance(api_url, str): + api_url = None + + if api_url is not None: + token = _github_token() + if token is not None: + request = urllib.request.Request( + api_url, + headers=_github_api_headers("application/octet-stream"), + ) + try: + with ( + urllib.request.urlopen(request) as response, + archive_path.open("wb") as fh, + ): + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + pass + + if shutil.which("gh") is None: + raise RuntimeSetupError( + f"Unable to download {asset_name} for rust-v{version}. " + "Provide GH_TOKEN/GITHUB_TOKEN or install/authenticate GitHub CLI." + ) + + try: + subprocess.run( + [ + "gh", + "release", + "download", + release_tag, + "--repo", + REPO_SLUG, + "--pattern", + asset_name, + "--dir", + str(temp_root), + ], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeSetupError( + f"gh release download failed for {release_tag} asset {asset_name}.\n" + f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}" + ) from exc + return archive_path + + +def _stage_runtime_package( + sdk_python_dir: Path, + runtime_version: str, + runtime_package_archive: Path, + staging_dir: Path, +) -> Path: + script_module = _load_update_script_module(sdk_python_dir) + return script_module.stage_python_runtime_package( # type: ignore[no-any-return] + staging_dir, + runtime_version, + runtime_package_archive.resolve(), + ) + + +def _install_runtime_package( + python_executable: str | Path, + staged_runtime_dir: Path, + install_target: Path | None, +) -> None: + args = [ + str(python_executable), + "-m", + "pip", + "install", + "--force-reinstall", + "--no-deps", + ] + if install_target is not None: + install_target.mkdir(parents=True, exist_ok=True) + args.extend(["--target", str(install_target)]) + args.append(str(staged_runtime_dir)) + try: + subprocess.run( + args, + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeSetupError( + f"Failed to install {PACKAGE_NAME} into {python_executable} from {staged_runtime_dir}.\n" + f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}" + ) from exc + + +def _load_update_script_module(sdk_python_dir: Path): + script_path = sdk_python_dir / "scripts" / "update_sdk_artifacts.py" + spec = importlib.util.spec_from_file_location("update_sdk_artifacts", script_path) + if spec is None or spec.loader is None: + raise RuntimeSetupError(f"Failed to load {script_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _github_api_headers(accept: str) -> dict[str, str]: + headers = { + "Accept": accept, + "User-Agent": "codex-python-runtime-setup", + } + token = _github_token() + if token is not None: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _github_token() -> str | None: + for env_name in ("GH_TOKEN", "GITHUB_TOKEN"): + token = os.environ.get(env_name) + if token: + return token + return None + + +def _source_tree_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin when the SDK is running from a checkout.""" + pyproject_path = Path(__file__).resolve().parent / "pyproject.toml" + if not pyproject_path.exists(): + return None + + match = re.search(_runtime_dependency_pin_pattern(), pyproject_path.read_text()) + if match is None: + return None + return match.group(1) + + +def _installed_sdk_runtime_dependency_version() -> str | None: + """Read the runtime dependency pin from installed package metadata.""" + requirements = importlib.metadata.requires(SDK_PACKAGE_NAME) or [] + for requirement in requirements: + match = re.search(_runtime_dependency_pin_pattern(), requirement) + if match is not None: + return match.group(1) + return None + + +def _runtime_dependency_pin_pattern() -> str: + """Match the exact runtime dependency pin in TOML and wheel metadata.""" + return rf'{re.escape(PACKAGE_NAME)}\s*==\s*"?([^",;\s]+)"?' + + +__all__ = [ + "PACKAGE_NAME", + "SDK_PACKAGE_NAME", + "RuntimeSetupError", + "ensure_runtime_package_installed", + "pinned_runtime_version", + "platform_asset_name", +] diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..1e08cd3ce6310389c18931263317ccfbb4bc1f9e --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +requires = ["uv_build>=0.11.19,<0.12"] +build-backend = "uv_build" + +[project] +name = "openai-codex" +version = "0.0.0-dev" +description = "Python SDK for Codex" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [{ name = "OpenAI" }] +keywords = ["codex", "sdk", "llm", "ai", "agents"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["pydantic>=2.12", "packaging>=26.2", "openai-codex-cli-bin==0.153.4"] + +[project.urls] +Homepage = "https://github.com/openai/codex" +Repository = "https://github.com/openai/codex" +Issues = "https://github.com/openai/codex/issues" +Documentation = "https://github.com/openai/codex/tree/main/sdk/python/docs" + +[dependency-groups] +format = ["ruff>=0.15.8"] +test = [ + "pytest>=8.0", + "datamodel-code-generator==0.31.2", + { include-group = "format" }, +] +dev = [ + { include-group = "test" }, + { include-group = "format" }, +] + +[tool.codex.codegen] +schema-dir = "../../codex-rs/app-server-protocol/schema/json" + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +required-version = ">=0.15.8" +line-length = 100 +extend-exclude = [ + "notebooks/**", + "src/openai_codex/generated/**", +] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "C4"] +ignore = ["E501"] +preview = true +extend-safe-fixes = ["ALL"] +unfixable = ["F841"] + +[tool.ruff.lint.per-file-ignores] +"examples/**/*.py" = ["E402"] +"tests/test_real_app_server_integration.py" = ["E402"] + +[tool.ruff.lint.isort] +combine-as-imports = true + +[tool.uv] +exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-09-09T06:06:45Z" } +index-strategy = "first-index" + +[tool.uv.pip] +exclude-newer = "7 days" +exclude-newer-package = { openai-codex-cli-bin = "2026-09-09T06:06:45Z" } +index-strategy = "first-index" diff --git a/sdk/python/release_version.py b/sdk/python/release_version.py new file mode 100644 index 0000000000000000000000000000000000000000..86345550f368a61c256011a1782d48c85e901215 --- /dev/null +++ b/sdk/python/release_version.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +import argparse +import re +import sys +from pathlib import Path +from typing import Sequence + +_PYTHON_RUNTIME_VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:a[0-9]+(?:\.post[0-9]+)?)?") +_NORMALIZED_CODEX_VERSION_PATTERN = re.compile( + r"[0-9]+(?:\.[0-9]+)*(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?" +) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Resolve a Python runtime version or Codex release tag to both versions." + ) + parser.add_argument("python_version") + parser.add_argument("--github-output", type=Path, required=True) + args = parser.parse_args(argv) + + try: + python_version, release_tag = resolve_python_runtime_release( + normalize_codex_version(args.python_version) + ) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + + with args.github_output.open("a", encoding="utf-8") as output: + print(f"python_version={python_version}", file=output) + print(f"release_tag={release_tag}", file=output) + return 0 + + +def resolve_python_runtime_release(python_version: str) -> tuple[str, str]: + if _PYTHON_RUNTIME_VERSION_PATTERN.fullmatch(python_version) is None: + raise RuntimeError( + "Python runtime version must be stable, a numbered alpha, or an " + "alpha post-release, for example 0.136.0, 0.136.0a2, or " + f"0.136.0a2.post1; found {python_version}" + ) + return python_version, codex_release_tag(python_version) + + +def codex_release_tag(version: str) -> str: + return f"rust-v{codex_release_version(version)}" + + +def codex_release_version(version: str) -> str: + normalized = normalize_codex_version(version) + alpha_hotfix = re.fullmatch( + r"([0-9]+(?:\.[0-9]+)*)a([0-9]+)\.post([0-9]+)", + normalized, + ) + if alpha_hotfix is not None: + base, alpha, hotfix = alpha_hotfix.groups() + return f"{base}-alpha.{alpha}.{hotfix}" + + prerelease = re.fullmatch(r"([0-9]+(?:\.[0-9]+)*)(a|b|rc)([0-9]+)", normalized) + if prerelease is None: + return normalized + + base, prerelease_kind, number = prerelease.groups() + prerelease_name = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease_kind] + return f"{base}-{prerelease_name}.{number}" + + +def normalize_codex_version(version: str) -> str: + normalized = version.strip() + if normalized.startswith("rust-v"): + normalized = normalized.removeprefix("rust-v") + elif normalized.startswith("v"): + normalized = normalized.removeprefix("v") + + normalized = re.sub(r"-alpha\.?([0-9]+)\.([0-9]+)$", r"a\1.post\2", normalized) + normalized = re.sub(r"-alpha\.?([0-9]+)$", r"a\1", normalized) + normalized = re.sub(r"-beta\.?([0-9]+)$", r"b\1", normalized) + normalized = re.sub(r"-rc\.?([0-9]+)$", r"rc\1", normalized) + + if _NORMALIZED_CODEX_VERSION_PATTERN.fullmatch(normalized) is None: + raise RuntimeError(f"Could not normalize Codex version {version!r} to a PEP 440 version") + return normalized + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..f2727dc1620c12367a86a0553250fefeb1afa63f --- /dev/null +++ b/sdk/python/uv.lock @@ -0,0 +1,750 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +openai-codex-cli-bin = "2026-09-09T06:06:45Z" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "datamodel-code-generator" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/40/26399980314bedb3a8ec6a31da646d190cd945192410ad3ab6eabe0a1c73/datamodel_code_generator-0.31.2.tar.gz", hash = "sha256:47887b8aa6fd69865e07e2893c1e76e34dae753b9a97f1020357af8337bc4cdb", size = 453381, upload-time = "2025-06-22T17:40:56.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/82/9bda726b117967b8056315e51125633ec7a0d2f862c202aed01c3fde2e9f/datamodel_code_generator-0.31.2-py3-none-any.whl", hash = "sha256:78f200a9e673ee4d041e96e82a835273baf4ce15e446d46501c0433d82af3ef5", size = 119369, upload-time = "2025-06-22T17:40:54.615Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "openai-codex" +version = "0.0.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "packaging" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "datamodel-code-generator" }, + { name = "pytest" }, + { name = "ruff" }, +] +format = [ + { name = "ruff" }, +] +test = [ + { name = "datamodel-code-generator" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "openai-codex-cli-bin", specifier = "==0.153.4" }, + { name = "packaging", specifier = ">=26.2" }, + { name = "pydantic", specifier = ">=2.12" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "datamodel-code-generator", specifier = "==0.31.2" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.8" }, +] +format = [{ name = "ruff", specifier = ">=0.15.8" }] +test = [ + { name = "datamodel-code-generator", specifier = "==0.31.2" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.15.8" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.153.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/16/b28109968c0912956a52f41eb022605c6eaba2d4793b5e9f4b9479c954e2/openai_codex_cli_bin-0.153.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:637bdf17d6387e9ae12e1094af6aa22dabc2ba55c100ee0310178e79427b2e69", size = 121719355, upload-time = "2026-09-09T06:06:11.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/69/7dd4cb9135ac893985c92ecc03dc3df8408917d469fb3df6650af2fde328/openai_codex_cli_bin-0.153.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08c80523fd3674e7d6f36bccc69557f9bac257cbd7ad937309dad0457e1a7845", size = 111925786, upload-time = "2026-09-09T06:06:16.767Z" }, + { url = "https://files.pythonhosted.org/packages/16/e7/57e5dce0b13c0643cd20f308d9e727a44d542bca38f958f038204f959687/openai_codex_cli_bin-0.153.4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:f92f0aff384258f5bac3519cd9ad2639be08239004452d0e4b00725e5fe62bba", size = 117599105, upload-time = "2026-09-09T06:06:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/938bfc0cfdaa06881d904b1c9a3151c1252e278d00fe1afcbb954cee65c4/openai_codex_cli_bin-0.153.4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:584ecdbd81b01b3002ecbdddfa009dc8d7bfdbed9c208a0fa8c131a2e337bba6", size = 127000985, upload-time = "2026-09-09T06:06:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/59/e8/9743befc32fa292d3682075c15e056044652dae1bab9581a9e9ee0cd352b/openai_codex_cli_bin-0.153.4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:fef1cc54d7408da08f9bfa6179c7fad4231591c29f76eb339d0a59e540b3e718", size = 117599103, upload-time = "2026-09-09T06:06:30.967Z" }, + { url = "https://files.pythonhosted.org/packages/06/d2/ded14722031d8dda9c796ab8d39f8a29cbae27d28bf2b1a0b12c2e429c6f/openai_codex_cli_bin-0.153.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0f97697f39fd9fd32fbf9caa29df8ff0a6cfe33755f4f007bc97ee4cb88a7743", size = 127000983, upload-time = "2026-09-09T06:06:35.565Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/afec4ffe8a3e3f2b5fa7ef6d892b830f63ee8aaa5c222b23652329bc3e4e/openai_codex_cli_bin-0.153.4-py3-none-win_amd64.whl", hash = "sha256:9b52da6531617a363a165c246aded59a1a61e82fac623ab97e095cc37a5038c5", size = 136934929, upload-time = "2026-09-09T06:06:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d3/e8de747460d8d2934dce84867ac8227d4d2d4b31f111e881076f97525a72/openai_codex_cli_bin-0.153.4-py3-none-win_arm64.whl", hash = "sha256:23240c7041a8ef5f22297c3fead21da0143dcec2a186d9b64f386f52b396da47", size = 126478600, upload-time = "2026-09-09T06:06:44.431Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/sdk/typescript/.prettierignore b/sdk/typescript/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..007ea8a7ceb5d1545a77b354c04a9b556cd7a5d0 --- /dev/null +++ b/sdk/typescript/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +coverage diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6ac29c3b480f5e2ac347ab4defc46f0b5c5c0330 --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,160 @@ +# Codex SDK + +Embed the Codex agent in your workflows and apps. + +The TypeScript SDK wraps the `codex` CLI from `@openai/codex`. It spawns the CLI and exchanges JSONL events over stdin/stdout. + +## Installation + +```bash +npm install @openai/codex-sdk +``` + +Requires Node.js 18+. + +## Quickstart + +```typescript +import { Codex } from "@openai/codex-sdk"; + +const codex = new Codex(); +const thread = codex.startThread(); +const turn = await thread.run("Diagnose the test failure and propose a fix"); + +console.log(turn.finalResponse); +console.log(turn.items); +``` + +Call `run()` repeatedly on the same `Thread` instance to continue that conversation. + +```typescript +const nextTurn = await thread.run("Implement the fix"); +``` + +### Streaming responses + +`run()` buffers events until the turn finishes. To react to intermediate progress—tool calls, streaming responses, and file change notifications—use `runStreamed()` instead, which returns an async generator of structured events. + +```typescript +const { events } = await thread.runStreamed("Diagnose the test failure and propose a fix"); + +for await (const event of events) { + switch (event.type) { + case "item.completed": + console.log("item", event.item); + break; + case "turn.completed": + console.log("usage", event.usage); + break; + } +} +``` + +### Structured output + +The Codex agent can produce a JSON response that conforms to a specified schema. The schema can be provided for each turn as a plain JSON object. + +```typescript +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] }, + }, + required: ["summary", "status"], + additionalProperties: false, +} as const; + +const turn = await thread.run("Summarize repository status", { outputSchema: schema }); +console.log(turn.finalResponse); +``` + +You can also create a JSON schema from a [Zod schema](https://github.com/colinhacks/zod) using the [`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package and setting the `target` to `"openAi"`. + +```typescript +const schema = z.object({ + summary: z.string(), + status: z.enum(["ok", "action_required"]), +}); + +const turn = await thread.run("Summarize repository status", { + outputSchema: zodToJsonSchema(schema, { target: "openAi" }), +}); +console.log(turn.finalResponse); +``` + +### Attaching images + +Provide structured input entries when you need to include images alongside text. Text entries are concatenated into the final prompt while image entries are passed to the Codex CLI via `--image`. + +```typescript +const turn = await thread.run([ + { type: "text", text: "Describe these screenshots" }, + { type: "local_image", path: "./ui.png" }, + { type: "local_image", path: "./diagram.jpg" }, +]); +``` + +### Resuming an existing thread + +Threads are persisted in `~/.codex/sessions`. If you lose the in-memory `Thread` object, reconstruct it with `resumeThread()` and keep going. + +```typescript +const savedThreadId = process.env.CODEX_THREAD_ID!; +const thread = codex.resumeThread(savedThreadId); +await thread.run("Implement the fix"); +``` + +### Working directory controls + +Codex runs in the current working directory by default. To avoid unrecoverable errors, Codex requires the working directory to be a Git repository. You can skip the Git repository check by passing the `skipGitRepoCheck` option when creating a thread. + +```typescript +const thread = codex.startThread({ + workingDirectory: "/path/to/project", + skipGitRepoCheck: true, +}); +``` + +### Controlling the Codex CLI environment + +By default, the Codex CLI inherits the Node.js process environment. Provide the optional `env` parameter when instantiating the +`Codex` client to fully control which variables the CLI receives—useful for sandboxed hosts like Electron apps. + +```typescript +const codex = new Codex({ + env: { + PATH: "/usr/local/bin", + }, +}); +``` + +The SDK still injects its required variables (such as `CODEX_API_KEY`) on top of the environment you provide. If you set +`baseUrl`, the SDK passes it as a `--config openai_base_url=...` override. + +### Passing `--config` overrides + +Use the `config` option to provide additional Codex CLI configuration overrides. The SDK accepts a JSON object, flattens it +into dotted paths, and serializes values as TOML literals before passing them as repeated `--config key=value` flags. + +```typescript +const codex = new Codex({ + config: { + show_raw_agent_reasoning: true, + sandbox_workspace_write: { network_access: true }, + }, +}); +``` + +For configuration keys that cannot be expressed as dotted paths, pass raw TOML overrides with `configOverrides`. Each entry +is forwarded unchanged as a separate `--config` argument, without modifying `CODEX_HOME`: + +```typescript +const codex = new Codex({ + config: { default_permissions: "audit" }, + configOverrides: ['permissions.audit.filesystem={":root"="read","/path/to/project/.env"="deny"}'], +}); +``` + +Raw overrides are applied after structured `config` overrides and take precedence over them. SDK-managed settings, such as +`baseUrl`, and thread-specific options are applied afterward and take precedence. diff --git a/sdk/typescript/jest.config.cjs b/sdk/typescript/jest.config.cjs new file mode 100644 index 0000000000000000000000000000000000000000..37f03d51dba2cb71f9258504fff8829ab60a287d --- /dev/null +++ b/sdk/typescript/jest.config.cjs @@ -0,0 +1,32 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: "ts-jest/presets/default-esm", + testEnvironment: "node", + extensionsToTreatAsEsm: [".ts"], + setupFilesAfterEnv: ["/tests/setupCodexHome.ts"], + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + testMatch: ["**/tests/**/*.test.ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: "tsconfig.json", + diagnostics: { + ignoreCodes: [1343], + }, + astTransformers: { + before: [ + { + path: "ts-jest-mock-import-meta", + // Workaround for meta.url not working in jest + options: { metaObjectReplacement: { url: "file://" + __dirname + "/dist/index.js" } }, + }, + ], + }, + }, + ], + }, +};