diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cd2ffb54..4ff0c509 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,11 +6,89 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 1 + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 commit-message: prefix: "deps" labels: - "dependencies" groups: - uv-all-updates: + a2a-sdk: + patterns: + - "a2a-sdk" + runtime-framework: + patterns: + - "fastapi" + - "pydantic" + - "pydantic-settings" + - "sse-starlette" + - "starlette" + - "uvicorn" + tooling-and-transitive: + patterns: + - "*" + exclude-patterns: + - "a2a-sdk" + - "fastapi" + - "pydantic" + - "pydantic-settings" + - "sse-starlette" + - "starlette" + - "uvicorn" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + cooldown: + default-days: 7 + commit-message: + prefix: "deps" + labels: + - "dependencies" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + cooldown: + default-days: 7 + commit-message: + prefix: "deps" + labels: + - "dependencies" + groups: + pre-commit-hooks: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/build-requirements" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + cooldown: + default-days: 7 + semver-major-days: 30 + semver-minor-days: 7 + semver-patch-days: 3 + commit-message: + prefix: "deps" + labels: + - "dependencies" + groups: + build-system: patterns: - "*" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 94a4b949..aba1657e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,7 +1,23 @@ -name: Review Development Dependencies +name: Review Dependency Health on: workflow_dispatch: + pull_request: + paths: + - ".github/dependabot.yml" + - ".pre-commit-config.yaml" + - "pyproject.toml" + - "build-requirements/requirements.txt" + - "uv.lock" + push: + branches: + - main + paths: + - ".github/dependabot.yml" + - ".pre-commit-config.yaml" + - "pyproject.toml" + - "build-requirements/requirements.txt" + - "uv.lock" schedule: # 03:00 UTC on day-of-month 1 - cron: "0 3 1 * *" @@ -11,22 +27,35 @@ permissions: jobs: dependency-health: - name: Audit Development Dependencies + name: Audit Development and Build Dependencies runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false - name: Run dependency health checks run: bash ./scripts/dependency_health.sh + env: + DEPENDENCY_AUDIT_OUTPUT_DIR: dependency-audit + + - name: Upload dependency audit reports + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: dependency-audit + path: dependency-audit/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 193f6bc6..c50e0650 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,32 +25,38 @@ concurrency: group: publish-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} cancel-in-progress: false -permissions: - contents: write - id-token: write +permissions: {} jobs: - publish: - name: Build and Publish Release Artifacts + build-and-audit: + name: Build and Audit Release Artifacts runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false + - name: Verify release tag matches checkout + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + run: bash ./scripts/validate_release_tag.sh "${RELEASE_TAG}" + - name: Run default regression baseline run: bash ./scripts/doctor.sh @@ -60,13 +66,33 @@ jobs: --output-file /tmp/runtime-requirements.txt >/dev/null - name: Run runtime dependency vulnerability audit - run: uv run pip-audit --requirement /tmp/runtime-requirements.txt + run: > + uv run pip-audit --requirement /tmp/runtime-requirements.txt + --disable-pip --require-hashes --strict + + - name: Export development requirements for vulnerability audit + run: > + uv export --format requirements.txt --extra dev --no-dev --locked --no-emit-project + --output-file /tmp/dev-requirements.txt >/dev/null + + - name: Run development dependency vulnerability audit + run: > + uv run pip-audit --requirement /tmp/dev-requirements.txt + --disable-pip --require-hashes --strict + + - name: Run build dependency vulnerability audit + run: > + uv run pip-audit --requirement build-requirements/requirements.txt + --disable-pip --require-hashes --strict - name: Clean previous build artifacts run: rm -rf build dist - name: Build package artifacts - run: uv build --no-sources + run: > + uv build --no-sources --force-pep517 + --build-constraints build-requirements/requirements.txt + --require-hashes - name: Verify published version matches tag env: @@ -101,14 +127,97 @@ jobs: - name: Smoke test sdist artifact run: bash ./scripts/smoke_test_built_cli.sh dist/opencode_a2a-*.tar.gz + - name: Generate runtime SBOM + run: > + uv run pip-audit --requirement /tmp/runtime-requirements.txt + --disable-pip --require-hashes --strict + --format cyclonedx-json --output dist/opencode-a2a.cdx.json + + - name: Generate release checksums + run: | + python - <<'PY' + import hashlib + from pathlib import Path + + dist_dir = Path("dist") + assets = sorted( + path + for path in dist_dir.iterdir() + if path.is_file() and path.name != "SHA256SUMS" + ) + lines = [ + f"{hashlib.sha256(asset.read_bytes()).hexdigest()} {asset.name}" + for asset in assets + ] + (dist_dir / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") + PY + + - name: Upload verified release artifacts + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: release-dist + path: dist/ + if-no-files-found: error + retention-days: 7 + + publish-pypi: + name: Publish Verified Artifacts to PyPI + needs: build-and-audit + if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_to_pypi }} + runs-on: ubuntu-latest + permissions: + id-token: write + + steps: + - name: Download verified release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-dist + path: dist + + - name: Verify release checksums + run: cd dist && sha256sum -c SHA256SUMS + + - name: Prepare PyPI distribution set + run: | + mkdir -p pypi-dist + cp dist/*.whl dist/*.tar.gz pypi-dist/ + - name: Publish to PyPI - if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_to_pypi }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1 + with: + packages-dir: pypi-dist + + sync-release: + name: Sync Verified GitHub Release + needs: + - build-and-audit + - publish-pypi + if: >- + ${{ + always() + && needs.build-and-audit.result == 'success' + && (needs.publish-pypi.result == 'success' || needs.publish-pypi.result == 'skipped') + && (github.event_name != 'workflow_dispatch' || inputs.sync_github_release) + }} + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download verified release artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: release-dist + path: dist + + - name: Verify release checksums + run: cd dist && sha256sum -c SHA256SUMS - name: Sync GitHub Release - if: ${{ github.event_name != 'workflow_dispatch' || inputs.sync_github_release }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | set -euo pipefail @@ -146,30 +255,11 @@ jobs: ensure_release - python - <<'PY' - import hashlib - from pathlib import Path - - dist_dir = Path("dist") - lines = [] - for asset in sorted( - ( - path - for path in dist_dir.iterdir() - if path.is_file() and path.name.endswith((".whl", ".tar.gz")) - ), - key=lambda item: item.name, - ): - digest = hashlib.sha256(asset.read_bytes()).hexdigest() - lines.append(f"{digest} {asset.name}") - (dist_dir / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8") - PY - mapfile -t existing_assets < <( retry 3 5 gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name' ) - for asset in dist/*.tar.gz dist/*.whl dist/SHA256SUMS; do + for asset in dist/*.tar.gz dist/*.whl dist/*.cdx.json dist/SHA256SUMS; do asset_name="$(basename "$asset")" if printf '%s\n' "${existing_assets[@]}" | grep -Fxq "$asset_name"; then echo "Release asset already present: $asset_name" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 81d2c1aa..4b77b0eb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,15 +16,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false @@ -46,13 +48,18 @@ jobs: --output-file /tmp/runtime-requirements.txt >/dev/null - name: Run runtime dependency vulnerability audit - run: uv run pip-audit --requirement /tmp/runtime-requirements.txt + run: > + uv run pip-audit --requirement /tmp/runtime-requirements.txt + --disable-pip --require-hashes --strict - name: Clean previous build artifacts run: rm -rf build dist - name: Build package artifacts - run: uv build --no-sources + run: > + uv build --no-sources --force-pep517 + --build-constraints build-requirements/requirements.txt + --require-hashes - name: Smoke test built wheel run: bash ./scripts/smoke_test_built_cli.sh dist/opencode_a2a-*.whl @@ -70,15 +77,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false @@ -94,15 +103,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.11" - name: Set up uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bea9ad8a..7c6cdb16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ minimum_pre_commit_version: 4.5.1 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: 2c9f875913ee60ca25ce70243dc24d5b6415598c # frozen: v4.6.0 # pragma: allowlist secret hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.11.0.1 + rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1 # pragma: allowlist secret hooks: - id: shellcheck args: ["--severity=error"] @@ -28,7 +28,7 @@ repos: types_or: [python, pyi, jupyter, markdown] require_serial: true - repo: https://github.com/Yelp/detect-secrets - rev: v1.5.0 + rev: 01886c8a910c64595c47f186ca1ffc0b77fa5458 # frozen: v1.5.0 # pragma: allowlist secret hooks: - id: detect-secrets args: ["--baseline", ".secrets.baseline"] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5791c5b6..6f66bb6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Run the default validation baseline before opening a PR: bash ./scripts/doctor.sh ``` -`doctor.sh` is the primary repository validation entrypoint. It currently runs locked-environment sync, dependency compatibility checks, `pre-commit`, `mypy`, `pytest`, the repository coverage gate, package build, and a built-wheel smoke test. +`doctor.sh` is the primary repository validation entrypoint. It currently runs locked-environment sync, dependency compatibility checks, `pre-commit`, `mypy`, `pytest`, the repository coverage gate, a hash-locked PEP 517 package build, and a built-wheel smoke test. Ruff runs with the `S` (flake8-bandit) security rules enabled for shipped code. `tests/**` is exempt from `S` because test modules intentionally use asserts and fake credentials; do not widen that exemption beyond `tests/**`. @@ -58,6 +58,10 @@ bash -n scripts/doctor.sh bash -n scripts/lint.sh ``` +If you change `[build-system]` in `pyproject.toml`, update +`build-requirements/requirements.txt` in the same change. Release validation +builds with that hash-pinned constraint file. + Repository-owned black-box compatibility probes stay outside the default regression baseline. Run them when transport or A2A protocol behavior changes: ```bash diff --git a/SECURITY.md b/SECURITY.md index 3cfe302a..1b4217bb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,7 @@ This repository wraps OpenCode as an A2A runtime. It exposes A2A HTTP+JSON and J - LLM provider keys are consumed by the `opencode` process. Prompt injection or indirect exfiltration attempts may still expose sensitive values. - Payload logging is opt-in. When `A2A_LOG_PAYLOADS=true`, operators should treat logs as potentially sensitive operational data. - This project does not ship host bootstrap or process-manager wrappers as an official product capability. Operators remain responsible for file permissions, secret storage, service users, and supervisor-specific hardening. +- Release artifacts are built in a read-only job, audited, and checksummed before separate least-privilege PyPI and GitHub Release jobs can upload them. Releases include a CycloneDX runtime SBOM. ## Threat Model diff --git a/build-requirements/requirements.txt b/build-requirements/requirements.txt new file mode 100644 index 00000000..90dfe112 --- /dev/null +++ b/build-requirements/requirements.txt @@ -0,0 +1,15 @@ +# Pinned build-system closure for isolated PEP 517 builds. +# Keep this file in sync with pyproject.toml [build-system] and regenerate hashes +# when a build dependency changes. +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +setuptools==84.2.1 \ + --hash=sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc \ + --hash=sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2 +setuptools-scm==10.3.4 \ + --hash=sha256:82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e \ + --hash=sha256:a69f28bfc245608781205e912faae437c2b2165773afa4e7b979d77447a69dd2 +vcs-versioning==2.6.0 \ + --hash=sha256:22e9159288e2d8bca2fa6f6c31c34c9c66aec157f60e9e526f5d160d2516d60f \ + --hash=sha256:f8bebd1302ebc12e67ceb30fc7dd698ec969414b6fa43b30c3f14ac9b1e72c41 diff --git a/docs/security-architecture.md b/docs/security-architecture.md index e9b365ee..0bf5a8a8 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -137,12 +137,15 @@ it becomes a trust concern. ### Release Integrity -Every GitHub Release must ship a `SHA256SUMS` checksum manifest alongside the -wheel and sdist artifacts so consumers can verify artifact integrity -independently of the registry. `.github/workflows/publish.yml` regenerates -`dist/SHA256SUMS` at publish time from the built assets (sorted by basename, -`sha256sum -c` compatible) and uploads it as a release asset; existing assets -are skipped idempotently. +Every GitHub Release must ship a `SHA256SUMS` checksum manifest and a CycloneDX +runtime SBOM alongside the wheel and sdist artifacts so consumers can verify +artifact integrity and inspect the shipped component set independently of the +registry. `.github/workflows/publish.yml` builds and audits artifacts in a +read-only job, generates `dist/SHA256SUMS` from the build outputs (sorted by +basename, `sha256sum -c` compatible), and transfers them to separate PyPI and +GitHub Release jobs with only their required write permissions. Each publish +job verifies the checksums before uploading; existing release assets are +skipped idempotently. ## End-to-End Mapping: Remote A2A Input → OpenCode Side Effects diff --git a/scripts/README.md b/scripts/README.md index 394c82eb..802ad640 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,13 +11,14 @@ Executable scripts live in this directory. This file is the entry index for the ## Other Scripts -- [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + built-wheel smoke test) +- [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + hash-locked PEP 517 build + built-wheel smoke test) - [`conformance.sh`](./conformance.sh): repository-owned A2A 1.0 black-box compatibility probes; can launch a dummy-backed local SUT and preserves machine-readable artifacts under `run/conformance/` - [`live_opencode_smoke.sh`](./live_opencode_smoke.sh): local/manual live integration smoke against a real OpenCode runtime; boots an isolated password-hardened `opencode serve` plus the opencode-a2a runtime in throwaway directories and verifies upstream auth, JSON-RPC extension reads, and a streaming prompt round-trip -- [`dependency_health.sh`](./dependency_health.sh): development dependency review entrypoint (`sync`/`pip check` + outdated + dev audit), while blocking CI/publish audits focus on runtime dependencies +- [`dependency_health.sh`](./dependency_health.sh): development and build dependency review entrypoint (`sync`/`pip check` + outdated + strict hash-locked audits), while blocking CI/publish audits focus on runtime dependencies - [`check_coverage.py`](./check_coverage.py): enforces the overall coverage floor and per-file minimums for critical modules - [`find_thin_wrappers.py`](./find_thin_wrappers.py): static analysis helper that reports local functions with low caller counts and flags likely thin forwarding wrappers for manual abstraction review - [`lint.sh`](./lint.sh): lint entrypoint used by CI; runs the repository `pre-commit` pipeline and **rejects hook-generated rewrites** instead of retrying them, so an unformatted or un-fixed commit cannot pass the gate +- [`validate_release_tag.sh`](./validate_release_tag.sh): verifies that a release input is an existing `v*` tag pointing at the checked-out commit before any audit or publish step runs - [`smoke_test_built_cli.sh`](./smoke_test_built_cli.sh): built-artifact smoke test for the released CLI runtime; defaults to the only local wheel, supports explicit wheel/sdist paths, and rejects ambiguous local artifact selection ## Notes @@ -26,5 +27,8 @@ Executable scripts live in this directory. This file is the entry index for the - `doctor.sh` covers the default local validation baseline, while `dependency_health.sh` remains focused on standalone dependency review and audit flow. - `doctor.sh` stops early when `pre-commit` rewrites files so you can review the changes and rerun `doctor.sh` from the updated worktree. - `lint.sh` shares the same working-tree fingerprint check through [`health_common.sh`](./health_common.sh): auto-fixing hooks (`trailing-whitespace`, `end-of-file-fixer`, `ruff --fix`, `ruff format`) make the gate fail instead of being masked by a retry. -- [`.github/dependabot.yml`](../.github/dependabot.yml) prefers a single weekly grouped Dependabot PR for `uv`, while `dependency_health.sh` remains the explicit review/audit entrypoint. +- [`.github/dependabot.yml`](../.github/dependabot.yml) separates `a2a-sdk`, runtime framework, routine tooling/transitive, GitHub Actions, and pre-commit updates; all version updates use a cooldown. +- Dependency-manifest changes run development and build audits through the `Review Dependency Health` workflow and retain machine-readable reports. +- [`build-requirements/requirements.txt`](../build-requirements/requirements.txt) pins and hashes the isolated PEP 517 build closure. Dependabot maintains it through the separate `pip` ecosystem entry; update it together with `[build-system]` in `pyproject.toml`. +- `dependency_health.sh` remains the explicit local review/audit entrypoint. - Repository-owned compatibility probes remain separate from the default regression path. See [`../docs/conformance.md`](../docs/conformance.md). diff --git a/scripts/dependency_health.sh b/scripts/dependency_health.sh index d793e920..b87f84f6 100755 --- a/scripts/dependency_health.sh +++ b/scripts/dependency_health.sh @@ -15,5 +15,27 @@ trap 'rm -f "${dev_requirements}"' EXIT echo "[dependency-health] export dev extra requirements" uv export --format requirements.txt --extra dev --no-dev --locked --no-emit-project --output-file "${dev_requirements}" >/dev/null -echo "[dependency-health] run dev dependency vulnerability audit" -uv run pip-audit --requirement "${dev_requirements}" +run_dependency_audit() { + local label="$1" + local requirements_file="$2" + local output_name="$3" + local -a audit_args=( + --requirement "${requirements_file}" + --disable-pip + --require-hashes + --strict + ) + + echo "[dependency-health] run ${label} dependency vulnerability audit" + if [[ -n "${DEPENDENCY_AUDIT_OUTPUT_DIR:-}" ]]; then + mkdir -p "${DEPENDENCY_AUDIT_OUTPUT_DIR}" + uv run pip-audit "${audit_args[@]}" \ + --format json \ + --output "${DEPENDENCY_AUDIT_OUTPUT_DIR}/${output_name}-pip-audit.json" + else + uv run pip-audit "${audit_args[@]}" + fi +} + +run_dependency_audit "dev" "${dev_requirements}" "dev" +run_dependency_audit "build" "build-requirements/requirements.txt" "build" diff --git a/scripts/doctor.sh b/scripts/doctor.sh index bebf8ea1..ba848b8a 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -47,7 +47,9 @@ run_doctor_package_phase() { echo "[doctor] build release artifacts" rm -f dist/opencode_a2a-*.whl dist/opencode_a2a-*.tar.gz - uv build --no-sources + uv build --no-sources --force-pep517 \ + --build-constraints build-requirements/requirements.txt \ + --require-hashes echo "[doctor] smoke test built wheel" bash ./scripts/smoke_test_built_cli.sh dist/opencode_a2a-*.whl diff --git a/scripts/validate_release_tag.sh b/scripts/validate_release_tag.sh new file mode 100755 index 00000000..2063d999 --- /dev/null +++ b/scripts/validate_release_tag.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +release_tag="$1" +case "${release_tag}" in + v*) ;; + *) + echo "Release tag must start with 'v': ${release_tag}" >&2 + exit 1 + ;; +esac + +tag_ref="refs/tags/${release_tag}" +if ! git check-ref-format "${tag_ref}" || ! git show-ref --verify --quiet "${tag_ref}"; then + echo "Release tag does not exist or is not a valid tag: ${release_tag}" >&2 + exit 1 +fi + +tag_commit="$(git rev-parse "${tag_ref}^{commit}")" +checkout_commit="$(git rev-parse --verify HEAD)" +if [[ "${tag_commit}" != "${checkout_commit}" ]]; then + echo "Release tag ${release_tag} does not point to the checked-out commit." >&2 + exit 1 +fi + +echo "Validated release tag ${release_tag} at ${checkout_commit}." diff --git a/src/opencode_a2a/jsonrpc/dispatch.py b/src/opencode_a2a/jsonrpc/dispatch.py index 813624fc..07298abe 100644 --- a/src/opencode_a2a/jsonrpc/dispatch.py +++ b/src/opencode_a2a/jsonrpc/dispatch.py @@ -102,9 +102,6 @@ def __init__(self, specs: Iterable[ExtensionMethodSpec]) -> None: method_map[method] = spec self._method_map = method_map - def methods(self) -> frozenset[str]: - return frozenset(self._method_map) - def resolve(self, method: str) -> ExtensionMethodSpec | None: return self._method_map.get(method) diff --git a/src/opencode_a2a/jsonrpc/models.py b/src/opencode_a2a/jsonrpc/models.py index a8ec638a..9777dbfb 100644 --- a/src/opencode_a2a/jsonrpc/models.py +++ b/src/opencode_a2a/jsonrpc/models.py @@ -24,9 +24,3 @@ class JSONRPCRequest(JSONRPCBaseModel): method: str params: dict[str, Any] | None = None id: str | int | None = None - - -class JSONRPCErrorResponse(JSONRPCBaseModel): - jsonrpc: Literal["2.0"] = "2.0" - id: str | int | None = None - error: JSONRPCError diff --git a/src/opencode_a2a/server/application.py b/src/opencode_a2a/server/application.py index 99f83e57..431a8850 100644 --- a/src/opencode_a2a/server/application.py +++ b/src/opencode_a2a/server/application.py @@ -13,14 +13,8 @@ SendMessageRequest, SubscribeToTaskRequest, ) -from a2a.utils.errors import ( - A2A_REST_ERROR_MAPPING, - A2AError, - ErrorMapping, -) from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, PlainTextResponse -from google.protobuf.json_format import ParseError from pydantic_settings import BaseSettings from starlette.middleware.gzip import GZipMiddleware @@ -40,7 +34,6 @@ from ..jsonrpc.application import ( OpencodeSessionManagementJSONRPCApplication, ) -from ..jsonrpc.error_responses import build_http_error_body from ..opencode_upstream_client import OpencodeUpstreamClient from ..profile.runtime import build_runtime_profile from ..protocol_versions import A2A_PROTOCOL_VERSION @@ -61,7 +54,7 @@ ) from .request_handler import OpencodeRequestHandler from .rest_responses import A2AJSONResponse -from .runtime_limits import StreamBudgetExceeded, apply_stream_budget +from .runtime_limits import apply_stream_budget from .state_store import ( build_runtime_state_runtime, ) @@ -70,86 +63,6 @@ describe_lightweight_persistence_backend, ) -logger = logging.getLogger(__name__) -TASK_STORE_ERROR_TYPE = "TASK_STORE_UNAVAILABLE" -PUSH_NOTIFICATIONS_UNSUPPORTED_MESSAGE = "Push notifications are not supported by the agent" - - -def _rest_error_response( - *, - request: Request, - error: Exception, -) -> JSONResponse: - del request - logger_fn = logger.exception - logger_message = "Unexpected REST message route failure" - - if isinstance(error, A2AError): - mapping = A2A_REST_ERROR_MAPPING.get( - type(error), - ErrorMapping(500, "INTERNAL", "INTERNAL_ERROR"), - ) - message = getattr(error, "message", str(error)) - metadata = getattr(error, "data", None) or {} - logger_fn = logger.error if mapping.http_code >= 500 else logger.warning - logger_message = ( - f"REST message route failed status={mapping.http_code} " - f"reason={mapping.reason} message={message}" - ) - logger_fn(logger_message) - return JSONResponse( - build_http_error_body( - status_code=mapping.http_code, - status=mapping.grpc_status, - message=message, - reason=mapping.reason, - metadata=metadata, - ), - status_code=mapping.http_code, - ) - - if isinstance(error, StreamBudgetExceeded): - logger.warning( - "REST stream rejected before SSE start: %s", - error, - ) - return JSONResponse( - build_http_error_body( - status_code=429, - status="RESOURCE_EXHAUSTED", - message=str(error), - reason="STREAM_BUDGET_EXCEEDED", - ), - status_code=429, - ) - - if isinstance(error, ParseError): - message = str(error) - logger_fn = logger.warning - logger_message = f"REST message payload parse error: {message}" - logger_fn(logger_message) - return JSONResponse( - build_http_error_body( - status_code=400, - status="INVALID_ARGUMENT", - message=message, - reason="INVALID_REQUEST", - ), - status_code=400, - ) - - logger_fn(logger_message) - return JSONResponse( - build_http_error_body( - status_code=500, - status="INTERNAL", - message="unknown exception", - reason="INTERNAL_ERROR", - ), - status_code=500, - ) - - if TYPE_CHECKING: from collections.abc import AsyncIterator diff --git a/src/opencode_a2a/server/request_handler.py b/src/opencode_a2a/server/request_handler.py index 877caeb1..c0122a86 100644 --- a/src/opencode_a2a/server/request_handler.py +++ b/src/opencode_a2a/server/request_handler.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from collections.abc import AsyncGenerator, Mapping from contextlib import aclosing from typing import Any, cast @@ -61,10 +60,6 @@ task_store_get, ) -logger = logging.getLogger(__name__) - -TASK_STORE_ERROR_TYPE = "TASK_STORE_UNAVAILABLE" - def _accepted_output_modes_from_params(params: Any) -> tuple[str, ...] | None: configuration = getattr(params, "configuration", None) @@ -453,14 +448,3 @@ async def on_message_send_stream( raise self._task_store_server_error(exc) from exc finally: emit_stream_request_metrics(active_delta=-1.0) - - @staticmethod - def _task_error_metadata(operation: str) -> dict[str, Any]: - return { - "opencode": { - "error": { - "type": TASK_STORE_ERROR_TYPE, - "operation": operation, - } - } - } diff --git a/src/opencode_a2a/server/task_store.py b/src/opencode_a2a/server/task_store.py index ef2ceb7c..72e4df44 100644 --- a/src/opencode_a2a/server/task_store.py +++ b/src/opencode_a2a/server/task_store.py @@ -330,14 +330,6 @@ def _validate_sdk_task_table(connection: Any, *, database_url: str) -> None: ) -def build_task_store( - settings: Settings, - *, - engine: Any | None = None, -) -> TaskStore | VersionedTaskStore: - return build_task_store_runtime(settings, engine=engine).task_store - - def describe_lightweight_persistence_backend(settings: Settings) -> dict[str, str]: summary = { "backend": settings.a2a_task_store_backend, @@ -351,70 +343,16 @@ def describe_lightweight_persistence_backend(settings: Settings) -> dict[str, st return summary -def unwrap_task_store(task_store: TaskStore | VersionedTaskStore) -> TaskStore | VersionedTaskStore: - if isinstance(task_store, VersionedDatabaseTaskStore): - return task_store.as_task_store - if isinstance(task_store, NegotiatingVersionedTaskStore): - return unwrap_task_store(task_store._inner) - inner = getattr(task_store, "_store", None) - if isinstance(inner, TaskStore | VersionedTaskStore): - return unwrap_task_store(inner) - return task_store - - -async def initialize_task_store(task_store: TaskStore | VersionedTaskStore) -> None: - initialize = getattr(task_store, "initialize", None) - if callable(initialize): - await initialize() - - -async def task_store_snapshot( - task_store: TaskStore | VersionedTaskStore, - task_id: str, - context: ServerCallContext | None = None, -) -> StoredTask | None: - return await as_versioned_task_store(task_store).get( - task_id, - _normalize_task_store_context(context), - ) - - async def task_store_get( task_store: TaskStore | VersionedTaskStore, task_id: str, context: ServerCallContext | None = None, ) -> Task | None: - stored = await task_store_snapshot(task_store, task_id, context) - return stored.task if stored is not None else None - - -async def task_store_save( - task_store: TaskStore | VersionedTaskStore, - task: Task, - context: ServerCallContext | None = None, -) -> TaskVersion: - await initialize_task_store(task_store) - normalized_context = _normalize_task_store_context(context) - versioned_store = as_versioned_task_store(task_store) - stored = await versioned_store.get(task.id, normalized_context) - return await versioned_store.save( - task, - event=None, - prev=stored.task if stored is not None else None, - prev_version=stored.version if stored is not None else TaskVersion.MISSING, - context=normalized_context, - ) - - -async def task_store_delete( - task_store: TaskStore | VersionedTaskStore, - task_id: str, - context: ServerCallContext | None = None, -) -> None: - await as_versioned_task_store(task_store).delete( + stored = await as_versioned_task_store(task_store).get( task_id, _normalize_task_store_context(context), ) + return stored.task if stored is not None else None def _normalize_task_store_context( diff --git a/tests/client/test_client_facade.py b/tests/client/test_client_facade.py index ebc0f75a..a3e2cc7b 100644 --- a/tests/client/test_client_facade.py +++ b/tests/client/test_client_facade.py @@ -17,11 +17,12 @@ A2ATimeoutError, A2AUnsupportedOperationError, ) -from opencode_a2a.jsonrpc.models import JSONRPCError, JSONRPCErrorResponse +from opencode_a2a.jsonrpc.models import JSONRPCError from tests.support.fake_client_errors import ( FakeA2AClientHTTPError, FakeA2AClientJSONError, FakeA2AClientJSONRPCError, + FakeJSONRPCErrorResponse, ) @@ -436,7 +437,7 @@ async def test_send_message_prefers_explicit_authorization_without_default_token async def test_send_message_maps_jsonrpc_not_supported( monkeypatch: pytest.MonkeyPatch, ) -> None: - rpc_error = JSONRPCErrorResponse( + rpc_error = FakeJSONRPCErrorResponse( error=JSONRPCError(code=-32601, message="Unsupported method: SendMessage"), id="req-1", ) diff --git a/tests/client/test_error_mapping.py b/tests/client/test_error_mapping.py index 040ff4c9..cf462ceb 100644 --- a/tests/client/test_error_mapping.py +++ b/tests/client/test_error_mapping.py @@ -35,11 +35,12 @@ A2ATimeoutError, A2AUnsupportedOperationError, ) -from opencode_a2a.jsonrpc.models import JSONRPCError, JSONRPCErrorResponse +from opencode_a2a.jsonrpc.models import JSONRPCError from tests.support.fake_client_errors import ( FakeA2AClientHTTPError, FakeA2AClientJSONError, FakeA2AClientJSONRPCError, + FakeJSONRPCErrorResponse, ) @@ -164,19 +165,19 @@ def test_map_client_error_without_status_returns_protocol_error() -> None: def test_map_jsonrpc_error_variants() -> None: invalid_params_error = FakeA2AClientJSONRPCError( - JSONRPCErrorResponse( + FakeJSONRPCErrorResponse( error=JSONRPCError(code=-32602, message="bad params", data={"field": "limit"}), id="req-1", ) ) internal_error = FakeA2AClientJSONRPCError( - JSONRPCErrorResponse( + FakeJSONRPCErrorResponse( error=JSONRPCError(code=-32603, message="internal"), id="req-2", ) ) generic_error = FakeA2AClientJSONRPCError( - JSONRPCErrorResponse( + FakeJSONRPCErrorResponse( error=JSONRPCError(code=-32000, message="generic"), id="req-3", ) diff --git a/tests/execution/test_opencode_agent_session_binding.py b/tests/execution/test_opencode_agent_session_binding.py index 88ca6900..d25c9773 100644 --- a/tests/execution/test_opencode_agent_session_binding.py +++ b/tests/execution/test_opencode_agent_session_binding.py @@ -31,7 +31,7 @@ maybe_handle_tools, merge_streamed_tool_output, ) -from opencode_a2a.jsonrpc.models import JSONRPCError, JSONRPCErrorResponse +from opencode_a2a.jsonrpc.models import JSONRPCError from opencode_a2a.opencode_upstream_client import OpencodeMessage from opencode_a2a.server.client_manager import A2AClientManager from opencode_a2a.trace_context import TraceContext, bind_trace_context @@ -39,6 +39,7 @@ from tests.support.fake_client_errors import ( FakeA2AClientHTTPError, FakeA2AClientJSONRPCError, + FakeJSONRPCErrorResponse, ) from tests.support.helpers import ( DummyChatOpencodeUpstreamClient, @@ -668,7 +669,7 @@ async def test_agent_a2a_call_propagates_current_trace_headers( def test_map_a2a_tool_exception_protocol_and_unavailable_variants() -> None: rpc_error = FakeA2AClientJSONRPCError( - JSONRPCErrorResponse( + FakeJSONRPCErrorResponse( error=JSONRPCError(code=-32602, message="bad params"), id="req-1", ) diff --git a/tests/jsonrpc/test_dispatch_registry.py b/tests/jsonrpc/test_dispatch_registry.py index b2991a6c..eef8d3fa 100644 --- a/tests/jsonrpc/test_dispatch_registry.py +++ b/tests/jsonrpc/test_dispatch_registry.py @@ -27,18 +27,21 @@ async def test_extension_registry_tracks_configured_methods(monkeypatch) -> None ) ) - registry_methods = _jsonrpc_app(app)._extension_method_registry.methods() # noqa: SLF001 - assert "opencode.sessions.status" in registry_methods - assert "opencode.sessions.list" in registry_methods - assert "opencode.sessions.fork" in registry_methods - assert "opencode.sessions.summarize" in registry_methods - assert "opencode.sessions.revert" in registry_methods - assert "opencode.sessions.unrevert" in registry_methods - assert "opencode.providers.list" in registry_methods - assert "opencode.projects.list" in registry_methods - assert "opencode.permissions.list" in registry_methods - assert "a2a.interrupt.permission.reply" in registry_methods - assert "opencode.sessions.shell" not in registry_methods + registry = _jsonrpc_app(app)._extension_method_registry # noqa: SLF001 + for method in ( + "opencode.sessions.status", + "opencode.sessions.list", + "opencode.sessions.fork", + "opencode.sessions.summarize", + "opencode.sessions.revert", + "opencode.sessions.unrevert", + "opencode.providers.list", + "opencode.projects.list", + "opencode.permissions.list", + "a2a.interrupt.permission.reply", + ): + assert registry.resolve(method) is not None + assert registry.resolve("opencode.sessions.shell") is None @pytest.mark.asyncio diff --git a/tests/scripts/test_script_health_contract.py b/tests/scripts/test_script_health_contract.py index df134426..59fd8570 100644 --- a/tests/scripts/test_script_health_contract.py +++ b/tests/scripts/test_script_health_contract.py @@ -1,9 +1,14 @@ import json import os +import re import socket import subprocess +import tomllib from pathlib import Path +from packaging.requirements import Requirement +from packaging.version import Version + DOCTOR_TEXT = Path("scripts/doctor.sh").read_text() CONFORMANCE_TEXT = Path("scripts/conformance.sh").read_text() DEPENDENCY_HEALTH_TEXT = Path("scripts/dependency_health.sh").read_text() @@ -11,11 +16,17 @@ HEALTH_COMMON_PATH = Path("scripts/health_common.sh").resolve() LINT_TEXT = Path("scripts/lint.sh").read_text() SMOKE_TEST_TEXT = Path("scripts/smoke_test_built_cli.sh").read_text() +RELEASE_TAG_SCRIPT_PATH = Path("scripts/validate_release_tag.sh").resolve() COVERAGE_GATE_TEXT = Path("scripts/check_coverage.py").read_text() THIN_WRAPPER_FINDER_TEXT = Path("scripts/find_thin_wrappers.py").read_text() SCRIPTS_INDEX_TEXT = Path("scripts/README.md").read_text() PYPROJECT_TEXT = Path("pyproject.toml").read_text() DEPENDABOT_TEXT = Path(".github/dependabot.yml").read_text() +PRECOMMIT_TEXT = Path(".pre-commit-config.yaml").read_text() +BUILD_REQUIREMENTS_TEXT = Path("build-requirements/requirements.txt").read_text() +WORKFLOW_TEXTS = { + path: path.read_text() for path in sorted(Path(".github/workflows").glob("*.yml")) +} def test_shared_repo_health_prerequisites_live_in_common_helper() -> None: @@ -60,6 +71,76 @@ def fingerprint() -> str: assert fingerprint() != untracked_added +def test_release_tag_validation_requires_existing_tag_at_checkout(tmp_path: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + tracked = tmp_path / "tracked.txt" + tracked.write_text("release") + subprocess.run(["git", "add", "tracked.txt"], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + "release", + ], + cwd=tmp_path, + check=True, + ) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "tag", + "-a", + "-m", + "release", + "v1.2.3", + ], + cwd=tmp_path, + check=True, + ) + + def validate(tag: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(RELEASE_TAG_SCRIPT_PATH), tag], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + ) + + assert validate("v1.2.3").returncode == 0 + assert validate("release/v1").returncode == 1 + assert validate("v9.9.9").returncode == 1 + + tracked.write_text("next") + subprocess.run(["git", "add", "tracked.txt"], cwd=tmp_path, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + "next", + ], + cwd=tmp_path, + check=True, + ) + mismatch = validate("v1.2.3") + assert mismatch.returncode == 1 + assert "does not point to the checked-out commit" in mismatch.stderr + + def test_doctor_keeps_local_regression_scope() -> None: assert 'source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/health_common.sh"' in DOCTOR_TEXT assert 'run_shared_repo_health_prerequisites "doctor"' in DOCTOR_TEXT @@ -76,7 +157,9 @@ def test_doctor_keeps_local_regression_scope() -> None: assert "uv run mypy src/opencode_a2a" in DOCTOR_TEXT assert "uv run pytest" in DOCTOR_TEXT assert "uv run python ./scripts/check_coverage.py" in DOCTOR_TEXT - assert "uv build --no-sources" in DOCTOR_TEXT + assert "uv build --no-sources --force-pep517" in DOCTOR_TEXT + assert "--build-constraints build-requirements/requirements.txt" in DOCTOR_TEXT + assert "--require-hashes" in DOCTOR_TEXT assert "bash ./scripts/smoke_test_built_cli.sh dist/opencode_a2a-*.whl" in DOCTOR_TEXT assert "uv pip list --outdated" not in DOCTOR_TEXT assert "uv run pip-audit" not in DOCTOR_TEXT @@ -97,6 +180,14 @@ def test_dependency_health_keeps_dependency_review_scope() -> None: assert 'run_shared_repo_health_prerequisites "dependency-health"' in DEPENDENCY_HEALTH_TEXT assert "uv pip list --outdated" in DEPENDENCY_HEALTH_TEXT assert "uv run pip-audit" in DEPENDENCY_HEALTH_TEXT + assert "--disable-pip" in DEPENDENCY_HEALTH_TEXT + assert "--require-hashes" in DEPENDENCY_HEALTH_TEXT + assert "--strict" in DEPENDENCY_HEALTH_TEXT + assert "DEPENDENCY_AUDIT_OUTPUT_DIR" in DEPENDENCY_HEALTH_TEXT + assert "--format json" in DEPENDENCY_HEALTH_TEXT + assert 'run_dependency_audit "build" "build-requirements/requirements.txt" "build"' in ( + DEPENDENCY_HEALTH_TEXT + ) assert "uv run pytest" not in DEPENDENCY_HEALTH_TEXT assert "uv run pre-commit run --all-files" not in DEPENDENCY_HEALTH_TEXT @@ -110,14 +201,101 @@ def test_scripts_index_documents_split_health_entrypoints() -> None: assert "built-wheel smoke test" in SCRIPTS_INDEX_TEXT assert "fix/verify/package phases" in SCRIPTS_INDEX_TEXT assert "review the changes and rerun `doctor.sh`" in SCRIPTS_INDEX_TEXT - assert "single weekly grouped Dependabot PR for `uv`" in SCRIPTS_INDEX_TEXT + assert "dependabot.yml" in SCRIPTS_INDEX_TEXT -def test_dependabot_configuration_prefers_a_single_grouped_uv_pr() -> None: +def test_dependabot_configuration_separates_critical_and_routine_updates() -> None: assert 'package-ecosystem: "uv"' in DEPENDABOT_TEXT - assert 'package-ecosystem: "github-actions"' not in DEPENDABOT_TEXT - assert "open-pull-requests-limit: 1" in DEPENDABOT_TEXT - assert "uv-all-updates" in DEPENDABOT_TEXT + assert 'package-ecosystem: "github-actions"' in DEPENDABOT_TEXT + assert 'package-ecosystem: "pre-commit"' in DEPENDABOT_TEXT + assert 'package-ecosystem: "pip"' in DEPENDABOT_TEXT + assert 'directory: "/build-requirements"' in DEPENDABOT_TEXT + assert DEPENDABOT_TEXT.count("cooldown:") == 4 + assert "semver-major-days: 30" in DEPENDABOT_TEXT + assert "a2a-sdk:" in DEPENDABOT_TEXT + assert "runtime-framework:" in DEPENDABOT_TEXT + assert "tooling-and-transitive:" in DEPENDABOT_TEXT + assert "build-system:" in DEPENDABOT_TEXT + assert "uv-all-updates" not in DEPENDABOT_TEXT + + +def test_workflow_actions_are_pinned_to_immutable_commits() -> None: + action_reference = re.compile(r"^[^@\s]+@[0-9a-f]{40}$") + references = [ + line.split("uses:", 1)[1].split("#", 1)[0].strip() + for text in WORKFLOW_TEXTS.values() + for line in text.splitlines() + if line.strip().startswith("uses:") + ] + + assert references + assert all(action_reference.fullmatch(reference) for reference in references) + + +def test_pre_commit_hooks_are_pinned_to_immutable_commits() -> None: + revisions = re.findall(r"^\s+rev:\s+(\S+)", PRECOMMIT_TEXT, flags=re.MULTILINE) + + assert revisions + assert all(re.fullmatch(r"[0-9a-f]{40}", revision) for revision in revisions) + assert PRECOMMIT_TEXT.count("# frozen: v") == len(revisions) + + +def test_build_dependencies_are_pinned_with_hashes() -> None: + build_system = tomllib.loads(PYPROJECT_TEXT)["build-system"]["requires"] + assert "setuptools>=80" in build_system + assert "setuptools-scm[toml]>=8,<11" in build_system + + pins = { + requirement.name: requirement + for line in BUILD_REQUIREMENTS_TEXT.splitlines() + if (stripped := line.strip()) + and not stripped.startswith(("#", "--hash")) + and (requirement := Requirement(stripped.removesuffix("\\").strip())) + } + assert { + "packaging", + "setuptools", + "setuptools-scm", + "vcs-versioning", + } <= pins.keys() + + for raw in build_system: + build_requirement = Requirement(raw) + pin = pins[build_requirement.name] + assert len(pin.specifier) == 1 + assert all( + spec.operator == "==" + and build_requirement.specifier.contains(Version(spec.version), prereleases=True) + for spec in pin.specifier + ) + assert BUILD_REQUIREMENTS_TEXT.count("--hash=sha256:") >= 2 * len(pins) + + +def test_release_workflow_separates_privileged_publish_from_audited_build() -> None: + publish_text = WORKFLOW_TEXTS[Path(".github/workflows/publish.yml")] + assert "permissions: {}" in publish_text + assert "build-and-audit:" in publish_text + assert "needs: build-and-audit" in publish_text + assert "publish-pypi:" in publish_text + assert "sync-release:" in publish_text + assert "needs.publish-pypi.result" in publish_text + assert "persist-credentials: false" in publish_text + assert "contents: read" in publish_text + assert "contents: write" in publish_text + assert "id-token: write" in publish_text + assert 'bash ./scripts/validate_release_tag.sh "${RELEASE_TAG}"' in publish_text + assert "Verify release checksums" in publish_text + assert "packages-dir: pypi-dist" in publish_text + assert "cyclonedx-json" in publish_text + + +def test_dependency_review_runs_for_dependency_manifest_changes() -> None: + review_text = WORKFLOW_TEXTS[Path(".github/workflows/dependency-review.yml")] + assert "pull_request:" in review_text + assert ' - "pyproject.toml"' in review_text + assert ' - "build-requirements/requirements.txt"' in review_text + assert ' - "uv.lock"' in review_text + assert "Upload dependency audit reports" in review_text def test_conformance_script_is_repository_owned_and_external_tck_independent() -> None: diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 800b9399..2a2fe438 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -22,7 +22,6 @@ ) from a2a.utils.errors import InvalidRequestError from fastapi import Request -from google.protobuf.json_format import ParseError import opencode_a2a.server.application as app_module from opencode_a2a.a2a_protocol import CORE_JSONRPC_METHODS @@ -41,7 +40,6 @@ OpencodeRequestHandler, _configure_logging, _normalize_log_level, - _rest_error_response, create_app, ) from opencode_a2a.server.openapi import ( @@ -160,76 +158,6 @@ def test_request_payload_helpers_cover_edge_cases() -> None: } -def test_rest_message_parsing_helpers_cover_upgrade_paths() -> None: - request_v1 = _request("/message:send") - request_v1.state.a2a_protocol_version = "1.0" - v1_error = _rest_error_response( - request=request_v1, - error=InvalidRequestError(message="bad payload", data={"path": "/message:send"}), - ) - assert v1_error.status_code == 400 - assert json.loads(v1_error.body) == { - "error": { - "code": 400, - "status": "INVALID_ARGUMENT", - "message": "bad payload", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "INVALID_REQUEST", - "domain": "a2a-protocol.org", - "metadata": {"path": "/message:send"}, - }, - { - "@type": "type.googleapis.com/opencode_a2a.HttpErrorContext", - "path": "/message:send", - }, - ], - } - } - - request_default = _request("/message:send") - parse_error = _rest_error_response( - request=request_default, - error=ParseError("bad parse"), - ) - assert parse_error.status_code == 400 - assert json.loads(parse_error.body) == { - "error": { - "code": 400, - "status": "INVALID_ARGUMENT", - "message": "bad parse", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "INVALID_REQUEST", - "domain": "a2a-protocol.org", - } - ], - } - } - - generic_error = _rest_error_response( - request=request_default, - error=RuntimeError("boom"), - ) - assert generic_error.status_code == 500 - assert json.loads(generic_error.body) == { - "error": { - "code": 500, - "status": "INTERNAL", - "message": "unknown exception", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "INTERNAL_ERROR", - "domain": "a2a-protocol.org", - } - ], - } - } - - def test_agent_card_helper_builders_cover_optional_branches() -> None: settings = make_settings( test_bearer_token="test-token", diff --git a/tests/server/test_database_app_persistence.py b/tests/server/test_database_app_persistence.py index e93ff465..2497511b 100644 --- a/tests/server/test_database_app_persistence.py +++ b/tests/server/test_database_app_persistence.py @@ -8,9 +8,10 @@ from a2a.types import Task, TaskState, TaskStatus from opencode_a2a.opencode_upstream_client import OpencodeMessage -from opencode_a2a.server.task_store import task_store_get, task_store_save +from opencode_a2a.server.task_store import task_store_get from tests.support.session_extensions import _extension_headers from tests.support.settings import make_settings +from tests.support.task_store import save_task def _task(task_id: str, *, context_id: str = "ctx-1") -> Task: @@ -166,7 +167,7 @@ async def permission_reply( upstream_client = app1.state._jsonrpc_app._upstream_client context = ServerCallContext(state={"identity": "automation"}) - await task_store_save(task_store, _task("task-1"), context) + await save_task(task_store, _task("task-1"), context) session_id, pending = await executor._session_manager.get_or_create_session( identity="user-1", context_id="ctx-1", diff --git a/tests/server/test_request_handler_lifecycle.py b/tests/server/test_request_handler_lifecycle.py index 3a1ba0f7..ae5bc2fc 100644 --- a/tests/server/test_request_handler_lifecycle.py +++ b/tests/server/test_request_handler_lifecycle.py @@ -34,10 +34,10 @@ from opencode_a2a.server.task_store import ( build_task_store_runtime, task_store_get, - task_store_save, ) from opencode_a2a.trace_context import TraceContext, bind_trace_context, get_current_trace_context from tests.support.settings import make_settings +from tests.support.task_store import save_task def _context(identity: str = "owner") -> ServerCallContext: @@ -131,7 +131,7 @@ async def execute(context, queue): # noqa: ANN001 @pytest.mark.asyncio async def test_terminal_send_is_unsupported(runtime) -> None: - await task_store_save( + await save_task( runtime.task_store, Task( id="terminal-task", @@ -348,7 +348,7 @@ async def execute(self, context, queue): # noqa: ANN001 @pytest.mark.asyncio async def test_cancel_is_idempotent_for_canceled_task(runtime) -> None: - await task_store_save( + await save_task( runtime.task_store, Task( id="canceled-task", @@ -357,7 +357,7 @@ async def test_cancel_is_idempotent_for_canceled_task(runtime) -> None: ), _context(), ) - await task_store_save( + await save_task( runtime.task_store, Task( id="canceled-task", @@ -377,7 +377,7 @@ async def test_cancel_is_idempotent_for_canceled_task(runtime) -> None: @pytest.mark.asyncio async def test_subscribe_terminal_task_is_unsupported(runtime) -> None: - await task_store_save( + await save_task( runtime.task_store, Task( id="terminal-subscribe", diff --git a/tests/server/test_runtime_limits.py b/tests/server/test_runtime_limits.py index 3ca1a6e0..386b0be6 100644 --- a/tests/server/test_runtime_limits.py +++ b/tests/server/test_runtime_limits.py @@ -15,13 +15,13 @@ apply_stream_budget, json_event_size, ) -from opencode_a2a.server.task_store import task_store_save from tests.server.test_transport_contract import ( _authenticated_task_context, _task_for_listing, ) from tests.support.helpers import DummyChatOpencodeUpstreamClient, make_basic_auth_header from tests.support.settings import make_settings +from tests.support.task_store import save_task class FakeClock: @@ -446,7 +446,7 @@ async def test_streaming_byte_budget_integration_rejects_subscribe_before_sse(mo a2a_stream_idle_timeout_seconds=0, ) task_store = app.state.task_store - await task_store_save( + await save_task( task_store, _task_for_listing( task_id="task-sub", diff --git a/tests/server/test_task_store_runtime.py b/tests/server/test_task_store_runtime.py index 949ac8e0..55b459a4 100644 --- a/tests/server/test_task_store_runtime.py +++ b/tests/server/test_task_store_runtime.py @@ -21,14 +21,13 @@ NegotiatingTaskStore, TaskOutputModeRegistry, TaskStoreOperationError, + as_versioned_task_store, build_task_store_runtime, describe_lightweight_persistence_backend, - initialize_task_store, - task_store_delete, task_store_get, - task_store_save, ) from tests.support.settings import make_settings +from tests.support.task_store import save_task def _task(task_id: str, *, context_id: str = "ctx-1") -> Task: @@ -49,9 +48,9 @@ async def test_memory_runtime_uses_sdk_copying_store_and_versioned_adapter() -> assert isinstance(LegacyTaskStoreAdapter(runtime.task_store), LegacyTaskStoreAdapter) await runtime.startup() - await task_store_save(runtime.task_store, _task("task-1")) + await save_task(runtime.task_store, _task("task-1")) assert await task_store_get(runtime.task_store, "task-1") == _task("task-1") - await task_store_delete(runtime.task_store, "task-1") + await as_versioned_task_store(runtime.task_store).delete("task-1", ServerCallContext()) assert await task_store_get(runtime.task_store, "task-1") is None await runtime.shutdown() @@ -70,7 +69,7 @@ async def test_database_runtime_uses_native_versioned_store_and_event_stream( await runtime.startup() context = ServerCallContext(state={"identity": "automation"}) - await task_store_save(runtime.task_store, _task("task-db"), context) + await save_task(runtime.task_store, _task("task-db"), context) assert await task_store_get(runtime.task_store, "task-db", context) == _task("task-db") await runtime.shutdown() @@ -82,7 +81,7 @@ async def test_database_runtime_initializes_lazily_without_lifespan(tmp_path: Pa a2a_task_store_database_url=f"sqlite+aiosqlite:///{tmp_path / 'lazy.db'}", ) runtime = build_task_store_runtime(settings) - await task_store_save(runtime.task_store, _task("task-lazy")) + await save_task(runtime.task_store, _task("task-lazy")) assert await task_store_get(runtime.task_store, "task-lazy") == _task("task-lazy") await runtime.shutdown() @@ -180,7 +179,7 @@ async def test_shared_engine_is_not_disposed(tmp_path: Path) -> None: make_settings(test_bearer_token="test-token"), engine=engine, ) - await initialize_task_store(runtime.task_store) + await runtime.startup() await runtime.shutdown() async with engine.connect() as connection: await connection.execute(text("SELECT 1")) diff --git a/tests/server/test_task_store_upgrade.py b/tests/server/test_task_store_upgrade.py index 6a0eed52..2ce4f6fc 100644 --- a/tests/server/test_task_store_upgrade.py +++ b/tests/server/test_task_store_upgrade.py @@ -18,9 +18,9 @@ as_versioned_task_store, build_task_store_runtime, task_store_get, - task_store_save, ) from tests.support.settings import make_settings +from tests.support.task_store import save_task def _legacy_database(path: Path, state: str) -> None: @@ -131,7 +131,7 @@ async def test_versioned_store_rejects_stale_first_write(tmp_path: Path) -> None ) try: await runtime.startup() - await task_store_save(runtime.task_store, task, context) + await save_task(runtime.task_store, task, context) with pytest.raises(ConcurrentTaskModificationError): await as_versioned_task_store(runtime.task_store).save( task, @@ -164,7 +164,7 @@ async def test_list_tasks_keyset_token_survives_first_page_delete( _timed_task("task-middle", "2026-10-24T02:00:00Z"), _timed_task("task-oldest", "2026-10-24T01:00:00Z"), ): - await task_store_save(runtime.task_store, task, context) + await save_task(runtime.task_store, task, context) first_page = await as_versioned_task_store(runtime.task_store).list( ListTasksRequest(page_size=1), diff --git a/tests/support/fake_client_errors.py b/tests/support/fake_client_errors.py index d1bb1191..3e29cba2 100644 --- a/tests/support/fake_client_errors.py +++ b/tests/support/fake_client_errors.py @@ -3,6 +3,12 @@ from a2a.client.errors import A2AClientError +class FakeJSONRPCErrorResponse: + def __init__(self, *, error: object, id: str | int | None = None) -> None: + self.error = error + self.id = id + + class FakeA2AClientHTTPError(A2AClientError): def __init__(self, status_code: int, message: str) -> None: self.status_code = status_code diff --git a/tests/support/task_store.py b/tests/support/task_store.py new file mode 100644 index 00000000..c3efeca2 --- /dev/null +++ b/tests/support/task_store.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from a2a.server.cluster import TaskVersion, VersionedTaskStore +from a2a.server.context import ServerCallContext +from a2a.server.tasks.task_store import TaskStore +from a2a.types import Task + +from opencode_a2a.server.context_helpers import normalize_server_call_context +from opencode_a2a.server.task_store import as_versioned_task_store + + +async def save_task( + task_store: TaskStore | VersionedTaskStore, + task: Task, + context: ServerCallContext | None = None, +) -> TaskVersion: + normalized_context = normalize_server_call_context(context) + versioned_store = as_versioned_task_store(task_store) + initialize = getattr(versioned_store, "initialize", None) + if callable(initialize): + await initialize() + stored = await versioned_store.get(task.id, normalized_context) + return await versioned_store.save( + task, + event=None, + prev=stored.task if stored is not None else None, + prev_version=stored.version if stored is not None else TaskVersion.MISSING, + context=normalized_context, + ) diff --git a/uv.lock b/uv.lock index 1e12d0c7..aa2847df 100644 --- a/uv.lock +++ b/uv.lock @@ -2110,15 +2110,15 @@ asyncio = [ [[package]] name = "sse-starlette" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2e/16b9cce59c2eebd2f944b41b01df119f8ee65535ce839c462f1b2c9ae2e0/sse_starlette-3.5.1.tar.gz", hash = "sha256:dbb1461cd53c1e284537c3ddcf92f54f24178519a3f1e335e49e5283993db0f7", size = 38356, upload-time = "2026-10-24T08:45:12.444Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, + { url = "https://files.pythonhosted.org/packages/61/98/658d1681bc6dc0b0f7f695aee4337de79decd0ccaec6d000a76b5cbdca6d/sse_starlette-3.5.1-py3-none-any.whl", hash = "sha256:a6cdcfec47b952911b1d99a7b8056429b5984039b875bf6575174220364b551f", size = 17538, upload-time = "2026-10-24T08:45:10.981Z" }, ] [[package]]