From 82a341e0586581d88b84129a65008299e69c0a59 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:15:50 -0700 Subject: [PATCH 1/7] fix: update PyO3 security dependencies Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- Cargo.lock | 43 ++++++++++++------------------- bindings/python/Cargo.toml | 8 +++--- crates/amplifier-core/Cargo.toml | 2 +- pyproject.toml | 2 +- python/amplifier_core/__init__.py | 2 +- uv.lock | 2 +- 6 files changed, 24 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 505ed7e..ec2e1ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] name = "amplifier-core" -version = "2.0.0" +version = "2.0.1" dependencies = [ "base64", "chrono", @@ -77,7 +77,7 @@ dependencies = [ [[package]] name = "amplifier-core-py" -version = "2.0.0" +version = "2.0.1" dependencies = [ "amplifier-core", "log", @@ -1787,9 +1787,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.28.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf85e27e86080aafd5a22eae58a162e133a589551542b3e5cee4beb27e54f8e1" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "inventory", "libc", @@ -1802,9 +1802,9 @@ dependencies = [ [[package]] name = "pyo3-async-runtimes" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e7364a95bf00e8377bbf9b0f09d7ff9715a29d8fcf93b47d1a967363b973178" +checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046" dependencies = [ "futures-channel", "futures-util", @@ -1816,19 +1816,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.28.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ - "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.28.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491aa5fc66d8059dd44a75f4580a2962c1862a1c2945359db36f6c2818b748dc" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -1836,9 +1835,9 @@ dependencies = [ [[package]] name = "pyo3-log" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c2ec80932c5c3b2d4fbc578c9b56b2d4502098587edb8bef5b6bfcad43682e" +checksum = "f64083bd3a16a353d9d62335808e8e13d0552d2a2b83fdb084496192dcfa9fcd" dependencies = [ "arc-swap", "log", @@ -1847,9 +1846,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.28.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d671734e9d7a43449f8480f8b38115df67bef8d21f76837fa75ee7aaa5e52e" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -1859,26 +1858,16 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.28.2" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22faaa1ce6c430a1f71658760497291065e6450d7b5dc2bcf254d49f66ee700a" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", - "pyo3-build-config", "quote", "syn", ] -[[package]] -name = "python3-dll-a" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d381ef313ae70b4da5f95f8a4de773c6aa5cd28f73adec4b4a31df70b66780d8" -dependencies = [ - "cc", -] - [[package]] name = "quote" version = "1.0.44" diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 9de970b..27e2060 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core-py" -version = "2.0.0" +version = "2.0.1" edition = "2021" description = "PyO3 bridge for amplifier-core Rust kernel" license = "MIT" @@ -16,9 +16,9 @@ wasm = ["amplifier-core/wasm"] [dependencies] amplifier-core = { path = "../../crates/amplifier-core" } -pyo3 = { version = "0.28.2", features = ["generate-import-lib", "multiple-pymethods", "abi3-py311"] } -pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } -pyo3-log = "0.13" +pyo3 = { version = "0.29.2", features = ["generate-import-lib", "multiple-pymethods", "abi3-py311"] } +pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } +pyo3-log = "0.13.4" log = "0.4" prost = "0.13" serde_json = "1" diff --git a/crates/amplifier-core/Cargo.toml b/crates/amplifier-core/Cargo.toml index f959282..9f0179d 100644 --- a/crates/amplifier-core/Cargo.toml +++ b/crates/amplifier-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core" -version = "2.0.0" +version = "2.0.1" edition = "2021" description = "Pure Rust kernel for the Amplifier modular AI agent system" license = "MIT" diff --git a/pyproject.toml b/pyproject.toml index 5269da7..32c49db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amplifier-core" -version = "2.0.0" +version = "2.0.1" description = "Rust kernel with Python bindings for the Amplifier modular AI agent framework" license = "MIT" readme = "README.md" diff --git a/python/amplifier_core/__init__.py b/python/amplifier_core/__init__.py index 3727942..f2f7619 100644 --- a/python/amplifier_core/__init__.py +++ b/python/amplifier_core/__init__.py @@ -6,7 +6,7 @@ AmplifierSession`) still give the pure-Python implementations. """ -__version__ = "2.0.0" +__version__ = "2.0.1" # --- Rust-backed primary types (THE SWITCHOVER) --- # These four were previously imported from their Python submodules. diff --git a/uv.lock b/uv.lock index 1f55b0f..fa26c1e 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "click" }, From 0a4efc081d7ce8e5dbbde7150b9be27ce9c89674 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:15:57 -0700 Subject: [PATCH 2/7] ci: qualify native security-release artifacts Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .github/workflows/proto-check.yml | 6 +- .github/workflows/rust-core-ci.yml | 10 +- .github/workflows/rust-core-wheels.yml | 289 +++++++- README.md | 36 +- docs/NATIVE_ARTIFACTS.md | 117 ++++ scripts/qualify_native_wheel.py | 811 +++++++++++++++++++++++ tests/test_ci_workflows.py | 53 +- tests/test_native_wheel_qualification.py | 354 ++++++++++ 8 files changed, 1637 insertions(+), 39 deletions(-) create mode 100644 docs/NATIVE_ARTIFACTS.md create mode 100644 scripts/qualify_native_wheel.py create mode 100644 tests/test_native_wheel_qualification.py diff --git a/.github/workflows/proto-check.yml b/.github/workflows/proto-check.yml index 5f9bcfe..3f5880f 100644 --- a/.github/workflows/proto-check.yml +++ b/.github/workflows/proto-check.yml @@ -27,7 +27,7 @@ jobs: env: PROTO_CHECK_DIR: /tmp/proto_check steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install protoc run: | @@ -35,7 +35,7 @@ jobs: sudo apt-get install -y protobuf-compiler - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' @@ -94,7 +94,7 @@ jobs: name: Rust build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable diff --git a/.github/workflows/rust-core-ci.yml b/.github/workflows/rust-core-ci.yml index 7a4e572..6070008 100644 --- a/.github/workflows/rust-core-ci.yml +++ b/.github/workflows/rust-core-ci.yml @@ -11,7 +11,7 @@ jobs: name: Rust Kernel Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable @@ -30,12 +30,12 @@ jobs: name: Node.js Binding Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - name: Build native module @@ -56,12 +56,12 @@ jobs: matrix: python-version: ['3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: toolchain: stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Create venv and build diff --git a/.github/workflows/rust-core-wheels.yml b/.github/workflows/rust-core-wheels.yml index 9e028ea..a9acb5f 100644 --- a/.github/workflows/rust-core-wheels.yml +++ b/.github/workflows/rust-core-wheels.yml @@ -12,97 +12,313 @@ jobs: timeout-minutes: 60 strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-24.04 + artifact: wheels-ubuntu-latest + target: linux-x64 + architecture: x64 + - os: macos-15 + artifact: wheels-macos-latest + target: macos-arm64 + architecture: arm64 + - os: windows-2025 + artifact: wheels-windows-latest + target: windows-x64 + architecture: x64 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' + architecture: ${{ matrix.architecture }} + check-latest: false + freethreaded: false - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: - args: --profile dist --out dist --interpreter python3.11 + args: --locked --profile dist --out dist --interpreter python3.11 manylinux: auto + - name: Record wheel provenance + run: python scripts/qualify_native_wheel.py record --wheel-dir dist --output dist/build.json --source-sha "${{ github.sha }}" --target "${{ matrix.target }}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: wheels-${{ matrix.os }} - path: dist/*.whl + name: ${{ matrix.artifact }} + path: | + dist/*.whl + dist/build.json build-linux-aarch64: name: Build wheels (Linux aarch64) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm timeout-minutes: 60 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' + architecture: arm64 + check-latest: false + freethreaded: false - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: aarch64-unknown-linux-gnu - args: --profile dist --out dist --interpreter python3.11 + args: --locked --profile dist --out dist --interpreter python3.11 manylinux: auto + - name: Record wheel provenance + run: python scripts/qualify_native_wheel.py record --wheel-dir dist --output dist/build.json --source-sha "${{ github.sha }}" --target linux-arm64 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-linux-aarch64 - path: dist/*.whl + path: | + dist/*.whl + dist/build.json build-macos-x86_64: name: Build wheels (macOS x86_64) - runs-on: macos-latest + runs-on: macos-15-intel timeout-minutes: 60 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' + architecture: x64 + check-latest: false + freethreaded: false - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: x86_64-apple-darwin - args: --profile dist --out dist + args: --locked --profile dist --out dist --interpreter python3.11 + - name: Record wheel provenance + run: python scripts/qualify_native_wheel.py record --wheel-dir dist --output dist/build.json --source-sha "${{ github.sha }}" --target macos-x64 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-macos-x86_64 - path: dist/*.whl + path: | + dist/*.whl + dist/build.json build-windows-arm64: name: Build wheels (Windows arm64) - runs-on: windows-latest + runs-on: windows-11-arm timeout-minutes: 60 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: ". -> target" cache-on-failure: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' + architecture: arm64 + check-latest: false + freethreaded: false - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 with: target: aarch64-pc-windows-msvc - args: --profile dist --out dist + args: --locked --profile dist --out dist --interpreter python3.11 + - name: Record wheel provenance + run: python scripts/qualify_native_wheel.py record --wheel-dir dist --output dist/build.json --source-sha "${{ github.sha }}" --target windows-arm64 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: wheels-windows-arm64 - path: dist/*.whl + path: | + dist/*.whl + dist/build.json + + qualify-wheels: + name: Qualify ${{ matrix.artifact }} on Python ${{ matrix.python-version }} + needs: [build-wheels, build-linux-aarch64, build-macos-x86_64, build-windows-arm64] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { artifact: wheels-ubuntu-latest, os: ubuntu-24.04, architecture: x64, target: linux-x64, python-version: '3.11' } + - { artifact: wheels-ubuntu-latest, os: ubuntu-24.04, architecture: x64, target: linux-x64, python-version: '3.12' } + - { artifact: wheels-ubuntu-latest, os: ubuntu-24.04, architecture: x64, target: linux-x64, python-version: '3.13' } + - { artifact: wheels-linux-aarch64, os: ubuntu-24.04-arm, architecture: arm64, target: linux-arm64, python-version: '3.11' } + - { artifact: wheels-linux-aarch64, os: ubuntu-24.04-arm, architecture: arm64, target: linux-arm64, python-version: '3.12' } + - { artifact: wheels-linux-aarch64, os: ubuntu-24.04-arm, architecture: arm64, target: linux-arm64, python-version: '3.13' } + - { artifact: wheels-macos-latest, os: macos-15, architecture: arm64, target: macos-arm64, python-version: '3.11' } + - { artifact: wheels-macos-latest, os: macos-15, architecture: arm64, target: macos-arm64, python-version: '3.12' } + - { artifact: wheels-macos-latest, os: macos-15, architecture: arm64, target: macos-arm64, python-version: '3.13' } + - { artifact: wheels-macos-x86_64, os: macos-15-intel, architecture: x64, target: macos-x64, python-version: '3.11' } + - { artifact: wheels-macos-x86_64, os: macos-15-intel, architecture: x64, target: macos-x64, python-version: '3.12' } + - { artifact: wheels-macos-x86_64, os: macos-15-intel, architecture: x64, target: macos-x64, python-version: '3.13' } + - { artifact: wheels-windows-latest, os: windows-2025, architecture: x64, target: windows-x64, python-version: '3.11' } + - { artifact: wheels-windows-latest, os: windows-2025, architecture: x64, target: windows-x64, python-version: '3.12' } + - { artifact: wheels-windows-latest, os: windows-2025, architecture: x64, target: windows-x64, python-version: '3.13' } + - { artifact: wheels-windows-arm64, os: windows-11-arm, architecture: arm64, target: windows-arm64, python-version: '3.11' } + - { artifact: wheels-windows-arm64, os: windows-11-arm, architecture: arm64, target: windows-arm64, python-version: '3.12' } + - { artifact: wheels-windows-arm64, os: windows-11-arm, architecture: arm64, target: windows-arm64, python-version: '3.13' } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} + architecture: ${{ matrix.architecture }} + check-latest: false + freethreaded: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.artifact }} + path: artifacts + - name: Create fresh virtual environment, install wheel, and verify + shell: bash + run: | + python - <<'PY' + import os + import subprocess + import sys + import tempfile + from pathlib import Path + + venv_dir = Path(tempfile.mkdtemp( + prefix="amplifier-core-qualification-", + dir=os.environ["RUNNER_TEMP"], + )) + subprocess.check_call([sys.executable, "-m", "venv", str(venv_dir)]) + venv_python = venv_dir / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + wheels = list(Path("artifacts").glob("*.whl")) + if len(wheels) != 1: + raise SystemExit(f"expected one downloaded wheel, found {len(wheels)}") + subprocess.check_call([str(venv_python), "-m", "pip", "install", str(wheels[0])]) + subprocess.check_call([ + str(venv_python), "-I", str(Path.cwd() / "scripts/qualify_native_wheel.py"), "verify", + "--artifact-dir", "artifacts", + "--source-sha", "${{ github.sha }}", + "--output", "qualification-report.json", + ]) + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: qualification-${{ matrix.artifact }}-py${{ matrix.python-version }} + path: qualification-report.json + + release-evidence: + name: Validate and preserve release evidence + runs-on: ubuntu-24.04 + needs: [build-wheels, build-linux-aarch64, build-macos-x86_64, build-windows-arm64, qualify-wheels] + permissions: + contents: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: 'wheels-*' + path: evidence/wheels + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: 'qualification-*' + path: evidence/qualification + - name: Validate evidence and create archive + run: | + python - <<'PY' + import hashlib + import json + import os + import shutil + from pathlib import Path + + source_sha = os.environ["GITHUB_SHA"] + wheel_artifacts = { + "wheels-ubuntu-latest", "wheels-linux-aarch64", "wheels-macos-latest", + "wheels-macos-x86_64", "wheels-windows-latest", "wheels-windows-arm64", + } + versions = ("3.11", "3.12", "3.13") + expected_reports = { + f"qualification-{artifact}-py{version}" + for artifact in wheel_artifacts for version in versions + } + wheels_root = Path("evidence/wheels") + reports_root = Path("evidence/qualification") + if {path.name for path in wheels_root.iterdir()} != wheel_artifacts: + raise SystemExit("wheel receipt artifact set is not exactly the six build targets") + if {path.name for path in reports_root.iterdir()} != expected_reports: + raise SystemExit("qualification artifact set is not exactly the 18 required cells") + receipts = {} + checksums = [] + for artifact in wheel_artifacts: + artifact_dir = wheels_root / artifact + wheels = list(artifact_dir.glob("*.whl")) + receipts_files = list(artifact_dir.glob("build.json")) + if len(wheels) != 1 or len(receipts_files) != 1: + raise SystemExit(f"{artifact} must preserve one wheel and one build receipt") + receipt = json.loads(receipts_files[0].read_text()) + if receipt.get("source_sha") != source_sha: + raise SystemExit(f"{artifact} receipt source SHA mismatch") + wheel_sha = hashlib.sha256(wheels[0].read_bytes()).hexdigest() + if receipt.get("wheel", {}).get("sha256") != wheel_sha: + raise SystemExit(f"{artifact} wheel SHA mismatch") + receipts[receipt["target"]["label"]] = wheel_sha + checksums.extend((wheels[0], receipts_files[0])) + if len(receipts) != 6: + raise SystemExit("build receipts do not identify six unique native targets") + for artifact in expected_reports: + reports = list((reports_root / artifact).rglob("qualification-report.json")) + if len(reports) != 1: + raise SystemExit(f"{artifact} must preserve one qualification report") + report = json.loads(reports[0].read_text()) + if report.get("source_sha") != source_sha or report.get("passed") is not True: + raise SystemExit(f"{artifact} is not a passing report for this source") + target = report.get("target", {}).get("label") + if receipts.get(target) != report.get("wheel", {}).get("sha256"): + raise SystemExit(f"{artifact} wheel does not match its build receipt") + checksums.append(reports[0]) + lines = [ + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.as_posix()}" + for path in sorted(checksums) + ] + Path("SHA256SUMS").write_text("\n".join(lines) + "\n") + archive = f"release-evidence-{source_sha}" + shutil.make_archive(archive, "zip", root_dir=".", base_dir="evidence") + with Path(f"{archive}.zip").open("rb") as handle: + archive_sha = hashlib.sha256(handle.read()).hexdigest() + with Path("SHA256SUMS").open("a") as handle: + handle.write(f"{archive_sha} {archive}.zip\n") + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-evidence-${{ github.sha }} + path: | + release-evidence-*.zip + SHA256SUMS + - name: Attach evidence to a draft GitHub release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "${GITHUB_REF_NAME}" --json isDraft --jq .isDraft 2>/dev/null | grep -qx true; then + : + elif gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "refusing to modify an already-published release" >&2 + exit 1 + else + gh release create "${GITHUB_REF_NAME}" --verify-tag --draft --title "${GITHUB_REF_NAME}" + fi + gh release upload "${GITHUB_REF_NAME}" release-evidence-*.zip SHA256SUMS --clobber publish: name: Publish to PyPI runs-on: ubuntu-latest timeout-minutes: 10 - needs: [build-wheels, build-linux-aarch64, build-macos-x86_64, build-windows-arm64] + needs: [release-evidence, qualify-wheels] if: startsWith(github.ref, 'refs/tags/v') permissions: id-token: write @@ -112,4 +328,33 @@ jobs: pattern: 'wheels-*' merge-multiple: true path: dist/ + - name: Select wheels for publication + run: | + python - <<'PY' + import shutil + from pathlib import Path + + publish_dir = Path("publish-dist") + publish_dir.mkdir() + for wheel in Path("dist").glob("*.whl"): + shutil.copy2(wheel, publish_dir / wheel.name) + if len(list(publish_dir.glob("*.whl"))) != 6: + raise SystemExit("expected six qualified wheels for publication") + PY - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + packages-dir: publish-dist/ + + publish-release: + name: Publish GitHub release + runs-on: ubuntu-24.04 + needs: [publish, release-evidence] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - name: Publish the evidence-bearing release after PyPI succeeds + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit "${GITHUB_REF_NAME}" --draft=false \ No newline at end of file diff --git a/README.md b/README.md index 3f76052..37887e2 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,21 @@ The kernel provides **capabilities** without **decisions**: ### For consumers +The current PyPI release is `1.6.1`. Version `2.0.1` is a **proposed** +security release, not a published release. Do not treat this repository state +as a released or qualified binary. + +After a release is qualified and its official release note names it, install +the exact version as a binary-only dependency: + ```bash -pip install amplifier-core +python -m pip install --only-binary=:all: amplifier-core== ``` -This installs a pre-built wheel with the Rust kernel included. No Rust toolchain required. +Verify the official release's automated evidence archive and `SHA256SUMS` +before adoption. Follow [Native artifact qualification](docs/NATIVE_ARTIFACTS.md) +to match the installed extension and wheel to its build receipt. Do not rebuild +native Core as part of every application release when a qualified wheel exists. For complete Amplifier installation and usage: **-> https://github.com/microsoft/amplifier** @@ -122,7 +132,26 @@ python scripts/generate_grpc_stubs.py See [docs/RUST_CORE_TESTING.md](docs/RUST_CORE_TESTING.md) for the full development setup guide. -**Build dependencies**: Rust 1.70+, maturin +**Build dependencies**: current stable Rust and maturin, using the locked +dependencies. The resolved graph currently has a Wasmtime-driven Rust floor of +at least 1.92 (PyO3 alone has a 1.83 floor); the project's actual MSRV has not +been independently validated. This is guidance, not a new minimum-Rust CI +requirement. + +## Proposed native-artifact security release + +Proposed version `2.0.1` updates PyO3 to `0.29.2`, +`pyo3-async-runtimes` to `0.29.0`, and `pyo3-log` to `0.13.4`, outside the +affected ranges for GHSA-36hh-v3qg-5jq4, GHSA-chgr-c6px-7xpp, +RustSec-2026-0176, and RustSec-2026-0177. + +The binding keeps `abi3-py311`, `multiple-pymethods`, and +`generate-import-lib` (deprecated in PyO3 0.29 in favor of `raw-dylib`) and +requires Windows qualification rather than a linkage behavior change. This +security work is separate from lifecycle PR #114, which also proposes `2.0.1`; +release sequencing and the lifecycle follow-up version are owner decisions. +See [Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) for status, +evidence, and adoption requirements. ## Core Concepts @@ -248,6 +277,7 @@ For complete module development guide: - [Module Source Protocol](docs/MODULE_SOURCE_PROTOCOL.md) - Custom module loading - [Rust Core Testing](docs/RUST_CORE_TESTING.md) - Development setup and testing guide - [Rust Core Limitations](docs/RUST_CORE_LIMITATIONS.md) - Known limitations +- [Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) - proposed security-release evidence and consumer verification **Philosophy**: diff --git a/docs/NATIVE_ARTIFACTS.md b/docs/NATIVE_ARTIFACTS.md new file mode 100644 index 0000000..1a4bb9d --- /dev/null +++ b/docs/NATIVE_ARTIFACTS.md @@ -0,0 +1,117 @@ +# Native Artifact Qualification + +## Release-status snapshot + +As of this document's update, `2.0.1` is proposed and PyPI serves `1.6.1`. +This snapshot must be updated by the release owner when publishing. Until +then, no artifact is described as published or qualified and no alert is +described as resolved. + +The proposed binding resolves PyO3 `0.29.2`, `pyo3-async-runtimes` `0.29.0`, +and `pyo3-log` `0.13.4`, preserving `abi3-py311`, `multiple-pymethods`, and +`generate-import-lib`. The latter is deprecated by PyO3 0.29 in favor of +`raw-dylib`, so Windows qualification is required before changing no linkage +behavior. + +This is not lifecycle PR #114. Although both candidates propose `2.0.1`, the +security candidate is independently qualified; release ordering and a later +lifecycle version remain owner decisions. + +## Security and support boundary + +Review of Core source and inspected companion/macros found no direct +`nth`/`nth_back` iterator or `PyCFunction::new_closure` calls associated with +GHSA-36hh-v3qg-5jq4, GHSA-chgr-c6px-7xpp, RustSec-2026-0176, and +RustSec-2026-0177. That finding does not dismiss transitive exposure or +establish exploitability; the patched dependency graph and qualified artifacts +are the remediation evidence. + +Qualification is configured for normal-GIL CPython 3.11–3.13. Free-threaded +CPython 3.13 (`3.13t`) is not qualified. The smoke check imports the installed +extension from a fresh virtual environment; verifies Rust/Python callbacks, +conversion, and cancellation; and records the observed interpreter libc. + +## Evidence and provenance + +The builder writes a `build.json` beside each wheel. It records source and +lockfile identity, project/version metadata, binding features, the patched +PyO3 package set, target/platform facts, and the wheel's filename, SHA-256, +tags, and native-member hashes. + +`resolved_dependency_graph_sha256` is the canonical hash of the supplementary +`resolved_dependency_graph`. The graph is from `cargo metadata --locked` for +the workspace, **not** a target-specific binary SBOM. `source_sha` and +`cargo_lock_sha256` establish source provenance; they do not mean a compiled +native binary contains `Cargo.lock`. + +Each verifier writes `qualification-report.json`. Its smoke evidence includes +the observed libc at +`smoke_checks.interpreter.libc`; the imported member and its observed hash are +`smoke_checks.engine.native_payload.relative_path` and `.sha256`. + +## Configured qualification matrix + +The workflow builds six families—Linux x64/ARM64, macOS x64/ARM64, and Windows +x64/ARM64—and configures all 18 normal-GIL CPython 3.11/3.12/3.13 verification +cells. These are required configured checks, **not validation results yet**. +Windows ARM64/Python 3.11 download availability remains unknown until its +green run; it is not a support or publication claim. Linux ARM64/Python 3.13 +must be accepted only with its target and observed-libc report. + +After all six builds and all 18 qualifiers pass, `release-evidence` validates +the complete set and creates `release-evidence-.zip` plus +`SHA256SUMS`. A branch or manual-dispatch run retains those as Actions +artifacts only. A tag run first creates a draft GitHub release and attaches +that evidence before PyPI publication; the PyPI job requires +`release-evidence`. The final `publish-release` job makes the GitHub release +public only after PyPI succeeds. This pipeline has not run for the proposed +release. + +## Consumer installation and verification + +1. Obtain the official release's `release-evidence-.zip` and + `SHA256SUMS`, verify the archive checksum, then use its matching + `build.json` and qualification report. Match wheel tags, CPU/platform, + and—for Linux—the recorded target and observed libc to the deployment. +2. Check the downloaded wheel against `build.json` before installing: + + ```bash + python -m pip install --only-binary=:all: amplifier-core== + ``` + +3. Keep the downloaded wheel and receipt together, then run the following + standard-library verification with their paths substituted: + + ```python + import hashlib + import importlib + import importlib.metadata + import json + import sys + from pathlib import Path + + receipt = json.loads(Path("build.json").read_text()) + wheel_path = Path("amplifier_core--.whl") + assert hashlib.sha256(wheel_path.read_bytes()).hexdigest() == receipt["wheel"]["sha256"] + + distribution = importlib.metadata.distribution(receipt["pyproject_name"]) + metadata_version = importlib.metadata.version(receipt["pyproject_name"]) + engine = importlib.import_module("amplifier_core._engine") + native_path = Path(engine.__file__).resolve() + distribution_root = Path(distribution.locate_file("")).resolve() + member_key = native_path.relative_to(distribution_root).as_posix() + loaded_sha256 = hashlib.sha256(native_path.read_bytes()).hexdigest() + + assert metadata_version == engine.__version__ == receipt["pyproject_version"] + assert loaded_sha256 == receipt["wheel"]["native_payload_sha256"][member_key] + assert native_path.is_relative_to(Path(sys.prefix).resolve()) + print("distribution:", metadata_version) + print("native member:", member_key, loaded_sha256) + print("proposed receipt source_sha:", receipt["source_sha"]) + ``` + +The loaded engine version proves package-version agreement, not a Git revision. +Use the receipt's `source_sha`, plus its wheel and native-member hash matches, +to map the installed binary to the proposed source record. Prefer qualified +wheels over independently rebuilding native Core for routine application +releases. \ No newline at end of file diff --git a/scripts/qualify_native_wheel.py b/scripts/qualify_native_wheel.py new file mode 100644 index 0000000..8042575 --- /dev/null +++ b/scripts/qualify_native_wheel.py @@ -0,0 +1,811 @@ +#!/usr/bin/env python3 +"""Record and verify provenance for one official amplifier-core native wheel.""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import importlib.metadata +import json +import platform +import subprocess +import sys +import sysconfig +import tempfile +import tomllib +import zipfile +from pathlib import Path +from typing import Any + + +REQUIRED_PYO3_FEATURES = { + "generate-import-lib", + "multiple-pymethods", + "abi3-py311", +} + + +class QualificationError(RuntimeError): + """A qualification assertion that must stop publishing.""" + + +def sha256_bytes(data: bytes) -> str: + """Return the SHA-256 digest for bytes.""" + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + """Return the SHA-256 digest for a file without loading it all at once.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def version_at_least(version: str, minimum: tuple[int, int, int]) -> bool: + """Compare the numeric prefix of a Cargo version without third-party packages.""" + parts = version.split(".") + try: + parsed = tuple(int(part) for part in parts[:3]) + except ValueError as error: + raise QualificationError(f"invalid package version {version!r}") from error + parsed = parsed + (0,) * (3 - len(parsed)) + return parsed >= minimum + + +def load_lock_packages(lock_path: Path) -> list[dict[str, Any]]: + """Load the Cargo lockfile's package records.""" + try: + packages = tomllib.loads(lock_path.read_text(encoding="utf-8"))["package"] + except (KeyError, OSError, tomllib.TOMLDecodeError) as error: + raise QualificationError(f"cannot read Cargo lockfile: {error}") from error + if not isinstance(packages, list): + raise QualificationError("Cargo lockfile package table is not a list") + return packages + + +def qualified_pyo3_packages(packages: list[dict[str, Any]]) -> list[dict[str, str | None]]: + """Reject vulnerable or incoherent PyO3 package sets and retain their checksums.""" + by_name: dict[str, list[dict[str, Any]]] = {} + for package in packages: + name = package.get("name") + version = package.get("version") + if isinstance(name, str) and isinstance(version, str): + by_name.setdefault(name, []).append(package) + + for name, minimum in ( + ("pyo3", (0, 29, 0)), + ("pyo3-async-runtimes", (0, 29, 0)), + ("pyo3-log", (0, 13, 4)), + ): + matches = by_name.get(name, []) + if not matches: + raise QualificationError(f"Cargo.lock does not resolve required package {name}") + if any(not version_at_least(match["version"], minimum) for match in matches): + versions = ", ".join(match["version"] for match in matches) + raise QualificationError(f"{name} has unpatched resolved version(s): {versions}") + + pyo3_family = [ + package + for name, records in by_name.items() + if name.startswith("pyo3") + and name not in {"pyo3-async-runtimes", "pyo3-log"} + for package in records + ] + if not pyo3_family: + raise QualificationError("Cargo.lock has no PyO3 family packages") + if any(not version_at_least(package["version"], (0, 29, 0)) for package in pyo3_family): + versions = ", ".join( + f'{package["name"]} {package["version"]}' for package in pyo3_family + ) + raise QualificationError(f"PyO3 family is not coherently patched: {versions}") + + selected = [ + package + for name, records in by_name.items() + if name.startswith("pyo3") + for package in records + ] + return [ + { + "name": package["name"], + "version": package["version"], + "checksum": package.get("checksum"), + } + for package in sorted(selected, key=lambda item: (item["name"], item["version"])) + ] + + +def resolved_packages(packages: list[dict[str, Any]]) -> list[dict[str, str | None]]: + """Retain resolved versions and checksums without sources or filesystem paths.""" + records = [ + { + "name": package["name"], + "version": package["version"], + "checksum": package.get("checksum"), + } + for package in packages + if isinstance(package.get("name"), str) and isinstance(package.get("version"), str) + ] + return sorted(records, key=lambda item: (item["name"], item["version"])) + + +def binding_features(binding_manifest: Path) -> list[str]: + """Read and validate the PyO3 compatibility features from the binding manifest.""" + try: + dependency = tomllib.loads(binding_manifest.read_text(encoding="utf-8"))["dependencies"]["pyo3"] + features = dependency["features"] + except (KeyError, OSError, tomllib.TOMLDecodeError, TypeError) as error: + raise QualificationError(f"cannot read PyO3 binding features: {error}") from error + if not isinstance(features, list) or not all(isinstance(item, str) for item in features): + raise QualificationError("PyO3 binding features are not a list of strings") + missing = REQUIRED_PYO3_FEATURES - set(features) + if missing: + raise QualificationError(f"required PyO3 features are missing: {', '.join(sorted(missing))}") + return sorted(features) + + +def wheel_members(wheel: Path) -> dict[str, Any]: + """Extract only the immutable wheel facts needed for artifact verification.""" + try: + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + wheel_files = [name for name in names if name.endswith(".dist-info/WHEEL")] + metadata_files = [name for name in names if name.endswith(".dist-info/METADATA")] + native_files = [name for name in names if name.lower().endswith((".so", ".pyd"))] + if len(wheel_files) != 1 or len(metadata_files) != 1: + raise QualificationError("wheel must contain exactly one WHEEL and one METADATA file") + if not native_files: + raise QualificationError("wheel has no native .so or .pyd payload") + wheel_headers = parse_rfc822(archive.read(wheel_files[0]).decode("utf-8")) + metadata_headers = parse_rfc822(archive.read(metadata_files[0]).decode("utf-8")) + name = metadata_headers.get("Name") + version = metadata_headers.get("Version") + tags = wheel_headers.get("Tag", []) + if not isinstance(name, str) or not isinstance(version, str) or not tags: + raise QualificationError("wheel WHEEL/METADATA fields are incomplete") + payloads = { + name: sha256_bytes(archive.read(name)) + for name in sorted(native_files) + } + except (OSError, UnicodeDecodeError, zipfile.BadZipFile) as error: + raise QualificationError(f"cannot inspect wheel {wheel}: {error}") from error + return { + "filename": wheel.name, + "sha256": sha256_file(wheel), + "tags": tags, + "metadata_name": name, + "metadata_version": version, + "native_payload_sha256": payloads, + } + + +def project_metadata(pyproject_path: Path) -> tuple[str, str]: + """Read the project identity that must agree with the built wheel.""" + try: + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"] + name = project["name"] + version = project["version"] + except (KeyError, OSError, tomllib.TOMLDecodeError, TypeError) as error: + raise QualificationError(f"cannot read project metadata: {error}") from error + if not isinstance(name, str) or not isinstance(version, str): + raise QualificationError("project name or version is not a string") + return name, version + + +def target_info(label: str, observed_system: str) -> dict[str, str]: + """Normalize a CI target label to its expected OS and architecture.""" + normalized = label.lower().replace("_", "-") + system = ( + "Linux" if "linux" in normalized or "ubuntu" in normalized + else "Darwin" if "macos" in normalized or "darwin" in normalized + else "Windows" if "windows" in normalized or normalized.startswith("win-") + else observed_system + ) + return { + "label": label, + "system": system, + "architecture": architecture_label(label), + } + + +def wheel_tags_match_target(tags: list[str], target: dict[str, str]) -> bool: + """Require wheel tags to declare the platform and CPU the receipt claims.""" + combined = " ".join(tags).lower() + platform_matches = { + "Linux": ("linux" in combined or "manylinux" in combined), + "Darwin": "macosx" in combined, + "Windows": "win" in combined, + } + architecture_matches = { + "x64": ("x86_64" in combined or "amd64" in combined), + "arm64": ("aarch64" in combined or "arm64" in combined), + } + return platform_matches.get(target["system"], False) and architecture_matches[ + target["architecture"] + ] + + +def parse_rfc822(text: str) -> dict[str, str | list[str]]: + """Parse the small header subset used by wheel metadata.""" + headers: dict[str, str | list[str]] = {} + for line in text.splitlines(): + if not line or line[0].isspace() or ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + existing = headers.get(key) + if existing is None: + headers[key] = value + elif isinstance(existing, list): + existing.append(value) + else: + headers[key] = [existing, value] + tag = headers.get("Tag") + if isinstance(tag, str): + headers["Tag"] = [tag] + return headers + + +def only_wheel(directory: Path) -> Path: + """Require the build artifact directory to contain exactly one wheel.""" + wheels = sorted(directory.glob("*.whl")) + if len(wheels) != 1: + raise QualificationError( + f"expected exactly one wheel in {directory}, found {len(wheels)}" + ) + return wheels[0] + + +def validate_download_artifact_dir(artifact_dir: Path, repo_root: Path) -> None: + """Accept checkout-local input only at the workflow's isolated artifacts path.""" + if relative_to(artifact_dir, repo_root) and artifact_dir != repo_root / "artifacts": + raise QualificationError("checkout-local artifact directory must be exactly ./artifacts") + if not artifact_dir.is_dir(): + raise QualificationError("artifact directory does not exist") + unexpected = [ + path.name + for path in artifact_dir.iterdir() + if path.name != "build.json" and not (path.is_file() and path.suffix == ".whl") + ] + if unexpected: + raise QualificationError( + "artifact directory contains unexpected files: " + ", ".join(sorted(unexpected)) + ) + + +def run_text(command: list[str], cwd: Path) -> str: + """Run a provenance command and return its stdout.""" + return subprocess.run( + command, cwd=cwd, check=True, text=True, capture_output=True + ).stdout + + +def current_head(repo_root: Path) -> str: + """Read the checkout revision from Git rather than trusting caller input.""" + return run_text(["git", "rev-parse", "HEAD"], repo_root).strip() + + +def allowed_generated_path(path: str) -> bool: + """Allow only root build output and Python bytecode outside tracked source.""" + normalized = path.rstrip("/") + if normalized in {"dist", "target"} or normalized.startswith(("dist/", "target/")): + return True + parts = normalized.split("/") + return ( + len(parts) >= 2 + and parts[-2] == "__pycache__" + and parts[-1].endswith(".pyc") + ) + + +def require_clean_build_inputs(repo_root: Path) -> None: + """Reject tracked or source-like untracked/ignored inputs from a build receipt.""" + command = ["git", "diff", "--quiet", "HEAD"] + result = subprocess.run(command, cwd=repo_root, check=False) + if result.returncode == 1: + raise QualificationError("tracked source differs from HEAD") + if result.returncode != 0: + raise QualificationError(f"could not check source status: {' '.join(command)}") + status = run_text( + [ + "git", + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + ], + repo_root, + ) + unexpected = [ + line[3:] + for line in status.splitlines() + if line[:2] in {"??", "!!"} and not allowed_generated_path(line[3:]) + ] + if unexpected: + raise QualificationError( + "unexpected untracked or ignored source input: " + ", ".join(unexpected) + ) + + +def sanitized_cargo_graph(metadata: dict[str, Any]) -> dict[str, list[Any]]: + """Keep package names, versions, and dependency edges; discard metadata paths.""" + packages = metadata.get("packages") + resolve = metadata.get("resolve") + if not isinstance(packages, list) or not isinstance(resolve, dict): + raise QualificationError("cargo metadata is missing packages or resolve") + identifiers: dict[str, str] = {} + nodes = [] + for package in packages: + if not all(isinstance(package.get(key), str) for key in ("id", "name", "version")): + raise QualificationError("cargo metadata package has incomplete identity") + identity = f'{package["name"]}@{package["version"]}' + identifiers[package["id"]] = identity + nodes.append(identity) + edges: set[tuple[str, str]] = set() + for node in resolve.get("nodes", []): + node_id = node.get("id") + if node_id not in identifiers: + raise QualificationError("cargo metadata resolve node is unknown") + for dependency in node.get("deps", []): + package_id = dependency.get("pkg") + if package_id not in identifiers: + raise QualificationError("cargo metadata dependency is unknown") + edges.add((identifiers[node_id], identifiers[package_id])) + return { + "packages": sorted(set(nodes)), + "dependency_edges": [list(edge) for edge in sorted(edges)], + } + + +def canonical_json_sha256(value: Any) -> str: + """Hash canonical JSON for a supplementary, portable receipt structure.""" + return sha256_bytes( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ) + + +def validate_supplementary_graph( + graph: Any, resolved: list[dict[str, str | None]] +) -> None: + """Ensure a build-time graph references only recorded lockfile package identities.""" + if not isinstance(graph, dict): + raise QualificationError("supplementary dependency graph is missing") + nodes = graph.get("packages") + edges = graph.get("dependency_edges") + if not isinstance(nodes, list) or not isinstance(edges, list): + raise QualificationError("supplementary dependency graph is incomplete") + if not all(isinstance(node, str) for node in nodes) or len(nodes) != len(set(nodes)): + raise QualificationError("supplementary dependency graph has invalid package nodes") + recorded = {f'{item["name"]}@{item["version"]}' for item in resolved} + node_set = set(nodes) + if not node_set or not node_set.issubset(recorded): + raise QualificationError("supplementary dependency graph references unknown packages") + edge_tuples: list[tuple[str, str]] = [] + for edge in edges: + if ( + not isinstance(edge, list) + or len(edge) != 2 + or not all(isinstance(item, str) for item in edge) + or not set(edge).issubset(node_set) + ): + raise QualificationError("supplementary dependency graph has an unknown edge") + edge_tuples.append((edge[0], edge[1])) + if len(edge_tuples) != len(set(edge_tuples)): + raise QualificationError("supplementary dependency graph has duplicate edges") + + +def architecture_label(value: str) -> str: + """Normalize matrix labels, Rust triples, and platform.machine() output.""" + normalized = value.lower().replace("-", "_") + if "aarch64" in normalized or "arm64" in normalized: + return "arm64" + if ( + "x86_64" in normalized + or "amd64" in normalized + or normalized == "x64" + or normalized.endswith("_x64") + ): + return "x64" + raise QualificationError(f"cannot determine CPU architecture from {value!r}") + + +def write_json(path: Path, data: dict[str, Any]) -> None: + """Atomically write a JSON report only after all assertions have passed.""" + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, delete=False + ) as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + temporary = Path(handle.name) + temporary.replace(path) + + +def record(args: argparse.Namespace) -> None: + """Create a build receipt next to one wheel.""" + repo_root = Path.cwd().resolve() + wheel_dir = Path(args.wheel_dir).resolve() + output = Path(args.output).resolve() + if output.parent != wheel_dir: + raise QualificationError("--output must be alongside the wheel") + if output.suffix == ".whl": + raise QualificationError("--output must not overwrite a wheel artifact") + output.unlink(missing_ok=True) + require_clean_build_inputs(repo_root) + observed_head = current_head(repo_root) + if args.source_sha != observed_head: + raise QualificationError("--source-sha does not match the current Git HEAD") + + wheel = only_wheel(wheel_dir) + lock_path = repo_root / "Cargo.lock" + pyproject_path = repo_root / "pyproject.toml" + binding_manifest = repo_root / "bindings/python/Cargo.toml" + packages = load_lock_packages(lock_path) + metadata = json.loads(run_text( + ["cargo", "metadata", "--locked", "--format-version", "1"], repo_root + )) + project_name, project_version = project_metadata(pyproject_path) + target = args.target or platform.machine() + target = target_info(target, platform.system()) + if target["system"] != platform.system(): + raise QualificationError("target OS does not match the wheel build runner OS") + wheel_facts = wheel_members(wheel) + if ( + wheel_facts["metadata_name"] != project_name + or wheel_facts["metadata_version"] != project_version + ): + raise QualificationError("wheel metadata name or version does not match pyproject.toml") + if not wheel_tags_match_target(wheel_facts["tags"], target): + raise QualificationError("wheel tags do not match the declared target OS and CPU") + receipt = { + "schema_version": 1, + "source_sha": observed_head, + "cargo_lock_sha256": sha256_file(lock_path), + "pyproject_name": project_name, + "pyproject_version": project_version, + "binding_features": binding_features(binding_manifest), + "rustc_version": run_text(["rustc", "--version"], repo_root).strip(), + "platform": { + "system": platform.system(), + "machine": platform.machine(), + "python": platform.python_version(), + }, + "target": target, + "pyo3_packages": qualified_pyo3_packages(packages), + "resolved_packages": resolved_packages(packages), + "resolved_dependency_graph": sanitized_cargo_graph(metadata), + "resolved_dependency_graph_scope": ( + "cargo metadata --locked resolved workspace graph; " + "not a target-specific binary SBOM" + ), + "wheel": wheel_facts, + } + receipt["resolved_dependency_graph_sha256"] = canonical_json_sha256( + receipt["resolved_dependency_graph"] + ) + # Raw Cargo metadata is an internal CI receipt. It is deliberately not uploaded. + write_json(wheel_dir / "cargo-metadata.json", metadata) + write_json(output, receipt) + + +def relative_to(path: Path, parent: Path) -> bool: + """Return whether path is inside parent on supported Python versions.""" + try: + path.resolve().relative_to(parent.resolve()) + except ValueError: + return False + return True + + +def verify_integrity( + artifact_dir: Path, receipt: dict[str, Any], source_sha: str, repo_root: Path +) -> dict[str, Any]: + """Verify immutable source, lockfile, wheel, and native-payload provenance.""" + if receipt.get("source_sha") != source_sha: + raise QualificationError("build receipt source SHA does not match --source-sha") + if current_head(repo_root) != source_sha: + raise QualificationError("current checkout HEAD does not match --source-sha") + lock_sha = sha256_file(repo_root / "Cargo.lock") + if receipt.get("cargo_lock_sha256") != lock_sha: + raise QualificationError("current Cargo.lock hash does not match build receipt") + packages = load_lock_packages(repo_root / "Cargo.lock") + if receipt.get("pyo3_packages") != qualified_pyo3_packages(packages): + raise QualificationError("current resolved PyO3 packages do not match build receipt") + if receipt.get("resolved_packages") != resolved_packages(packages): + raise QualificationError("current resolved packages do not match build receipt") + graph = receipt.get("resolved_dependency_graph") + if receipt.get("resolved_dependency_graph_sha256") != canonical_json_sha256(graph): + raise QualificationError("supplementary dependency graph hash does not match") + validate_supplementary_graph(graph, receipt["resolved_packages"]) + if receipt.get("binding_features") != binding_features( + repo_root / "bindings/python/Cargo.toml" + ): + raise QualificationError("current PyO3 binding features do not match build receipt") + current_name, current_version = project_metadata(repo_root / "pyproject.toml") + if ( + receipt.get("pyproject_name") != current_name + or receipt.get("pyproject_version") != current_version + ): + raise QualificationError("current project metadata does not match build receipt") + wheel = only_wheel(artifact_dir) + actual = wheel_members(wheel) + expected = receipt.get("wheel") + if not isinstance(expected, dict): + raise QualificationError("build receipt has no wheel record") + for field in ("filename", "sha256", "metadata_name", "metadata_version", "tags", "native_payload_sha256"): + if actual.get(field) != expected.get(field): + raise QualificationError(f"wheel {field} does not match build receipt") + return actual + + +def validate_install_locations( + engine_path: Path, package_path: Path, venv_root: Path, repo_root: Path +) -> None: + """Require the imported package and extension to come from a venv outside checkout.""" + if not relative_to(engine_path, venv_root) or not relative_to(package_path, venv_root): + raise QualificationError("amplifier_core did not import from the current virtual environment") + if relative_to(venv_root, repo_root) or relative_to(engine_path, repo_root) or relative_to( + package_path, repo_root + ): + raise QualificationError("amplifier_core imported from the checkout rather than the installed wheel") + + +def verify_installed_metadata( + distribution_name: str | None, + distribution_version: str, + package_version: str, + engine_version: str, + expected_name: str, + expected_version: str, +) -> None: + """Ensure all installed metadata surfaces name and version-identically.""" + if distribution_name != expected_name: + raise QualificationError("installed distribution name does not match the qualified wheel") + if { + distribution_version, + package_version, + engine_version, + } != {expected_version}: + raise QualificationError("installed package or engine version does not match the qualified wheel") + + +def installed_native_payload( + engine_path: Path, distribution_root: Path, expected_payloads: dict[str, Any] +) -> dict[str, str]: + """Hash the imported extension and map it to its exact native wheel member.""" + try: + relative_path = engine_path.resolve().relative_to(distribution_root.resolve()).as_posix() + except ValueError as error: + raise QualificationError("imported native engine is outside the installed distribution") from error + expected_hash = expected_payloads.get(relative_path) + if not isinstance(expected_hash, str): + raise QualificationError("imported native engine is not an expected wheel payload member") + observed_hash = sha256_file(engine_path) + if observed_hash != expected_hash: + raise QualificationError("imported native engine hash does not match the qualified wheel") + return {"relative_path": relative_path, "sha256": observed_hash} + + +def registered_handler_names(value: Any) -> set[str]: + """Extract handler names from the public registry's list response.""" + if isinstance(value, str): + return {value} + if isinstance(value, dict): + names = {value["name"]} if isinstance(value.get("name"), str) else set() + for item in value.values(): + names.update(registered_handler_names(item)) + return names + if isinstance(value, (list, tuple, set)): + return set().union(*(registered_handler_names(item) for item in value)) + return set() + + +async def run_native_smoke( + target: dict[str, str], + expected_name: str, + expected_version: str, + expected_payloads: dict[str, Any], + repo_root: Path, +) -> dict[str, Any]: + """Exercise public Rust-backed callbacks and cancellation after wheel installation.""" + import amplifier_core + import amplifier_core._engine as engine + from amplifier_core._engine import RustCancellationToken, RustHookRegistry + + if engine.RUST_AVAILABLE is not True: + raise QualificationError("native engine does not report RUST_AVAILABLE=True") + distribution = importlib.metadata.distribution(expected_name) + verify_installed_metadata( + distribution.metadata.get("Name"), + distribution.version, + amplifier_core.__version__, + engine.__version__, + expected_name, + expected_version, + ) + engine_path = Path(engine.__file__).resolve() + package_path = Path(amplifier_core.__file__).resolve() + distribution_root = Path(distribution.locate_file("")).resolve() + validate_install_locations(engine_path, package_path, Path(sys.prefix), repo_root) + if sysconfig.get_config_var("Py_GIL_DISABLED") not in (None, 0, "0", False): + raise QualificationError("qualification requires a normal-GIL Python interpreter") + if platform.system() != target["system"]: + raise QualificationError("running operating system does not match the wheel target") + if architecture_label(platform.machine()) != target["architecture"]: + raise QualificationError("running Python CPU does not match the wheel target") + + sync_called = False + registry = RustHookRegistry() + + def sync_handler(event: str, data: dict[str, Any]) -> dict[str, Any]: + nonlocal sync_called + sync_called = event == "qualification:sync" and data["conversion"]["integer"] == 1 + return {"action": "continue", "data": {"mode": "sync"}} + + registry.register("qualification:sync", sync_handler, name="qualification-sync") + if "qualification-sync" not in registered_handler_names( + registry.list_handlers("qualification:sync") + ): + raise QualificationError("Rust hook callback was not registered") + sync_result = await registry.emit("qualification:sync", {"conversion": {"integer": 1}}) + registry.unregister("qualification-sync") + if not sync_called or sync_result.action != "continue": + raise QualificationError("synchronous Rust hook callback did not return a continue result") + if "qualification-sync" in registered_handler_names( + registry.list_handlers("qualification:sync") + ): + raise QualificationError("Rust hook callback did not unregister") + + async_called = False + + async def async_handler(event: str, data: dict[str, Any]) -> dict[str, Any]: + nonlocal async_called + await asyncio.sleep(0) + async_called = event == "qualification:async" and data.get("safe") is True + return {"action": "continue", "data": {"mode": "async"}} + + registry.register("qualification:async", async_handler, name="qualification-async") + if "qualification-async" not in registered_handler_names( + registry.list_handlers("qualification:async") + ): + raise QualificationError("asynchronous Rust hook callback was not registered") + async_result = await registry.emit("qualification:async", {"safe": True}) + registry.unregister("qualification-async") + if not async_called or async_result.action != "continue": + raise QualificationError("asynchronous Rust hook callback did not return a continue result") + if "qualification-async" in registered_handler_names( + registry.list_handlers("qualification:async") + ): + raise QualificationError("asynchronous Rust hook callback did not unregister") + + callback_called = False + token = RustCancellationToken() + + async def cancellation_callback() -> None: + nonlocal callback_called + callback_called = True + + token.on_cancel(cancellation_callback) + if not token.request_graceful() or token.state != "graceful" or not token.is_cancelled: + raise QualificationError("Rust cancellation token did not enter graceful state") + await token.trigger_callbacks() + token.request_immediate() + if not callback_called or token.state != "immediate": + raise QualificationError("Rust cancellation callback or transition failed") + token.reset() + if token.state != "none" or token.is_cancelled: + raise QualificationError("Rust cancellation token did not reset") + return { + "engine": { + "version": engine.__version__, + "native_payload": installed_native_payload( + engine_path, distribution_root, expected_payloads + ), + }, + "interpreter": { + "implementation": platform.python_implementation(), + "version": platform.python_version(), + "libc": list(platform.libc_ver()), + }, + "checks": { + "rust_available": True, + "installed_from_venv": True, + "normal_gil": True, + "native_cpu_matches_target": True, + "sync_callback_and_unregister": True, + "async_callback_and_conversion": True, + "cancellation_callback_transition_and_reset": True, + }, + } + + +def verify(args: argparse.Namespace) -> None: + """Verify an installed wheel and write a report only when every assertion succeeds.""" + artifact_dir = Path(args.artifact_dir).resolve() + output = Path(args.output).resolve() + if output.suffix == ".whl": + raise QualificationError("--output must not overwrite a wheel artifact") + output.unlink(missing_ok=True) + receipt_path = artifact_dir / "build.json" + try: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise QualificationError(f"cannot read build receipt: {error}") from error + repo_root = Path.cwd().resolve() + validate_download_artifact_dir(artifact_dir, repo_root) + wheel = verify_integrity(artifact_dir, receipt, args.source_sha, repo_root) + target = receipt.get("target") + if not isinstance(target, dict) or not all( + isinstance(target.get(field), str) for field in ("system", "architecture") + ): + raise QualificationError("build receipt target is incomplete") + expected_name = receipt.get("pyproject_name") + expected_version = receipt.get("pyproject_version") + expected_payloads = wheel.get("native_payload_sha256") + if ( + not isinstance(expected_name, str) + or not isinstance(expected_version, str) + or not isinstance(expected_payloads, dict) + ): + raise QualificationError("build receipt project or native payload details are missing") + if ( + wheel["metadata_name"] != expected_name + or wheel["metadata_version"] != expected_version + or not wheel_tags_match_target(wheel["tags"], target) + ): + raise QualificationError("wheel metadata or tags do not match build receipt project target") + smoke_checks = asyncio.run( + run_native_smoke( + target, expected_name, expected_version, expected_payloads, repo_root + ) + ) + report = { + "schema_version": 1, + "source_sha": args.source_sha, + "cargo_lock_sha256": receipt["cargo_lock_sha256"], + "wheel": wheel, + "platform": { + "system": platform.system(), + "machine": platform.machine(), + "python": platform.python_version(), + }, + "target": target, + "smoke_checks": smoke_checks, + "passed": True, + } + write_json(output, report) + + +def parser() -> argparse.ArgumentParser: + """Build the small, dependency-free command-line interface.""" + result = argparse.ArgumentParser(description=__doc__) + commands = result.add_subparsers(dest="command", required=True) + record_parser = commands.add_parser("record", help="write a wheel build receipt") + record_parser.add_argument("--wheel-dir", required=True) + record_parser.add_argument("--output", required=True) + record_parser.add_argument("--source-sha", required=True) + record_parser.add_argument("--target", help="matrix target label or Rust target triple") + record_parser.set_defaults(handler=record) + verify_parser = commands.add_parser("verify", help="verify an installed wheel") + verify_parser.add_argument("--artifact-dir", required=True) + verify_parser.add_argument("--source-sha", required=True) + verify_parser.add_argument("--output", required=True) + verify_parser.set_defaults(handler=verify) + return result + + +def main() -> None: + """Run the requested qualification phase.""" + argument_parser = parser() + args = argument_parser.parse_args() + try: + args.handler(args) + except QualificationError as error: + argument_parser.exit(1, f"qualification failed: {error}\n") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_ci_workflows.py b/tests/test_ci_workflows.py index 3a3b4fe..efc11af 100644 --- a/tests/test_ci_workflows.py +++ b/tests/test_ci_workflows.py @@ -193,10 +193,12 @@ def test_has_build_wheels_job(self): def test_build_wheels_matrix_covers_all_os(self): wf = self._load() matrix = wf["jobs"]["build-wheels"]["strategy"]["matrix"] - os_list = matrix["os"] - assert "ubuntu-latest" in os_list - assert "macos-latest" in os_list - assert "windows-latest" in os_list + includes = matrix["include"] + os_list = {entry["os"] for entry in includes} + assert "ubuntu-24.04" in os_list + assert "macos-15" in os_list + assert "windows-2025" in os_list + assert all("artifact" in entry and "target" in entry for entry in includes) def test_build_wheels_uses_maturin_action(self): wf = self._load() @@ -233,11 +235,14 @@ def test_has_publish_job(self): wf = self._load() assert "publish" in wf["jobs"] - def test_publish_needs_build_jobs(self): + def test_release_evidence_needs_all_build_jobs_before_publish(self): wf = self._load() - needs = wf["jobs"]["publish"]["needs"] + needs = wf["jobs"]["release-evidence"]["needs"] assert "build-wheels" in needs assert "build-linux-aarch64" in needs + assert "build-macos-x86_64" in needs + assert "build-windows-arm64" in needs + assert "release-evidence" in wf["jobs"]["publish"]["needs"] def test_publish_only_on_tag(self): wf = self._load() @@ -250,6 +255,42 @@ def test_publish_uses_pypi_action(self): uses_list = [s.get("uses", "") for s in steps] assert any("pypi-publish" in u for u in uses_list) + def test_qualification_matrix_has_all_native_python_combinations(self): + wf = self._load() + includes = wf["jobs"]["qualify-wheels"]["strategy"]["matrix"]["include"] + combinations = {(item["artifact"], str(item["python-version"])) for item in includes} + artifacts = { + "wheels-ubuntu-latest", "wheels-linux-aarch64", "wheels-macos-latest", + "wheels-macos-x86_64", "wheels-windows-latest", "wheels-windows-arm64", + } + assert len(includes) == 18 + assert combinations == {(artifact, version) for artifact in artifacts for version in ("3.11", "3.12", "3.13")} + + def test_release_evidence_preserves_unmerged_six_and_eighteen_artifacts(self): + wf = self._load() + job = wf["jobs"]["release-evidence"] + assert "qualify-wheels" in job["needs"] + downloads = [step for step in job["steps"] if "download-artifact" in step.get("uses", "")] + assert [step["with"]["pattern"] for step in downloads] == ["wheels-*", "qualification-*"] + assert all("merge-multiple" not in step["with"] for step in downloads) + run = next(step["run"] for step in job["steps"] if step.get("name") == "Validate evidence and create archive") + assert "six build targets" in run + assert "18 required cells" in run + assert 'receipt.get("source_sha") != source_sha' in run + assert 'receipt.get("wheel", {}).get("sha256") != wheel_sha' in run + assert 'report.get("source_sha") != source_sha' in run + assert 'report.get("passed") is not True' in run + + def test_tag_publication_is_gated_on_evidence_and_pypi(self): + wf = self._load() + assert "release-evidence" in wf["jobs"]["publish"]["needs"] + assert "qualify-wheels" in wf["jobs"]["publish"]["needs"] + assert "refs/tags/v" in wf["jobs"]["release-evidence"]["steps"][-1]["if"] + final = wf["jobs"]["publish-release"] + assert "publish" in final["needs"] + assert "release-evidence" in final["needs"] + assert "refs/tags/v" in final["if"] + class TestNodeBindingsCIWorkflow: """Node.js binding tests in CI workflow.""" diff --git a/tests/test_native_wheel_qualification.py b/tests/test_native_wheel_qualification.py new file mode 100644 index 0000000..8a30bf0 --- /dev/null +++ b/tests/test_native_wheel_qualification.py @@ -0,0 +1,354 @@ +"""Unit tests for the dependency-free native wheel qualification helpers.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import tempfile +import types +import unittest +import zipfile +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "qualify_native_wheel.py" +SPEC = importlib.util.spec_from_file_location("qualify_native_wheel", SCRIPT) +assert SPEC and SPEC.loader +qualify = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(qualify) + + +def cargo_lock(pyo3_version: str = "0.29.2") -> str: + """Return a minimal coherent Cargo.lock package table.""" + return f""" +version = 4 + +[[package]] +name = "pyo3" +version = "{pyo3_version}" +checksum = "pyo3" + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +checksum = "build" + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +checksum = "ffi" + +[[package]] +name = "pyo3-async-runtimes" +version = "0.29.0" +checksum = "async" + +[[package]] +name = "pyo3-log" +version = "0.13.4" +checksum = "log" +""" + + +def write_wheel(path: Path, native: bytes = b"native-one") -> None: + """Create the smallest wheel archive the inspector accepts.""" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + "amplifier_core-2.0.1.dist-info/WHEEL", + "Wheel-Version: 1.0\nTag: cp311-abi3-manylinux_2_28_x86_64\n", + ) + archive.writestr( + "amplifier_core-2.0.1.dist-info/METADATA", + "Name: amplifier-core\nVersion: 2.0.1\n", + ) + archive.writestr("amplifier_core/_engine.so", native) + + +class PyO3LockTests(unittest.TestCase): + def test_accepts_patched_coherent_pyo3_set(self) -> None: + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "Cargo.lock" + lock.write_text(cargo_lock()) + selected = qualify.qualified_pyo3_packages(qualify.load_lock_packages(lock)) + self.assertEqual(selected[0]["name"], "pyo3") + self.assertEqual(selected[0]["version"], "0.29.2") + + def test_rejects_vulnerable_pyo3(self) -> None: + with tempfile.TemporaryDirectory() as directory: + lock = Path(directory) / "Cargo.lock" + lock.write_text(cargo_lock("0.28.2")) + with self.assertRaisesRegex(qualify.QualificationError, "unpatched"): + qualify.qualified_pyo3_packages(qualify.load_lock_packages(lock)) + + def test_requires_compatibility_features(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "Cargo.toml" + manifest.write_text('[dependencies]\npyo3 = { features = ["abi3-py311"] }\n') + with self.assertRaisesRegex(qualify.QualificationError, "required PyO3 features"): + qualify.binding_features(manifest) + + +class WheelReceiptTests(unittest.TestCase): + def test_records_wheel_tags_metadata_and_native_payload(self) -> None: + with tempfile.TemporaryDirectory() as directory: + wheel = Path(directory) / "amplifier_core-2.0.1.whl" + write_wheel(wheel) + facts = qualify.wheel_members(wheel) + self.assertEqual(facts["metadata_name"], "amplifier-core") + self.assertEqual(facts["tags"], ["cp311-abi3-manylinux_2_28_x86_64"]) + self.assertEqual( + facts["native_payload_sha256"]["amplifier_core/_engine.so"], + hashlib.sha256(b"native-one").hexdigest(), + ) + + def test_rejects_zero_or_multiple_wheels(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifact_dir = Path(directory) + with self.assertRaisesRegex(qualify.QualificationError, "exactly one"): + qualify.only_wheel(artifact_dir) + write_wheel(artifact_dir / "first.whl") + write_wheel(artifact_dir / "second.whl") + with self.assertRaisesRegex(qualify.QualificationError, "exactly one"): + qualify.only_wheel(artifact_dir) + + def test_verify_integrity_rejects_source_wheel_or_native_payload_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact_dir = root / "dist" + artifact_dir.mkdir() + wheel = artifact_dir / "amplifier_core-2.0.1.whl" + write_wheel(wheel) + lock = root / "Cargo.lock" + lock.write_text(cargo_lock()) + binding = root / "bindings/python" + binding.mkdir(parents=True) + (binding / "Cargo.toml").write_text( + '[dependencies]\npyo3 = { features = ' + '["generate-import-lib", "multiple-pymethods", "abi3-py311"] }\n' + ) + (root / "pyproject.toml").write_text( + '[project]\nname = "amplifier-core"\nversion = "2.0.1"\n' + ) + receipt = { + "source_sha": "expected-sha", + "cargo_lock_sha256": qualify.sha256_file(lock), + "pyo3_packages": qualify.qualified_pyo3_packages( + qualify.load_lock_packages(lock) + ), + "resolved_packages": qualify.resolved_packages( + qualify.load_lock_packages(lock) + ), + "binding_features": qualify.binding_features(binding / "Cargo.toml"), + "pyproject_name": "amplifier-core", + "pyproject_version": "2.0.1", + "resolved_dependency_graph": { + "packages": ["pyo3@0.29.2"], + "dependency_edges": [], + }, + "wheel": qualify.wheel_members(wheel), + } + receipt["resolved_dependency_graph_sha256"] = qualify.canonical_json_sha256( + receipt["resolved_dependency_graph"] + ) + original_head = qualify.current_head + qualify.current_head = lambda _: "expected-sha" + try: + with self.assertRaisesRegex(qualify.QualificationError, "source SHA"): + qualify.verify_integrity(artifact_dir, receipt, "wrong-sha", root) + receipt["resolved_dependency_graph"]["packages"].append("other@1.0.0") + with self.assertRaisesRegex(qualify.QualificationError, "graph hash"): + qualify.verify_integrity(artifact_dir, receipt, "expected-sha", root) + receipt["resolved_dependency_graph"]["packages"].pop() + write_wheel(wheel, b"native-two") + with self.assertRaisesRegex(qualify.QualificationError, "wheel sha256"): + qualify.verify_integrity(artifact_dir, receipt, "expected-sha", root) + receipt["wheel"]["sha256"] = qualify.sha256_file(wheel) + with self.assertRaisesRegex(qualify.QualificationError, "native_payload_sha256"): + qualify.verify_integrity(artifact_dir, receipt, "expected-sha", root) + finally: + qualify.current_head = original_head + + +class VerificationGuardTests(unittest.TestCase): + def test_rejects_dirty_tracked_rust_source(self) -> None: + original_run = qualify.subprocess.run + qualify.subprocess.run = lambda *args, **kwargs: types.SimpleNamespace(returncode=1) + try: + with self.assertRaisesRegex(qualify.QualificationError, "tracked source"): + qualify.require_clean_build_inputs(Path("/checkout")) + finally: + qualify.subprocess.run = original_run + + def test_rejects_untracked_source_and_cargo_configuration(self) -> None: + original_run = qualify.subprocess.run + + def fake_run(command, **kwargs): + if command[:3] == ["git", "diff", "--quiet"]: + return types.SimpleNamespace(returncode=0) + if command[:3] == ["git", "status", "--porcelain=v1"]: + return types.SimpleNamespace(stdout="?? src/negative_untracked.py\n") + raise AssertionError(command) + + qualify.subprocess.run = fake_run + try: + with self.assertRaisesRegex(qualify.QualificationError, "negative_untracked.py"): + qualify.require_clean_build_inputs(Path("/checkout")) + + def cargo_config_run(command, **kwargs): + if command[:3] == ["git", "diff", "--quiet"]: + return types.SimpleNamespace(returncode=0) + return types.SimpleNamespace(stdout="!! .cargo/config.toml\n") + + qualify.subprocess.run = cargo_config_run + with self.assertRaisesRegex(qualify.QualificationError, ".cargo/config.toml"): + qualify.require_clean_build_inputs(Path("/checkout")) + finally: + qualify.subprocess.run = original_run + + def test_allows_only_generated_untracked_build_output(self) -> None: + original_run = qualify.subprocess.run + + def fake_run(command, **kwargs): + if command[:3] == ["git", "diff", "--quiet"]: + return types.SimpleNamespace(returncode=0) + return types.SimpleNamespace( + stdout="?? dist/wheel.whl\n!! target/debug/cache\n?? python/pkg/__pycache__/module.pyc\n" + ) + + qualify.subprocess.run = fake_run + try: + qualify.require_clean_build_inputs(Path("/checkout")) + finally: + qualify.subprocess.run = original_run + + def test_record_removes_stale_receipt_before_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + wheel_dir = Path(directory) + output = wheel_dir / "build.json" + output.write_text('{"passed": true}') + original_clean = qualify.require_clean_build_inputs + qualify.require_clean_build_inputs = lambda _: (_ for _ in ()).throw( + qualify.QualificationError("dirty") + ) + try: + with self.assertRaisesRegex(qualify.QualificationError, "dirty"): + qualify.record( + argparse.Namespace( + wheel_dir=str(wheel_dir), + output=str(output), + source_sha="unused", + target=None, + ) + ) + finally: + qualify.require_clean_build_inputs = original_clean + self.assertFalse(output.exists()) + + def test_verify_removes_stale_report_before_invalid_receipt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "qualification.json" + output.write_text('{"passed": true}') + with self.assertRaisesRegex(qualify.QualificationError, "cannot read build receipt"): + qualify.verify( + argparse.Namespace( + artifact_dir=str(root / "missing"), + output=str(output), + source_sha="unused", + ) + ) + self.assertFalse(output.exists()) + + def test_rejects_modified_imported_native_payload_and_engine_version(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + engine = root / "amplifier_core" / "_engine.so" + engine.parent.mkdir() + engine.write_bytes(b"qualified-native") + payloads = { + "amplifier_core/_engine.so": hashlib.sha256(b"qualified-native").hexdigest() + } + observed = qualify.installed_native_payload(engine, root, payloads) + self.assertEqual(observed["relative_path"], "amplifier_core/_engine.so") + engine.write_bytes(b"modified-native") + with self.assertRaisesRegex(qualify.QualificationError, "engine hash"): + qualify.installed_native_payload(engine, root, payloads) + with self.assertRaisesRegex(qualify.QualificationError, "engine version"): + qualify.verify_installed_metadata( + "amplifier-core", "2.0.1", "2.0.1", "2.0.0", "amplifier-core", "2.0.1" + ) + + def test_rejects_venv_inside_checkout_and_understands_handler_entry_shapes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + checkout = Path(directory) / "checkout" + venv = checkout / ".venv" + engine = venv / "site-packages/amplifier_core/_engine.so" + package = venv / "site-packages/amplifier_core/__init__.py" + engine.parent.mkdir(parents=True) + engine.write_bytes(b"native") + package.write_text("") + with self.assertRaisesRegex(qualify.QualificationError, "checkout"): + qualify.validate_install_locations(engine, package, venv, checkout) + with self.assertRaisesRegex(qualify.QualificationError, "exactly ./artifacts"): + qualify.validate_download_artifact_dir(checkout / "dist", checkout) + self.assertIn( + "handler", + qualify.registered_handler_names({"event": [{"name": "handler"}]}), + ) + self.assertNotIn( + "handler", + qualify.registered_handler_names({"event": [{"name": "other"}]}), + ) + + def test_download_directory_rejects_extra_source_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + artifacts = Path(directory) / "artifacts" + artifacts.mkdir() + (artifacts / "build.json").write_text("{}") + (artifacts / "unexpected.py").write_text("") + with self.assertRaisesRegex(qualify.QualificationError, "unexpected.py"): + qualify.validate_download_artifact_dir(artifacts, Path(directory) / "checkout") + + +class CargoGraphTests(unittest.TestCase): + def test_sanitizes_metadata_to_names_versions_and_edges(self) -> None: + metadata = { + "workspace_root": "/private/checkout", + "packages": [ + {"id": "a", "name": "amplifier-core", "version": "2.0.1", "manifest_path": "/private/a"}, + {"id": "b", "name": "pyo3", "version": "0.29.2", "manifest_path": "/private/b"}, + ], + "resolve": {"nodes": [{"id": "a", "deps": [{"pkg": "b"}]}, {"id": "b", "deps": []}]}, + } + graph = qualify.sanitized_cargo_graph(metadata) + self.assertEqual(graph["packages"], ["amplifier-core@2.0.1", "pyo3@0.29.2"]) + self.assertEqual(graph["dependency_edges"], [["amplifier-core@2.0.1", "pyo3@0.29.2"]]) + self.assertNotIn("/private", json.dumps(graph)) + + def test_supplementary_graph_requires_valid_hash_and_recorded_nodes(self) -> None: + resolved = [{"name": "pyo3", "version": "0.29.2", "checksum": "pyo3"}] + graph = {"packages": ["pyo3@0.29.2"], "dependency_edges": []} + qualify.validate_supplementary_graph(graph, resolved) + self.assertEqual( + qualify.canonical_json_sha256(graph), + qualify.canonical_json_sha256({"dependency_edges": [], "packages": ["pyo3@0.29.2"]}), + ) + with self.assertRaisesRegex(qualify.QualificationError, "unknown packages"): + qualify.validate_supplementary_graph( + {"packages": ["pyo3@0.28.2"], "dependency_edges": []}, resolved + ) + with self.assertRaisesRegex(qualify.QualificationError, "unknown edge"): + qualify.validate_supplementary_graph( + {"packages": ["pyo3@0.29.2"], "dependency_edges": [["pyo3@0.29.2", "other@1"]]}, + resolved, + ) + + def test_rejects_unknown_architecture(self) -> None: + with self.assertRaisesRegex(qualify.QualificationError, "CPU architecture"): + qualify.architecture_label("mips64") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 846ea25490d541661838d8cbb3788d259bfbf1f9 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:58:40 -0700 Subject: [PATCH 3/7] fix: select foundation bundle for e2e smoke test Generated with Amplifier\n\nCo-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- scripts/e2e-smoke-test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/e2e-smoke-test.sh b/scripts/e2e-smoke-test.sh index 75f92d7..9764629 100755 --- a/scripts/e2e-smoke-test.sh +++ b/scripts/e2e-smoke-test.sh @@ -306,11 +306,12 @@ log " Timeout: ${TIMEOUT_SECONDS}s" log "============================================================" echo "" -# Run the smoke test; capture output even if timeout exits non-zero +# Foundation provides the recipe-author agent required by the default prompt. +# Run the smoke test; capture output even if timeout exits non-zero. SMOKE_EXIT_CODE=0 SMOKE_OUTPUT=$(docker exec "$CONTAINER_NAME" bash -c " export PATH=/root/.local/bin:\$PATH - timeout $TIMEOUT_SECONDS amplifier run '$SMOKE_PROMPT' 2>&1 + timeout $TIMEOUT_SECONDS amplifier run --bundle foundation '$SMOKE_PROMPT' 2>&1 " 2>&1) || SMOKE_EXIT_CODE=$? # --------------------------------------------------------------------------- From e898b777e06d25dcfde85aa1c58e03e00e3b2249 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 22 Sep 2026 19:35:41 -0700 Subject: [PATCH 4/7] fix: Fix Rust hook cleanup ordering and registration ownership (cherry picked from commit 3654f7d034d62e1da261bc6bdce745fa7a34fb7c) Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- bindings/python/src/bridges.rs | 61 ++- bindings/python/src/hooks.rs | 71 +++- bindings/python/src/session.rs | 41 +- .../python/tests/test_cleanup_ownership.py | 387 ++++++++++++++++++ python/amplifier_core/_async_compat.py | 34 ++ 5 files changed, 557 insertions(+), 37 deletions(-) create mode 100644 bindings/python/tests/test_cleanup_ownership.py diff --git a/bindings/python/src/bridges.rs b/bindings/python/src/bridges.rs index 9092c6d..9ee9d64 100644 --- a/bindings/python/src/bridges.rs +++ b/bindings/python/src/bridges.rs @@ -44,6 +44,32 @@ pub(crate) struct PyHookHandlerBridge { unsafe impl Send for PyHookHandlerBridge {} unsafe impl Sync for PyHookHandlerBridge {} +/// into_future_with_locals does not cancel its Python task when the Rust +/// receiver is dropped. Keep exact ownership so cancellation cannot orphan a +/// hook callback or affect an unrelated task on the same event loop. +struct OwnedHookTask { + owner: Py, + event_loop: Py, + cancel_on_drop: bool, +} + +impl Drop for OwnedHookTask { + fn drop(&mut self) { + if self.cancel_on_drop { + let result = Python::try_attach(|py| -> PyResult<()> { + let cancel = self.owner.bind(py).getattr("cancel")?; + self.event_loop + .bind(py) + .call_method1("call_soon_threadsafe", (cancel,))?; + Ok(()) + }); + if let Some(Err(error)) = result { + log::warn!("Could not cancel owned Python hook task: {error}"); + } + } + } +} + impl HookHandler for PyHookHandlerBridge { fn handle( &self, @@ -100,7 +126,7 @@ impl HookHandler for PyHookHandlerBridge { // fall back to the registration loop when a native callback (such as // a blocking WASM host import) has lost Tokio task-local state. let py_result = if is_coro { - let future = Python::try_attach(|py| { + let (future, mut task) = Python::try_attach(|py| -> PyResult<_> { let locals = pyo3_async_runtimes::tokio::get_current_locals(py) .ok() .or_else(|| self.fallback_locals.clone()) @@ -109,10 +135,31 @@ impl HookHandler for PyHookHandlerBridge { "No running Python event loop available for coroutine conversion", ) })?; - pyo3_async_runtimes::into_future_with_locals( - &locals, - py_result_or_coro.into_bound(py), - ) + let owner = py + .import("amplifier_core._async_compat")? + .getattr("_OwnedHookTask")? + .call1((py_result_or_coro.bind(py),))?; + let runner = owner.call_method0("run")?; + let future = + match pyo3_async_runtimes::into_future_with_locals(&locals, runner.clone()) + { + Ok(future) => future, + Err(error) => { + // Scheduling failed (for example, the loop closed). + // Neither coroutine was adopted by a Python task. + let _ = runner.call_method0("close"); + let _ = owner.call_method0("cancel"); + return Err(error); + } + }; + Ok(( + future, + OwnedHookTask { + owner: owner.unbind(), + event_loop: locals.event_loop(py).unbind(), + cancel_on_drop: true, + }, + )) }) .ok_or_else(|| HookError::HandlerFailed { message: "Failed to attach to Python runtime for coroutine conversion" @@ -127,7 +174,9 @@ impl HookHandler for PyHookHandlerBridge { // Await OUTSIDE the GIL — drives the Python coroutine on the // current task's loop, or its registration loop after native // re-entry has lost task-local state. - future.await.map_err(|e| HookError::HandlerFailed { + let result = future.await; + task.cancel_on_drop = false; + result.map_err(|e| HookError::HandlerFailed { message: format!("Python async handler error: {e}"), handler_name: None, })? diff --git a/bindings/python/src/hooks.rs b/bindings/python/src/hooks.rs index 054c911..dbd3979 100644 --- a/bindings/python/src/hooks.rs +++ b/bindings/python/src/hooks.rs @@ -13,6 +13,13 @@ use serde_json::Value; use crate::bridges::PyHookHandlerBridge; use crate::helpers::{json_dumps_safe, try_model_dump, wrap_future_as_coroutine}; +struct Registration { + id: uuid::Uuid, + unregister: Box, +} + +type Registrations = Arc>>>; + // --------------------------------------------------------------------------- // PyUnregisterFn — callable returned by PyHookRegistry.register() // --------------------------------------------------------------------------- @@ -24,18 +31,35 @@ use crate::helpers::{json_dumps_safe, try_model_dump, wrap_future_as_coroutine}; /// a callable that unregisters the handler when invoked. #[pyclass(name = "RustUnregisterFn")] pub(crate) struct PyUnregisterFn { - #[allow(clippy::type_complexity)] - unregister_fns: Arc>>>, + unregister_fns: Registrations, name: String, + registration_id: uuid::Uuid, } #[pymethods] impl PyUnregisterFn { fn __call__(&self) -> PyResult<()> { - if let Ok(mut fns) = self.unregister_fns.lock() { - if let Some(unreg) = fns.remove(&self.name) { - unreg(); + let registration = { + let mut fns = self + .unregister_fns + .lock() + .map_err(|e| PyErr::new::(format!("Lock poisoned: {e}")))?; + let mut owned = None; + if let Some(registrations) = fns.get_mut(&self.name) { + if let Some(index) = registrations + .iter() + .position(|r| r.id == self.registration_id) + { + owned = Some(registrations.remove(index)); + } + if registrations.is_empty() { + fns.remove(&self.name); + } } + owned + }; + if let Some(registration) = registration { + (registration.unregister)(); } Ok(()) } @@ -56,9 +80,8 @@ impl PyUnregisterFn { #[pyclass(name = "RustHookRegistry")] pub(crate) struct PyHookRegistry { pub(crate) inner: Arc, - /// Stored unregister closures keyed by handler name. - #[allow(clippy::type_complexity)] - unregister_fns: Arc>>>, + /// Names are labels, not identities: retain each registration's own closure. + unregister_fns: Registrations, } impl PyHookRegistry { @@ -118,10 +141,16 @@ impl PyHookRegistry { self.inner .register(event, bridge, priority, Some(handler_name.clone())); + let registration_id = uuid::Uuid::new_v4(); self.unregister_fns .lock() .map_err(|e| PyErr::new::(format!("Lock poisoned: {e}")))? - .insert(handler_name.clone(), unregister_fn); + .entry(handler_name.clone()) + .or_default() + .push(Registration { + id: registration_id, + unregister: unregister_fn, + }); // Return a callable that unregisters this handler when invoked. // Matches the Python HookRegistry.register() contract. @@ -130,6 +159,7 @@ impl PyHookRegistry { PyUnregisterFn { unregister_fns: self.unregister_fns.clone(), name: handler_name, + registration_id, }, )?; Ok(callable.into_any()) @@ -189,15 +219,22 @@ impl PyHookRegistry { ) } - /// Unregister a handler by name. + /// Unregister the most recent remaining registration with this name. + /// Returned callables still own their exact registration independently. fn unregister(&self, name: &str) -> PyResult<()> { - let mut fns = self - .unregister_fns - .lock() - .map_err(|e| PyErr::new::(format!("Lock poisoned: {e}")))?; - - if let Some(unreg) = fns.remove(name) { - unreg(); + let registration = { + let mut fns = self + .unregister_fns + .lock() + .map_err(|e| PyErr::new::(format!("Lock poisoned: {e}")))?; + let latest = fns.get_mut(name).and_then(Vec::pop); + if fns.get(name).is_some_and(Vec::is_empty) { + fns.remove(name); + } + latest + }; + if let Some(registration) = registration { + (registration.unregister)(); } Ok(()) } diff --git a/bindings/python/src/session.rs b/bindings/python/src/session.rs index b6233b4..1d2f71d 100644 --- a/bindings/python/src/session.rs +++ b/bindings/python/src/session.rs @@ -34,6 +34,10 @@ use crate::hooks::PyHookRegistry; pub(crate) struct PySession { /// Rust kernel session (for session_id, parent_id, initialized flag). inner: Arc>, + /// Serialize the entire end-event/drain sequence. The boolean records an + /// attempted terminal event for the current initialized lifetime, including + /// a cleanup whose waiter is cancelled during that event. + cleanup_state: Arc>, /// The PyCoordinator instance owned by this session. coordinator: Py, /// Original config dict (Python dict). @@ -183,6 +187,7 @@ impl PySession { Ok(Self { inner: Arc::new(tokio::sync::Mutex::new(session)), + cleanup_state: Arc::new(tokio::sync::Mutex::new(false)), coordinator: coord_any, config: config.clone().unbind(), is_resumed, @@ -275,7 +280,7 @@ impl PySession { } // Step 2: Extract what we need before entering the async block - let (coro_py, inner) = { + let (coro_py, inner, cleanup_state) = { let this = slf.borrow(); let helper = py.import("amplifier_core._session_init")?; let init_fn = helper.getattr("initialize_session")?; @@ -288,7 +293,7 @@ impl PySession { // Convert to an owned Py so it's 'static + Send let coro_py: Py = coro.unbind(); let inner = this.inner.clone(); - (coro_py, inner) + (coro_py, inner, this.cleanup_state.clone()) }; // Step 3: Patch the coordinator's session back-reference to point to @@ -326,8 +331,10 @@ impl PySession { // Step 5: Mark session as initialized in Rust kernel { + let mut end_emitted = cleanup_state.lock().await; let session = inner.lock().await; session.set_initialized(); + *end_emitted = false; } Ok(()) @@ -554,14 +561,17 @@ impl PySession { /// Clean up session resources. /// /// Rust controls the full cleanup lifecycle: - /// 1. Call all registered cleanup functions (reverse order, error-tolerant) - /// 2. Emit `session:end` event via hooks + /// 1. Await `session:end` once for the initialized lifetime, while hooks are live + /// 2. Call all registered cleanup functions (reverse order, error-tolerant) /// 3. Reset the initialized flag + /// Concurrent cleanup waits for this entire sequence. Uninitialized and + /// repeated cleanup still release resources but do not emit another end. /// /// Errors in cleanup functions and event emission are logged but never /// propagate — cleanup must always complete. fn cleanup<'py>(&self, py: Python<'py>) -> PyResult> { let inner = self.inner.clone(); + let cleanup_state = self.cleanup_state.clone(); // Grab references we need inside the async block let session_id = self.cached_session_id.clone(); @@ -601,8 +611,20 @@ impl PySession { wrap_future_as_coroutine( py, pyo3_async_runtimes::tokio::future_into_py(py, async move { + // Keep module cleanup behind the awaited terminal event even + // when another caller concurrently requests cleanup. + let mut end_emitted = cleanup_state.lock().await; + let initialized = inner.lock().await.is_initialized(); + if initialized && !*end_emitted { + // Claim before awaiting so a cancelled waiter cannot cause + // a subsequent cleanup to emit the terminal event twice. + *end_emitted = true; + let end_data = serde_json::json!({ "session_id": session_id }); + hooks_inner_for_end.emit("session:end", end_data).await; + } + // ---------------------------------------------------------- - // Step 1: Call all cleanup functions in reverse order + // Step 2: Call all cleanup functions in reverse order // Matches Python main's coordinator.cleanup() pattern: // if callable(fn): // if iscoroutinefunction(fn): await fn() @@ -674,15 +696,6 @@ impl PySession { } } - // ---------------------------------------------------------- - // Step 2: Emit session:end event (best-effort) - // Direct Rust emit — avoids Future/coroutine mismatch when going - // through the Python PyO3 bridge (future_into_py returns a Future, - // but into_future() expects a native coroutine). - // ---------------------------------------------------------- - let end_data = serde_json::json!({ "session_id": session_id }); - hooks_inner_for_end.emit("session:end", end_data).await; - // ---------------------------------------------------------- // Step 3: Reset the initialized flag // ---------------------------------------------------------- diff --git a/bindings/python/tests/test_cleanup_ownership.py b/bindings/python/tests/test_cleanup_ownership.py new file mode 100644 index 0000000..6936b0e --- /dev/null +++ b/bindings/python/tests/test_cleanup_ownership.py @@ -0,0 +1,387 @@ +"""Lifecycle ownership through the public, compiled Rust session and registry. + +The tiny filesystem modules only make initialization possible without providers. +Module loading, hook dispatch, session cleanup and unregister are all real Core. +""" + +import asyncio +import sys + +import pytest + +from amplifier_core import AmplifierSession, HookRegistry +from amplifier_core._engine import RustHookRegistry, RustSession + + +@pytest.fixture +def session_factory(tmp_path, monkeypatch): + names = [] + for module_type, mount_point in (("orchestrator", "orchestrator"), ("context", "context")): + name = f"amplifier_module_cleanup_fixture_{module_type}" + names.append(name) + package = tmp_path / name + package.mkdir() + (package / "__init__.py").write_text( + f'__amplifier_module_type__ = "{module_type}"\n' + "class FixtureModule:\n" + " async def execute(self, *args, **kwargs):\n" + " raise AssertionError('cleanup fixture must never execute a turn')\n" + "async def mount(coordinator, config=None):\n" + " if config and config.get('fail'):\n" + " raise RuntimeError('fixture mount failed')\n" + f' await coordinator.mount("{mount_point}", FixtureModule())\n' + " cleanup_log = coordinator.get_capability('fixture.cleanup')\n" + " if cleanup_log is not None:\n" + f" return lambda: cleanup_log.append('{module_type}')\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + def create(): + assert AmplifierSession is RustSession + return AmplifierSession( + config={ + "session": { + "orchestrator": "cleanup-fixture-orchestrator", + "context": "cleanup-fixture-context", + }, + "providers": [], + } + ) + + yield create + for name in names: + sys.modules.pop(name, None) + + +@pytest.mark.asyncio +async def test_end_is_awaited_before_module_cleanup_unregisters_it(session_factory): + session = session_factory() + await session.initialize() + calls = [] + + async def end(event, data): + await asyncio.sleep(0) + calls.append((event, data["session_id"])) + + unregister = session.coordinator.hooks.register("session:end", end, name="telemetry") + + def close(): + calls.append("close") + unregister() + + session.coordinator.register_cleanup(close) + await session.cleanup() + assert calls == [("session:end", session.session_id), "close"] + assert not session.initialized + + +@pytest.mark.asyncio +async def test_concurrent_cleanup_cannot_close_while_end_handler_is_running(session_factory): + session = session_factory() + await session.initialize() + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def end(event, data): + calls.append("end:entered") + entered.set() + await release.wait() + calls.append("end:finished") + + session.coordinator.hooks.register("session:end", end, name="telemetry") + session.coordinator.register_cleanup(lambda: calls.append("close")) + first = asyncio.create_task(session.cleanup()) + second = None + try: + await asyncio.wait_for(entered.wait(), timeout=2) + second = asyncio.create_task(session.cleanup()) + done, _ = await asyncio.wait({second}, timeout=0.05) + assert not done + assert calls == ["end:entered"] + finally: + release.set() + await asyncio.gather(first, *([second] if second else [])) + assert calls.count("end:entered") == 1 + assert calls.index("end:finished") < calls.index("close") + + +@pytest.mark.asyncio +async def test_cancelled_end_attempt_is_not_replayed_and_python_handler_exits(session_factory): + session = session_factory() + await session.initialize() + entered, release, exited = asyncio.Event(), asyncio.Event(), asyncio.Event() + calls = [] + + async def end(event, data): + calls.append("end:entered") + entered.set() + try: + await release.wait() + finally: + calls.append("end:exited") + exited.set() + + session.coordinator.hooks.register("session:end", end, name="telemetry") + session.coordinator.register_cleanup(lambda: calls.append("close")) + first = asyncio.create_task(session.cleanup()) + try: + await asyncio.wait_for(entered.wait(), timeout=2) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + await asyncio.wait_for(exited.wait(), timeout=2) + await asyncio.wait_for(session.cleanup(), timeout=2) + assert calls == ["end:entered", "end:exited", "close"] + assert not session.initialized + finally: + release.set() + if not first.done(): + first.cancel() + await asyncio.gather(first, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_uninitialized_cleanup_still_releases_partial_resources(session_factory): + session = session_factory() + calls = [] + session.coordinator.hooks.register( + "session:end", lambda event, data: calls.append("end"), name="telemetry" + ) + session.coordinator.register_cleanup(lambda: calls.append("partial cleanup")) + await session.cleanup() + assert calls == ["partial cleanup"] + + +@pytest.mark.asyncio +async def test_failed_initialization_cleans_up_already_mounted_module(session_factory): + session = session_factory() + calls = [] + session.coordinator.register_capability("fixture.cleanup", calls) + session.config["session"]["context"] = { + "module": "cleanup-fixture-context", "config": {"fail": True} + } + session.coordinator.hooks.register( + "session:end", lambda event, data: calls.append("end"), name="telemetry" + ) + with pytest.raises(RuntimeError, match="fixture mount failed"): + await session.initialize() + assert not session.initialized + await session.cleanup() + assert calls == ["orchestrator"] + + +@pytest.mark.asyncio +async def test_end_once_per_initialized_lifetime(session_factory): + session = session_factory() + calls = [] + session.coordinator.hooks.register( + "session:end", lambda event, data: calls.append("end"), name="telemetry" + ) + session.coordinator.register_cleanup(lambda: calls.append("close")) + await session.initialize() + await session.cleanup() + await session.cleanup() + # Cleanup callbacks remain best-effort/idempotent resources, as before; + # a second cleanup does not fabricate another terminal lifecycle event. + assert calls == ["end", "close", "close"] + await session.initialize() + await session.cleanup() + assert calls == ["end", "close", "close", "end", "close"] + + +@pytest.mark.asyncio +async def test_end_handler_failure_does_not_skip_cleanup(session_factory): + session = session_factory() + await session.initialize() + calls = [] + + async def end(event, data): + calls.append("end") + raise RuntimeError("fixture failure") + + session.coordinator.hooks.register("session:end", end, name="telemetry") + session.coordinator.register_cleanup(lambda: calls.append("close")) + await session.cleanup() + assert calls == ["end", "close"] + assert not session.initialized + + +@pytest.mark.asyncio +@pytest.mark.parametrize("same_event", [False, True]) +async def test_unregister_owns_one_registration_even_with_repeated_names(same_event): + assert HookRegistry is RustHookRegistry + hooks = HookRegistry() + calls = [] + second_event = "first:event" if same_event else "second:event" + first = hooks.register( + "first:event", lambda event, data: calls.append("first"), name="shared" + ) + second = hooks.register( + second_event, lambda event, data: calls.append("second"), name="shared" + ) + first() + first() + for event in dict.fromkeys(("first:event", second_event)): + await hooks.emit(event, {}) + assert calls == ["second"] + second() + second() + calls.clear() + for event in dict.fromkeys(("first:event", second_event)): + await hooks.emit(event, {}) + assert calls == [] + + +@pytest.mark.asyncio +async def test_old_unregister_handle_cannot_remove_new_registration(): + hooks = HookRegistry() + calls = [] + old = hooks.register("event", lambda event, data: calls.append("old"), name="shared") + old() + new = hooks.register("event", lambda event, data: calls.append("new"), name="shared") + old() + await hooks.emit("event", {}) + assert calls == ["new"] + new() + + +@pytest.mark.asyncio +async def test_name_unregister_and_owned_handles_do_not_remove_other_registrations(): + hooks = HookRegistry() + calls = [] + old = hooks.register("event", lambda event, data: calls.append("old"), name="shared") + latest = hooks.register("event", lambda event, data: calls.append("latest"), name="shared") + # Preserve the existing name API's most-recent-registration behavior. + hooks.unregister("shared") + latest() + await hooks.emit("event", {}) + assert calls == ["old"] + old() + calls.clear() + await hooks.emit("event", {}) + assert calls == [] + + +@pytest.mark.asyncio +async def test_cancelled_hook_only_cancels_its_owned_python_task(): + hooks = HookRegistry() + entered, exited, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + sibling_started = asyncio.Event() + + async def sibling(): + sibling_started.set() + await release.wait() + return "sibling completed" + + async def hook(event, data): + entered.set() + try: + await release.wait() + finally: + exited.set() + + hooks.register("event", hook, name="owned") + other = asyncio.create_task(sibling()) + pending = asyncio.create_task(hooks.emit("event", {})) + try: + await asyncio.wait_for(entered.wait(), timeout=2) + await asyncio.wait_for(sibling_started.wait(), timeout=2) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + await asyncio.wait_for(exited.wait(), timeout=2) + assert not other.done() + finally: + release.set() + await asyncio.gather(pending, return_exceptions=True) + assert await other == "sibling completed" + + +@pytest.mark.asyncio +async def test_async_hook_result_error_and_emitting_context_are_preserved(): + import contextvars + + value = contextvars.ContextVar("owned-hook-context", default="unset") + hooks = HookRegistry() + calls = [] + + async def failing(event, data): + await asyncio.sleep(0) + calls.append(value.get()) + raise RuntimeError("expected hook failure") + + async def result(event, data): + await asyncio.sleep(0) + return {"action": "deny", "reason": value.get()} + + value.set("registration") + hooks.register("event", failing, priority=0, name="failure") + hooks.register("event", result, priority=1, name="result") + value.set("emission") + answer = await hooks.emit("event", {}) + assert calls == ["emission"] + assert answer.action == "deny" + assert answer.reason == "emission" + + +@pytest.mark.asyncio +async def test_owned_hook_cancel_before_start_closes_unstarted_coroutine(): + import inspect + from amplifier_core._async_compat import _OwnedHookTask + + calls = [] + + async def hook(): + calls.append("started") + + coroutine = hook() + owner = _OwnedHookTask(coroutine) + owner.cancel() + assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED + with pytest.raises(asyncio.CancelledError): + await owner.run() + assert calls == [] + + +@pytest.mark.asyncio +async def test_hook_scheduling_failure_closes_both_unawaited_coroutines(monkeypatch): + import inspect + from amplifier_core import _async_compat + + coroutines, calls, refusals = [], [], [] + + class ObservedOwner(_async_compat._OwnedHookTask): + def __init__(self, coroutine): + super().__init__(coroutine) + coroutines.append(coroutine) + + def run(self): + runner = super().run() + coroutines.append(runner) + return runner + + loop = asyncio.get_running_loop() + original = loop.call_soon_threadsafe + + def refuse_hook_schedule(callback, *args, **kwargs): + # Refuse only the actual PyO3 Python-awaitable conversion. Keep its + # result-delivery callback working so the real registry returns the + # normal fail-open HookResult for a failed hook. + if type(callback).__name__ == "PyEnsureFuture": + refusals.append(type(callback).__name__) + raise RuntimeError("fixture hook scheduling refused") + return original(callback, *args, **kwargs) + + async def hook(event, data): + calls.append("started") + + monkeypatch.setattr(_async_compat, "_OwnedHookTask", ObservedOwner) + monkeypatch.setattr(loop, "call_soon_threadsafe", refuse_hook_schedule) + hooks = HookRegistry() + hooks.register("event", hook, name="fixture") + result = await hooks.emit("event", {}) + assert result.action == "continue" + assert refusals == ["PyEnsureFuture"] + assert calls == [] + assert len(coroutines) == 2 + assert all(inspect.getcoroutinestate(c) == inspect.CORO_CLOSED for c in coroutines) diff --git a/python/amplifier_core/_async_compat.py b/python/amplifier_core/_async_compat.py index 71e7f62..c1a8d9a 100644 --- a/python/amplifier_core/_async_compat.py +++ b/python/amplifier_core/_async_compat.py @@ -6,6 +6,40 @@ methods are drop-in compatible with the old pure-Python async def methods. """ +import asyncio + + +class _OwnedHookTask: + """Let a dropped Rust hook waiter cancel only its scheduled Python task. + + ``run`` is scheduled by into_future_with_locals, retaining the emitting + task's context. ``cancel`` must be called on that same event loop. Remember + cancellation before startup too: a dropped waiter must not start its hook + later merely because the loop has not yet run the scheduled coroutine. + """ + + def __init__(self, coroutine): + self._coroutine = coroutine + self._task = None + self._cancel_requested = False + + async def run(self): + if self._cancel_requested: + self._coroutine.close() + raise asyncio.CancelledError + self._task = asyncio.current_task() + try: + return await self._coroutine + finally: + self._task = None + + def cancel(self): + self._cancel_requested = True + if self._task is not None: + self._task.cancel() + else: + self._coroutine.close() + async def _wrap(awaitable): """Wrap a PyO3 awaitable in a proper Python coroutine.""" From 5418c0c386c987d6f8a91fe5dc10050f6c131c89 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 22 Sep 2026 20:38:05 -0700 Subject: [PATCH 5/7] fix: Prepare lifecycle fix for versioned CLI smoke validation (cherry picked from commit 3e9cc379df41f51fabd30a36bb71df560a8ab82a) Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- scripts/e2e-smoke-test.sh | 63 +++++++--- tests/test_e2e_smoke_script.py | 221 +++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 tests/test_e2e_smoke_script.py diff --git a/scripts/e2e-smoke-test.sh b/scripts/e2e-smoke-test.sh index 9764629..3bef076 100755 --- a/scripts/e2e-smoke-test.sh +++ b/scripts/e2e-smoke-test.sh @@ -7,7 +7,7 @@ set -euo pipefail # # Prerequisites: # - Docker installed and running -# - ANTHROPIC_API_KEY set in environment (or in ~/.amplifier/keys.env) +# - Selected provider API key in environment (or in ~/.amplifier/keys.env) # - maturin installed (pip install maturin) # # Usage: @@ -20,6 +20,10 @@ set -euo pipefail # --local-source /path/to/amplifier-foundation # # Environment variables: +# SMOKE_PROVIDER anthropic (default) or openai +# SMOKE_MODEL Optional model override (otherwise use provider default) +# SMOKE_BUNDLE Optional bundle override (otherwise use CLI default) +# ANTHROPIC_BASE_URL / OPENAI_BASE_URL Optional selected provider endpoint # SMOKE_PROMPT Override the default test prompt # SMOKE_TIMEOUT Override the timeout in seconds (default: 360) @@ -31,6 +35,11 @@ SKIP_BUILD=false SMOKE_PROMPT="${SMOKE_PROMPT:-Ask recipe author to run one of its example recipes}" TIMEOUT_SECONDS="${SMOKE_TIMEOUT:-360}" LOCAL_SOURCES=() +SMOKE_PROVIDER="${SMOKE_PROVIDER:-anthropic}" +SMOKE_MODEL="${SMOKE_MODEL:-}" +# Default to Foundation so the recipe-author agent is available in the +# maintained smoke path; callers can override this with SMOKE_BUNDLE. +SMOKE_BUNDLE="${SMOKE_BUNDLE:-foundation}" # Colors (defined early so fail() works during arg parsing) RED='\033[0;31m' @@ -73,7 +82,11 @@ while [[ $# -gt 0 ]]; do echo " --local-source ../amplifier-bundle-modes/modules/hooks-mode" echo "" echo "Environment variables:" - echo " ANTHROPIC_API_KEY Required (or set in ~/.amplifier/keys.env)" + echo " SMOKE_PROVIDER anthropic (default) or openai" + echo " SMOKE_MODEL Optional model override" + echo " SMOKE_BUNDLE Optional bundle override" + echo " Selected provider API key required (environment or ~/.amplifier/keys.env)" + echo " ANTHROPIC_BASE_URL / OPENAI_BASE_URL Optional selected provider endpoint" echo " SMOKE_PROMPT Test prompt (default: 'Ask recipe author to run one of its example recipes')" echo " SMOKE_TIMEOUT Timeout in seconds (default: 360)" exit 0 @@ -92,8 +105,20 @@ trap cleanup EXIT # Step 0: Resolve API keys # --------------------------------------------------------------------------- -# If ANTHROPIC_API_KEY is not set, try to load from ~/.amplifier/keys.env -if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then +# Select the credential family before falling back to the existing keys file. +case "$SMOKE_PROVIDER" in + anthropic) + PROVIDER_KEY_ENV=ANTHROPIC_API_KEY + PROVIDER_BASE_URL_ENV=ANTHROPIC_BASE_URL + ;; + openai) + PROVIDER_KEY_ENV=OPENAI_API_KEY + PROVIDER_BASE_URL_ENV=OPENAI_BASE_URL + ;; + *) fail "SMOKE_PROVIDER must be anthropic or openai" ;; +esac + +if [[ -z "${!PROVIDER_KEY_ENV:-}" ]]; then KEYS_ENV="$HOME/.amplifier/keys.env" if [[ -f "$KEYS_ENV" ]]; then log "Loading API keys from $KEYS_ENV..." @@ -104,7 +129,13 @@ if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then fi fi -[[ -z "${ANTHROPIC_API_KEY:-}" ]] && fail "ANTHROPIC_API_KEY not set. Set it in your environment or in ~/.amplifier/keys.env" +[[ -z "${!PROVIDER_KEY_ENV:-}" ]] && fail "$PROVIDER_KEY_ENV not set. Set it in your environment or in ~/.amplifier/keys.env" +# Pass names, not values, in Docker argv. Only the selected family reaches +# noninteractive CLI auto-init, which otherwise prefers ambient Anthropic keys. +PROVIDER_ENV_ARGS=(-e "$PROVIDER_KEY_ENV") +if [[ -n "${!PROVIDER_BASE_URL_ENV:-}" ]]; then + PROVIDER_ENV_ARGS+=(-e "$PROVIDER_BASE_URL_ENV") +fi command -v docker &>/dev/null || fail "Docker not installed or not in PATH" # --------------------------------------------------------------------------- @@ -187,9 +218,7 @@ log "Pristine-import preflight passed." log "Creating isolated Docker container..." docker run -d --name "$CONTAINER_NAME" \ - -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ - -e OPENAI_API_KEY="${OPENAI_API_KEY:-}" \ - -e AZURE_OPENAI_API_KEY="${AZURE_OPENAI_API_KEY:-}" \ + "${PROVIDER_ENV_ARGS[@]}" \ python:3.12-slim \ sleep 3600 \ || fail "Container creation failed" @@ -306,13 +335,19 @@ log " Timeout: ${TIMEOUT_SECONDS}s" log "============================================================" echo "" -# Foundation provides the recipe-author agent required by the default prompt. -# Run the smoke test; capture output even if timeout exits non-zero. +# Run the smoke test; capture output even if timeout exits non-zero SMOKE_EXIT_CODE=0 -SMOKE_OUTPUT=$(docker exec "$CONTAINER_NAME" bash -c " - export PATH=/root/.local/bin:\$PATH - timeout $TIMEOUT_SECONDS amplifier run --bundle foundation '$SMOKE_PROMPT' 2>&1 -" 2>&1) || SMOKE_EXIT_CODE=$? +SMOKE_OUTPUT=$(docker exec "$CONTAINER_NAME" bash -c ' + export PATH=/root/.local/bin:$PATH + smoke_args=(--provider "$2") + if [[ -n "$3" ]]; then + smoke_args+=(--model "$3") + fi + if [[ -n "$5" ]]; then + smoke_args+=(--bundle "$5") + fi + timeout "$1" amplifier run "${smoke_args[@]}" -- "$4" 2>&1 +' smoke-run "$TIMEOUT_SECONDS" "$SMOKE_PROVIDER" "$SMOKE_MODEL" "$SMOKE_PROMPT" "$SMOKE_BUNDLE" 2>&1) || SMOKE_EXIT_CODE=$? # --------------------------------------------------------------------------- # Step 8: Evaluate results diff --git a/tests/test_e2e_smoke_script.py b/tests/test_e2e_smoke_script.py new file mode 100644 index 0000000..4327315 --- /dev/null +++ b/tests/test_e2e_smoke_script.py @@ -0,0 +1,221 @@ +"""Exercise the release script with inert Docker and CLI process fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/e2e-smoke-test.sh" +BASH = shutil.which("bash") +pytestmark = pytest.mark.skipif(BASH is None, reason="release script requires bash") + + +@pytest.fixture +def smoke(tmp_path): + repo = tmp_path / "source" + (repo / "scripts").mkdir(parents=True) + shutil.copyfile(SCRIPT, repo / "scripts/e2e-smoke-test.sh") + (repo / "dist").mkdir() + (repo / "dist/amplifier_core-2.0.0-py3-none-any.whl").write_text("inert wheel") + home = tmp_path / "home" + (home / ".amplifier").mkdir(parents=True) + commands = tmp_path / "bin" + commands.mkdir() + log = tmp_path / "calls.jsonl" + state = tmp_path / "container.json" + fixture = tmp_path / "fixture.py" + fixture.write_text( + """import json, os, pathlib, subprocess, sys +kind, *args = sys.argv[1:] +with open(os.environ['FIXTURE_LOG'], 'a') as f: + f.write(json.dumps({'kind': kind, 'args': args, + 'provider_env': {k:v for k,v in os.environ.items() + if k.startswith(('ANTHROPIC_', 'OPENAI_', 'AZURE_'))}}) + '\\n') +if kind == 'cli': + print(os.environ.get('FIXTURE_SMOKE_OUTPUT', 'inert CLI completed')) + sys.exit(int(os.environ.get('FIXTURE_SMOKE_EXIT', '0'))) +if kind == 'timeout': + sys.exit(0) +if kind != 'docker': + sys.exit(90) +if args[0] in ('rm', 'cp'): + sys.exit(0) +if args[0] == 'run': + if '-d' in args: + selected = {} + for i, arg in enumerate(args): + if arg == '-e': + key, sep, value = args[i+1].partition('=') + if sep or key in os.environ: + selected[key] = value if sep else os.environ[key] + pathlib.Path(os.environ['FIXTURE_STATE']).write_text(json.dumps(selected)) + print('inert-container') + else: + sys.exit(int(os.environ.get('FIXTURE_PREFLIGHT_EXIT', '0'))) + sys.exit(0) +if args[0] == 'exec' and args[2:4] == ['bash', '-c']: + command = args[4] + if 'amplifier run' in command: + env = {k:v for k,v in os.environ.items() + if not k.startswith(('ANTHROPIC_', 'OPENAI_', 'AZURE_'))} + env.update(json.loads(pathlib.Path(os.environ['FIXTURE_STATE']).read_text())) + result = subprocess.run([os.environ['FIXTURE_BASH'], '-c', *args[4:]], + env=env, timeout=5) + sys.exit(result.returncode) + if 'amplifier --version' in command: + print('amplifier fixture (core 2.0.0)') + elif 'uv pip install' in command: + code = int(os.environ.get('FIXTURE_INSTALL_EXIT', '0')) + print('Installed inert wheel' if code == 0 else 'install failed') + sys.exit(code) + elif 'apt-get' not in command and 'uv tool install' not in command: + sys.exit(91) + sys.exit(0) +sys.exit(92) +""" + ) + docker = commands / "docker" + docker.write_text( + f"#!{sys.executable}\n" + "import os, sys\n" + "os.execv(sys.executable, [sys.executable, os.environ['FIXTURE_PROGRAM']," + " 'docker', *sys.argv[1:]])\n" + ) + docker.chmod(0o700) + # Shell functions take precedence over PATH, including /root/.local/bin + # inserted by the actual smoke command. No installed CLI can be reached. + shell_env = tmp_path / "shell-env" + shell_env.write_text( + 'amplifier() { "$FIXTURE_PYTHON" "$FIXTURE_PROGRAM" cli "$@"; }\n' + 'timeout() { "$FIXTURE_PYTHON" "$FIXTURE_PROGRAM" timeout "$1"; ' + 'shift; "$@"; }\n' + ) + env = { + "PATH": f"{commands}:/usr/bin:/bin", + "HOME": str(home), + "BASH_ENV": str(shell_env), + "FIXTURE_PROGRAM": str(fixture), + "FIXTURE_PYTHON": sys.executable, + "FIXTURE_BASH": BASH, + "FIXTURE_LOG": str(log), + "FIXTURE_STATE": str(state), + } + + def run(extra=None, keys=None): + if keys is not None: + (home / ".amplifier/keys.env").write_text(keys) + result = subprocess.run( + [BASH, str(repo / "scripts/e2e-smoke-test.sh"), "--skip-build"], + env=env | (extra or {}), + capture_output=True, + text=True, + timeout=15, + ) + calls = [json.loads(line) for line in log.read_text().splitlines()] + return result, calls + + return run + + +def cli_call(calls): + found = [call for call in calls if call["kind"] == "cli"] + assert len(found) == 1 + return found[0] + + +def test_default_anthropic_keeps_optional_model_and_endpoint(smoke): + result, calls = smoke({"ANTHROPIC_API_KEY": "fixture-anthropic"}) + assert result.returncode == 0, result.stdout + result.stderr + call = cli_call(calls) + assert call["args"] == [ + "run", "--provider", "anthropic", "--", + "Ask recipe author to run one of its example recipes", + ] + assert call["provider_env"] == {"ANTHROPIC_API_KEY": "fixture-anthropic"} + + +@pytest.mark.parametrize("provider", ["anthropic", "openai"]) +def test_selected_family_only_with_endpoint_model_and_literal_arguments(smoke, tmp_path, provider): + prefix = provider.upper() + marker = tmp_path / "must-not-exist" + prompt = f"literal'; touch {marker}; # $(touch {marker})" + model = f"model' $(touch {marker})" + result, calls = smoke({ + "SMOKE_PROVIDER": provider, "SMOKE_MODEL": model, "SMOKE_PROMPT": prompt, + "ANTHROPIC_API_KEY": "fixture-anthropic", "OPENAI_API_KEY": "fixture-openai", + "AZURE_OPENAI_API_KEY": "fixture-azure", + "ANTHROPIC_BASE_URL": "https://anthropic.invalid/", "OPENAI_BASE_URL": "https://openai.invalid/v1", + }, keys="exit 87\n") + assert result.returncode == 0, result.stdout + result.stderr + call = cli_call(calls) + assert call["args"] == ["run", "--provider", provider, "--model", model, "--", prompt] + assert call["provider_env"] == { + f"{prefix}_API_KEY": f"fixture-{provider}", + f"{prefix}_BASE_URL": f"https://{provider}.invalid/" + ("v1" if provider == "openai" else ""), + } + assert not marker.exists() + assert "Loading API keys" not in result.stdout + + +def test_leading_dash_prompt_cannot_become_cli_help(smoke): + result, calls = smoke({"ANTHROPIC_API_KEY": "fixture-key", "SMOKE_PROMPT": "--help"}) + assert result.returncode == 0, result.stdout + result.stderr + assert cli_call(calls)["args"] == ["run", "--provider", "anthropic", "--", "--help"] + + +def test_optional_bundle_uses_supported_cli_argument(smoke): + result, calls = smoke({ + "OPENAI_API_KEY": "fixture-key", "SMOKE_PROVIDER": "openai", + "SMOKE_MODEL": "gpt-5.6-terra", "SMOKE_BUNDLE": "foundation", + "SMOKE_PROMPT": "fixture recipe", + }) + assert result.returncode == 0, result.stdout + result.stderr + assert cli_call(calls)["args"] == [ + "run", "--provider", "openai", "--model", "gpt-5.6-terra", + "--bundle", "foundation", "--", "fixture recipe", + ] + + +@pytest.mark.parametrize("provider", ["anthropic", "openai"]) +def test_selected_key_fallback_is_preserved(smoke, provider): + prefix = provider.upper() + result, calls = smoke( + {"SMOKE_PROVIDER": provider}, + keys=f'{prefix}_API_KEY="fixture-key"\n{prefix}_BASE_URL="https://fixture.invalid/"\n', + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "Loading API keys" in result.stdout + assert cli_call(calls)["provider_env"] == { + f"{prefix}_API_KEY": "fixture-key", f"{prefix}_BASE_URL": "https://fixture.invalid/", + } + + +@pytest.mark.parametrize("extra", [{"SMOKE_PROVIDER": "unsupported"}, {"SMOKE_PROVIDER": "openai"}]) +def test_invalid_selection_or_missing_selected_key_stops_before_container(smoke, extra): + result, calls = smoke({"ANTHROPIC_API_KEY": "fixture-unselected"} | extra) + assert result.returncode != 0 + assert all(call["kind"] == "docker" and call["args"][0] == "rm" for call in calls) + + +@pytest.mark.parametrize( + ("extra", "message"), + [ + ({"FIXTURE_SMOKE_EXIT": "7"}, "amplifier exited with status 7"), + ({"FIXTURE_SMOKE_EXIT": "124"}, "TIMED OUT"), + ({"FIXTURE_SMOKE_OUTPUT": "Tool fixture failed: synthetic"}, "tool failure"), + ({"FIXTURE_PREFLIGHT_EXIT": "9"}, "Pristine-import preflight failed"), + ({"FIXTURE_INSTALL_EXIT": "8"}, "Wheel override failed"), + ], +) +def test_real_script_propagates_fixture_failures_and_cleans_container(smoke, extra, message): + result, calls = smoke({"ANTHROPIC_API_KEY": "fixture-key"} | extra) + assert result.returncode != 0 + assert message in result.stdout + assert "SMOKE TEST PASSED" not in result.stdout + assert calls[-1]["kind"] == "docker" and calls[-1]["args"][:2] == ["rm", "-f"] From 26171294a7ebc4e27e91ab4194b2e35d6f288ccc Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:59:45 -0700 Subject: [PATCH 6/7] fix: clarify Python session cleanup ownership Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> (cherry picked from commit 1e3ef7c3dba78cac74b1383115b143fef1db2265) Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- CONTRACTS.md | 23 ++++++++++++++++ bindings/python/src/session.rs | 26 ++++++++++++++----- .../python/tests/test_cleanup_ownership.py | 22 +++++++++++++--- .../python/tests/test_coroutine_compat.py | 8 ++++-- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CONTRACTS.md b/CONTRACTS.md index aa65b54..77f87c8 100644 --- a/CONTRACTS.md +++ b/CONTRACTS.md @@ -235,6 +235,29 @@ but do not prevent the remaining callables from running. Source reference: `bindings/python/src/coordinator/mod.rs::cleanup()` and `bindings/python/src/coordinator/capabilities.rs::register_cleanup()`. +### Rust-backed Python Session Cleanup + +For the PyO3 `RustSession`, `cleanup()` claims and then awaits `session:end` at most +once per successfully initialized lifetime, before registered resource callbacks run. +A successful later `initialize()` resets that claim. Cancellation while a claimed +terminal dispatch is in progress may abort it: handlers not yet reached may never +receive the event, and a later cleanup does not replay it. Cancellation during +resource callbacks may interrupt remaining teardown without allowing a second +terminal attempt. The caller or host retaining and awaiting the cleanup task owns any +deadline or abandonment decision; waiter cancellation does not guarantee callbacks +drain. + +Handler cancellation is requested asynchronously. An immediate retry may run +resource callbacks before a prior terminal handler observes cancellation; the +cancellation path has no handler-drain or ordering guarantee. + +Uninitialized or partially initialized sessions still run registered resource +callbacks. Callbacks run in reverse registration order, tolerate errors, and may run +again on repeated cleanup, so they must be idempotent. Error tolerance does not make +cleanup cancellation-proof. Hooks and cleanup callbacks must not recursively await +`session.cleanup()`, because the outer cleanup is awaiting them and would form an +await-dependency cycle; this is a caller contract, not a runtime-enforced guard. + ### `on_session_ready(coordinator)` — Optional ```python diff --git a/bindings/python/src/session.rs b/bindings/python/src/session.rs index 1d2f71d..ab15745 100644 --- a/bindings/python/src/session.rs +++ b/bindings/python/src/session.rs @@ -34,9 +34,10 @@ use crate::hooks::PyHookRegistry; pub(crate) struct PySession { /// Rust kernel session (for session_id, parent_id, initialized flag). inner: Arc>, - /// Serialize the entire end-event/drain sequence. The boolean records an - /// attempted terminal event for the current initialized lifetime, including - /// a cleanup whose waiter is cancelled during that event. + /// Records whether cleanup has claimed `session:end` for the current + /// initialized lifetime. The claim happens before awaiting handlers. If + /// cancellation occurs while the claimed terminal dispatch is in progress, + /// a later cleanup does not replay it. cleanup_state: Arc>, /// The PyCoordinator instance owned by this session. coordinator: Py, @@ -561,14 +562,25 @@ impl PySession { /// Clean up session resources. /// /// Rust controls the full cleanup lifecycle: - /// 1. Await `session:end` once for the initialized lifetime, while hooks are live + /// 1. Claim then await `session:end` once for the initialized lifetime, + /// while hooks are live /// 2. Call all registered cleanup functions (reverse order, error-tolerant) /// 3. Reset the initialized flag - /// Concurrent cleanup waits for this entire sequence. Uninitialized and - /// repeated cleanup still release resources but do not emit another end. + /// Concurrent cleanup waits for this entire sequence absent cancellation. + /// Uninitialized and repeated cleanup still release resources but do not + /// emit another end. + /// Cancellation while the claimed terminal dispatch is in progress may + /// abort it; handlers not yet reached are not replayed by a later cleanup. + /// Cancellation during resource callbacks may interrupt remaining teardown + /// without allowing a second terminal attempt. The caller or host retaining + /// and awaiting the cleanup task owns any deadline or abandonment decision; + /// waiter cancellation does not guarantee callbacks drain. + /// Handler cancellation is requested asynchronously, so an immediate retry + /// may run resource callbacks before a prior terminal handler observes + /// cancellation; this path has no handler-drain or ordering guarantee. /// /// Errors in cleanup functions and event emission are logged but never - /// propagate — cleanup must always complete. + /// propagate. Error-tolerant cleanup is not cancellation-proof. fn cleanup<'py>(&self, py: Python<'py>) -> PyResult> { let inner = self.inner.clone(); let cleanup_state = self.cleanup_state.clone(); diff --git a/bindings/python/tests/test_cleanup_ownership.py b/bindings/python/tests/test_cleanup_ownership.py index 6936b0e..0b3e495 100644 --- a/bindings/python/tests/test_cleanup_ownership.py +++ b/bindings/python/tests/test_cleanup_ownership.py @@ -112,7 +112,7 @@ async def test_cancelled_end_attempt_is_not_replayed_and_python_handler_exits(se entered, release, exited = asyncio.Event(), asyncio.Event(), asyncio.Event() calls = [] - async def end(event, data): + async def blocking_end(event, data): calls.append("end:entered") entered.set() try: @@ -121,7 +121,15 @@ async def end(event, data): calls.append("end:exited") exited.set() - session.coordinator.hooks.register("session:end", end, name="telemetry") + async def later_end(event, data): + calls.append("end:later") + + session.coordinator.hooks.register( + "session:end", blocking_end, priority=10, name="blocking-telemetry" + ) + session.coordinator.hooks.register( + "session:end", later_end, priority=20, name="later-telemetry" + ) session.coordinator.register_cleanup(lambda: calls.append("close")) first = asyncio.create_task(session.cleanup()) try: @@ -129,15 +137,21 @@ async def end(event, data): first.cancel() with pytest.raises(asyncio.CancelledError): await first + assert "close" not in calls + await session.cleanup() await asyncio.wait_for(exited.wait(), timeout=2) - await asyncio.wait_for(session.cleanup(), timeout=2) - assert calls == ["end:entered", "end:exited", "close"] + assert calls.count("end:entered") == 1 + assert calls.count("end:exited") == 1 + assert calls.count("close") == 1 + assert "end:later" not in calls assert not session.initialized finally: release.set() if not first.done(): first.cancel() await asyncio.gather(first, return_exceptions=True) + if entered.is_set() and not exited.is_set(): + await asyncio.wait_for(exited.wait(), timeout=2) @pytest.mark.asyncio diff --git a/bindings/python/tests/test_coroutine_compat.py b/bindings/python/tests/test_coroutine_compat.py index 4ad5d88..0ff0834 100644 --- a/bindings/python/tests/test_coroutine_compat.py +++ b/bindings/python/tests/test_coroutine_compat.py @@ -27,7 +27,9 @@ async def test_hook_registry_emit_returns_coroutine(self): assert inspect.iscoroutine(result), ( f"emit() should return a coroutine, got {type(result).__name__}" ) - result.close() # cleanup + # The Rust future starts before this Python wrapper is awaited, so drain it + # before pytest tears down the event loop. + await result @pytest.mark.asyncio async def test_hook_registry_emit_and_collect_returns_coroutine(self): @@ -36,7 +38,9 @@ async def test_hook_registry_emit_and_collect_returns_coroutine(self): assert inspect.iscoroutine(result), ( f"emit_and_collect() should return a coroutine, got {type(result).__name__}" ) - result.close() + # The Rust future starts before this Python wrapper is awaited, so drain it + # before pytest tears down the event loop. + await result @pytest.mark.asyncio async def test_emit_works_with_create_task(self): From 0f5181ec774ad04e97035c5ac8e66d0344a5e613 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:02:07 -0700 Subject: [PATCH 7/7] docs: align combined 2.0.1 release guidance Generated with Amplifier\n\nCo-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 36 +++++++++++++++++------------ docs/NATIVE_ARTIFACTS.md | 42 +++++++++++++++++----------------- scripts/e2e-smoke-test.sh | 4 ++-- tests/test_e2e_smoke_script.py | 15 ++++++++---- 4 files changed, 55 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 37887e2..ec420b9 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,13 @@ The kernel provides **capabilities** without **decisions**: ### For consumers -The current PyPI release is `1.6.1`. Version `2.0.1` is a **proposed** -security release, not a published release. Do not treat this repository state -as a released or qualified binary. +Core `2.0.1` combines the patched PyO3 dependency family, lifecycle contract +work, and updated Actions pins. It is available only when the exact version is +on PyPI, a non-draft GitHub release is published, and that release includes +the matching qualification receipt. Branch and pull-request artifacts are not +published release artifacts. -After a release is qualified and its official release note names it, install -the exact version as a binary-only dependency: +Install an available, qualified version as a binary-only dependency: ```bash python -m pip install --only-binary=:all: amplifier-core== @@ -138,20 +139,27 @@ at least 1.92 (PyO3 alone has a 1.83 floor); the project's actual MSRV has not been independently validated. This is guidance, not a new minimum-Rust CI requirement. -## Proposed native-artifact security release +## Core 2.0.1 native-artifact and lifecycle release -Proposed version `2.0.1` updates PyO3 to `0.29.2`, +Version `2.0.1` updates PyO3 to `0.29.2`, `pyo3-async-runtimes` to `0.29.0`, and `pyo3-log` to `0.13.4`, outside the affected ranges for GHSA-36hh-v3qg-5jq4, GHSA-chgr-c6px-7xpp, RustSec-2026-0176, and RustSec-2026-0177. The binding keeps `abi3-py311`, `multiple-pymethods`, and -`generate-import-lib` (deprecated in PyO3 0.29 in favor of `raw-dylib`) and -requires Windows qualification rather than a linkage behavior change. This -security work is separate from lifecycle PR #114, which also proposes `2.0.1`; -release sequencing and the lifecycle follow-up version are owner decisions. -See [Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) for status, -evidence, and adoption requirements. +the deprecated `generate-import-lib` feature for compatibility; Windows +qualification is required before any future linkage migration to `raw-dylib`. +The lifecycle work from [PR #114](https://github.com/microsoft/amplifier-core/pull/114) +and the PyO3 remediation from +[PR #115](https://github.com/microsoft/amplifier-core/pull/115) retain their +separate attribution while shipping together in `2.0.1`. + +The Rust-backed Python session attempts `session:end` at most once per +initialized lifetime; cancellation can interrupt delivery, and the host owns +cleanup completion. See [CONTRACTS.md](CONTRACTS.md) for the authoritative +lifecycle and registration-ownership contracts. See +[Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) for evidence and +adoption requirements. ## Core Concepts @@ -277,7 +285,7 @@ For complete module development guide: - [Module Source Protocol](docs/MODULE_SOURCE_PROTOCOL.md) - Custom module loading - [Rust Core Testing](docs/RUST_CORE_TESTING.md) - Development setup and testing guide - [Rust Core Limitations](docs/RUST_CORE_LIMITATIONS.md) - Known limitations -- [Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) - proposed security-release evidence and consumer verification +- [Native Artifact Qualification](docs/NATIVE_ARTIFACTS.md) - release evidence and consumer verification **Philosophy**: diff --git a/docs/NATIVE_ARTIFACTS.md b/docs/NATIVE_ARTIFACTS.md index 1a4bb9d..9fcdeb2 100644 --- a/docs/NATIVE_ARTIFACTS.md +++ b/docs/NATIVE_ARTIFACTS.md @@ -1,21 +1,22 @@ # Native Artifact Qualification -## Release-status snapshot +## Core 2.0.1 release boundary -As of this document's update, `2.0.1` is proposed and PyPI serves `1.6.1`. -This snapshot must be updated by the release owner when publishing. Until -then, no artifact is described as published or qualified and no alert is -described as resolved. +Core `2.0.1` combines the PyO3 remediation, lifecycle work, and updated +Actions pins. The PyO3 work is attributed to +[PR #115](https://github.com/microsoft/amplifier-core/pull/115); the lifecycle +work is attributed to [PR #114](https://github.com/microsoft/amplifier-core/pull/114). -The proposed binding resolves PyO3 `0.29.2`, `pyo3-async-runtimes` `0.29.0`, +The binding resolves PyO3 `0.29.2`, `pyo3-async-runtimes` `0.29.0`, and `pyo3-log` `0.13.4`, preserving `abi3-py311`, `multiple-pymethods`, and `generate-import-lib`. The latter is deprecated by PyO3 0.29 in favor of -`raw-dylib`, so Windows qualification is required before changing no linkage -behavior. +`raw-dylib`; the deprecated feature remains for compatibility, and Windows +qualification is required before any future linkage migration. -This is not lifecycle PR #114. Although both candidates propose `2.0.1`, the -security candidate is independently qualified; release ordering and a later -lifecycle version remain owner decisions. +A `2.0.1` artifact is a published release only when its exact version is +available on PyPI, a non-draft GitHub release is published, and that release +contains the matching qualification receipt. Branch and pull-request artifacts +are evidence for review, not published release artifacts. ## Security and support boundary @@ -51,12 +52,12 @@ the observed libc at ## Configured qualification matrix -The workflow builds six families—Linux x64/ARM64, macOS x64/ARM64, and Windows -x64/ARM64—and configures all 18 normal-GIL CPython 3.11/3.12/3.13 verification -cells. These are required configured checks, **not validation results yet**. -Windows ARM64/Python 3.11 download availability remains unknown until its -green run; it is not a support or publication claim. Linux ARM64/Python 3.13 -must be accepted only with its target and observed-libc report. +The workflow requires six families—Linux x64/ARM64, macOS x64/ARM64, and +Windows x64/ARM64—and all 18 normal-GIL CPython 3.11/3.12/3.13 verification +cells. Every required build and verification cell must pass for the current +release source SHA; Windows checks are required. Consult the qualification +reports for the actual platform versions, target facts, and, for Linux, the +observed libc for that release. After all six builds and all 18 qualifiers pass, `release-evidence` validates the complete set and creates `release-evidence-.zip` plus @@ -64,8 +65,7 @@ the complete set and creates `release-evidence-.zip` plus artifacts only. A tag run first creates a draft GitHub release and attaches that evidence before PyPI publication; the PyPI job requires `release-evidence`. The final `publish-release` job makes the GitHub release -public only after PyPI succeeds. This pipeline has not run for the proposed -release. +public only after PyPI succeeds. ## Consumer installation and verification @@ -107,11 +107,11 @@ release. assert native_path.is_relative_to(Path(sys.prefix).resolve()) print("distribution:", metadata_version) print("native member:", member_key, loaded_sha256) - print("proposed receipt source_sha:", receipt["source_sha"]) + print("receipt source_sha:", receipt["source_sha"]) ``` The loaded engine version proves package-version agreement, not a Git revision. Use the receipt's `source_sha`, plus its wheel and native-member hash matches, -to map the installed binary to the proposed source record. Prefer qualified +to map the installed binary to the release source record. Prefer qualified wheels over independently rebuilding native Core for routine application releases. \ No newline at end of file diff --git a/scripts/e2e-smoke-test.sh b/scripts/e2e-smoke-test.sh index 3bef076..a18d244 100755 --- a/scripts/e2e-smoke-test.sh +++ b/scripts/e2e-smoke-test.sh @@ -22,7 +22,7 @@ set -euo pipefail # Environment variables: # SMOKE_PROVIDER anthropic (default) or openai # SMOKE_MODEL Optional model override (otherwise use provider default) -# SMOKE_BUNDLE Optional bundle override (otherwise use CLI default) +# SMOKE_BUNDLE Optional bundle override (default: foundation) # ANTHROPIC_BASE_URL / OPENAI_BASE_URL Optional selected provider endpoint # SMOKE_PROMPT Override the default test prompt # SMOKE_TIMEOUT Override the timeout in seconds (default: 360) @@ -84,7 +84,7 @@ while [[ $# -gt 0 ]]; do echo "Environment variables:" echo " SMOKE_PROVIDER anthropic (default) or openai" echo " SMOKE_MODEL Optional model override" - echo " SMOKE_BUNDLE Optional bundle override" + echo " SMOKE_BUNDLE Optional bundle override (default: foundation)" echo " Selected provider API key required (environment or ~/.amplifier/keys.env)" echo " ANTHROPIC_BASE_URL / OPENAI_BASE_URL Optional selected provider endpoint" echo " SMOKE_PROMPT Test prompt (default: 'Ask recipe author to run one of its example recipes')" diff --git a/tests/test_e2e_smoke_script.py b/tests/test_e2e_smoke_script.py index 4327315..f5943a1 100644 --- a/tests/test_e2e_smoke_script.py +++ b/tests/test_e2e_smoke_script.py @@ -134,7 +134,7 @@ def test_default_anthropic_keeps_optional_model_and_endpoint(smoke): assert result.returncode == 0, result.stdout + result.stderr call = cli_call(calls) assert call["args"] == [ - "run", "--provider", "anthropic", "--", + "run", "--provider", "anthropic", "--bundle", "foundation", "--", "Ask recipe author to run one of its example recipes", ] assert call["provider_env"] == {"ANTHROPIC_API_KEY": "fixture-anthropic"} @@ -154,7 +154,10 @@ def test_selected_family_only_with_endpoint_model_and_literal_arguments(smoke, t }, keys="exit 87\n") assert result.returncode == 0, result.stdout + result.stderr call = cli_call(calls) - assert call["args"] == ["run", "--provider", provider, "--model", model, "--", prompt] + assert call["args"] == [ + "run", "--provider", provider, "--model", model, + "--bundle", "foundation", "--", prompt, + ] assert call["provider_env"] == { f"{prefix}_API_KEY": f"fixture-{provider}", f"{prefix}_BASE_URL": f"https://{provider}.invalid/" + ("v1" if provider == "openai" else ""), @@ -166,19 +169,21 @@ def test_selected_family_only_with_endpoint_model_and_literal_arguments(smoke, t def test_leading_dash_prompt_cannot_become_cli_help(smoke): result, calls = smoke({"ANTHROPIC_API_KEY": "fixture-key", "SMOKE_PROMPT": "--help"}) assert result.returncode == 0, result.stdout + result.stderr - assert cli_call(calls)["args"] == ["run", "--provider", "anthropic", "--", "--help"] + assert cli_call(calls)["args"] == [ + "run", "--provider", "anthropic", "--bundle", "foundation", "--", "--help", + ] def test_optional_bundle_uses_supported_cli_argument(smoke): result, calls = smoke({ "OPENAI_API_KEY": "fixture-key", "SMOKE_PROVIDER": "openai", - "SMOKE_MODEL": "gpt-5.6-terra", "SMOKE_BUNDLE": "foundation", + "SMOKE_MODEL": "gpt-5.6-terra", "SMOKE_BUNDLE": "custom-fixture-bundle", "SMOKE_PROMPT": "fixture recipe", }) assert result.returncode == 0, result.stdout + result.stderr assert cli_call(calls)["args"] == [ "run", "--provider", "openai", "--model", "gpt-5.6-terra", - "--bundle", "foundation", "--", "fixture recipe", + "--bundle", "custom-fixture-bundle", "--", "fixture recipe", ]